simplecov 0.22.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +71 -916
  4. data/exe/simplecov +11 -0
  5. data/lib/minitest/simplecov_plugin.rb +13 -5
  6. data/lib/simplecov/atomic_file.rb +70 -0
  7. data/lib/simplecov/autostart.rb +11 -0
  8. data/lib/simplecov/cli/clean.rb +87 -0
  9. data/lib/simplecov/cli/command_helpers.rb +55 -0
  10. data/lib/simplecov/cli/coverage.rb +86 -0
  11. data/lib/simplecov/cli/coverage_file.rb +65 -0
  12. data/lib/simplecov/cli/diff.rb +147 -0
  13. data/lib/simplecov/cli/dotfile.rb +111 -0
  14. data/lib/simplecov/cli/merge.rb +120 -0
  15. data/lib/simplecov/cli/open.rb +48 -0
  16. data/lib/simplecov/cli/report.rb +97 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  19. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  20. data/lib/simplecov/cli/serve.rb +85 -0
  21. data/lib/simplecov/cli/uncovered.rb +103 -0
  22. data/lib/simplecov/cli.rb +163 -0
  23. data/lib/simplecov/color.rb +83 -0
  24. data/lib/simplecov/combine/branches_combiner.rb +54 -7
  25. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  26. data/lib/simplecov/combine/identity_interner.rb +30 -0
  27. data/lib/simplecov/combine/interned_counts.rb +30 -0
  28. data/lib/simplecov/combine/lines_combiner.rb +53 -23
  29. data/lib/simplecov/combine/methods_combiner.rb +74 -0
  30. data/lib/simplecov/combine/results_combiner.rb +13 -36
  31. data/lib/simplecov/combine.rb +5 -23
  32. data/lib/simplecov/command_guesser.rb +104 -33
  33. data/lib/simplecov/configuration/coverage.rb +172 -0
  34. data/lib/simplecov/configuration/coverage_criteria.rb +154 -0
  35. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  36. data/lib/simplecov/configuration/filters.rb +164 -0
  37. data/lib/simplecov/configuration/formatting.rb +138 -0
  38. data/lib/simplecov/configuration/groups.rb +42 -0
  39. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  40. data/lib/simplecov/configuration/merging.rb +165 -0
  41. data/lib/simplecov/configuration/thresholds.rb +179 -0
  42. data/lib/simplecov/configuration.rb +83 -420
  43. data/lib/simplecov/coverage_json.rb +24 -0
  44. data/lib/simplecov/coverage_statistics.rb +16 -10
  45. data/lib/simplecov/coverage_violations.rb +159 -0
  46. data/lib/simplecov/defaults.rb +34 -23
  47. data/lib/simplecov/deprecation.rb +45 -0
  48. data/lib/simplecov/directive.rb +167 -0
  49. data/lib/simplecov/exit_codes/check.rb +33 -0
  50. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  51. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +18 -65
  52. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +32 -0
  53. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -36
  54. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +30 -0
  55. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -33
  56. data/lib/simplecov/exit_codes.rb +20 -0
  57. data/lib/simplecov/exit_handling.rb +128 -0
  58. data/lib/simplecov/file_list.rb +70 -24
  59. data/lib/simplecov/filter.rb +105 -26
  60. data/lib/simplecov/formatter/base.rb +118 -0
  61. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  62. data/lib/simplecov/formatter/html_formatter/public/index.html +87 -0
  63. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  64. data/lib/simplecov/formatter/html_formatter.rb +105 -0
  65. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +88 -0
  66. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +118 -0
  67. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +93 -0
  68. data/lib/simplecov/formatter/json_formatter.rb +46 -0
  69. data/lib/simplecov/formatter/multi_formatter.rb +12 -8
  70. data/lib/simplecov/formatter/simple_formatter.rb +14 -11
  71. data/lib/simplecov/formatter.rb +16 -0
  72. data/lib/simplecov/group_names.rb +32 -0
  73. data/lib/simplecov/last_run.rb +18 -4
  74. data/lib/simplecov/lines_classifier.rb +48 -15
  75. data/lib/simplecov/load_global_config.rb +12 -4
  76. data/lib/simplecov/parallel_adapters/base.rb +78 -0
  77. data/lib/simplecov/parallel_adapters/generic.rb +45 -0
  78. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  79. data/lib/simplecov/parallel_adapters.rb +85 -0
  80. data/lib/simplecov/parallel_coordination.rb +134 -0
  81. data/lib/simplecov/parallel_result_merger.rb +230 -0
  82. data/lib/simplecov/process.rb +52 -12
  83. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  84. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  85. data/lib/simplecov/profiles/rails.rb +24 -10
  86. data/lib/simplecov/profiles/root_filter.rb +6 -5
  87. data/lib/simplecov/profiles/strict.rb +32 -0
  88. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  89. data/lib/simplecov/profiles.rb +32 -3
  90. data/lib/simplecov/report_deferral.rb +49 -0
  91. data/lib/simplecov/report_stamp.rb +28 -0
  92. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  93. data/lib/simplecov/result/source_file_builder.rb +51 -0
  94. data/lib/simplecov/result.rb +145 -26
  95. data/lib/simplecov/result_adapter.rb +139 -8
  96. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  97. data/lib/simplecov/result_merger/resultset_file.rb +74 -0
  98. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  99. data/lib/simplecov/result_merger/resultset_store.rb +53 -0
  100. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  101. data/lib/simplecov/result_merger.rb +109 -114
  102. data/lib/simplecov/result_processing.rb +218 -0
  103. data/lib/simplecov/run_identity.rb +77 -0
  104. data/lib/simplecov/simulate_coverage.rb +83 -12
  105. data/lib/simplecov/source_file/branch.rb +1 -3
  106. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  107. data/lib/simplecov/source_file/builder_context.rb +28 -0
  108. data/lib/simplecov/source_file/line.rb +9 -4
  109. data/lib/simplecov/source_file/line_builder.rb +43 -0
  110. data/lib/simplecov/source_file/method.rb +58 -0
  111. data/lib/simplecov/source_file/method_builder.rb +60 -0
  112. data/lib/simplecov/source_file/ruby_data_parser.rb +110 -0
  113. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  114. data/lib/simplecov/source_file/source_loader.rb +79 -0
  115. data/lib/simplecov/source_file/statistics.rb +65 -0
  116. data/lib/simplecov/source_file.rb +66 -232
  117. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
  118. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
  119. data/lib/simplecov/static_coverage_extractor/method_collector.rb +62 -0
  120. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  121. data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
  122. data/lib/simplecov/static_coverage_extractor/visitor.rb +183 -0
  123. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  124. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  125. data/lib/simplecov/useless_results_remover.rb +16 -7
  126. data/lib/simplecov/version.rb +1 -1
  127. data/lib/simplecov-html.rb +4 -0
  128. data/lib/simplecov.rb +185 -380
  129. data/lib/simplecov_json_formatter.rb +4 -0
  130. data/sig/simplecov.rbs +1777 -0
  131. metadata +111 -62
  132. data/CHANGELOG.md +0 -191
  133. data/doc/alternate-formatters.md +0 -71
  134. data/doc/commercial-services.md +0 -25
  135. data/doc/editor-integration.md +0 -18
  136. data/lib/simplecov/combine/files_combiner.rb +0 -24
  137. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module SimpleCov
