rubocop-hq/rubocop

View on GitHub
lib/rubocop/rspec/expect_offense.rb

Summary

Maintainability
A
1 hr
Test Coverage
A
99%
# frozen_string_literal: true

module RuboCop
  module RSpec
    # Mixin for `expect_offense` and `expect_no_offenses`
    #
    # This mixin makes it easier to specify strict offense expectations
    # in a declarative and visual fashion. Just type out the code that
    # should generate an offense, annotate code by writing '^'s
    # underneath each character that should be highlighted, and follow
    # the carets with a string (separated by a space) that is the
    # message of the offense. You can include multiple offenses in
    # one code snippet.
    #
    # @example Usage
    #
    #     expect_offense(<<~RUBY)
    #       a do
    #         b
    #       end.c
    #       ^^^^^ Avoid chaining a method call on a do...end block.
    #     RUBY
    #
    # @example Equivalent assertion without `expect_offense`
    #
    #     inspect_source(<<~RUBY)
    #       a do
    #         b
    #       end.c
    #     RUBY
    #
    #     expect(cop.offenses.size).to be(1)
    #
    #     offense = cop.offenses.first
    #     expect(offense.line).to be(3)
    #     expect(offense.column_range).to be(0...5)
    #     expect(offense.message).to eql(
    #       'Avoid chaining a method call on a do...end block.'
    #     )
    #
    # Autocorrection can be tested using `expect_correction` after
    # `expect_offense`.
    #
    # @example `expect_offense` and `expect_correction`
    #
    #   expect_offense(<<~RUBY)
    #     x % 2 == 0
    #     ^^^^^^^^^^ Replace with `Integer#even?`.
    #   RUBY
    #
    #   expect_correction(<<~RUBY)
    #     x.even?
    #   RUBY
    #
    # If you do not want to specify an offense then use the
    # companion method `expect_no_offenses`. This method is a much
    # simpler assertion since it just inspects the source and checks
    # that there were no offenses. The `expect_offense` method has
    # to do more work by parsing out lines that contain carets.
    #
    # If the code produces an offense that could not be autocorrected, you can
    # use `expect_no_corrections` after `expect_offense`.
    #
    # @example `expect_offense` and `expect_no_corrections`
    #
    #   expect_offense(<<~RUBY)
    #     a do
    #       b
    #     end.c
    #     ^^^^^ Avoid chaining a method call on a do...end block.
    #   RUBY
    #
    #   expect_no_corrections
    #
    # If your code has variables of different lengths, you can use `%{foo}`,
    # `^{foo}`, and `_{foo}` to format your template; you can also abbreviate
    # offense messages with `[...]`:
    #
    #   %w[raise fail].each do |keyword|
    #     expect_offense(<<~RUBY, keyword: keyword)
    #       %{keyword}(RuntimeError, msg)
    #       ^{keyword}^^^^^^^^^^^^^^^^^^^ Redundant `RuntimeError` argument [...]
    #     RUBY
    #
    #   %w[has_one has_many].each do |type|
    #     expect_offense(<<~RUBY, type: type)
    #       class Book
    #         %{type} :chapter, foreign_key: 'book_id'
    #         _{type}           ^^^^^^^^^^^^^^^^^^^^^^ Specifying the default [...]
    #       end
    #     RUBY
    #   end
    #
    # If you need to specify an offense on a blank line, use the empty `^{}` marker:
    #
    # @example `^{}` empty line offense
    #
    #   expect_offense(<<~RUBY)
    #
    #     ^{} Missing frozen string literal comment.
    #     puts 1
    #   RUBY
    module ExpectOffense
      def format_offense(source, **replacements)
        replacements.each do |keyword, value|
          value = value.to_s
          source = source.gsub("%{#{keyword}}", value)
                         .gsub("^{#{keyword}}", '^' * value.size)
                         .gsub("_{#{keyword}}", ' ' * value.size)
        end
        source
      end

      # rubocop:disable Metrics/AbcSize
      def expect_offense(source, file = nil, severity: nil, chomp: false, **replacements)
        expected_annotations = parse_annotations(source, **replacements)
        source = expected_annotations.plain_source
        source = source.chomp if chomp

        @processed_source = parse_processed_source(source, file)
        @offenses = _investigate(cop, @processed_source)
        actual_annotations = expected_annotations.with_offense_annotations(@offenses)

        expect(actual_annotations).to eq(expected_annotations), ''
        expect(@offenses.map(&:severity).uniq).to eq([severity]) if severity

        # Validate that all offenses have a range that formatters can display
        expect do
          @offenses.each { |offense| offense.location.source_line }
        end.not_to raise_error, 'One of the offenses has a misconstructed range, for ' \
                                'example if the offense is on line 1 and the source is empty'

        @offenses
      end
      # rubocop:enable Metrics/AbcSize

      # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
      def expect_correction(correction, loop: true, source: nil)
        if source
          expected_annotations = parse_annotations(source, raise_error: false)
          @processed_source = parse_processed_source(expected_annotations.plain_source)
          _investigate(cop, @processed_source)
        end

        raise '`expect_correction` must follow `expect_offense`' unless @processed_source

        source = @processed_source.raw_source

        raise 'Use `expect_no_corrections` if the code will not change' if correction == source

        iteration = 0
        new_source = loop do
          iteration += 1

          corrected_source = @last_corrector.rewrite

          break corrected_source unless loop
          break corrected_source if @last_corrector.empty?

          if iteration > RuboCop::Runner::MAX_ITERATIONS
            raise RuboCop::Runner::InfiniteCorrectionLoop.new(@processed_source.path, [@offenses])
          end

          # Prepare for next loop
          @processed_source = parse_source(corrected_source, @processed_source.path)
          _investigate(cop, @processed_source)
        end

        raise 'Expected correction but no corrections were made' if new_source == source

        expect(new_source).to eq(correction)
      end
      # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity

      def expect_no_corrections
        raise '`expect_no_corrections` must follow `expect_offense`' unless @processed_source

        return if @last_corrector.empty?

        # This is just here for a pretty diff if the source actually got changed
        new_source = @last_corrector.rewrite
        expect(new_source).to eq(@processed_source.buffer.source)

        # There is an infinite loop if a corrector is present that did not make
        # any changes. It will cause the same offense/correction on the next loop.
        raise RuboCop::Runner::InfiniteCorrectionLoop.new(@processed_source.path, [@offenses])
      end

      def expect_no_offenses(source, file = nil)
        offenses = inspect_source(source, file)

        expected_annotations = AnnotatedSource.parse(source)
        actual_annotations = expected_annotations.with_offense_annotations(offenses)
        expect(actual_annotations.to_s).to eq(source)
      end

      def parse_annotations(source, raise_error: true, **replacements)
        set_formatter_options

        source = format_offense(source, **replacements)
        annotations = AnnotatedSource.parse(source)
        return annotations unless raise_error && annotations.plain_source == source

        raise 'Use `expect_no_offenses` to assert that no offenses are found'
      end

      def parse_processed_source(source, file = nil)
        processed_source = parse_source(source, file)
        return processed_source if processed_source.valid_syntax?

        raise 'Error parsing example code: ' \
              "#{processed_source.diagnostics.map(&:render).join("\n")}"
      end

      def set_formatter_options
        RuboCop::Formatter::DisabledConfigFormatter.config_to_allow_offenses = {}
        RuboCop::Formatter::DisabledConfigFormatter.detected_styles = {}
        cop.instance_variable_get(:@options)[:autocorrect] = true
      end

      # Parsed representation of code annotated with the `^^^ Message` style
      class AnnotatedSource
        ANNOTATION_PATTERN = /\A\s*(\^+|\^{}) ?/.freeze
        ABBREV = "[...]\n"

        # @param annotated_source [String] string passed to the matchers
        #
        # Separates annotation lines from source lines. Tracks the real
        # source line number that each annotation corresponds to.
        #
        # @return [AnnotatedSource]
        def self.parse(annotated_source)
          source      = []
          annotations = []

          annotated_source.each_line do |source_line|
            if ANNOTATION_PATTERN.match?(source_line)
              annotations << [source.size, source_line]
            else
              source << source_line
            end
          end
          annotations.each { |a| a[0] = 1 } if source.empty?

          new(source, annotations)
        end

        # @param lines [Array<String>]
        # @param annotations [Array<(Integer, String)>]
        #   each entry is the annotated line number and the annotation text
        #
        # @note annotations are sorted so that reconstructing the annotation
        #   text via {#to_s} is deterministic
        def initialize(lines, annotations)
          @lines       = lines.freeze
          @annotations = annotations.sort.freeze
        end

        def ==(other)
          other.is_a?(self.class) && other.lines == lines && match_annotations?(other)
        end

        # Dirty hack: expectations with [...] are rewritten when they match
        # This way the diff is clean.
        def match_annotations?(other)
          annotations.zip(other.annotations) do |(_actual_line, actual_annotation),
                                                 (_expected_line, expected_annotation)|
            if expected_annotation&.end_with?(ABBREV) &&
               actual_annotation.start_with?(expected_annotation[0...-ABBREV.length])

              expected_annotation.replace(actual_annotation)
            end
          end

          annotations == other.annotations
        end

        # Construct annotated source string (like what we parse)
        #
        # Reconstruct a deterministic annotated source string. This is
        # useful for eliminating semantically irrelevant annotation
        # ordering differences.
        #
        # @example standardization
        #
        #     source1 = AnnotatedSource.parse(<<-RUBY)
        #     line1
        #     ^ Annotation 1
        #      ^^ Annotation 2
        #     RUBY
        #
        #     source2 = AnnotatedSource.parse(<<-RUBY)
        #     line1
        #      ^^ Annotation 2
        #     ^ Annotation 1
        #     RUBY
        #
        #     source1.to_s == source2.to_s # => true
        #
        # @return [String]
        def to_s
          reconstructed = lines.dup

          annotations.reverse_each do |line_number, annotation|
            reconstructed.insert(line_number, annotation)
          end

          reconstructed.join
        end
        alias inspect to_s

        # Return the plain source code without annotations
        #
        # @return [String]
        def plain_source
          lines.join
        end

        # Annotate the source code with the RuboCop offenses provided
        #
        # @param offenses [Array<RuboCop::Cop::Offense>]
        #
        # @return [self]
        def with_offense_annotations(offenses)
          offense_annotations =
            offenses.map do |offense|
              indent     = ' ' * offense.column
              carets     = '^' * offense.column_length
              carets     = '^{}' if offense.column_length.zero?

              [offense.line, "#{indent}#{carets} #{offense.message}\n"]
            end

          self.class.new(lines, offense_annotations)
        end

        protected

        attr_reader :lines, :annotations
      end
    end
  end
end