Module: SimpleCov::SimulateCoverage

Defined in:
lib/simplecov/simulate_coverage.rb

Overview

Responsible for producing file coverage metrics.

Class Method Summary collapse

Class Method Details

.call(absolute_path, synthesize: true, lines: true) ⇒ Hash

Simulate a file coverage report for a file that was tracked but never required. Returns the same hash shape as Coverage.result (lines, branches, methods).

The line classification comes from Coverage.line_stub — the same classification the runtime would have produced if the file had been required — overlaid with SimpleCov's # :nocov: toggles and # simplecov:disable line directive ranges, which Coverage doesn't know about. This keeps "relevant lines" identical whether a file was loaded or just tracked, fixing the multi-line statement discrepancy in https://github.com/simplecov-ruby/simplecov/issues/654.

Branches and methods are enumerated by static analysis (via StaticCoverageExtractor, which uses Prism). Earlier behavior left both as empty hashes, which made unloaded files invisible to the branch/method denominators while their lines DID count — so a track_files/cover glob that picked up files without specs silently inflated branch% relative to line%. See https://github.com/simplecov-ruby/simplecov/issues/1059. When Prism isn't loadable (Ruby < 3.3 without the prism gem) or the file can't be parsed, fall back to the old empty hashes — old behavior, old tradeoff.

Pass synthesize: false to skip the static analysis and return the empty hashes directly. Callers use it when neither branch nor method coverage is enabled, since nothing will read the tuples and the Prism parse is about half the cost of simulating a file. See #1250.

Pass lines: false to omit the "lines" key entirely, mirroring what Coverage.result reports for a file loaded under a branch-only or method-only run. Emitting zeroed lines there would make a simulated file indistinguishable from one a sibling process actually loaded once the two are merged.

Returns:

  • (Hash)


50
51
52
53
54
55
56
57
58
# File 'lib/simplecov/simulate_coverage.rb', line 50

def call(absolute_path, synthesize: true, lines: true)
  source_lines = read_lines(absolute_path)
  simulated = synthesized_tuples(source_lines, synthesize)
  return simulated unless lines

  classified = coverage_stub(absolute_path, source_lines) ||
               LinesClassifier.new.classify(source_lines)
  {"lines" => classified}.merge(simulated)
end

.coverage_stub(path, source_lines) ⇒ Object

Combine Coverage.line_stub (which gets multi-line statements right) with LinesClassifier (which knows about # :nocov: toggles and # simplecov:disable line ranges). Returns nil — and the caller falls back to LinesClassifier alone — when Coverage can't read or parse the file, or when the runtime doesn't expose line_stub (JRuby and TruffleRuby).



86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/simplecov/simulate_coverage.rb', line 86

def coverage_stub(path, source_lines)
  return nil unless Coverage.respond_to?(:line_stub)

  stub = Coverage.line_stub(path)
  classifier_output = LinesClassifier.new.classify(source_lines)
  stub.each_index { |idx| stub[idx] = nil if classifier_output[idx].nil? }
  stub
rescue SystemCallError, SyntaxError
  # SystemCallError for the same reason as read_lines above:
  # line_stub reads the file itself, so EACCES/EISDIR surface here
  # too.
  nil
end

.read_lines(path) ⇒ Object

SystemCallError, not just ENOENT: a track_files glob can sweep up an unreadable file or a directory named like a Ruby file (EACCES, EISDIR), and simulation must degrade to "empty file" rather than crash the merge or report step.



74
75
76
77
78
# File 'lib/simplecov/simulate_coverage.rb', line 74

def read_lines(path)
  File.readlines(path)
rescue SystemCallError
  []
end

.synthesized_tuples(source_lines, synthesize) ⇒ Object

The branch and method tuples for a file, or empty hashes when the static analysis is skipped (nothing enabled reads them) or unavailable (no Prism, or the file doesn't parse).



63
64
65
66
67
68
# File 'lib/simplecov/simulate_coverage.rb', line 63

def synthesized_tuples(source_lines, synthesize)
  empty = {"branches" => {}, "methods" => {}} #: Hash[String, Hash[untyped, untyped]]
  synthesized = (StaticCoverageExtractor.call(source_lines.join) if synthesize) || empty

  {"branches" => synthesized["branches"], "methods" => synthesized["methods"]}
end