simplecov 1.0.2 → 1.1.0

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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1425
  3. data/lib/simplecov/atomic_file.rb +70 -0
  4. data/lib/simplecov/cli/clean.rb +43 -3
  5. data/lib/simplecov/cli/command_helpers.rb +55 -0
  6. data/lib/simplecov/cli/coverage.rb +21 -28
  7. data/lib/simplecov/cli/coverage_file.rb +65 -0
  8. data/lib/simplecov/cli/diff.rb +43 -48
  9. data/lib/simplecov/cli/dotfile.rb +12 -6
  10. data/lib/simplecov/cli/merge.rb +12 -10
  11. data/lib/simplecov/cli/open.rb +3 -5
  12. data/lib/simplecov/cli/report.rb +31 -23
  13. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  14. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  15. data/lib/simplecov/cli/serve.rb +30 -94
  16. data/lib/simplecov/cli/uncovered.rb +20 -25
  17. data/lib/simplecov/cli.rb +15 -2
  18. data/lib/simplecov/combine/branches_combiner.rb +43 -19
  19. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  20. data/lib/simplecov/combine/identity_interner.rb +30 -0
  21. data/lib/simplecov/combine/interned_counts.rb +30 -0
  22. data/lib/simplecov/combine/lines_combiner.rb +48 -20
  23. data/lib/simplecov/combine/methods_combiner.rb +46 -21
  24. data/lib/simplecov/combine/results_combiner.rb +12 -37
  25. data/lib/simplecov/combine.rb +5 -23
  26. data/lib/simplecov/command_guesser.rb +66 -9
  27. data/lib/simplecov/configuration/coverage.rb +12 -15
  28. data/lib/simplecov/configuration/coverage_criteria.rb +34 -38
  29. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  30. data/lib/simplecov/configuration/filters.rb +11 -44
  31. data/lib/simplecov/configuration/formatting.rb +18 -9
  32. data/lib/simplecov/configuration/groups.rb +42 -0
  33. data/lib/simplecov/configuration/merging.rb +8 -7
  34. data/lib/simplecov/configuration/thresholds.rb +14 -13
  35. data/lib/simplecov/configuration.rb +13 -43
  36. data/lib/simplecov/coverage_json.rb +24 -0
  37. data/lib/simplecov/coverage_statistics.rb +1 -1
  38. data/lib/simplecov/coverage_violations.rb +15 -5
  39. data/lib/simplecov/defaults.rb +7 -3
  40. data/lib/simplecov/directive.rb +1 -1
  41. data/lib/simplecov/exit_codes/check.rb +33 -0
  42. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
  43. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +3 -16
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
  45. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
  46. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +4 -17
  47. data/lib/simplecov/exit_codes.rb +1 -0
  48. data/lib/simplecov/exit_handling.rb +11 -41
  49. data/lib/simplecov/file_list.rb +5 -10
  50. data/lib/simplecov/filter.rb +43 -9
  51. data/lib/simplecov/formatter/base.rb +11 -0
  52. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  53. data/lib/simplecov/formatter/html_formatter/public/index.html +35 -5
  54. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  55. data/lib/simplecov/formatter/html_formatter.rb +67 -47
  56. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -56
  57. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
  58. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
  59. data/lib/simplecov/formatter/json_formatter.rb +5 -48
  60. data/lib/simplecov/formatter/multi_formatter.rb +1 -1
  61. data/lib/simplecov/formatter/simple_formatter.rb +8 -5
  62. data/lib/simplecov/formatter.rb +12 -0
  63. data/lib/simplecov/group_names.rb +32 -0
  64. data/lib/simplecov/last_run.rb +16 -9
  65. data/lib/simplecov/lines_classifier.rb +29 -8
  66. data/lib/simplecov/load_global_config.rb +5 -2
  67. data/lib/simplecov/parallel_adapters/base.rb +17 -0
  68. data/lib/simplecov/parallel_adapters/generic.rb +2 -2
  69. data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
  70. data/lib/simplecov/parallel_coordination.rb +6 -1
  71. data/lib/simplecov/parallel_result_merger.rb +230 -0
  72. data/lib/simplecov/report_deferral.rb +49 -0
  73. data/lib/simplecov/report_stamp.rb +28 -0
  74. data/lib/simplecov/result.rb +40 -10
  75. data/lib/simplecov/result_adapter.rb +48 -16
  76. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  77. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  78. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  79. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  80. data/lib/simplecov/result_merger.rb +69 -42
  81. data/lib/simplecov/result_processing.rb +82 -43
  82. data/lib/simplecov/run_identity.rb +77 -0
  83. data/lib/simplecov/simulate_coverage.rb +35 -11
  84. data/lib/simplecov/source_file/method.rb +7 -1
  85. data/lib/simplecov/source_file/ruby_data_parser.rb +25 -3
  86. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  87. data/lib/simplecov/source_file/source_loader.rb +23 -7
  88. data/lib/simplecov/source_file/statistics.rb +24 -16
  89. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
  90. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
  91. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  92. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  93. data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
  94. data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
  95. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  96. data/lib/simplecov/version.rb +1 -1
  97. data/lib/simplecov.rb +11 -5
  98. data/schemas/coverage-v1.0.schema.json +2 -2
  99. data/schemas/coverage.schema.json +2 -2
  100. data/sig/simplecov.rbs +206 -67
  101. metadata +28 -14
  102. data/doc/alternate-formatters.md +0 -66
  103. data/doc/commercial-services.md +0 -25
  104. data/doc/editor-integration.md +0 -18
  105. data/lib/simplecov/combine/files_combiner.rb +0 -70
  106. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  107. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  108. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  109. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  110. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
