Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
Unreleased
==========

## Performance
* Fix 5x performance regression on report combining (introduced in `1.0.0` as a result of using `Ripper#parse` in a hot path) by adding parsed key memoisation to `RubyDataParser.call`.

1.0.2 (2026-07-18)
==================

Expand Down
18 changes: 17 additions & 1 deletion lib/simplecov/source_file/ruby_data_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,26 @@ module RubyDataParser

# 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:
# every caller destructures without mutating, and sharing one array
# across callers must stay that way.
def call(structure)
return structure if structure.is_a?(Array)

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

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

# Parse a string like '[:if, 0, 3, 4, 3, 21]' or
Expand Down
6 changes: 6 additions & 0 deletions sig/internal/simplecov/source_file/ruby_data_parser.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@ module SimpleCov
# #801). The grammar covers symbols, strings, integers, unary minus,
# and constant paths — every shape Coverage ever emits.
module RubyDataParser
# Memoizes string parses; see the implementation comment on `call`.
self.@parse_cache: Hash[String, untyped]?
@parse_cache: Hash[String, untyped]?

# Tests use the real data structures (except for integration tests)
# so no need to put them through here.
def self?.call: (untyped structure) -> untyped

def self?.parse_cache: () -> Hash[String, untyped]

# Parse a string like '[:if, 0, 3, 4, 3, 21]' or
# '["ClassName", :method1, 2, 2, 5, 5]' back into a Ruby array.
def self?.parse_array_string: (untyped str) -> untyped
Expand Down
21 changes: 21 additions & 0 deletions spec/source_file_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1316,6 +1316,27 @@ def build(coverage_data, source_lines)
expect { described_class.parse_array_string("42") }.to raise_error(ArgumentError, /array literal/)
end
end

describe ".call" do
it "parses a stringified tuple into its array form" do
expect(described_class.call("[:if, 0, 3, 4, 3, 21]")).to eq([:if, 0, 3, 4, 3, 21])
end

it "returns arrays untouched and unfrozen" do
tuple = [:then, 4, 8, 6, 8, 12]

expect(described_class.call(tuple)).to be(tuple)
expect(tuple).not_to be_frozen
end

it "memoizes string parses, returning one frozen array for equal keys" do
first = described_class.call("[:while, 1, 5, 2, 7, 5]")
second = described_class.call(+"[:while, 1, 5, 2, 7, 5]")

expect(second).to be(first)
expect(first).to be_frozen
end
end
end

describe "method-coverage round-trip with a dynamic-symbol method name" do
Expand Down