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.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +1058 -508
- data/doc/alternate-formatters.md +0 -5
- data/doc/commercial-services.md +5 -5
- data/exe/simplecov +11 -0
- data/lib/minitest/simplecov_plugin.rb +13 -5
- data/lib/simplecov/autostart.rb +11 -0
- data/lib/simplecov/cli/clean.rb +47 -0
- data/lib/simplecov/cli/coverage.rb +93 -0
- data/lib/simplecov/cli/diff.rb +152 -0
- data/lib/simplecov/cli/dotfile.rb +105 -0
- data/lib/simplecov/cli/merge.rb +118 -0
- data/lib/simplecov/cli/open.rb +50 -0
- data/lib/simplecov/cli/report.rb +89 -0
- data/lib/simplecov/cli/run.rb +36 -0
- data/lib/simplecov/cli/serve.rb +149 -0
- data/lib/simplecov/cli/uncovered.rb +108 -0
- data/lib/simplecov/cli.rb +150 -0
- data/lib/simplecov/color.rb +79 -0
- data/lib/simplecov/combine/branches_combiner.rb +28 -5
- data/lib/simplecov/combine/files_combiner.rb +47 -1
- data/lib/simplecov/combine/lines_combiner.rb +19 -17
- data/lib/simplecov/combine/methods_combiner.rb +49 -0
- data/lib/simplecov/combine/results_combiner.rb +7 -5
- data/lib/simplecov/command_guesser.rb +46 -32
- data/lib/simplecov/configuration/coverage.rb +175 -0
- data/lib/simplecov/configuration/coverage_criteria.rb +158 -0
- data/lib/simplecov/configuration/filters.rb +197 -0
- data/lib/simplecov/configuration/formatting.rb +129 -0
- data/lib/simplecov/configuration/ignored_entries.rb +63 -0
- data/lib/simplecov/configuration/merging.rb +164 -0
- data/lib/simplecov/configuration/thresholds.rb +178 -0
- data/lib/simplecov/configuration.rb +105 -412
- data/lib/simplecov/coverage_statistics.rb +15 -9
- data/lib/simplecov/coverage_violations.rb +149 -0
- data/lib/simplecov/defaults.rb +29 -22
- data/lib/simplecov/deprecation.rb +45 -0
- data/lib/simplecov/directive.rb +167 -0
- data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
- data/lib/simplecov/exit_codes.rb +15 -0
- data/lib/simplecov/exit_handling.rb +158 -0
- data/lib/simplecov/file_list.rb +70 -19
- data/lib/simplecov/filter.rb +69 -24
- data/lib/simplecov/formatter/base.rb +102 -0
- data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
- data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
- data/lib/simplecov/formatter/html_formatter.rb +85 -0
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +90 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +128 -0
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
- data/lib/simplecov/formatter/json_formatter.rb +89 -0
- data/lib/simplecov/formatter/multi_formatter.rb +11 -7
- data/lib/simplecov/formatter/simple_formatter.rb +11 -11
- data/lib/simplecov/formatter.rb +4 -0
- data/lib/simplecov/last_run.rb +10 -3
- data/lib/simplecov/lines_classifier.rb +25 -13
- data/lib/simplecov/load_global_config.rb +9 -4
- data/lib/simplecov/parallel_adapters/base.rb +61 -0
- data/lib/simplecov/parallel_adapters/generic.rb +44 -0
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
- data/lib/simplecov/parallel_adapters.rb +83 -0
- data/lib/simplecov/parallel_coordination.rb +129 -0
- data/lib/simplecov/process.rb +52 -12
- data/lib/simplecov/profiles/bundler_filter.rb +1 -1
- data/lib/simplecov/profiles/hidden_filter.rb +1 -1
- data/lib/simplecov/profiles/rails.rb +24 -10
- data/lib/simplecov/profiles/root_filter.rb +6 -5
- data/lib/simplecov/profiles/strict.rb +32 -0
- data/lib/simplecov/profiles/test_frameworks.rb +1 -4
- data/lib/simplecov/profiles.rb +32 -3
- data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
- data/lib/simplecov/result/source_file_builder.rb +51 -0
- data/lib/simplecov/result.rb +108 -19
- data/lib/simplecov/result_adapter.rb +105 -8
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
- data/lib/simplecov/result_merger/resultset_file.rb +38 -0
- data/lib/simplecov/result_merger/resultset_store.rb +50 -0
- data/lib/simplecov/result_merger.rb +59 -91
- data/lib/simplecov/result_processing.rb +179 -0
- data/lib/simplecov/simulate_coverage.rb +54 -8
- data/lib/simplecov/source_file/branch.rb +1 -3
- data/lib/simplecov/source_file/branch_builder.rb +114 -0
- data/lib/simplecov/source_file/builder_context.rb +28 -0
- data/lib/simplecov/source_file/line.rb +9 -4
- data/lib/simplecov/source_file/line_builder.rb +43 -0
- data/lib/simplecov/source_file/method.rb +52 -0
- data/lib/simplecov/source_file/method_builder.rb +60 -0
- data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
- data/lib/simplecov/source_file/skip_chunks.rb +75 -0
- data/lib/simplecov/source_file/source_loader.rb +63 -0
- data/lib/simplecov/source_file/statistics.rb +57 -0
- data/lib/simplecov/source_file.rb +66 -232
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +49 -0
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +247 -0
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +55 -0
- data/lib/simplecov/static_coverage_extractor/value_position.rb +82 -0
- data/lib/simplecov/static_coverage_extractor/visitor.rb +196 -0
- data/lib/simplecov/static_coverage_extractor.rb +110 -0
- data/lib/simplecov/useless_results_remover.rb +16 -7
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov-html.rb +4 -0
- data/lib/simplecov.rb +178 -380
- data/lib/simplecov_json_formatter.rb +4 -0
- data/schemas/coverage-v1.0.schema.json +306 -0
- data/schemas/coverage.schema.json +306 -0
- data/sig/simplecov.rbs +1638 -0
- metadata +94 -57
- data/CHANGELOG.md +0 -191
- 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
|