@@ -17,17 +17,27 @@ module SimpleCov
17
17
  lines = [] #: Array[String]
18
18
  # The default encoding is UTF-8
19
19
  File.open(filename, "rb:UTF-8") do |file|
20
- current_line = file.gets
20
+ current_line = scrub_invalid(file.gets)
21
21
 
22
22
  if current_line && shebang?(current_line)
23
23
  lines << current_line
24
- current_line = file.gets
24
+ current_line = scrub_invalid(file.gets)
25
25
  end
26
26
 
27
27
  read_lines(file, lines, current_line)
28
28
  end
29
29
  end
30
30
 
31
+ # A line read as UTF-8 can still carry invalid bytes (a Latin-1
32
+ # source file without a magic comment, say). Replace them before
33
+ # any regex sees the line: the shebang and magic-comment checks
34
+ # would otherwise raise ArgumentError and take the report down.
35
+ def scrub_invalid(line)
36
+ return line if line.nil? || line.valid_encoding?
37
+
38
+ line.scrub
39
+ end
40
+
31
41
  def shebang?(line)
32
42
  SHEBANG_REGEX.match?(line)
33
43
  end
@@ -47,14 +57,20 @@ module SimpleCov
47
57
  end
48
58
  end
49
59
 
50
- # invalid/undef replace are technically not really necessary but
51
- # nice to have and work around a JRuby incompatibility. Setting
52
- # these options on `file.set_encoding` doesn't seem to work
53
- # properly, so it has to be done here.
60
+ # Guarantee every line leaves the loader as valid UTF-8, replacing
61
+ # what can't be represented: transcode non-UTF-8 lines (setting
62
+ # invalid/undef options on `file.set_encoding` doesn't work
63
+ # properly, and this also works around a JRuby incompatibility)
64
+ # and scrub UTF-8-tagged lines that carry invalid bytes, which
65
+ # would otherwise raise from every regex the classifier runs.
54
66
  def ensure_remove_undefs(file_lines)
55
67
  file_lines.each do |line|
56
68
  # simplecov:disable — defensive: only fires for non-UTF-8 source files
57
- line.encode!("UTF-8", invalid: :replace, undef: :replace) unless line.encoding == Encoding::UTF_8
69
+ if line.encoding == Encoding::UTF_8
70
+ line.scrub! unless line.valid_encoding?
71
+ else
72
+ line.encode!("UTF-8", invalid: :replace, undef: :replace)
73
+ end
58
74
  # simplecov:enable
59
75
  end
60
76
  end
@@ -22,35 +22,43 @@ module SimpleCov
22
22
  private
23
23
 
24
24
  def line_statistics