6
+ # Identifies one test invocation and each top-level parallel worker within
7
+ # it. Forked subprocesses inherit both values from their parent worker.
8
+ module RunIdentity
9
+ module_function
10
+
11
+ # Returns the run id together with its provenance: `true` when the id
12
+ # alone proves same-run membership (explicitly configured, derived from
13
+ # parallel_tests' per-invocation pid file, or freshly random), `false`
14
+ # when it was inferred from the parent pid, which consecutive runs
15
+ # launched by the same long-lived parent process share.
16
+ def generate
17
+ explicit = ENV.fetch("SIMPLECOV_RUN_ID", nil)
18
+ return [explicit, true] if explicit && !explicit.empty?
19
+
20
+ pid_file = ENV.fetch("PARALLEL_PID_FILE", nil)
21
+ return ["parallel-tests:#{File.basename(pid_file)}", true] if pid_file && !pid_file.empty?
22
+
23
+ return ["parallel-parent:#{Process.ppid}", false] if SimpleCov::ParallelAdapters.current
24
+
25
+ [SecureRandom.uuid, true]
26
+ end
27
+
28
+ def worker_id
29
+ explicit = ENV.fetch("SIMPLECOV_WORKER_ID", nil)
30
+ return explicit if explicit && !explicit.empty?
31
+
32
+ number = ENV.fetch("TEST_ENV_NUMBER", nil)
33
+ return number.empty? ? "1" : number if number
34
+
35
+ Process.pid.to_s
36
+ end
37
+
38
+ # Whether the current run id alone proves same-run membership. Decided
39
+ # where the id is generated, never re-inferred from the id's shape, so
40
+ # an explicit SIMPLECOV_RUN_ID that happens to look like an inferred
41
+ # one is still trusted.
42
+ def authoritative?
43
+ materialize_current
44
+ @authoritative
45
+ end
46
+
47
+ def current
48
+ materialize_current
49
+ @current
50
+ end
51
+
52
+ def materialize_current
53
+ @current, @authoritative = generate unless defined?(@current)
54
+ end
55
+
56
+ def current_worker_id
57
+ @current_worker_id ||= worker_id
58
+ end
59
+
60
+ def prepare
61
+ SimpleCov::ParallelAdapters.current
62
+ SimpleCov.run_id
63
+ SimpleCov.worker_id
64
+ end
65
+
66
+ # Mixed into SimpleCov after ParallelAdapters has loaded.
67
+ module Accessors
68
+ def run_id
69
+ SimpleCov::RunIdentity.current
70
+ end
71
+
72
+ def worker_id
73
+ SimpleCov::RunIdentity.current_worker_id
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "coverage"
4
+ require_relative "static_coverage_extractor"
5
+
3
6
  module SimpleCov
