simplecov 1.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14c29bfd96c91bb39e5893969a5f35a320e0d3b23b3ae47161aabc21a182f69f
4
- data.tar.gz: 2436a11c76e48df0df5c7432e1c1163f0bf36f9090e04d035017c99769e74b7d
3
+ metadata.gz: 1f159cd5427690b20f11c1e4a3f1a90c23c16f5f65e2043205bf132a5abc0218
4
+ data.tar.gz: d5a28a2c529ff2b59652bd0c1ba1f9d649c7c24fd02521905c6d2cbe6b1eeae3
5
5
  SHA512:
6
- metadata.gz: 13bdde3f26f987d864caf3c2a861c479cac8e03c12fcf12752aad70b0ca55793e1dc529490d5ad9fc6250c780d6131fd78264ddaf0ca3158ae4cea5ffdb3b3b5
7
- data.tar.gz: 21239f67622ae18b306783d6a355dd728247128ece00935601334b82f4f7f8948150768d0ac949c1db5c859c23187b4a9f568b7a45a49bf5a7a1ab40a8178951
6
+ metadata.gz: 792c138d327b5d305ebe273dfd5a97d70eb8104102557d5ae6452a26d5cc64e1db7720c3de5e2c556a78b22561c53778579909ddef7f4615e1c447f041fa921c
7
+ data.tar.gz: 9f6479ceabbd5f6fc24585a2134a0f4e1270dbd988922d4d387e5695055b811655999b3d95c2a9b31c960cf0c22aa884d5bf94eae93ca6fb97868bbec0de3aab
@@ -34,7 +34,12 @@ module SimpleCov
34
34
  # doesn't get ANSI sequences. See the module-level comment for
35
35
  # precedence.
36
36
  def enabled?(stream = $stderr)
37
- config = SimpleCov.color
37
+ # `SimpleCov.color` only exists once the full library is loaded.
38
+ # The standalone CLI (`exe/simplecov`) loads `simplecov/color`
39
+ # without `simplecov` itself to stay lightweight, so treat a
40
+ # missing config the same as its `:auto` default: fall through to
41
+ # the env vars and tty check below.
42
+ config = SimpleCov.color if SimpleCov.respond_to?(:color)
38
43
  return config if [true, false].include?(config)
39
44
  return false if env_set?("NO_COLOR")
40
45
  return true if env_set?("FORCE_COLOR")
@@ -9,22 +9,62 @@ module SimpleCov
9
9
  module FilesCombiner
10
10
  module_function
11
11
 
12
+ empty_table = {} #: Hash[untyped, untyped]
13
+ EMPTY_TABLE = empty_table.freeze
14
+ private_constant :EMPTY_TABLE
15
+
16
+ # Branch/method tuples drawn from a simulated (never-loaded) file
17
+ # when the other side of the merge was actually executed.
18
+ NO_SYNTHESIZED = {"branches" => EMPTY_TABLE, "methods" => EMPTY_TABLE}.freeze
19
+
12
20
  #
13
21
  # Combines the results for 2 coverages of a file.
14
22
  #
15
23
  # @return [Hash]
16
24
  #
17
25
  def combine(coverage_a, coverage_b)
26
+ source_a, source_b = reconcile_synthesized(coverage_a, coverage_b)
27
+
18
28
  combination = {"lines" => Combine.combine(LinesCombiner, coverage_a["lines"], coverage_b["lines"])}
19
29
  if SimpleCov.branch_coverage?
20
- combined_branches = Combine.combine(BranchesCombiner, coverage_a["branches"], coverage_b["branches"])
30
+ combined_branches = Combine.combine(BranchesCombiner, source_a["branches"], source_b["branches"])
21
31
  combination["branches"] = combined_branches || {}
22
32
  end
23
33
  if SimpleCov.method_coverage?
24
- combination["methods"] = Combine.combine(MethodsCombiner, coverage_a["methods"], coverage_b["methods"])
34
+ combination["methods"] = Combine.combine(MethodsCombiner, source_a["methods"], source_b["methods"])
25
35
  end
26
36
  combination
27
37
  end
