lib/mutant/matcher/method.rb



# frozen_string_literal: true

module Mutant
  class Matcher
    # Abstract base class for method matchers
    class Method < self
      include AbstractType,
              Adamantium,
              Anima.new(:scope, :target_method, :evaluator)

      SOURCE_LOCATION_WARNING_FORMAT =
        '%s does not have a valid source location, unable to emit subject'

      CLOSURE_WARNING_FORMAT =
        '%s is dynamically defined in a closure, unable to emit subject'

      CONSTANT_SCOPES = {
        class:  Context::ConstantScope::Class,
        module: Context::ConstantScope::Module
      }.freeze

      # Matched subjects
      #
      # @param [Env] env
      #
      # @return [Enumerable<Subject>]
      def call(env)
        evaluator.call(scope:, target_method:, env:)
      end

      # Abstract method match evaluator
      #
      # Present to avoid passing the env argument around in case the
      # logic would be implemented directly on the Matcher::Method
      # instance
      #
      # rubocop:disable Metrics/ClassLength
      class Evaluator
        include(
          AbstractType,
          Adamantium,
          Anima.new(:scope, :target_method, :env),
          Procto,
          AST::NodePredicates
        )

        # Matched subjects
        #
        # @return [Enumerable<Subject>]
        def call
          location = source_location

          if location.nil? || !location.first.end_with?('.rb')
            env.warn(SOURCE_LOCATION_WARNING_FORMAT % target_method)

            return EMPTY_ARRAY
          end

          match_view
        end

      private

        def match_view
          return EMPTY_ARRAY if matched_view.nil?

          if matched_view.stack.any? { |node| node.type.equal?(:block) }
            env.warn(CLOSURE_WARNING_FORMAT % target_method)

            return EMPTY_ARRAY
          end

          [subject]
        end

        def subject
          self.class::SUBJECT_CLASS.new(
            config:     subject_config(matched_view.node),
            context:,
            node:       matched_view.node,
            visibility:
          )
        end

        def method_name
          target_method.name
        end

        def context
          Context.new(constant_scope:, scope:, source_path:)
        end

        # rubocop:disable Metrics/MethodLength
        def constant_scope
          matched_view
            .stack
            .reverse
            .reduce(Context::ConstantScope::None.new) do |descendant, node|
              klass = CONSTANT_SCOPES[node.type]

              if klass
                klass.new(
                  const:      node.children.fetch(0),
                  descendant:
                )
              else
                descendant
              end
          end
        end

        def ast
          env.parser.call(source_path)
        end

        def source_path
          env.world.pathname.new(source_location.first)
        end
        memoize :source_path

        def source_line
          source_location.last
        end

        def source_location
          signature = sorbet_signature

          if signature
            signature.method.source_location
          else
            target_method.source_location
          end
        end

        def sorbet_signature
          T::Private::Methods.signature_for_method(target_method)
        end

        def subject_config(node)
          Subject::Config.parse(
            comments: ast.comment_associations.fetch(node, []),
            mutation: env.config.mutation
          )
        end

        def matched_view
          return if source_location.nil?

          # This is a performance optimization when using --since to avoid the cost of parsing
          # every source file that could possibly map to a subject. A more fine-grained filtering
          # takes places later in the process.
          return unless relevant_source_file?

          ast
            .on_line(source_line)
            .select { |view| view.node.type.eql?(self.class::MATCH_NODE_TYPE) && match?(view.node) }
            .last
        end
        memoize :matched_view

        def relevant_source_file?
          env.config.matcher.diffs.all? { |diff| diff.touches_path?(source_path) }
        end

        def visibility
          if scope.raw.private_methods.include?(method_name)
            :private
          elsif scope.raw.protected_methods.include?(method_name)
            :protected
          else
            :public
          end
        end
      end # Evaluator

      private_constant(*constants(false))

    end # Method
  end # Matcher
end # Mutant