simplecov 0.22.0 → 1.0.2

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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +1058 -508
  4. data/doc/alternate-formatters.md +0 -5
  5. data/doc/commercial-services.md +5 -5
  6. data/exe/simplecov +11 -0
  7. data/lib/minitest/simplecov_plugin.rb +13 -5
  8. data/lib/simplecov/autostart.rb +11 -0
  9. data/lib/simplecov/cli/clean.rb +47 -0
  10. data/lib/simplecov/cli/coverage.rb +93 -0
  11. data/lib/simplecov/cli/diff.rb +152 -0
  12. data/lib/simplecov/cli/dotfile.rb +105 -0
  13. data/lib/simplecov/cli/merge.rb +118 -0
  14. data/lib/simplecov/cli/open.rb +50 -0
  15. data/lib/simplecov/cli/report.rb +89 -0
  16. data/lib/simplecov/cli/run.rb +36 -0
  17. data/lib/simplecov/cli/serve.rb +149 -0
  18. data/lib/simplecov/cli/uncovered.rb +108 -0
  19. data/lib/simplecov/cli.rb +150 -0
  20. data/lib/simplecov/color.rb +79 -0
  21. data/lib/simplecov/combine/branches_combiner.rb +28 -5
  22. data/lib/simplecov/combine/files_combiner.rb +47 -1
  23. data/lib/simplecov/combine/lines_combiner.rb +19 -17
  24. data/lib/simplecov/combine/methods_combiner.rb +49 -0
  25. data/lib/simplecov/combine/results_combiner.rb +7 -5
  26. data/lib/simplecov/command_guesser.rb +46 -32
  27. data/lib/simplecov/configuration/coverage.rb +175 -0
  28. data/lib/simplecov/configuration/coverage_criteria.rb +158 -0
  29. data/lib/simplecov/configuration/filters.rb +197 -0
  30. data/lib/simplecov/configuration/formatting.rb +129 -0
  31. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  32. data/lib/simplecov/configuration/merging.rb +164 -0
  33. data/lib/simplecov/configuration/thresholds.rb +178 -0
  34. data/lib/simplecov/configuration.rb +105 -412
  35. data/lib/simplecov/coverage_statistics.rb +15 -9
  36. data/lib/simplecov/coverage_violations.rb +149 -0
  37. data/lib/simplecov/defaults.rb +29 -22
  38. data/lib/simplecov/deprecation.rb +45 -0
  39. data/lib/simplecov/directive.rb +167 -0
  40. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  41. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
  42. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
  43. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
  45. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
  46. data/lib/simplecov/exit_codes.rb +15 -0
  47. data/lib/simplecov/exit_handling.rb +158 -0
  48. data/lib/simplecov/file_list.rb +70 -19
  49. data/lib/simplecov/filter.rb +69 -24
  50. data/lib/simplecov/formatter/base.rb +102 -0
  51. data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
  52. data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
  53. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  54. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  55. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
  56. data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
  57. data/lib/simplecov/formatter/html_formatter.rb +85 -0
  58. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +90 -0
  59. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +128 -0
  60. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
  61. data/lib/simplecov/formatter/json_formatter.rb +89 -0
  62. data/lib/simplecov/formatter/multi_formatter.rb +11 -7
  63. data/lib/simplecov/formatter/simple_formatter.rb +11 -11
  64. data/lib/simplecov/formatter.rb +4 -0
  65. data/lib/simplecov/last_run.rb +10 -3
  66. data/lib/simplecov/lines_classifier.rb +25 -13
  67. data/lib/simplecov/load_global_config.rb +9 -4
  68. data/lib/simplecov/parallel_adapters/base.rb +61 -0
  69. data/lib/simplecov/parallel_adapters/generic.rb +44 -0
  70. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  71. data/lib/simplecov/parallel_adapters.rb +83 -0
  72. data/lib/simplecov/parallel_coordination.rb +129 -0
  73. data/lib/simplecov/process.rb +52 -12
  74. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  75. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  76. data/lib/simplecov/profiles/rails.rb +24 -10
  77. data/lib/simplecov/profiles/root_filter.rb +6 -5
  78. data/lib/simplecov/profiles/strict.rb +32 -0
  79. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  80. data/lib/simplecov/profiles.rb +32 -3
  81. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  82. data/lib/simplecov/result/source_file_builder.rb +51 -0
  83. data/lib/simplecov/result.rb +108 -19
  84. data/lib/simplecov/result_adapter.rb +105 -8
  85. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  86. data/lib/simplecov/result_merger/resultset_file.rb +38 -0
  87. data/lib/simplecov/result_merger/resultset_store.rb +50 -0
  88. data/lib/simplecov/result_merger.rb +59 -91
  89. data/lib/simplecov/result_processing.rb +179 -0
  90. data/lib/simplecov/simulate_coverage.rb +54 -8
  91. data/lib/simplecov/source_file/branch.rb +1 -3
  92. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  93. data/lib/simplecov/source_file/builder_context.rb +28 -0
  94. data/lib/simplecov/source_file/line.rb +9 -4
  95. data/lib/simplecov/source_file/line_builder.rb +43 -0
  96. data/lib/simplecov/source_file/method.rb +52 -0
  97. data/lib/simplecov/source_file/method_builder.rb +60 -0
  98. data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
  99. data/lib/simplecov/source_file/skip_chunks.rb +75 -0
  100. data/lib/simplecov/source_file/source_loader.rb +63 -0
  101. data/lib/simplecov/source_file/statistics.rb +57 -0
  102. data/lib/simplecov/source_file.rb +66 -232
  103. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +49 -0
  104. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +247 -0
  105. data/lib/simplecov/static_coverage_extractor/method_collector.rb +55 -0
  106. data/lib/simplecov/static_coverage_extractor/value_position.rb +82 -0
  107. data/lib/simplecov/static_coverage_extractor/visitor.rb +196 -0
  108. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  109. data/lib/simplecov/useless_results_remover.rb +16 -7
  110. data/lib/simplecov/version.rb +1 -1
  111. data/lib/simplecov-html.rb +4 -0
  112. data/lib/simplecov.rb +178 -380
  113. data/lib/simplecov_json_formatter.rb +4 -0
  114. data/schemas/coverage-v1.0.schema.json +306 -0
  115. data/schemas/coverage.schema.json +306 -0
  116. data/sig/simplecov.rbs +1638 -0
  117. metadata +94 -57
  118. data/CHANGELOG.md +0 -191
  119. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # Detects the `if` / `unless` / ternary conditions CRuby folds away.
