diff --git a/CHANGELOG.md b/CHANGELOG.md index da2546dab..f8e69b08f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ================== diff --git a/lib/simplecov/source_file/ruby_data_parser.rb b/lib/simplecov/source_file/ruby_data_parser.rb index 9a5ffebf9..8a1ca34cd 100644 --- a/lib/simplecov/source_file/ruby_data_parser.rb +++ b/lib/simplecov/source_file/ruby_data_parser.rb @@ -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 diff --git a/sig/internal/simplecov/source_file/ruby_data_parser.rbs b/sig/internal/simplecov/source_file/ruby_data_parser.rbs index 60a1f873c..cb147cb3e 100644 --- a/sig/internal/simplecov/source_file/ruby_data_parser.rbs +++ b/sig/internal/simplecov/source_file/ruby_data_parser.rbs @@ -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 diff --git a/spec/source_file_spec.rb b/spec/source_file_spec.rb index ac65042db..5508fb573 100644 --- a/spec/source_file_spec.rb +++ b/spec/source_file_spec.rb @@ -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