4
7
  #
5
8
  # Responsible for producing file coverage metrics.
@@ -8,22 +11,90 @@ module SimpleCov
8
11
  module_function
9
12
 
10
13
  #
11
- # Simulate normal file coverage report on
12
- # ruby 2.5 and return similar hash with lines and branches keys
14
+ # Simulate a file coverage report for a file that was tracked but never
15
+ # required. Returns the same hash shape as `Coverage.result` (lines,
16
+ # branches, methods).
17
+ #
18
+ # The line classification comes from `Coverage.line_stub` — the same
19
+ # classification the runtime would have produced if the file had been
20
+ # required — overlaid with SimpleCov's `# :nocov:` toggles and
21
+ # `# simplecov:disable line` directive ranges, which `Coverage` doesn't
22
+ # know about. This keeps "relevant lines" identical whether a file was
23
+ # loaded or just tracked, fixing the multi-line statement discrepancy
24
+ # in https://github.com/simplecov-ruby/simplecov/issues/654.
25
+ #
26
+ # Branches and methods are enumerated by static analysis (via
27
+ # `StaticCoverageExtractor`, which uses Prism). Earlier behavior left
28
+ # both as empty hashes, which made unloaded files invisible to the
29
+ # branch/method denominators while their lines DID count — so a
30
+ # `track_files`/`cover` glob that picked up files without specs
31
+ # silently inflated branch% relative to line%. See
32
+ # https://github.com/simplecov-ruby/simplecov/issues/1059. When Prism
33
+ # isn't loadable (Ruby < 3.3 without the prism gem) or the file
34
+ # can't be parsed, fall back to the old empty hashes — old behavior,
35
+ # old tradeoff.
36
+ #
37
+ # Pass `synthesize: false` to skip the static analysis and return the
38
+ # empty hashes directly. Callers use it when neither branch nor method
39
+ # coverage is enabled, since nothing will read the tuples and the Prism
40
+ # parse is about half the cost of simulating a file. See #1250.
13
41
  #
14
- # Happens when a file wasn't required but still tracked.
42
+ # Pass `lines: false` to omit the `"lines"` key entirely, mirroring what
43
+ # `Coverage.result` reports for a file loaded under a branch-only or
44
+ # method-only run. Emitting zeroed lines there would make a simulated
45
+ # file indistinguishable from one a sibling process actually loaded once
46
+ # the two are merged.
15
47
  #
16
48
  # @return [Hash]
17
49
  #