25
- sf = @source_file
26
25
  {
27
- line: CoverageStatistics.new(
28
- total_strength: sf.lines.sum { |line| line.coverage.to_i },
29
- covered: sf.covered_lines.size,
30
- missed: sf.missed_lines.size,
31
- omitted: sf.never_lines.size
26
+ line: coverage_statistics(
27
+ @source_file.covered_lines,
28
+ @source_file.missed_lines,
29
+ omitted: @source_file.never_lines.size
32
30
  )
33
31
  }
34
32
  end
35
33
 
34
+ # Files added via track_files but never loaded have no branch/method
35
+ # data. Report 0% instead of the empty-set default of 100% (see #902).
36
36
  def branch_statistics
37
37
  sf = @source_file
38
- # Files added via track_files but never loaded/required have no
39
- # branch data. Report 0% instead of misleading 100% (see #902).
40
- if sf.not_loaded? && sf.covered_branches.empty? && sf.missed_branches.empty?
41
- return {branch: CoverageStatistics.new(covered: 0, missed: 0, percent: 0.0)}
42
- end
38
+ covered = sf.covered_branches
39
+ missed = sf.missed_branches
40
+ percent = 0.0 if sf.not_loaded? && covered.empty? && missed.empty?
43
41
 
44
- {branch: CoverageStatistics.new(covered: sf.covered_branches.size, missed: sf.missed_branches.size)}
42
+ {branch: coverage_statistics(covered, missed, percent: percent)}
45
43
  end
46
44
 
47
45
  def method_statistics
48
46
  sf = @source_file
49
- if sf.not_loaded? && sf.covered_methods.empty? && sf.missed_methods.empty?
50
- return {method: CoverageStatistics.new(covered: 0, missed: 0, percent: 0.0)}
51
- end
47
+ covered = sf.covered_methods
48
+ missed = sf.missed_methods
49
+ percent = 0.0 if sf.not_loaded? && covered.empty? && missed.empty?
50
+
51
+ {method: coverage_statistics(covered, missed, percent: percent)}
52
+ end
52
53
 
53
- {method: CoverageStatistics.new(covered: sf.covered_methods.size, missed: sf.missed_methods.size)}
54
+ def coverage_statistics(covered, missed, omitted: 0, percent: nil)
55
+ CoverageStatistics.new(
56
+ total_strength: covered.sum { |item| item.coverage.to_i },
57
+ covered: covered.size,
58
+ missed: missed.size,
59
+ omitted: omitted,
60
+ percent: percent
61
+ )
54
62
  end
55
63
  end
56
64
  end
@@ -9,41 +9,231 @@ module SimpleCov
9
9
  # phantom that no loaded run can ever hit, the same unmergeable-tuple
10
10
  # failure mode as #1226 / #1233.
11
11
  module ConditionFolding
12
+ # CRuby 3.4 rebuilt the fold on the Prism compiler, and the
13
+ # parse.y-based fold it replaced differed in three observable ways,
14
+ # each pinned by the runtime tuple equivalence battery on CI:
15
+ # `__FILE__` folded on 3.2/3.3 but no longer does; parentheses were
16
+ # transparent for every literal on 3.2 (opacity starts at 3.3); and
17
+ # on 3.2 the dead arm's branch table entries survive the fold —
18
+ # parse.y instrumented branches before eliminating dead code — while
19
+ # its `def`s still never register.
20
+ FOLDS_SOURCE_FILE = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")
21
+ PARENS_ALWAYS_TRANSPARENT = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.3")
22
+ DEAD_ARM_BRANCHES_SURVIVE = PARENS_ALWAYS_TRANSPARENT
23
+ # Container literals in discarded position are eliminated from 3.3
24
+ # on, but the contents rule differs: 3.3's compile.c elides a
25
+ # container whose contents are merely effect-free (`[x]`, `[self]`),
26
+ # while the Prism compiler (3.4+) demands fully static literals
27
+ # (`[1]` goes, `[x]` stays). See `static_container_literal?`.
28
+ CONTAINER_CONTENTS_NEED_STATIC_LITERALS = !FOLDS_SOURCE_FILE
29
+
12
30
  # Prism node types for the literals that fold. `while` / `until` do
13
31
  # NOT fold (`while true` is a real branch), so only the if-like
14
32
  # visitors consult this. Regexp and Range literals are excluded on
15
33
  # purpose: as conditions they mean `=~ $_` / flip-flop, which
16
- # Coverage does branch on.
34
+ # Coverage does branch on. `[]`, `{}`, and interpolated strings do
35
+ # not fold either, and `->` folds while a `lambda` call does not —
36
+ # the compiler only folds what it can prove at compile time, and a
37
+ # method named `lambda` proves nothing.
38
+ # simplecov:disable branch — which arm runs is fixed by the running Ruby's version
17
39
  STATIC_CONDITION_TYPES = [
18
40
  ::Prism::IntegerNode, ::Prism::FloatNode, ::Prism::RationalNode,
19
41
  ::Prism::ImaginaryNode, ::Prism::SymbolNode, ::Prism::StringNode,
20
- ::Prism::TrueNode, ::Prism::FalseNode, ::Prism::NilNode
42
+ ::Prism::TrueNode, ::Prism::FalseNode, ::Prism::NilNode,
43
+ ::Prism::SourceLineNode, ::Prism::SourceEncodingNode, ::Prism::LambdaNode,
44
+ *(::Prism::SourceFileNode if FOLDS_SOURCE_FILE)
45
+ ].freeze
46
+ # simplecov:enable branch
47
+
48
+ # The literals whose fold eliminates the *then* side: `if false` /
49
+ # `if nil` keep only the else arm. Every other folded literal is
50
+ # truthy and keeps only the then arm.
51
+ FALSY_CONDITION_TYPES = [::Prism::FalseNode, ::Prism::NilNode].freeze
52
+
53
+ # The literals whose fold does NOT see through parentheses: CRuby
54
+ # folds `if nil`, `if "x"`, and `if -> {}` but keeps a real branch
55
+ # for `if (nil)`, `if ("x")`, and `if (-> {})` — verified against
56
+ # Coverage, and pinned by the runtime tuple equivalence battery —
57
+ # while every other literal folds parenthesized or not. `__FILE__`
58
+ # is opaque like other strings on the Rubies that fold it at all.
59
+ # Consulted only when PARENS_ALWAYS_TRANSPARENT is false.
60
+ PAREN_OPAQUE_TYPES = [
61
+ ::Prism::NilNode, ::Prism::StringNode, ::Prism::LambdaNode, ::Prism::SourceFileNode
62
+ ].freeze
63
+
64
+ # The scalar literals the compiler treats as fully static: a
65
+ # multi-statement paren condition (`if (1; 2)`) folds by its last
66
+ # expression only when every leading statement is eliminated when
67
+ # discarded, and these — bare or composing an Array/Hash/Range —
68
+ # always are. Pinned against real Coverage on 3.2 through 4.0.
69
+ STATIC_LITERAL_LEAF_TYPES = [
70
+ ::Prism::IntegerNode, ::Prism::FloatNode, ::Prism::RationalNode,
71
+ ::Prism::ImaginaryNode, ::Prism::StringNode, ::Prism::SymbolNode,
72
+ ::Prism::TrueNode, ::Prism::FalseNode, ::Prism::NilNode,
73
+ ::Prism::SourceLineNode, ::Prism::SourceFileNode, ::Prism::SourceEncodingNode, ::Prism::RegularExpressionNode
74
+ ].freeze
75
+
76
+ # Non-literal reads that are also eliminated when discarded.
77
+ # `self` is eliminated by every supported compiler; local/ivar/
78
+ # defined? elimination arrived with the Prism-era compilers.
79
+ # Anything that can raise or run hooks (constants, globals, calls,
80
+ # writes) is never eliminated and keeps the branch real.
81
+ PRISM_ERA_ELIMINABLE_READS = [
82
+ ::Prism::LocalVariableReadNode, ::Prism::InstanceVariableReadNode, ::Prism::DefinedNode
83
+ ].freeze
84
+
85
+ # simplecov:disable branch — which arm runs is fixed by the running Ruby's version
86
+ ELIMINABLE_READ_TYPES = [
87
+ ::Prism::SelfNode,
88
+ *(PRISM_ERA_ELIMINABLE_READS unless PARENS_ALWAYS_TRANSPARENT)
21
89
  ].freeze
90
+ # simplecov:enable branch
22
91
 
23
92
  private
24
93
 
25
- # Parentheses are transparent to the fold (`if (1)` folds like
26
- # `if 1`), so see through a single parenthesized expression. Compound
27
- # forms (`!true`, `true || x`) are deliberately not folded: `!` never
28
- # folds, and `||` / `&&` constant-propagation diverges across Ruby
29
- # versions, so matching it would trade a rare, version-specific gain
30
- # for real risk.
31
- def static_condition?(node)
32
- node = unwrap_parentheses(node)
33
- STATIC_CONDITION_TYPES.any? { |type| node.is_a?(type) }
94
+ # A truthy verdict keeps the first arm live, a falsy one the
95
+ # second. `visit` is nil-safe, so a missing arm just visits
96
+ # nothing. On 3.2 the dead arm's branch table entries survive the
97
+ # fold (parse.y instrumented branches before eliminating dead
98
+ # code, even inside dead `def` bodies and lambdas) while its
99
+ # methods never register, so the dead arm is visited with method
100
+ # collection suppressed.
101
+ def visit_folded_arms(verdict, truthy_arm, falsy_arm)
102
+ live, dead = verdict == :truthy ? [truthy_arm, falsy_arm] : [falsy_arm, truthy_arm]
103
+ visit(live)
104
+ # simplecov:disable branch — the taken arm is fixed by the running Ruby's version
105
+ visit_dead_arm(dead) if DEAD_ARM_BRANCHES_SURVIVE
106
+ # simplecov:enable branch
107
+ end
108
+
109
+ # simplecov:disable — 3.2-only; unreachable on the modern dogfood Ruby
110
+ def visit_dead_arm(arm)
111
+ # Save/restore rather than set/clear: a fold nested inside this
112
+ # dead arm re-enters here (possibly with a nil arm) and must not
113
+ # clear suppression for the rest of the outer dead arm.
114
+ previous = @suppress_methods
115
+ begin
116
+ @suppress_methods = true
117
+ visit(arm)
118
+ ensure
119
+ @suppress_methods = previous
120
+ end
121
+ end
122
+ # simplecov:enable
123
+
124
+ # The compiler's verdict on a condition: `:truthy` or `:falsy` when
125
+ # it folds, nil when it doesn't. The verdict decides which arm
126
+ # survives — the compiler eliminates the other arm's entire subtree,
127
+ # so everything inside it (nested branches, methods) must go
128
+ # unvisited too, not just the folded condition's own tuple.
129
+ #
130
+ # Parentheses are transparent to the fold for most literals
131
+ # (`if (1)` folds like `if 1`) but not all — see
132
+ # PAREN_OPAQUE_TYPES. Compound forms (`!true`, `true || x`) are
133
+ # deliberately not folded: `!` never folds, and `||` / `&&`
134
+ # constant-propagation diverges across Ruby versions, so matching
135
+ # it would trade a rare, version-specific gain for real risk.
136
+ def folded_condition(node)
137
+ unwrapped = unwrap_parentheses(node)
138
+ return nil unless foldable?(node, unwrapped)
139
+
140
+ FALSY_CONDITION_TYPES.any? { |type| unwrapped.is_a?(type) } ? :falsy : :truthy
141
+ end
142
+
143
+ # A folding literal, minus the ones parentheses shield from the
144
+ # fold (`unwrapped` differing from `node` is what says parentheses
145
+ # were seen through).
146
+ def foldable?(node, unwrapped)
147
+ return false unless STATIC_CONDITION_TYPES.any? { |type| unwrapped.is_a?(type) }
148
+
149
+ # simplecov:disable — which of these lines runs is fixed by the
150
+ # running Ruby's version: on 3.2 the early return always fires and
151
+ # the opacity check below it is dead code, so it must be excluded
152
+ # from line coverage too, not only branch coverage.
153
+ return true if PARENS_ALWAYS_TRANSPARENT
154
+
155
+ unwrapped.equal?(node) || PAREN_OPAQUE_TYPES.none? { |type| unwrapped.is_a?(type) }
156
+ # simplecov:enable
34
157
  end
35
158
 
159
+ # Parentheses are transparent to the fold, and a multi-statement
160
+ # body (`if (1; 2)`) folds by its LAST expression — but only when
161
+ # the compiler eliminates every leading statement. Stopping at
162
+ # multi-statement bodies synthesized a phantom then/else pair no
163
+ # real run can ever hit.
36
164
  def unwrap_parentheses(node)
37
165
  # @type var current: untyped
38
166
  current = node
39
167
  while current.is_a?(::Prism::ParenthesesNode)
40
168
  body = current.body
41
- break unless body.is_a?(::Prism::StatementsNode) && body.body.size == 1
169
+ break unless body.is_a?(::Prism::StatementsNode) && !body.body.empty?
170
+
171
+ statements = body.body
172
+ break unless statements.take(statements.size - 1).all? { |leading| eliminable_when_discarded?(leading) }
42
173
 
43
- current = body.body.first
174
+ current = statements.last
44
175
  end
45
176
  current
46
177
  end
178
+
179
+ # Whether the compiler compiles `node` to nothing when its value is
180
+ # discarded.
181
+ def eliminable_when_discarded?(node)
182
+ return true if static_container_literal?(node)
183
+ return true if ELIMINABLE_READ_TYPES.any? { |type| node.is_a?(type) }
184
+
185
+ node.is_a?(::Prism::ParenthesesNode) &&
186
+ node.body.is_a?(::Prism::StatementsNode) &&
187
+ node.body.body.all? { |statement| eliminable_when_discarded?(statement) }
188
+ end
189
+
190
+ # A scalar literal leaf, or an Array/Hash/Range whose contents pass
191
+ # the running compiler's contents rule (see
192
+ # CONTAINER_CONTENTS_NEED_STATIC_LITERALS). Container elimination
193
+ # only exists from 3.3 on.
194
+ def static_container_literal?(node)
195
+ return true if STATIC_LITERAL_LEAF_TYPES.any? { |type| node.is_a?(type) }
196
+ # simplecov:disable — which of these lines runs is fixed by the
197
+ # running Ruby's version: parse.y (3.2) never eliminates
198
+ # container literals, so there the early return always fires and
199
+ # the dispatch below is dead code.
200
+ return false if PARENS_ALWAYS_TRANSPARENT
201
+
202
+ static_container?(node)
203
+ # simplecov:enable
204
+ end
205
+
206
+ # The container dispatch, version-free: the predicate specs
207
+ # exercise it directly on every supported Ruby, 3.2 included.
208
+ def static_container?(node)
209
+ case node
210
+ when ::Prism::ArrayNode then static_array_literal?(node)
211
+ when ::Prism::HashNode then static_hash_literal?(node)
212
+ when ::Prism::RangeNode then static_range_literal?(node)
213
+ else false
214
+ end
215
+ end
216
+
217
+ def static_array_literal?(node)
218
+ node.elements.all? { |element| container_contents_eliminable?(element) }
219
+ end
220
+
221
+ def static_hash_literal?(node)
222
+ node.elements.all? do |element|
223
+ element.is_a?(::Prism::AssocNode) &&
224
+ container_contents_eliminable?(element.key) && container_contents_eliminable?(element.value)
225
+ end
226
+ end
227
+
228
+ def static_range_literal?(node)
229
+ [node.left, node.right].all? { |bound| bound.nil? || container_contents_eliminable?(bound) }
230
+ end
231
+
232
+ def container_contents_eliminable?(node)
233
+ # simplecov:disable branch — which arm runs is fixed by the running Ruby's version
234
+ CONTAINER_CONTENTS_NEED_STATIC_LITERALS ? static_container_literal?(node) : eliminable_when_discarded?(node)
235
+ # simplecov:enable branch
236
+ end
47
237
  end
48
238
  end
49
239
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "prism_compat"
4
+
3
5
  module SimpleCov
4
6
  module StaticCoverageExtractor
5
7
  # The source ranges Ruby's Coverage assigns to branch conditions and
@@ -34,11 +36,7 @@ module SimpleCov
34
36
  def if_like_location(node, type)
35
37
  return node.location unless LEGACY_COVERAGE_LOCATIONS && type == :if && elsif_node?(node)
36
38
 
37
- content_end = legacy_content_end(node)
38
- PointLocation.new(
39
- start_line: node.location.start_line, start_column: node.location.start_column,
40
- end_line: content_end.end_line, end_column: content_end.end_column
41
- )
39
+ span(node.location, legacy_content_end(node))
42
40
  end
43
41
 
44
42
  def elsif_node?(node)
@@ -52,7 +50,7 @@ module SimpleCov
52
50
  def legacy_content_end(node)
53
51
  tail = node
54
52
  while tail.is_a?(::Prism::IfNode)
55
- sub = tail.public_send(IF_NODE_SUBSEQUENT_METHOD)
53
+ sub = PrismCompat.subsequent(tail)
56
54
  break unless sub
57
55
 
58
56
  tail = sub
@@ -76,14 +74,12 @@ module SimpleCov
76
74
  end
77
75
 
78
76
  # Resolve the source range Coverage attributes to a real-or-synthetic
79
- # `:else` arm of an if-like construct. IfNode uses
80
- # `subsequent` / `consequent` and UnlessNode `else_clause` /
81
- # `consequent`, both depending on Prism version (resolved to
82
- # `IF_NODE_SUBSEQUENT_METHOD` / `ELSE_CLAUSE_METHOD` at load time).
83
- # When neither is present, the synthesized else inherits the
84
- # condition's range (matches Coverage's convention).
77
+ # `:else` arm of an if-like construct (`PrismCompat` hides the
78
+ # per-Prism-version accessor split). When no else/elsif is present,
79
+ # the synthesized else inherits the condition's range (matches
80
+ # Coverage's convention).
85
81
  def if_like_else_location(node, type)
86
- sub = if_like_subsequent(node)
82
+ sub = PrismCompat.subsequent(node)
87
83
  return if_like_location(node, type) unless sub
88
84
  # An `elsif` arrives as a nested IfNode. Coverage attributes the
89
85
  # outer else arm to the clause's own range, not its then body
@@ -120,11 +116,7 @@ module SimpleCov
120
116
  end
121
117
 
122
118
  def legacy_when_value_location(case_node, when_node)
123
- tail_end = legacy_case_tail_end(case_node, when_node)
124
- PointLocation.new(
125
- start_line: when_node.location.start_line, start_column: when_node.location.start_column,
126
- end_line: tail_end.end_line, end_column: tail_end.end_column
127
- )
119
+ span(when_node.location, legacy_case_tail_end(case_node, when_node))
128
120
  end
129
121
 
130
122
  # The last body content in the case after `when_node`, falling
@@ -141,7 +133,7 @@ module SimpleCov
141
133
  # condition when empty), so the whole clause extends the range
142
134
  # through trailing EMPTY clauses that have no `statements`.
143
135
  content = clauses.drop(index + 1).map(&:location)
144
- else_statements = case_node.public_send(ELSE_CLAUSE_METHOD)&.statements
136
+ else_statements = PrismCompat.else_clause(case_node)&.statements
145
137
  content << else_statements.location if else_statements
146
138
  content
147
139
  end
@@ -152,7 +144,7 @@ module SimpleCov
152
144
  # explicit else with an empty body — the else..end span on modern
153
145
  # Rubies or the case's full range on legacy ones.
154
146
  def else_arm_location(node)
155
- else_clause = node.public_send(ELSE_CLAUSE_METHOD)
147
+ else_clause = PrismCompat.else_clause(node)
156
148
  return node.location unless else_clause
157
149
  return else_clause.statements.location if else_clause.statements
158
150
  return else_clause.location unless LEGACY_COVERAGE_LOCATIONS
@@ -200,10 +192,14 @@ module SimpleCov
200
192
  # This convention is the same on legacy and modern Rubies. See
201
193
  # issue #1233.
202
194
  def safe_navigation_location(node)
203
- end_loc = node.closing_loc || node.arguments&.location || node.message_loc
195
+ span(node.location, node.closing_loc || node.arguments&.location || node.message_loc)
196
+ end
197
+
198
+ # The range from `from`'s start through `to`'s end.
199
+ def span(from, to)
204
200
  PointLocation.new(
205
- start_line: node.location.start_line, start_column: node.location.start_column,
206
- end_line: end_loc.end_line, end_column: end_loc.end_column
201
+ start_line: from.start_line, start_column: from.start_column,
202
+ end_line: to.end_line, end_column: to.end_column
207
203
  )
208
204
  end
209
205
 
@@ -214,13 +210,6 @@ module SimpleCov
214
210
  )
