Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Unreleased
## Bugfixes
* `SimpleCov.formatter` and `SimpleCov.formatters` now accept formatter instances in addition to formatter classes, so constructor options can actually be passed — most notably `SimpleCov::Formatter::HTMLFormatter.new(silent: true)` to suppress the "Coverage report generated" status line. Previously SimpleCov unconditionally called `.new` on whatever was configured, so passing an instance crashed with `NoMethodError` at report time. See #1240.

## 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
20 changes: 19 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,28 @@ 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
# 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)

parse_array_string(structure.to_s)
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
Expand Down
10 changes: 10 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,20 @@ 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`.
# Declared in both contexts deliberately: `module_function` also
# copies each method as a private instance method, and Steep
# type-checks `self?.` bodies in the instance context too, so
# dropping the instance-level declaration fails `steep check`.
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
28 changes: 28 additions & 0 deletions spec/source_file_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1316,6 +1316,34 @@ 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

it "freezes the elements of a cached tuple, not just the tuple itself" do
tuple = described_class.call('["ClassName", :method1, 2, 2, 5, 5]')

expect(tuple).to eq(["ClassName", :method1, 2, 2, 5, 5])
expect(tuple).to all(be_frozen)
end
end
end

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