lib/simplecov/source_file/ruby_data_parser.rb



# frozen_string_literal: true

require "ripper"

module SimpleCov
  class SourceFile
    # `Coverage.result` reports condition and method keys as Ruby
    # arrays. When the resultset is round-tripped through JSON those
    # array keys become their stringified inspect form, so this parser
    # walks the literal back into a real Array without using `eval` (see
    # #801). The grammar covers symbols, strings, integers, unary minus,
    # and constant paths — every shape Coverage ever emits.
    module RubyDataParser
    module_function

      # Tests use the real data structures (except for integration tests)
      # so no need to put them through here.
      #
      # String parses are memoized: `Combine::BranchesCombiner` and
      # `Combine::MethodsCombiner` derive a merge identity from every key of
      # both sides on every pairwise merge, so collating N resultsets parses
      # each key string N-1 times — and Ripper dominates the wall time of a
      # large collate.
      # Key strings repeat across folds and within report building, while the
      # set of unique keys is bounded by the project's branch and method
      # count, so a permanent cache stays small. Cached arrays are frozen
      # element-wise (String class names included, not just the tuple):
      # every caller destructures without mutating, and sharing one array
      # across callers must stay that way. The cache key needs no such
      # care — `Hash#[]=` dups and freezes String keys on its own.
      def call(structure)
        return structure if structure.is_a?(Array)

        string = structure.to_s
        parse_cache[string] ||= parse_array_string(string).each(&:freeze).freeze
      end

      def parse_cache
        @parse_cache ||= {} #: Hash[String, untyped]
      end

      # Parse a string like '[:if, 0, 3, 4, 3, 21]' or
      # '["ClassName", :method1, 2, 2, 5, 5]' back into a Ruby array.
      def parse_array_string(str)
        # Try plain Ripper first; only pre-quote `#<...>` inspect segments
        # if the input isn't already valid Ruby (otherwise we corrupt
        # `"#<Class:Foo>"` strings that *are* valid Ruby literals — exactly
        # the shape simplecov-on-simplecov method-coverage keys take).
        sexp = Ripper.sexp(str) || Ripper.sexp(quote_inspected_class_segments(str))
        # simplecov:disable — defensive: Ripper.sexp returning nil from both passes requires malformed input
        array_node = sexp&.dig(1, 0)
        # simplecov:enable
        raise ArgumentError, "expected array literal: #{str.inspect}" unless array_node && array_node[0] == :array

        Array(array_node[1]).map { |element| parse_element(element) }
      end

      def parse_element(node)
        case node[0]
        when :@int, :unary                 then parse_integer_node(node)
        when :symbol_literal, :dyna_symbol then parse_symbol_node(node)
        when :string_literal               then unescape_ruby(string_literal_text(node[1]))
        when :var_ref                      then node.dig(1, 1) # `Foo`
        when :const_path_ref               then "#{parse_element(node[1])}::#{node[2][1]}" # `Foo::Bar`
        else
          # simplecov:disable — defensive fallback for unexpected Ripper node shapes
          raise ArgumentError, "unexpected element: #{node.inspect}"
          # simplecov:enable
        end
      end

      def parse_integer_node(node)
        node[0] == :@int ? node[1].to_i : -node[2][1].to_i
      end

      def parse_symbol_node(node)
        if node[0] == :symbol_literal
          node.dig(1, 1, 1).to_sym
        else
          unescape_ruby(string_literal_text(node[1])).to_sym
        end
      end

      # Concatenate the text fragments of a `:string_content` node. Ripper
      # may emit zero, one, or many `:@tstring_content` children depending
      # on the literal.
      def string_literal_text(string_content)
        Array(string_content[1..]).map { |child| child[1] }.join
      end

      # Undo the same backslash-prefix escapes the previous hand-rolled
      # parser undid: `\X` → `X` for any X.
      def unescape_ruby(raw)
        raw.gsub(/\\(.)/) { ::Regexp.last_match(1) }
      end

      # Method coverage keys can contain inspect-format class references
      # like `#<Class:Foo>` or `#<Class:0x...>`, which aren't valid Ruby
      # syntax. Wrap them in quotes so Ripper can parse the surrounding
      # array literal; downstream we treat them as opaque strings.
      def quote_inspected_class_segments(str)
        str.gsub(/#<[^>]*>/) { |segment| %("#{segment.gsub('"', '\\"')}") }
      end
    end
  end
end