215
211
  end
216
212
 
217
- # The `else`/`elsif` clause of an if-like node, under whichever
218
- # accessor this Prism version exposes (see the two *_METHOD
219
- # constants).
220
- def if_like_subsequent(node)
221
- node.is_a?(::Prism::IfNode) ? node.public_send(IF_NODE_SUBSEQUENT_METHOD) : node.public_send(ELSE_CLAUSE_METHOD)
222
- end
223
-
224
213
  # Whether an empty then arm collapses to a point at the predicate's
225
214
  # end. Modern Coverage does this for every `if` (but not `unless`);
226
215
  # legacy Coverage does it only in void position, for both.
@@ -22,7 +22,14 @@ module SimpleCov
22
22
  # `def name(...)` and `def self.name(...)` both produce DefNode.
23
23
  # The class context is the surrounding lexical class/module (or
24
24
  # `Object` at the top level, matching `Coverage`'s convention).
25
+ # Suppression covers 3.2's folded dead arms, where nested branches
26
+ # stay instrumented but a `def` never registers (see
27
+ # Visitor#visit_dead_arm).
25
28
  def visit_def_node(node)
29
+ # simplecov:disable branch — suppression is reachable on 3.2 only
30
+ return super if @suppress_methods
31
+
32
+ # simplecov:enable branch
26
33
  loc = node.location