38
+
39
+ # When exactly one side of the merge was actually executed, its branch
40
+ # and method tuples are authoritative and the other side's are dropped.
41
+ # A simulated entry (SimulateCoverage backfills tracked-but-unloaded
42
+ # files) synthesizes those tuples statically, so a location that drifts
43
+ # from what Coverage emits would otherwise be unioned in by position
44
+ # and survive as a phantom, permanently-missed branch (see #1233). This
45
+ # contains any such drift to denominator inflation for files no process
46
+ # loaded, rather than a false miss on a covered file. Lines are never
47
+ # dropped: a simulated file's line shape is correct and carries the
48
+ # unloaded-file denominator (#1059).
49
+ #
50
+ # Returns the two coverages to draw branch/method tuples from, blanking
51
+ # the non-executed side only when the other side was executed. When
52
+ # both sides were executed (two real runs) or neither was (all
53
+ # simulated), both are returned unchanged and combine normally.
54
+ def reconcile_synthesized(coverage_a, coverage_b)
55
+ executed_a = executed?(coverage_a)
56
+ executed_b = executed?(coverage_b)
57
+ return [coverage_a, coverage_b] if executed_a == executed_b
58
+
59
+ executed_a ? [coverage_a, NO_SYNTHESIZED] : [NO_SYNTHESIZED, coverage_b]
60
+ end
61
+
62
+ # A file some process actually loaded has at least one executed line;
63
+ # a simulated (never-loaded) file's lines are all `nil` or `0`.
64
+ def executed?(coverage)
65
+ lines = Array(coverage["lines"]) #: Array[Integer?]
66
+ lines.any? { |count| count&.positive? }
67
+ end
28
68
  end
29
69
  end
30
70
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../source_file/ruby_data_parser"
4
+
3
5
  module SimpleCov
4
6
  module Combine
5
7
  #
@@ -12,14 +14,35 @@ module SimpleCov
12
14
  #
13
15
  # Return merged methods or the existing methods if other is missing.
14
16
  #
15
- # Method coverage is a flat hash mapping method identifiers to hit counts.
16
- # Combining sums the hit counts for matching methods and preserves methods
17
- # that only appear in one result.
17
+ # Method coverage maps `[class, name, start_line, start_col, end_line,
18
+ # end_col]` keys to hit counts. Keys are matched on their SOURCE
19
+ # identity (name, location), ignoring the class element — because
20
+ # Ruby records one entry per receiver: the same `define_method` block
21
+ # defined onto different classes in different processes arrives with
22
+ # different (normalized) receivers for the same source method, and
23
+ # matching on the full key would keep both, letting a never-called
24
+ # receiver's 0 shadow a covered method after merge (issue #1234).
25
+ # Combining sums the hit counts for matching methods and preserves
26
+ # methods that only appear in one result.
18
27
  #
19
28
  # @return [Hash]
20
29
  #
21
30
  def combine(coverage_a, coverage_b)
22
- coverage_a.merge(coverage_b) { |_key, a_count, b_count| a_count + b_count }
31
+ merged = {} #: Hash[untyped, [untyped, Integer]]
32
+ [coverage_a, coverage_b].each_with_object(merged) do |coverage, memo|
33
+ coverage.each do |key, count|
34
+ method_key = source_identity(key)
35
+ retained_key, existing = memo[method_key] || [key, 0]
36
+ memo[method_key] = [retained_key, existing + count]
37
+ end
38
+ end
39
+
40
+ merged.values.to_h
41
+ end
42
+
43
+ def source_identity(key)
44
+ _class_name, *identity = SourceFile::RubyDataParser.call(key)
45
+ identity
23
46
  end
24
47
  end
25
48
  end
@@ -27,13 +27,14 @@ module SimpleCov
27
27
 
28
28
  # Pre-0.18 resultsets pointed each filename straight at a line-coverage
29
29
  # array; everything since uses the `{lines:, branches:, methods:}`
30
- # shape. Newer entries also need their methods table massaged before
31
- # downstream code merges across processes.
30
+ # shape. Newer entries also need their methods and branches tables
31
+ # massaged before downstream code reports or merges them.
32
32
  def adapt_one(file_name, cover_statistic)
33
33
  return {"lines" => cover_statistic} if cover_statistic.is_a?(Array)
34
34
 
35
35
  adapt_oneshot_lines_if_needed(file_name, cover_statistic)
36
36
  normalize_method_keys(cover_statistic)
37
+ aggregate_duplicated_branches(cover_statistic)
37
38
  cover_statistic
38
39
  end
39
40
 
@@ -59,20 +60,53 @@ module SimpleCov
59
60
  SINGLETON_WRAPPER_PATTERN = /\A#<Class:([A-Z_][\w:]*)>\z/
