lib/mutant/integration/rspec.rb



# frozen_string_literal: true

require 'rspec/core'

module Mutant
  class Integration
    # Rspec integration
    #
    # This looks so complicated, because rspec:
    #
    # * Keeps its state global in RSpec.world and lots of other places
    # * There is no API to "just run a subset of examples", the examples
    #   need to be selected in-place via mutating the `RSpec.filtered_examples`
    #   data structure
    # * Does not maintain a unique identification for an example,
    #   aside the instances of `RSpec::Core::Example` objects itself.
    #   For that reason identifying examples by:
    #   * full description
    #   * location
    #   Is NOT enough. It would not be unique. So we add an "example index"
    #   for unique reference.
    #
    # rubocop:disable Metrics/ClassLength
    class Rspec < self
      ALL_EXPRESSION       = Expression::Namespace::Recursive.new(scope_name: nil)
      EXPRESSION_CANDIDATE = /\A([^ ]+)(?: )?/
      EXIT_SUCCESS         = 0
      DEFAULT_CLI_OPTIONS  = %w[--fail-fast spec].freeze
      TEST_ID_FORMAT       = 'rspec:%<index>d:%<location>s/%<description>s'

      private_constant(*constants(false))

      def freeze
        super if @setup_elapsed
        self
      end

      # Initialize rspec integration
      #
      # @return [undefined]
      def initialize(*)
        super
        @runner      = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(effective_arguments))
        @rspec_world = RSpec.world
      end

      # Setup rspec integration
      #
      # @return [self]
      def setup
        @setup_elapsed = timer.elapsed do
          @runner.setup(world.stderr, world.stdout)
          fail 'RSpec setup failure' if rspec_setup_failure?
          example_group_map
        end
        @runner.configuration.force(color_mode: :on)
        @runner.configuration.reporter
        reset_examples
        freeze
      end
      memoize :setup

      # Run a collection of tests
      #
      # @param [Enumerable<Mutant::Test>] tests
      #
      # @return [Result::Test]
      #
      # rubocop:disable Metrics/AbcSize
      # rubocop:disable Metrics/MethodLength
      def call(tests)
        reset_examples
        setup_examples(tests.map(&all_tests_index))
        @runner.configuration.start_time = world.time.now - @setup_elapsed
        start = timer.now
        passed = @runner.run_specs(@rspec_world.ordered_example_groups).equal?(EXIT_SUCCESS)
        @runner.configuration.reset_reporter

        Result::Test.new(
          job_index: nil,
          output:    '',
          passed:,
          runtime:   timer.now - start
        )
      end
      # rubocop:enable Metrics/AbcSize
      # rubocop:enable Metrics/MethodLength

      # All tests
      #
      # @return [Enumerable<Test>]
      def all_tests
        all_tests_index.keys
      end
      memoize :all_tests

      # Available tests
      #
      # @return [Enumerable<Test>]
      def available_tests
        all_tests_index.select { |_test, example| example.metadata.fetch(:mutant, true) }.keys
      end
      memoize :available_tests

    private

      def rspec_setup_failure?
        @rspec_world.wants_to_quit || rspec_is_quitting?
      end

      def rspec_is_quitting?
        @rspec_world.respond_to?(:rspec_is_quitting) && @rspec_world.rspec_is_quitting
      end

      def effective_arguments
        arguments.empty? ? DEFAULT_CLI_OPTIONS : arguments
      end

      def reset_examples
        @rspec_world.filtered_examples.each_value(&:clear)
      end

      def setup_examples(examples)
        examples.each do |example|
          @rspec_world.filtered_examples.fetch(example_group_map.fetch(example)) << example
        end
      end

      def all_tests_index
        all_examples.each_with_index.with_object({}) do |(example, example_index), index|
          index[parse_example(example, example_index)] = example
        end
      end
      memoize :all_tests_index

      def parse_example(example, index)
        metadata = example.metadata

        id = TEST_ID_FORMAT % {
          index:,
          location:    metadata.fetch(:location),
          description: metadata.fetch(:full_description)
        }

        Test.new(
          expressions: parse_metadata(metadata),
          id:
        )
      end

      def example_group_map
        @rspec_world.example_groups.flat_map(&:descendants).each_with_object({}) do |example_group, map|
          example_group.examples.each do |example|
            map[example] = example_group
          end
        end
      end
      memoize :example_group_map

      # mutant:disable -- 3.3 specific mutation on match.captures -> match
      def parse_metadata(metadata)
        if metadata.key?(:mutant_expression)
          expression = metadata.fetch(:mutant_expression)

          expressions =
            expression.instance_of?(Array) ? expression : [expression]

          expressions.map(&method(:parse_expression))
        else
          match = EXPRESSION_CANDIDATE.match(metadata.fetch(:full_description)) or return [ALL_EXPRESSION]
          [parse_expression(Util.one(match.captures)) { ALL_EXPRESSION }]
        end
      end

      def parse_expression(input, &)
        expression_parser.call(input).from_right(&)
      end

      def all_examples
        @rspec_world.example_groups.flat_map(&:descendants).flat_map(&:examples)
      end
    end # Rspec
    # rubocop:enable Metrics/ClassLength
  end # Integration
end # Mutant