27
34
  class_name = @class_stack.last || "Object"
28
35
  key = [class_name, node.name, loc.start_line, loc.start_column, loc.end_line, loc.end_column]
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # The Prism 1.3 (Dec 2024) accessor renames for the trailing clause of
6
+ # conditional nodes, resolved ONCE at load so the per-node hot paths
7
+ # stay branch-free. Ruby 3.3's stdlib Prism (0.19) predates the
8
+ # renames; 3.4+ and any installed prism gem post-dates them. Reaching
9
+ # for the modern name on 0.19 raised NoMethodError inside the
10
+ # extractor — `call` swallowed it and the whole file silently fell
11
+ # back to no simulated data (see the 1.0.2 audit).
12
+ #
13
+ # This lives in its own file, required by every consumer, because the
14
+ # constants are referenced from several extractor files and defining
15
+ # them after those files were loaded worked only while the references
16
+ # happened at call time — a load-order trap for the next editor.
17
+ module PrismCompat
18
+ module_function
19
+
20
+ # `Prism::IfNode#subsequent` was renamed from `consequent`. The
21
+ # not-taken arm on whichever Prism version we're on can't be
22
+ # exercised by our own dogfood (we only run on one Prism at a time).
23
+ # simplecov:disable
24
+ IF_NODE_SUBSEQUENT_METHOD =
25
+ if ::Prism::IfNode.method_defined?(:subsequent)
26
+ :subsequent
27
+ else
28
+ :consequent
29
+ end
30
+
31
+ # The same rename hit the `else` accessor on `UnlessNode`,
32
+ # `CaseNode`, and `CaseMatchNode` (all three: `consequent` ->
33
+ # `else_clause`). All three renamed together, so one constant
34
+ # (probed off CaseNode) covers them.
35
+ ELSE_CLAUSE_METHOD =
36
+ if ::Prism::CaseNode.method_defined?(:else_clause)
37
+ :else_clause
38
+ else
39
+ :consequent
40
+ end
41
+ # simplecov:enable
42
+
43
+ # The `else`/`elsif` clause of an if-like node (an ElseNode, or a
44
+ # nested IfNode for `elsif`), or the `else` clause of anything else
45
+ # that has one, under whichever accessor this Prism exposes.
46
+ def subsequent(node)
47
+ node.is_a?(::Prism::IfNode) ? node.public_send(IF_NODE_SUBSEQUENT_METHOD) : else_clause(node)
48
+ end
49
+
50
+ def else_clause(node)
51
+ node.public_send(ELSE_CLAUSE_METHOD)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "prism_compat"
4
+
3
5
  module SimpleCov