60
61
  private_constant :SINGLETON_WRAPPER_PATTERN
61
62
 
63
+ # Ruby's method coverage records one entry per RECEIVER, not per source
64
+ # location: a block handed to `define_method` / `define_singleton_method`
65
+ # from a shared code path (a module's `included` hook, a builder) yields
66
+ # a separate `[receiver, name, location]` entry for every class it's
67
+ # defined on, all pointing at the same source. A file-based report can
68
+ # only express "was the method at this location ever executed", so
69
+ # entries are aggregated by (name, location), summing hits — otherwise
70
+ # each receiver whose copy never ran shows as a phantom uncovered method
71
+ # on a line whose line coverage is 100% (issue #1234). The first entry's
72
+ # (normalized) receiver is kept for display.
62
73
  def normalize_method_keys(cover_statistic)
63
74
  methods = cover_statistic[:methods]
64
75
  return unless methods
65
76
 
66
- normalized_methods = {} #: Hash[untyped, untyped]
67
- cover_statistic[:methods] = methods.each_with_object(normalized_methods) do |(key, count), normalized|
68
- normalized_key = key.dup
69
- normalized_key[0] = key[0].to_s
70
- .gsub(ADDRESS_PATTERN, ADDRESS_PLACEHOLDER)
71
- .sub(SINGLETON_WRAPPER_PATTERN, '\1')
72
- # Keys may collide after normalization (anonymous classes sharing a
73
- # method name, or singleton + instance forms of a module_function method).
74
- normalized[normalized_key] = normalized.fetch(normalized_key, 0) + count
77
+ aggregated = {} #: Hash[untyped, [untyped, Integer]]
78
+ methods.each_with_object(aggregated) do |(key, count), memo|
79
+ identity = key[1..] #: Array[untyped]
80
+ retained_key, existing = memo[identity] || [normalize_method_key(key), 0]
81
+ memo[identity] = [retained_key, existing + count]
75
82
  end
83
+ cover_statistic[:methods] = aggregated.values.to_h
84
+ end
85
+
86
+ def normalize_method_key(key)
87
+ normalized_key = key.dup
88
+ normalized_key[0] = key[0].to_s
89
+ .gsub(ADDRESS_PATTERN, ADDRESS_PLACEHOLDER)
90
+ .sub(SINGLETON_WRAPPER_PATTERN, '\1')
91
+ normalized_key
92
+ end
93
+
94
+ # Ruby's eval coverage records a fresh set of branch entries for every
95
+ # COMPILE of an eval'd string: a template rendered through multiple view
96
+ # classes (e.g. hanami-view compiles each template once per view) yields
97
+ # several `[:if, id, location]` conditions at identical coordinates in
98
+ # the same file, each counting only the renders that flowed through that
99
+ # compile. Reported as-is they inflate the branch denominator and turn a
100
+ # side covered under a different compile into a phantom miss (issue
101
+ # #1235). Aggregate them by (type, location) — combining a branches hash
102
+ # with an empty one dedups within it, since BranchesCombiner keys arms
103
+ # on location identity. Regular (non-eval) source can never produce two
104
+ # conditions at the same location, so this is a no-op outside eval.
105
+ def aggregate_duplicated_branches(cover_statistic)
106
+ branches = cover_statistic[:branches]
107
+ return unless branches
108
+
109
+ cover_statistic[:branches] = Combine::BranchesCombiner.combine(branches, {})
76
110
  end
77
111
 
78
112
  def adapt_oneshot_lines_if_needed(file_name, cover_statistic)
@@ -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
@@ -6,7 +6,11 @@ module SimpleCov
6
6
  # arms, resolved from Prism nodes. Simulated entries only ever merge
7
7
  # with real entries produced by the running Ruby, and CRuby 3.4
8
8
  # changed several of these conventions, so every resolver here emits
9
- # whichever shape this Ruby's Coverage uses. See issue #1226.
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.
10
14
  module LocationConventions
11
15
  LEGACY_COVERAGE_LOCATIONS = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")
12
16
 
@@ -59,24 +63,27 @@ module SimpleCov
59
63
  end
60
64
 
61
65
  # Location of the then arm. Coverage uses the body statements'
62
- # range; a modern (3.4+) `if` with an empty then body collapses the
63
- # arm to a zero-width point at the predicate's end, while `unless`
64
- # and legacy Rubies fall back to the node's range.
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.
65
71
  def if_like_then_location(node, type)
