lib/spoom/deadcode/indexer.rb



# typed: strict
# frozen_string_literal: true

module Spoom
  module Deadcode
    class Indexer < SyntaxTree::Visitor
      extend T::Sig

      sig { returns(String) }
      attr_reader :path, :file_name

      sig { returns(Index) }
      attr_reader :index

      sig { params(path: String, source: String, index: Index, plugins: T::Array[Plugins::Base]).void }
      def initialize(path, source, index, plugins: [])
        super()

        @path = path
        @file_name = T.let(File.basename(path), String)
        @source = source
        @index = index
        @plugins = plugins
        @previous_node = T.let(nil, T.nilable(SyntaxTree::Node))
        @names_nesting = T.let([], T::Array[String])
        @nodes_nesting = T.let([], T::Array[SyntaxTree::Node])
        @in_const_field = T.let(false, T::Boolean)
        @in_opassign = T.let(false, T::Boolean)
        @in_symbol_literal = T.let(false, T::Boolean)
      end

      # Visit

      sig { override.params(node: T.nilable(SyntaxTree::Node)).void }
      def visit(node)
        return unless node

        @nodes_nesting << node
        super
        @nodes_nesting.pop
        @previous_node = node
      end

      sig { override.params(node: SyntaxTree::AliasNode).void }
      def visit_alias(node)
        reference_method(node_string(node.right), node)
      end

      sig { override.params(node: SyntaxTree::ARef).void }
      def visit_aref(node)
        super

        reference_method("[]", node)
      end

      sig { override.params(node: SyntaxTree::ARefField).void }
      def visit_aref_field(node)
        super

        reference_method("[]=", node)
      end

      sig { override.params(node: SyntaxTree::ArgBlock).void }
      def visit_arg_block(node)
        value = node.value

        case value
        when SyntaxTree::SymbolLiteral
          # If the block call is something like `x.select(&:foo)`, we need to reference the `foo` method
          reference_method(symbol_string(value), node)
        when SyntaxTree::VCall
          # If the block call is something like `x.select { ... }`, we need to visit the block
          super
        end
      end

      sig { override.params(node: SyntaxTree::Binary).void }
      def visit_binary(node)
        super

        op = node.operator

        # Reference the operator itself
        reference_method(op.to_s, node)

        case op
        when :<, :>, :<=, :>=
          # For comparison operators, we also reference the `<=>` method
          reference_method("<=>", node)
        end
      end

      sig { override.params(node: SyntaxTree::CallNode).void }
      def visit_call(node)
        visit_send(
          Send.new(
            node: node,
            name: node_string(node.message),
            recv: node.receiver,
            args: call_args(node.arguments),
          ),
        )
      end

      sig { override.params(node: SyntaxTree::ClassDeclaration).void }
      def visit_class(node)
        const_name = node_string(node.constant)
        @names_nesting << const_name
        define_class(T.must(const_name.split("::").last), @names_nesting.join("::"), node)

        # We do not call `super` here because we don't want to visit the `constant` again
        visit(node.superclass) if node.superclass
        visit(node.bodystmt)

        @names_nesting.pop
      end

      sig { override.params(node: SyntaxTree::Command).void }
      def visit_command(node)
        visit_send(
          Send.new(
            node: node,
            name: node_string(node.message),
            args: call_args(node.arguments),
            block: node.block,
          ),
        )
      end

      sig { override.params(node: SyntaxTree::CommandCall).void }
      def visit_command_call(node)
        visit_send(
          Send.new(
            node: node,
            name: node_string(node.message),
            recv: node.receiver,
            args: call_args(node.arguments),
            block: node.block,
          ),
        )
      end

      sig { override.params(node: SyntaxTree::Const).void }
      def visit_const(node)
        reference_constant(node.value, node) unless @in_symbol_literal
      end

      sig { override.params(node: SyntaxTree::ConstPathField).void }
      def visit_const_path_field(node)
        # We do not call `super` here because we don't want to visit the `constant` again
        visit(node.parent)

        name = node.constant.value
        full_name = [*@names_nesting, node_string(node.parent), name].join("::")
        define_constant(name, full_name, node)
      end

      sig { override.params(node: SyntaxTree::DefNode).void }
      def visit_def(node)
        super

        name = node_string(node.name)
        define_method(name, [*@names_nesting, name].join("::"), node)
      end

      sig { override.params(node: SyntaxTree::Field).void }
      def visit_field(node)
        visit(node.parent)

        name = node.name
        case name
        when SyntaxTree::Const
          name = name.value
          full_name = [*@names_nesting, node_string(node.parent), name].join("::")
          define_constant(name, full_name, node)
        when SyntaxTree::Ident
          reference_method(name.value, node) if @in_opassign
          reference_method("#{name.value}=", node)
        end
      end

      sig { override.params(node: SyntaxTree::ModuleDeclaration).void }
      def visit_module(node)
        const_name = node_string(node.constant)
        @names_nesting << const_name
        define_module(T.must(const_name.split("::").last), @names_nesting.join("::"), node)

        # We do not call `super` here because we don't want to visit the `constant` again
        visit(node.bodystmt)

        @names_nesting.pop
      end

      sig { override.params(node: SyntaxTree::OpAssign).void }
      def visit_opassign(node)
        # Both `FOO = x` and `FOO += x` yield a VarField node, but the former is a constant definition and the latter is
        # a constant reference. We need to distinguish between the two cases.
        @in_opassign = true
        super
        @in_opassign = false
      end

      sig { params(send: Send).void }
      def visit_send(send)
        visit(send.recv)

        case send.name
        when "attr_reader"
          send.args.each do |arg|
            next unless arg.is_a?(SyntaxTree::SymbolLiteral)

            name = symbol_string(arg)
            define_attr_reader(name, [*@names_nesting, name].join("::"), arg)
          end
        when "attr_writer"
          send.args.each do |arg|
            next unless arg.is_a?(SyntaxTree::SymbolLiteral)

            name = symbol_string(arg)
            define_attr_writer("#{name}=", "#{[*@names_nesting, name].join("::")}=", arg)
          end
        when "attr_accessor"
          send.args.each do |arg|
            next unless arg.is_a?(SyntaxTree::SymbolLiteral)

            name = symbol_string(arg)
            full_name = [*@names_nesting, name].join("::")
            define_attr_reader(name, full_name, arg)
            define_attr_writer("#{name}=", "#{full_name}=", arg)
          end
        else
          @plugins.each do |plugin|
            plugin.on_send(self, send)
          end

          reference_method(send.name, send.node)
          visit_all(send.args)
          visit(send.block)
        end
      end

      sig { override.params(node: SyntaxTree::SymbolLiteral).void }
      def visit_symbol_literal(node)
        # Something like `:FOO` will yield a Const node but we do not want to treat it as a constant reference.
        # So we need to distinguish between the two cases.
        @in_symbol_literal = true
        super
        @in_symbol_literal = false
      end

      sig { override.params(node: SyntaxTree::TopConstField).void }
      def visit_top_const_field(node)
        define_constant(node.constant.value, node.constant.value, node)
      end

      sig { override.params(node: SyntaxTree::VarField).void }
      def visit_var_field(node)
        value = node.value
        case value
        when SyntaxTree::Const
          if @in_opassign
            reference_constant(value.value, node)
          else
            name = value.value
            define_constant(name, [*@names_nesting, name].join("::"), node)
          end
        when SyntaxTree::Ident
          reference_method(value.value, node) if @in_opassign
          reference_method("#{value.value}=", node)
        end
      end

      sig { override.params(node: SyntaxTree::VCall).void }
      def visit_vcall(node)
        visit_send(Send.new(node: node, name: node_string(node.value)))
      end

      # Definition indexing

      sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
      def define_attr_reader(name, full_name, node)
        definition = Definition.new(
          kind: Definition::Kind::AttrReader,
          name: name,
          full_name: full_name,
          location: node_location(node),
        )
        @index.define(definition)
        @plugins.each { |plugin| plugin.on_define_accessor(self, definition) }
      end

      sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
      def define_attr_writer(name, full_name, node)
        definition = Definition.new(
          kind: Definition::Kind::AttrWriter,
          name: name,
          full_name: full_name,
          location: node_location(node),
        )
        @index.define(definition)
        @plugins.each { |plugin| plugin.on_define_accessor(self, definition) }
      end

      sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
      def define_class(name, full_name, node)
        definition = Definition.new(
          kind: Definition::Kind::Class,
          name: name,
          full_name: full_name,
          location: node_location(node),
        )
        @index.define(definition)
        @plugins.each { |plugin| plugin.on_define_class(self, definition) }
      end

      sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
      def define_constant(name, full_name, node)
        definition = Definition.new(
          kind: Definition::Kind::Constant,
          name: name,
          full_name: full_name,
          location: node_location(node),
        )
        @index.define(definition)
        @plugins.each { |plugin| plugin.on_define_constant(self, definition) }
      end

      sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
      def define_method(name, full_name, node)
        definition = Definition.new(
          kind: Definition::Kind::Method,
          name: name,
          full_name: full_name,
          location: node_location(node),
        )
        @index.define(definition)
        @plugins.each { |plugin| plugin.on_define_method(self, definition) }
      end

      sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
      def define_module(name, full_name, node)
        definition = Definition.new(
          kind: Definition::Kind::Module,
          name: name,
          full_name: full_name,
          location: node_location(node),
        )
        @index.define(definition)
        @plugins.each { |plugin| plugin.on_define_module(self, definition) }
      end

      # Reference indexing

      sig { params(name: String, node: SyntaxTree::Node).void }
      def reference_constant(name, node)
        @index.reference(Reference.new(name: name, kind: Reference::Kind::Constant, location: node_location(node)))
      end

      sig { params(name: String, node: SyntaxTree::Node).void }
      def reference_method(name, node)
        @index.reference(Reference.new(name: name, kind: Reference::Kind::Method, location: node_location(node)))
      end

      # Node utils

      sig { params(node: T.any(Symbol, SyntaxTree::Node)).returns(String) }
      def node_string(node)
        case node
        when Symbol
          node.to_s
        else
          T.must(@source[node.location.start_char...node.location.end_char])
        end
      end

      sig { params(node: SyntaxTree::Node).returns(Location) }
      def node_location(node)
        Location.from_syntax_tree(@path, node.location)
      end

      sig { params(node: SyntaxTree::Node).returns(String) }
      def symbol_string(node)
        node_string(node).delete_prefix(":")
      end

      sig do
        params(
          node: T.any(SyntaxTree::Args, SyntaxTree::ArgParen, SyntaxTree::ArgsForward, NilClass),
        ).returns(T::Array[SyntaxTree::Node])
      end
      def call_args(node)
        case node
        when SyntaxTree::ArgParen
          call_args(node.arguments)
        when SyntaxTree::Args
          node.parts
        else
          []
        end
      end
    end
  end
end