4
6
  module StaticCoverageExtractor
5
7
  # Ruby 3.3 value-position analysis for the extractor's legacy branch
@@ -56,26 +58,16 @@ module SimpleCov
56
58
  return [node.body] if node.is_a?(::Prism::DefNode)
57
59
  return [] unless in_value
58
60
 
61
+ # `case/in` (CaseMatchNode) is intentionally not a tail construct:
62
+ # its `in` arms and `else` both discard tail position.
59
63
  case node
60
64
  when ::Prism::StatementsNode then [node.body.last]
61
- when ::Prism::IfNode, ::Prism::UnlessNode then [node.statements, subsequent(node)]
62
- when ::Prism::CaseNode then [*node.conditions, else_clause(node)]
65
+ when ::Prism::IfNode, ::Prism::UnlessNode then [node.statements, PrismCompat.subsequent(node)]
66
+ when ::Prism::CaseNode then [*node.conditions, PrismCompat.else_clause(node)]
63
67
  when ::Prism::ElseNode, ::Prism::WhenNode, ::Prism::BeginNode, ::Prism::ProgramNode then [node.statements]
64
68
  else []
65
69
  end
66
70
  end
67
-
68
- # The `else`/`elsif` clause of an if-like node, and the `else` clause
69
- # of a case, under whichever accessor this Prism version exposes.
70
- # `case/in` (CaseMatchNode) is intentionally not a tail construct: its
71
- # `in` arms and `else` both discard tail position.
72
- def subsequent(node)
73
- node.is_a?(::Prism::IfNode) ? node.public_send(IF_NODE_SUBSEQUENT_METHOD) : else_clause(node)
74
- end
75
-
76
- def else_clause(node)
77
- node.public_send(ELSE_CLAUSE_METHOD)
78
- end
79
71
  # simplecov:enable
80
72
  end
81
73
  end