18
- def call(absolute_path)
19
- lines = File.foreach(absolute_path)
20
-
21
- {
22
- "lines" => LinesClassifier.new.classify(lines),
23
- # we don't want to parse branches ourselves...
24
- # requiring files can have side effects and we don't want to trigger that
25
- "branches" => {}
26
- }
50
+ def call(absolute_path, synthesize: true, lines: true)
51
+ source_lines = read_lines(absolute_path)
52
+ simulated = synthesized_tuples(source_lines, synthesize)
53
+ return simulated unless lines
54
+
55
+ classified = coverage_stub(absolute_path, source_lines) ||
56
+ LinesClassifier.new.classify(source_lines)
57
+ {"lines" => classified}.merge(simulated)
58
+ end
59
+
60
+ # The branch and method tuples for a file, or empty hashes when the static
61
+ # analysis is skipped (nothing enabled reads them) or unavailable (no Prism,
62
+ # or the file doesn't parse).
63
+ def synthesized_tuples(source_lines, synthesize)
64
+ empty = {"branches" => {}, "methods" => {}} #: Hash[String, Hash[untyped, untyped]]
65
+ synthesized = (StaticCoverageExtractor.call(source_lines.join) if synthesize) || empty
66
+
67
+ {"branches" => synthesized["branches"], "methods" => synthesized["methods"]}
68
+ end
69
+
70
+ # SystemCallError, not just ENOENT: a `track_files` glob can sweep
71
+ # up an unreadable file or a directory named like a Ruby file
72
+ # (EACCES, EISDIR), and simulation must degrade to "empty file"
73
+ # rather than crash the merge or report step.
74
+ def read_lines(path)
75
+ File.readlines(path)
76
+ rescue SystemCallError
77
+ []
78
+ end
79
+
80
+ # Combine `Coverage.line_stub` (which gets multi-line statements right)
81
+ # with `LinesClassifier` (which knows about `# :nocov:` toggles and
82
+ # `# simplecov:disable line` ranges). Returns nil — and the caller
83
+ # falls back to `LinesClassifier` alone — when `Coverage` can't read
84
+ # or parse the file, or when the runtime doesn't expose `line_stub`
85
+ # (JRuby and TruffleRuby).
86
+ def coverage_stub(path, source_lines)
87
+ return nil unless Coverage.respond_to?(:line_stub)
88
+
89
+ stub = Coverage.line_stub(path)
90
+ classifier_output = LinesClassifier.new.classify(source_lines)
91
+ stub.each_index { |idx| stub[idx] = nil if classifier_output[idx].nil? }
92
+ stub
93
+ rescue SystemCallError, SyntaxError
94
+ # SystemCallError for the same reason as read_lines above:
95
+ # line_stub reads the file itself, so EACCES/EISDIR surface here
96
+ # too.
97
+ nil
27
98
  end
28
99
  end
29
100
  end
@@ -8,7 +8,6 @@ module SimpleCov
8
8
  class Branch
9
9
  attr_reader :start_line, :end_line, :coverage, :type
10
10
 
11
- # rubocop:disable Metrics/ParameterLists
12
11
  def initialize(start_line:, end_line:, coverage:, inline:, type:)
13
12
  @start_line = start_line
14
13
  @end_line = end_line
@@ -17,7 +16,6 @@ module SimpleCov
17
16
  @type = type
18
17
  @skipped = false
19
18
  end
20
- # rubocop:enable Metrics/ParameterLists
21
19
 
22
20
  def inline?
23
21
  @inline
@@ -33,7 +31,7 @@ module SimpleCov
33
31
  end
34
32
 
35
33
  #
36
- # Check if branche missed or not
34
+ # Check if branch missed or not
37
35
  #
38
36
  # @return [Boolean]