66
72
  return node.statements.location if node.statements
67
- return if_like_location(node, type) if LEGACY_COVERAGE_LOCATIONS || type != :if
73
+ return point_at_end(node.predicate.location) if empty_arm_collapses?(node, type)
68
74
 
69
- point_at_end(node.predicate.location)
75
+ if_like_location(node, type)
70
76
  end
71
77
 
72
78
  # Resolve the source range Coverage attributes to a real-or-synthetic
73
79
  # `:else` arm of an if-like construct. IfNode uses
74
- # `subsequent` / `consequent` depending on Prism version (resolved
75
- # to `IF_NODE_SUBSEQUENT_METHOD` at load time); UnlessNode uses
76
- # `else_clause`. When neither is present, the synthesized else
77
- # inherits the condition's range (matches Coverage's convention).
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).
78
85
  def if_like_else_location(node, type)
79
- sub = node.is_a?(::Prism::IfNode) ? node.public_send(IF_NODE_SUBSEQUENT_METHOD) : node.else_clause
86
+ sub = if_like_subsequent(node)
80
87
  return if_like_location(node, type) unless sub
81
88
  # An `elsif` arrives as a nested IfNode. Coverage attributes the
82
89
  # outer else arm to the clause's own range, not its then body
@@ -84,23 +91,35 @@ module SimpleCov
84
91
  return if_like_location(sub, :if) if sub.is_a?(::Prism::IfNode)
85
92
  return sub.statements.location if sub.statements
86
93
 
87
- # Empty explicit else: a modern `if` uses the else..end span,
88
- # while `unless` and legacy Rubies use the condition's range.
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)
89
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)
90
104
 
91
105
  if_like_location(node, type)
92
106
  end
93
107
 
94
- # Arm location for a when/in clause: its body statements, or —
95
- # when the body is empty — the clause's own range on modern Rubies,
96
- # and on legacy Rubies a point at the pattern's end for `in`, or
97
- # the keyword through the case's remaining trailing content for
98
- # `when` (the same tail convention as legacy elsif ranges).
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.
99
113
  def case_arm_location(case_node, when_node, when_type)
100
114
  return when_node.statements.location if when_node.statements
101
115
  return when_node.location unless LEGACY_COVERAGE_LOCATIONS
102
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)
103
118
 
119
+ legacy_when_value_location(case_node, when_node)
120
+ end
121
+
122
+ def legacy_when_value_location(case_node, when_node)
104
123
  tail_end = legacy_case_tail_end(case_node, when_node)
105
124
  PointLocation.new(
106
125
  start_line: when_node.location.start_line, start_column: when_node.location.start_column,
@@ -118,10 +137,13 @@ module SimpleCov
118
137
  def following_case_content(case_node, when_node)
119
138
  clauses = case_node.conditions
120
139
  index = clauses.index { |clause| clause.equal?(when_node) } || 0
121
- bodies = clauses.drop(index + 1).filter_map { |clause| clause.statements&.location }
122
- else_statements = case_node.else_clause&.statements
123
- bodies << else_statements.location if else_statements
124
- bodies
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
125
147
  end
126
148
 
127
149
  # Resolve the source range Coverage attributes to a synthetic-or-real
@@ -130,29 +152,96 @@ module SimpleCov
130
152
  # explicit else with an empty body — the else..end span on modern
131
153
  # Rubies or the case's full range on legacy ones.
132
154
  def else_arm_location(node)
133
- return node.location unless node.else_clause
134
- return node.else_clause.statements.location if node.else_clause.statements
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)
135
162
 
136
- LEGACY_COVERAGE_LOCATIONS ? node.location : node.else_clause.location
163
+ node.location
137
164
  end
138
165
 
139
166
  # An empty loop body falls back to the loop's range on modern
140
167
  # Rubies and collapses to a point at the predicate's end on legacy
141
168
  # ones.
142
169
  def loop_body_location(node)
170
+ return legacy_do_while_body_location(node) if LEGACY_COVERAGE_LOCATIONS && begin_modifier_loop?(node)
143
171
  return node.statements.location if node.statements
144
172
  return point_at_end(node.predicate.location) if LEGACY_COVERAGE_LOCATIONS
145
173
 
146
174
  node.location
147
175
  end
148
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
+
149
210
  def point_at_end(location)
150
211
  PointLocation.new(
151
212
  start_line: location.end_line, start_column: location.end_column,
152
213
  end_line: location.end_line, end_column: location.end_column
153
214
  )