6
+ # When a condition is a statically-known-truthy/falsy literal the
7
+ # compiler eliminates the dead arm and Coverage emits NO branch, so the
8
+ # extractor must not synthesize one either — otherwise the arm is a
9
+ # phantom that no loaded run can ever hit, the same unmergeable-tuple
10
+ # failure mode as #1226 / #1233.
11
+ module ConditionFolding
12
+ # Prism node types for the literals that fold. `while` / `until` do
13
+ # NOT fold (`while true` is a real branch), so only the if-like
14
+ # visitors consult this. Regexp and Range literals are excluded on
15
+ # purpose: as conditions they mean `=~ $_` / flip-flop, which
16
+ # Coverage does branch on.
17
+ STATIC_CONDITION_TYPES = [
18
+ ::Prism::IntegerNode, ::Prism::FloatNode, ::Prism::RationalNode,
19
+ ::Prism::ImaginaryNode, ::Prism::SymbolNode, ::Prism::StringNode,
20
+ ::Prism::TrueNode, ::Prism::FalseNode, ::Prism::NilNode
21
+ ].freeze
22
+
23
+ private
24
+
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) }
34
+ end
35
+
36
+ def unwrap_parentheses(node)
37
+ # @type var current: untyped
38
+ current = node
39
+ while current.is_a?(::Prism::ParenthesesNode)
40
+ body = current.body
41
+ break unless body.is_a?(::Prism::StatementsNode) && body.body.size == 1
42
+
43
+ current = body.body.first
44
+ end
45
+ current
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # The source ranges Ruby's Coverage assigns to branch conditions and
6
+ # arms, resolved from Prism nodes. Simulated entries only ever merge
7
+ # with real entries produced by the running Ruby, and CRuby 3.4
8
+ # changed several of these conventions, so every resolver here emits
9
+ # whichever shape this Ruby's Coverage uses. See issues #1226 / #1233.
10
+ #
11
+ # rubocop:disable Metrics/ModuleLength -- one cohesive home for the
12
+ # per-construct, per-Ruby-version Coverage location conventions;
13
+ # splitting it would scatter closely-related resolvers.
14
+ module LocationConventions
15
+ LEGACY_COVERAGE_LOCATIONS = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")
16
+
17
+ # A zero-width stand-in for Prism locations, for the arms Coverage
18
+ # anchors to a point rather than a range.
19
+ PointLocation = Data.define(:start_line, :start_column, :end_line, :end_column)
20
+
21
+ private
22
+
23
+ # simplecov:disable
24
+ # Which arm of each conditional below runs is fixed by the running
25
+ # Ruby's version, so no single process can cover both sides, and
26
+ # the legacy-only helpers are unreachable on modern Rubies (and
27
+ # vice versa). The "runtime tuple equivalence" spec exercises this
28
+ # module against real Coverage output on every CI Ruby.
29
+
30
+ # The range Coverage assigns to an if-like node itself. Modern
31
+ # CRuby uses the node's full source range for every form; 3.2/3.3
32
+ # end an `elsif` clause's range at its last content instead of the
33
+ # shared `end` keyword the clause doesn't own.
34
+ def if_like_location(node, type)
35
+ return node.location unless LEGACY_COVERAGE_LOCATIONS && type == :if && elsif_node?(node)
36
+
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
+ )
42
+ end
43
+
44
+ def elsif_node?(node)
45
+ keyword = node.if_keyword_loc
46
+ !keyword.nil? && keyword.slice == "elsif"
47
+ end
48
+
49
+ # Where an if/elsif chain's content ends, for the legacy range
50
+ # convention: the deepest trailing clause's statements, or that
51
+ # clause's predicate / `else` keyword when its body is empty.
52
+ def legacy_content_end(node)
53
+ tail = node
54
+ while tail.is_a?(::Prism::IfNode)
55
+ sub = tail.public_send(IF_NODE_SUBSEQUENT_METHOD)
56
+ break unless sub
57
+
58
+ tail = sub
59
+ end
60
+ return (tail.statements || tail.predicate).location if tail.is_a?(::Prism::IfNode)
61
+
62
+ tail.statements ? tail.statements.location : tail.else_keyword_loc
63
+ end
64
+
65
+ # Location of the then arm. Coverage uses the body statements'
66
+ # range; with an empty then body the arm collapses to a zero-width
67
+ # point at the predicate's end — always on a modern `if`, and on
68
+ # legacy Rubies only when the construct is in void position (a
69
+ # trailing statement discards its value). In value (tail) position,
70
+ # legacy Rubies and `unless` fall back to the node's range.
71
+ def if_like_then_location(node, type)
72
+ return node.statements.location if node.statements
73
+ return point_at_end(node.predicate.location) if empty_arm_collapses?(node, type)
74
+
75
+ if_like_location(node, type)
76
+ end
77
+
78
+ # 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).
85
+ def if_like_else_location(node, type)
86
+ sub = if_like_subsequent(node)
87
+ return if_like_location(node, type) unless sub
88
+ # An `elsif` arrives as a nested IfNode. Coverage attributes the
89
+ # outer else arm to the clause's own range, not its then body
90
+ # (which is what created phantom unmergeable arms).
91
+ return if_like_location(sub, :if) if sub.is_a?(::Prism::IfNode)
92
+ return sub.statements.location if sub.statements
93
+
94
+ empty_else_location(node, sub, type)
95
+ end
96
+
97
+ # Location of an empty explicit `else`: a modern `if` uses the
98
+ # else..end span; a legacy Ruby in void position collapses to a point
99
+ # at the `else` keyword's end; otherwise (legacy value position, or
100
+ # `unless`) it uses the condition's range.
101
+ def empty_else_location(node, sub, type)
102
+ return sub.location if !LEGACY_COVERAGE_LOCATIONS && type == :if
103
+ return point_at_end(sub.else_keyword_loc) if LEGACY_COVERAGE_LOCATIONS && !value_position?(node)
104
+
105
+ if_like_location(node, type)
106
+ end
107
+
108
+ # Arm location for a when/in clause: its body statements, or — when
109
+ # the body is empty — the clause's own range on modern Rubies, a
110
+ # point at the pattern's end for a legacy `in`, and for a legacy
111
+ # `when` a point at the clause's end in void position or the tail
112
+ # convention (keyword through the case's remaining content) in value.
113
+ def case_arm_location(case_node, when_node, when_type)
114
+ return when_node.statements.location if when_node.statements
115
+ return when_node.location unless LEGACY_COVERAGE_LOCATIONS
116
+ return point_at_end(when_node.pattern.location) if when_type == :in
117
+ return point_at_end(when_node.location) unless value_position?(case_node)
118
+
119
+ legacy_when_value_location(case_node, when_node)
120
+ end
121
+
122
+ 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
+ )
128
+ end
129
+
130
+ # The last body content in the case after `when_node`, falling
131
+ # back to the clause's final condition value.
132
+ def legacy_case_tail_end(case_node, when_node)
133
+ following_case_content(case_node, when_node).last ||
134
+ (when_node.conditions.last || when_node).location
135
+ end
136
+
137
+ def following_case_content(case_node, when_node)
138
+ clauses = case_node.conditions
139
+ index = clauses.index { |clause| clause.equal?(when_node) } || 0
140
+ # A when-clause's own location ends where its body ends (or at its
141
+ # condition when empty), so the whole clause extends the range
142
+ # through trailing EMPTY clauses that have no `statements`.
143
+ content = clauses.drop(index + 1).map(&:location)
144
+ else_statements = case_node.public_send(ELSE_CLAUSE_METHOD)&.statements
145
+ content << else_statements.location if else_statements
146
+ content
147
+ end
148
+
149
+ # Resolve the source range Coverage attributes to a synthetic-or-real
150
+ # `:else` arm of a case construct: the body of an explicit else,
151
+ # the case's full range when no else is present, and — for an
152
+ # explicit else with an empty body — the else..end span on modern
153
+ # Rubies or the case's full range on legacy ones.
154
+ def else_arm_location(node)
155
+ else_clause = node.public_send(ELSE_CLAUSE_METHOD)
156
+ return node.location unless else_clause
157
+ return else_clause.statements.location if else_clause.statements
158
+ return else_clause.location unless LEGACY_COVERAGE_LOCATIONS
159
+ # Empty explicit `else`: a point at the `else` keyword's end in void
160
+ # position, the whole case's range in value position.
161
+ return point_at_end(else_clause.else_keyword_loc) unless value_position?(node)
162
+
163
+ node.location
164
+ end
165
+
166
+ # An empty loop body falls back to the loop's range on modern
167
+ # Rubies and collapses to a point at the predicate's end on legacy
168
+ # ones.
169
+ def loop_body_location(node)
170
+ return legacy_do_while_body_location(node) if LEGACY_COVERAGE_LOCATIONS && begin_modifier_loop?(node)
171
+ return node.statements.location if node.statements
172
+ return point_at_end(node.predicate.location) if LEGACY_COVERAGE_LOCATIONS
173
+
174
+ node.location
175
+ end
176
+
177
+ # `begin ... end while/until cond` (the do-while form) parses as a
178
+ # while/until whose sole statement is the BeginNode. Modern Coverage
179
+ # attributes the body to that whole `begin ... end` span (which the
180
+ # generic `node.statements.location` already yields), but 3.3 uses
181
+ # the begin's inner statements instead — or a point at the end of
182
+ # the `begin` keyword when the body is empty.
183
+ def begin_modifier_loop?(node)
184
+ node.respond_to?(:begin_modifier?) && node.begin_modifier?
185
+ end
186
+
187
+ def legacy_do_while_body_location(node)
188
+ begin_node = node.statements.body.first
189
+ inner = begin_node.statements
190
+ inner ? inner.location : point_at_end(begin_node.begin_keyword_loc)
191
+ end
192
+
193
+ # Coverage's safe-navigation branch spans the receiver through the
194
+ # end of the call's arguments (or just the message when there are
195
+ # none), but never includes a trailing block: `x&.foo { ... }` and
196
+ # `x&.foo(1) { ... }` both end exactly where `x&.foo` / `x&.foo(1)`
197
+ # would without the block. `node.location` includes an attached
198
+ # block, so build the end position from `closing_loc` (closing
199
+ # paren) / `arguments` (paren-less args) / `message_loc` instead.
200
+ # This convention is the same on legacy and modern Rubies. See
201
+ # issue #1233.
202
+ def safe_navigation_location(node)
203
+ end_loc = node.closing_loc || node.arguments&.location || node.message_loc
204
+ 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
207
+ )
208
+ end
209
+
210
+ def point_at_end(location)
211
+ PointLocation.new(
212
+ start_line: location.end_line, start_column: location.end_column,
213
+ end_line: location.end_line, end_column: location.end_column
214
+ )
215
+ end
216
+
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
+ # Whether an empty then arm collapses to a point at the predicate's
225
+ # end. Modern Coverage does this for every `if` (but not `unless`);
226
+ # legacy Coverage does it only in void position, for both.
227
+ def empty_arm_collapses?(node, type)
228
+ return type == :if unless LEGACY_COVERAGE_LOCATIONS
229
+
230
+ !value_position?(node)
231
+ end
232
+
233
+ # Whether `node` sits in value (method-return) position, which on
234
+ # legacy Rubies keeps an empty arm's range instead of collapsing it
235
+ # to a point. `@value_positions` is computed once per parse by
236
+ # ValuePositions (only on legacy; nil elsewhere, which reads as
237
+ # "value" — the safe, pre-audit default).
238
+ def value_position?(node)
239
+ return true if @value_positions.nil?
240
+
241
+ @value_positions.key?(node)
242
+ end
243
+ # simplecov:enable
244
+ end
245
+ # rubocop:enable Metrics/ModuleLength
246
+ end
247
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # Visitor mixin that collects method tuples and tracks the lexical
6
+ # class / module nesting that names them, in the shape Ruby's
7
+ # `Coverage` reports methods. Mixed into `Visitor`, it shares that
8
+ # visitor's `@methods` / `@class_stack` state and keeps the
9
+ # method-collection concern separate from branch extraction.
10
+ module MethodCollector
11
+ # Track class/module nesting so method tuples carry the lexical
12
+ # class name. Module + Class are both treated as namespaces here
13
+ # since `Coverage` reports both as the constant.
14
+ def visit_class_node(node)
15
+ with_class(constant_name(node.constant_path)) { super }
16
+ end
17
+
18
+ def visit_module_node(node)
19
+ with_class(constant_name(node.constant_path)) { super }
20
+ end
21
+
22
+ # `def name(...)` and `def self.name(...)` both produce DefNode.
23
+ # The class context is the surrounding lexical class/module (or
24
+ # `Object` at the top level, matching `Coverage`'s convention).
25
+ def visit_def_node(node)
26
+ loc = node.location
27
+ class_name = @class_stack.last || "Object"
28
+ key = [class_name, node.name, loc.start_line, loc.start_column, loc.end_line, loc.end_column]
29
+ @methods[key] = 0
30
+ super
31
+ end
32
+
33
+ private
34
+
35
+ # Render a constant path (e.g., `Foo::Bar`) as its source-form
36
+ # string. Defensive nil / to_s fallbacks: ClassNode and ModuleNode
37
+ # always carry a constant_path in practice.
38
+ # simplecov:disable
39
+ def constant_name(node)
40
+ return "<anonymous>" if node.nil?
41
+ return node.slice if node.respond_to?(:slice)
42
+
43
+ node.to_s
44
+ end
45
+ # simplecov:enable
46
+
47
+ def with_class(name)
48
+ @class_stack.push(name)
49
+ yield
50
+ ensure
51
+ @class_stack.pop
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # Ruby 3.3 value-position analysis for the extractor's legacy branch
6
+ # conventions (see LocationConventions and the #1233 audit).
7
+ #
8
+ # On Ruby 3.3, the source range Coverage assigns to an EMPTY branch arm
9
+ # depends on whether its construct is in value position — its result is
10
+ # the method's return value — or void position, where the result is
11
+ # discarded. Value position keeps the whole-construct range; void
12
+ # collapses the arm to a point at its header's end. Ruby 3.4 dropped the
13
+ # distinction, so this pass only runs on legacy Rubies.
14
+ #
15
+ # "Value position" here is narrower than general value-use: it is
16
+ # strictly method-return (tail) position. It reaches a node only through
17
+ # statement tails and `if`/`unless`/`when` arms. Assignments, blocks,
18
+ # lambdas, method arguments, `case/in` arms, and loop bodies all discard
19
+ # it (Coverage treats their empty arms as void). So `tail_children`
20
+ # names the constructs that forward tail position and everything else
21
+ # falls through to the void default.
22
+ module ValuePositions
23
+ module_function
24
+
25
+ # simplecov:disable
26
+ # This whole pass runs only on legacy Rubies (the modern dogfood
27
+ # never calls it), so its lines can't be covered on the CI Ruby that
28
+ # enforces 100%. Behavior is pinned instead by the differential
29
+ # tuple-equivalence spec, which runs against real Coverage on 3.3.
30
+
31
+ # An identity set (a `compare_by_identity` Hash used as a set) of the
32
+ # Prism nodes Coverage treats as being in value position.
33
+ def call(root)
34
+ positions = {} #: Hash[untyped, bool]
35
+ positions.compare_by_identity
36
+ mark(root, true, positions)
37
+ positions
38
+ end
39
+
40
+ def mark(node, in_value, positions)
41
+ return unless node.is_a?(::Prism::Node)
42
+
43
+ positions[node] = true if in_value
44
+ children = tail_children(node, in_value)
45
+ node.compact_child_nodes.each do |child|
46
+ mark(child, children.any? { |c| c.equal?(child) }, positions)
47
+ end
48
+ end
49
+
50
+ # The children of `node` that inherit its tail position; empty for the
51
+ # void default. A method body is a tail context even when the `def`
52
+ # itself is not (the method still returns its last expression), so it
53
+ # is included regardless of `in_value`.
54
+ def tail_children(node, in_value)
55
+ # A method body is a tail context even when the `def` is not.
56
+ return [node.body] if node.is_a?(::Prism::DefNode)
57
+ return [] unless in_value
58
+
59
+ case node
60
+ 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)]
63
+ when ::Prism::ElseNode, ::Prism::WhenNode, ::Prism::BeginNode, ::Prism::ProgramNode then [node.statements]
64
+ else []
65
+ end
66
+ 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
+ # simplecov:enable
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "condition_folding"
4
+ require_relative "location_conventions"
5
+ require_relative "method_collector"
6
+ require_relative "value_position"
7
+
8
+ module SimpleCov
9
+ module StaticCoverageExtractor
10
+ # `Prism::IfNode#subsequent` was renamed from `consequent` in Prism
11
+ # 1.3 (Dec 2024). Ruby 3.3's stdlib still ships an older Prism that
12
+ # only exposes `consequent`; 3.4+ and any project that's done
13
+ # `gem install prism` exposes `subsequent`. Resolve the method name
14
+ # ONCE here so the per-node hot path stays branch-free. The
15
+ # not-taken arm on whichever Prism version we're on can't be
16
+ # exercised by our own dogfood (we only run on one Prism at a time).
17
+ # simplecov:disable
18
+ IF_NODE_SUBSEQUENT_METHOD =
19
+ if ::Prism::IfNode.method_defined?(:subsequent)
20
+ :subsequent
21
+ else
22
+ :consequent
23
+ end
24
+
25
+ # The same Prism 1.3 rename hit the `else` accessor on `UnlessNode`,
26
+ # `CaseNode`, and `CaseMatchNode` (all three: `consequent` ->
27
+ # `else_clause`). Ruby 3.3's stdlib Prism (0.19) only exposes
28
+ # `consequent`, so reaching for `else_clause` there raised
29
+ # NoMethodError inside the extractor — `call` swallowed it and the
30
+ # whole file silently fell back to no simulated data for any
31
+ # `unless`/`else` or empty-arm `case`. Resolve the name once, like
32
+ # IF_NODE_SUBSEQUENT_METHOD. All three nodes renamed together, so one
33
+ # constant (probed off CaseNode) covers them.
34
+ ELSE_CLAUSE_METHOD =
35
+ if ::Prism::CaseNode.method_defined?(:else_clause)
36
+ :else_clause
37
+ else
38
+ :consequent
39
+ end
40
+ # simplecov:enable
41
+
42
+ # Prism visitor that accumulates branch and method tuples in the
43
+ # shape Ruby's `Coverage` reports. Tuple ids are sequential across
44
+ # the file — `Coverage` uses sequential ids too, so this matches the
45
+ # conventional shape. Only defined when Prism is loadable;
46
+ # `StaticCoverageExtractor.available?` is the runtime gate.
47
+ class Visitor < ::Prism::Visitor
48
+ # Method tuples and the class/module nesting that names them are
49
+ # collected by this mixin; this class focuses on branch extraction.
50
+ include MethodCollector
51
+ # Source-range resolution, including the per-Ruby-version Coverage
52
+ # conventions. See issue #1226.
53
+ include LocationConventions
54
+ # Which literal `if`/`unless`/ternary conditions the compiler folds
55
+ # away (so we emit no branch for them).
56
+ include ConditionFolding
57
+
58
+ attr_reader :branches, :methods
59
+
60
+ def initialize
61
+ super
62
+ @branches = {}
63
+ @methods = {}
64
+ @next_id = 0
65
+ @class_stack = []
66
+ @value_positions = nil
67
+ end
68
+
69
+ # Entry point for a parsed file. On legacy Rubies the location of an
70
+ # empty branch arm depends on whether its construct is in value
71
+ # (tail) position, so precompute that once for the whole tree before
72
+ # emitting anything. Modern Rubies don't need it (see
73
+ # LocationConventions), so the pass is skipped there.
74
+ def visit_program_node(node)
75
+ # simplecov:disable branch — legacy-only arm; unreachable on the modern dogfood Ruby
76
+ @value_positions = ValuePositions.call(node) if LEGACY_COVERAGE_LOCATIONS
77
+ # simplecov:enable branch
78
+ super
79
+ end
80
+
81
+ # `if` / `unless` / postfix-if / postfix-unless / ternary all parse
82
+ # as IfNode (or UnlessNode). Both carry a `then` arm (the
83
+ # statements body) and an optional `subsequent` (an ElseNode for
84
+ # `else`, another IfNode for `elsif`). When the subsequent is
85
+ # missing, Coverage synthesizes a `:else` arm attributed to the
86
+ # whole condition's range — we do the same.
87
+ def visit_if_node(node)
88
+ emit_if_like(node, :if) unless static_condition?(node.predicate)
89
+ super
90
+ end
91
+
92
+ def visit_unless_node(node)
93
+ emit_if_like(node, :unless) unless static_condition?(node.predicate)
94
+ super
95
+ end
96
+
97
+ def visit_call_node(node)
98
+ emit_safe_navigation(node) if node.respond_to?(:safe_navigation?) && node.safe_navigation?
99
+ super
100
+ end
101
+
102
+ # `case`/`when` and `case`/`in` (pattern matching) parse as CaseNode
103
+ # and CaseMatchNode respectively. When there's no explicit `else`,
104
+ # Coverage synthesizes one at the case's range.
105
+ def visit_case_node(node)
106
+ emit_case_like(node, :when)
107
+ super
108
+ end
109
+
110
+ def visit_case_match_node(node)
111
+ emit_case_like(node, :in)
112
+ super
113
+ end
114
+
115
+ # One-line pattern matching: `x => pattern` (MatchRequiredNode) and
116
+ # `x in pattern` (MatchPredicateNode). Ruby 3.3's Coverage reports
117
+ # these as a `:case` with an `:in` and an `:else` arm; 3.4 dropped
118
+ # them entirely (no branch), so this is legacy-only. The two forms
119
+ # differ only in where Coverage anchors the synthesized `:else`:
120
+ # `=>` uses the whole expression, `in` uses just the pattern.
121
+ # simplecov:disable branch — legacy-only arms; unreachable on the modern dogfood Ruby
122
+ def visit_match_required_node(node)
123
+ emit_oneline_pattern(node, node.location) if LEGACY_COVERAGE_LOCATIONS
124
+ super
125
+ end
126
+
127
+ def visit_match_predicate_node(node)
128
+ emit_oneline_pattern(node, node.pattern.location) if LEGACY_COVERAGE_LOCATIONS
129
+ super
130
+ end
131
+ # simplecov:enable branch
132
+
133
+ # `while` / `until` loops get a single `:body` arm. No synthetic
134
+ # else (the loop either runs the body or doesn't).
135
+ def visit_while_node(node)
136
+ emit_loop(node, :while)
137
+ super
138
+ end
139
+
140
+ def visit_until_node(node)
141
+ emit_loop(node, :until)
142
+ super
143
+ end
144
+
145
+ private
146
+
147
+ # IfNode and UnlessNode share a shape (predicate + then body +
148
+ # optional else/elsif) but expose the trailing arm under different
149
+ # accessors. `if_like_else_location` hides that split.
150
+ def emit_if_like(node, type)
151
+ then_loc = if_like_then_location(node, type)
152
+ else_loc = if_like_else_location(node, type)
153
+ @branches[build_tuple(type, if_like_location(node, type))] = {
154
+ build_tuple(:then, then_loc) => 0,
155
+ build_tuple(:else, else_loc) => 0
156
+ }
157
+ end
158
+
159
+ def emit_safe_navigation(node)
160
+ loc = safe_navigation_location(node)
161
+ @branches[build_tuple(:"&.", loc)] = {
162
+ build_tuple(:then, loc) => 0,
163
+ build_tuple(:else, loc) => 0
164
+ }
165
+ end
166
+
167
+ # simplecov:disable — legacy-only (3.4 emits no branch for one-line patterns)
168
+ def emit_oneline_pattern(node, else_location)
169
+ @branches[build_tuple(:case, node.location)] = {
170
+ build_tuple(:in, node.pattern.location) => 0,
171
+ build_tuple(:else, else_location) => 0
172
+ }
173
+ end
174
+ # simplecov:enable
175
+
176
+ def emit_case_like(node, when_type)
177
+ arms = node.conditions.to_h do |when_node|
178
+ [build_tuple(when_type, case_arm_location(node, when_node, when_type)), 0]
179
+ end
180
+ arms[build_tuple(:else, else_arm_location(node))] = 0
181
+ @branches[build_tuple(:case, node.location)] = arms
182
+ end
183
+
184
+ def emit_loop(node, type)
185
+ cond_tuple = build_tuple(type, node.location)
186
+ @branches[cond_tuple] = {build_tuple(:body, loop_body_location(node)) => 0}
187
+ end
188
+
189
+ def build_tuple(type, location)
190
+ id = @next_id
191
+ @next_id += 1
192
+ [type, id, location.start_line, location.start_column, location.end_line, location.end_column]
193
+ end
194
+ end
195
+ end
196
+ end