39
37
  #
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Builds the `SourceFile::Branch` objects for a source file from
6
+ # the raw branch data Ruby's Coverage library reports. Applies the
7
+ # `ignore_branches :eval_generated` / `:implicit_else` filters and
8
+ # marks branches inside `# simplecov:disable` / `# :nocov:` chunks
9
+ # as skipped.
10
+ class BranchBuilder
11
+ def initialize(source_file)
12
+ @source_file = source_file
13
+ end
14
+
15
+ def call
16
+ coverage_branch_data = @source_file.coverage_data["branches"] || {}
17
+ branches = coverage_branch_data.flat_map do |condition, coverage_branches|
18
+ next [] if eval_generated_condition_to_ignore?(condition)
19
+
20
+ build_branches_from(condition, coverage_branches)
21
+ end
22
+
23
+ process_skipped(branches)
24
+ end
25
+
26
+ private
27
+
28
+ # Detect a Coverage-reported branch condition that originates from
29
+ # `eval`/`module_eval`/`class_eval`/`instance_eval` rather than from
30
+ # the file's literal source. Coverage attributes such branches to the
31
+ # caller's `__FILE__`/`__LINE__`, so a Rails `delegate :foo, to: :bar`
32
+ # call surfaces inside the source file as if there were branches at
33
+ # the `delegate` line. Prism never sees those branches in the static
34
+ # source, so a condition whose start_line isn't in the real-source
35
+ # branch set must be eval-generated. Only consulted when the user has
36
+ # opted in via `SimpleCov.ignore_branches :eval_generated`. See #1046.
37
+ def eval_generated_condition_to_ignore?(condition)
38
+ return false unless SimpleCov.ignored_branch?(:eval_generated)
39
+
40
+ positions = @source_file.real_source_positions
41
+ # simplecov:disable branch — nil branch fires only when Prism is unavailable
42
+ return false unless positions
43
+
44
+ # simplecov:enable branch
45
+
46
+ _type, _id, start_line, * = RubyDataParser.call(condition)
47
+ !positions[:branches].include?(start_line)
48
+ end
49
+
50
+ def build_branches_from(condition, branches)
51
+ # the format handed in from the coverage data is like this:
52
+ #
53
+ # [:then, 4, 6, 6, 6, 10]
54
+ #
55
+ # which is [type, id, start_line, start_col, end_line, end_col]
56
+ _condition_type, _condition_id, *condition_range = RubyDataParser.call(condition)
57
+
58
+ branches.filter_map do |branch_data, hit_count|
59
+ build_branch(RubyDataParser.call(branch_data), hit_count, condition_range)
60
+ end
61
+ end
62
+
63
+ def build_branch(branch_data, hit_count, condition_range)
64
+ type, _id, start_line, start_col, end_line, end_col = branch_data
65
+ return nil if implicit_else_to_ignore?(type, [start_line, start_col, end_line, end_col], condition_range)
66
+
67
+ SourceFile::Branch.new(
68
+ start_line: start_line,
69
+ end_line: end_line,
70
+ coverage: hit_count,
71
+ inline: start_line == condition_range.first,
72
+ type: type
73
+ )
74
+ end
75
+
76
+ # Detect synthetic `:else` branches that Ruby's Coverage library reports
77
+ # for constructs with no literal `else` keyword in source (`case/in` /
78
+ # `case/when` without else, `||=`, `&&=`, `if`/`unless` without else,
79
+ # and the postfix `return if cond` shape). The signal is structural:
80
+ # a synthetic else reuses its parent condition's *full source range*
81
+ # (start_line, start_col, end_line, end_col all identical), while an
82
+ # explicit `else` arm carries a narrower range — its own keyword/body
83
+ # position rather than the whole conditional. Comparing the full range
84
+ # (not just `start_line`) is what distinguishes a ternary's explicit
85
+ # else on the same line as the condition — `arg == 42 ? :yes : :no`,
86
+ # where the else's columns differ from the parent's — from a postfix
87
+ # `return if cond` where the synthetic else inherits the full range.
88
+ # Only consulted when the user has opted in via
89
+ # `SimpleCov.ignore_branches :implicit_else`. See #1033.
90
+ def implicit_else_to_ignore?(type, branch_range, condition_range)
91
+ return false unless type == :else
92
+ return false unless SimpleCov.ignored_branch?(:implicit_else)
93
+
94
+ branch_range == condition_range
95
+ end
96
+
97
+ def process_skipped(branches)
98
+ chunks = @source_file.skip_chunks_for(:branch)
99
+ return branches if chunks.empty?
100
+
101
+ # A non-inline branch's source range starts on its arm body (e.g. the
102
+ # `:yes` line of `if cond / :yes / else / :no / end`), but `report_line`
103
+ # is the condition line above it — that's where the user sees the
104
+ # branch in the report and where they would naturally place an inline
105
+ # `# simplecov:disable branch` directive. Honour both.
106
+ branches.each do |branch|
107
+ branch.skipped! if chunks.any? { |chunk| branch.overlaps_with?(chunk) || chunk.include?(branch.report_line) }
108
+ end
109
+
110
+ branches
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Helpers shared by the per-criterion builders (LineBuilder /
6
+ # BranchBuilder / MethodBuilder). Mixed into SourceFile so each
7
+ # builder can ask the file for its skip-chunk ranges and Prism-derived
8
+ # real source positions without duplicating the memoization.
9
+ module BuilderContext
10
+ # Skip-chunk lookup for the named criterion (`:line`, `:branch`,
11
+ # `:method`).
12
+ def skip_chunks_for(criterion)
13
+ (@skip_chunks ||= SkipChunks.new(filename, src)).for(criterion)
14
+ end
15
+
16
+ # Memoized set of real source positions (branch start lines, method
17
+ # name+line pairs) extracted via Prism. Returns nil when Prism is
18
+ # unavailable or parsing fails, signaling callers to keep every
19
+ # Coverage entry (no false drops). The `defined?` guard preserves a
20
+ # nil memoization across calls.
21
+ def real_source_positions
22
+ return @real_source_positions if defined?(@real_source_positions)
23
+
24
+ @real_source_positions = StaticCoverageExtractor.real_source_positions(src.join)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -25,7 +25,9 @@ module SimpleCov
25
25
  def initialize(src, line_number, coverage)
26
26
  raise ArgumentError, "Only String accepted for source" unless src.is_a?(String)
27
27
  raise ArgumentError, "Only Integer accepted for line_number" unless line_number.is_a?(Integer)
28
- raise ArgumentError, "Only Integer and nil accepted for coverage" unless coverage.is_a?(Integer) || coverage.nil?
28
+ unless coverage.is_a?(Integer) || coverage.nil?
29
+ raise ArgumentError, "Only Integer and nil accepted for coverage"
30
+ end
29
31
 
30
32
  @src = src
31
33
  @line_number = line_number
@@ -35,12 +37,12 @@ module SimpleCov
35
37
 
36
38
  # Returns true if this is a line that should have been covered, but was not
37
39
  def missed?
38
- !never? && !skipped? && coverage.zero?
40
+ !never? && !skipped? && coverage.to_i.zero?
39
41
  end
40
42
 
41
43
  # Returns true if this is a line that has been covered
42
44
  def covered?
43
- !never? && !skipped? && coverage.positive?
45
+ !never? && !skipped? && coverage.to_i.positive?
44
46
  end
45
47
 
46
48
  # Returns true if this line is not relevant for coverage
@@ -65,7 +67,10 @@ module SimpleCov
65
67
  return "skipped" if skipped?
66
68
  return "never" if never?
67
69
  return "missed" if missed?
68
- return "covered" if covered?
70
+
71
+ # simplecov:disable — defensive: covered? is the only state left after the three above
72
+ "covered" if covered?
73
+ # simplecov:enable
69
74
  end
70
75
  end
71
76
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Builds the `SourceFile::Line` objects for a source file from the
6
+ # raw line-coverage array. Each line carries its source text, its
7
+ # 1-based line number, and the Coverage hit count (or nil for
8
+ # never-counted lines). Applies `# simplecov:disable` /
9
+ # `# :nocov:` block ranges via `skipped!`.
10
+ class LineBuilder
11
+ def initialize(source_file)
12
+ @source_file = source_file
13
+ end
14
+
15
+ def call
16
+ lines = build_lines
17
+ mark_skipped(lines, @source_file.skip_chunks_for(:line))
18
+ lines
19
+ end
20
+
21
+ private
22
+
23
+ # When `:line` coverage is disabled, the Ruby Coverage module
24
+ # doesn't emit "lines" data, so look up `nil` (never-counted) for
25
+ # every position. The source rows are still useful — e.g. for the
26
+ # HTML report's source view — even without per-line hits.
27
+ def build_lines
28
+ line_coverage = @source_file.coverage_data["lines"] || []
29
+ @source_file.src.map.with_index(1) do |src, i|
30
+ SourceFile::Line.new(src, i, line_coverage[i - 1])
31
+ end
32
+ end
33
+
34
+ # The array the lines are kept in is 0-based whereas the line
35
+ # numbers in the chunks are 1-based (more understandable elsewhere),
36
+ # so each range needs to be shifted down by one to slice into the
37
+ # `lines` array.
38
+ def mark_skipped(lines, chunks)
39
+ chunks.each { |chunk| lines[(chunk.begin - 1)..(chunk.end - 1)].each(&:skipped!) }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Represents a single method detected in coverage data.
6
+ # Provides helpers similar to Branch for coverage status.
7
+ class Method
8
+ attr_reader :source_file, :coverage, :class_name, :method_name,
9
+ :start_line, :start_col, :end_line, :end_col
10
+
11
+ def initialize(source_file, info, coverage)
12
+ @source_file = source_file
13
+ @class_name, @method_name, @start_line, @start_col, @end_line, @end_col = info
14
+ @coverage = coverage
15
+ end
16
+
17
+ def covered?
18
+ !skipped? && coverage.positive?
19
+ end
20
+
21
+ # Criterion-level skips (nocov chunks, `# simplecov:disable` /
22
+ # `# simplecov:disable method` regions) arrive via `skipped!` from
23
+ # MethodBuilder. Deliberately NOT derived from the lines' skip
24
+ # state: a line-only directive around a def must not remove the
25
+ # method from method totals. Without line info there is nothing to
26
+ # report against, so such a method stays skipped.
27
+ def skipped?
28
+ return @skipped if defined?(@skipped)
29
+
30
+ @skipped = lines.empty?
31
+ end
32
+
33
+ # Flag the method as skipped directly, without going through its lines.
34
+ def skipped!
35
+ @skipped = true
36
+ end
37
+
38
+ def missed?
39
+ !skipped? && coverage.zero?
40
+ end
41
+
42
+ def lines
43
+ @lines ||= start_line && end_line ? source_file.lines[(start_line - 1)..(end_line - 1)] || [] : []
44
+ end
45
+
46
+ # Whether this method's source range intersects the given inclusive line range.
47
+ def overlaps_with?(line_range)
48
+ return false unless start_line && end_line
49
+
50
+ start_line <= line_range.end && end_line >= line_range.begin
51
+ end
52
+
53
+ def to_s
54
+ "#{class_name}##{method_name}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Builds the `SourceFile::Method` objects for a source file from
6
+ # the raw method-coverage data. Applies the
7
+ # `ignore_methods :eval_generated` filter and marks methods inside
8
+ # `# simplecov:disable` / `# :nocov:` chunks as skipped.
9
+ class MethodBuilder
10
+ def initialize(source_file)
11
+ @source_file = source_file
12
+ end
13
+
14
+ def call
15
+ none = {} #: Hash[untyped, Integer]
16
+ raw_methods = @source_file.coverage_data.fetch("methods", none)
17
+ methods = raw_methods.filter_map do |info, hit_count|
18
+ info = RubyDataParser.call(info)
19
+ next if eval_generated_to_ignore?(info)
20
+
21
+ SourceFile::Method.new(@source_file, info, hit_count)
22
+ end
23
+
24
+ process_skipped(methods)
25
+ end
26
+
27
+ private
28
+
29
+ # See `BranchBuilder#eval_generated_condition_to_ignore?` for the
30
+ # rationale. Coverage reports an eval'd `def` at the eval caller's
31
+ # line and name, so a method whose `(name, start_line)` is absent
32
+ # from the real-source `def` set is eval-generated. Only consulted
33
+ # when the user has opted in via
34
+ # `SimpleCov.ignore_methods :eval_generated`. See #1046.
35
+ def eval_generated_to_ignore?(info)
36
+ return false unless SimpleCov.ignored_method?(:eval_generated)
37
+
38
+ positions = @source_file.real_source_positions
39
+ # simplecov:disable branch — nil branch fires only when Prism is unavailable
40
+ return false unless positions
41
+
42
+ # simplecov:enable branch
43
+
44
+ _class_name, name, start_line, * = info
45
+ !positions[:methods].include?([name, start_line])
46
+ end
47
+
48
+ def process_skipped(methods)
49
+ chunks = @source_file.skip_chunks_for(:method)
50
+ return methods if chunks.empty?
51
+
52
+ methods.each do |method|
53
+ method.skipped! if chunks.any? { |chunk| method.overlaps_with?(chunk) }
54
+ end
55
+
56
+ methods
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module SimpleCov
6
+ class SourceFile
7
+ # `Coverage.result` reports condition and method keys as Ruby
8
+ # arrays. When the resultset is round-tripped through JSON those
9
+ # array keys become their stringified inspect form, so this parser
10
+ # walks the literal back into a real Array without using `eval` (see
11
+ # #801). The grammar covers symbols, strings, integers, unary minus,
12
+ # and constant paths — every shape Coverage ever emits.
13
+ module RubyDataParser
14
+ module_function
15
+
16
+ # Tests use the real data structures (except for integration tests)
17
+ # so no need to put them through here.
18
+ #
19
+ # String parses are memoized: `Combine::BranchesCombiner` and
20
+ # `Combine::MethodsCombiner` derive a merge identity from every key of
21
+ # both sides on every pairwise merge, so collating N resultsets parses
22
+ # each key string N-1 times — and Ripper dominates the wall time of a
23
+ # large collate.
24
+ # Key strings repeat across folds and within report building, while the
25
+ # set of unique keys is bounded by the project's branch and method
26
+ # count, so a permanent cache stays small. Cached arrays are frozen
27
+ # element-wise (String class names included, not just the tuple):
28
+ # every caller destructures without mutating, and sharing one array
29
+ # across callers must stay that way. The cache key needs no such
30
+ # care — `Hash#[]=` dups and freezes String keys on its own.
31
+ def call(structure)
32
+ return structure if structure.is_a?(Array)
33
+
34
+ string = structure.to_s
35
+ parse_cache[string] ||= parse_array_string(string).each(&:freeze).freeze
36
+ end
37
+
38
+ def parse_cache
39
+ @parse_cache ||= {} #: Hash[String, untyped]
40
+ end
41
+
42
+ # Parse a string like '[:if, 0, 3, 4, 3, 21]' or
43
+ # '["ClassName", :method1, 2, 2, 5, 5]' back into a Ruby array.
44
+ def parse_array_string(str)
45
+ # Try plain Ripper first; only pre-quote `#<...>` inspect segments
46
+ # if the input isn't already valid Ruby (otherwise we corrupt
47
+ # `"#<Class:Foo>"` strings that *are* valid Ruby literals — exactly
48
+ # the shape simplecov-on-simplecov method-coverage keys take).
49
+ sexp = Ripper.sexp(str) || Ripper.sexp(quote_inspected_class_segments(str))
50
+ # simplecov:disable — defensive: Ripper.sexp returning nil from both passes requires malformed input
51
+ array_node = sexp&.dig(1, 0)
52
+ # simplecov:enable
53
+ raise ArgumentError, "expected array literal: #{str.inspect}" unless array_node && array_node[0] == :array
54
+
55
+ Array(array_node[1]).map { |element| parse_element(element) }
56
+ end
57
+
58
+ def parse_element(node)
59
+ case node[0]
60
+ when :@int, :unary then parse_integer_node(node)
61
+ when :symbol_literal, :dyna_symbol then parse_symbol_node(node)
62
+ when :string_literal then unescape_ruby(string_literal_text(node[1]))
63
+ when :var_ref then node.dig(1, 1) # `Foo`
64
+ when :const_path_ref then "#{parse_element(node[1])}::#{node[2][1]}" # `Foo::Bar`
65
+ else
66
+ # simplecov:disable — defensive fallback for unexpected Ripper node shapes
67
+ raise ArgumentError, "unexpected element: #{node.inspect}"
68
+ # simplecov:enable
69
+ end
70
+ end
71
+
72
+ def parse_integer_node(node)
73
+ node[0] == :@int ? node[1].to_i : -node[2][1].to_i
74
+ end
75
+
76
+ def parse_symbol_node(node)
77
+ if node[0] == :symbol_literal
78
+ node.dig(1, 1, 1).to_sym
79
+ else
80
+ unescape_ruby(string_literal_text(node[1])).to_sym
81
+ end
82
+ end
83
+
84
+ # Concatenate the text fragments of a `:string_content` node. Ripper
85
+ # may emit zero, one, or many `:@tstring_content` children depending
86
+ # on the literal.
87
+ def string_literal_text(string_content)
88
+ Array(string_content[1..]).map { |child| child[1] }.join
89
+ end
90
+
91
+ # Undo the same backslash-prefix escapes the previous hand-rolled
92
+ # parser undid: `\X` → `X` for any X.
93
+ def unescape_ruby(raw)
94
+ raw.gsub(/\\(.)/) { ::Regexp.last_match(1) }
95
+ end
96
+
97
+ # Method coverage keys can contain inspect-format class references
98
+ # like `#<Class:Foo>` or `#<Class:0x...>`, which aren't valid Ruby
99
+ # syntax. Wrap them in quotes so Ripper can parse the surrounding
100
+ # array literal; downstream we treat them as opaque strings. The
101
+ # pattern recurses because singleton methods on instances nest one
102
+ # inspect segment inside another (`#<Class:#<Object:0x...>>`), and
103
+ # stopping at the first `>` leaves a dangling `>` that fails both
104
+ # Ripper passes and crashes the merge.
105
+ def quote_inspected_class_segments(str)
106
+ str.gsub(/#<(?:[^<>]|\g<0>)*>/) { |segment| %("#{segment.gsub('"', '\\"')}") }
107
+ end
108
+ end
109
+ end
110
+ end