154
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
155
243
  # simplecov:enable
156
244
  end
245
+ # rubocop:enable Metrics/ModuleLength
157
246
  end
158
247
  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
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "condition_folding"
3
4
  require_relative "location_conventions"
4
5
  require_relative "method_collector"
6
+ require_relative "value_position"
5
7
 
6
8
  module SimpleCov
7
9
  module StaticCoverageExtractor
@@ -19,6 +21,22 @@ module SimpleCov
19
21
  else
20
22
  :consequent
21
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
22
40
  # simplecov:enable
23
41
 
24
42
  # Prism visitor that accumulates branch and method tuples in the
@@ -33,6 +51,9 @@ module SimpleCov
33
51
  # Source-range resolution, including the per-Ruby-version Coverage
34
52
  # conventions. See issue #1226.
35
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
36
57
 
37
58
  attr_reader :branches, :methods
38
59
 
@@ -42,6 +63,19 @@ module SimpleCov
42
63
  @methods = {}
43
64
  @next_id = 0
44
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
45
79
  end
46
80
 
47
81
  # `if` / `unless` / postfix-if / postfix-unless / ternary all parse
@@ -51,12 +85,12 @@ module SimpleCov
51
85
  # missing, Coverage synthesizes a `:else` arm attributed to the
52
86
  # whole condition's range — we do the same.
53
87
  def visit_if_node(node)
54
- emit_if_like(node, :if)
88
+ emit_if_like(node, :if) unless static_condition?(node.predicate)
55
89
  super
56
90
  end
57
91
 
58
92
  def visit_unless_node(node)
59
- emit_if_like(node, :unless)
93
+ emit_if_like(node, :unless) unless static_condition?(node.predicate)
60
94
  super
61
95
  end
62
96
 
@@ -78,6 +112,24 @@ module SimpleCov
78
112
  super
79
113
  end
80
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
+
81
133
  # `while` / `until` loops get a single `:body` arm. No synthetic
82
134
  # else (the loop either runs the body or doesn't).
83
135
  def visit_while_node(node)
@@ -105,13 +157,22 @@ module SimpleCov
105
157
  end
106
158
 
107
159
  def emit_safe_navigation(node)
108
- loc = node.location
160
+ loc = safe_navigation_location(node)
109
161
  @branches[build_tuple(:"&.", loc)] = {
110
162
  build_tuple(:then, loc) => 0,
111
163
  build_tuple(:else, loc) => 0
112
164
  }
113
165
  end
114
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
+
115
176
  def emit_case_like(node, when_type)
116
177
  arms = node.conditions.to_h do |when_node|
117
178
  [build_tuple(when_type, case_arm_location(node, when_node, when_type)), 0]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleCov
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simplecov
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erik Berlin
@@ -137,8 +137,10 @@ files:
137
137
  - lib/simplecov/source_file/source_loader.rb
138
138
  - lib/simplecov/source_file/statistics.rb
139
139
  - lib/simplecov/static_coverage_extractor.rb
140
+ - lib/simplecov/static_coverage_extractor/condition_folding.rb
140
141
  - lib/simplecov/static_coverage_extractor/location_conventions.rb
141
142
  - lib/simplecov/static_coverage_extractor/method_collector.rb
143
+ - lib/simplecov/static_coverage_extractor/value_position.rb
142
144
  - lib/simplecov/static_coverage_extractor/visitor.rb
143
145
  - lib/simplecov/useless_results_remover.rb
144
146
  - lib/simplecov/version.rb
@@ -152,9 +154,9 @@ licenses:
152
154
  metadata:
153
155
  bug_tracker_uri: https://github.com/simplecov-ruby/simplecov/issues
154
156
  changelog_uri: https://github.com/simplecov-ruby/simplecov/blob/main/CHANGELOG.md
155
- documentation_uri: https://www.rubydoc.info/gems/simplecov/1.0.1
157
+ documentation_uri: https://www.rubydoc.info/gems/simplecov/1.0.2
156
158
  mailing_list_uri: https://groups.google.com/forum/#!forum/simplecov
157
- source_code_uri: https://github.com/simplecov-ruby/simplecov/tree/v1.0.1
159
+ source_code_uri: https://github.com/simplecov-ruby/simplecov/tree/v1.0.2
158
160
  rubygems_mfa_required: 'true'
159
161
  rdoc_options: []
160
162
  require_paths: