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 +4 -4
- data/lib/simplecov/color.rb +6 -1
- data/lib/simplecov/combine/files_combiner.rb +42 -2
- data/lib/simplecov/combine/methods_combiner.rb +27 -4
- data/lib/simplecov/result_adapter.rb +45 -11
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +49 -0
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +114 -25
- data/lib/simplecov/static_coverage_extractor/value_position.rb +82 -0
- data/lib/simplecov/static_coverage_extractor/visitor.rb +64 -3
- data/lib/simplecov/version.rb +1 -1
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1f159cd5427690b20f11c1e4a3f1a90c23c16f5f65e2043205bf132a5abc0218
|
|
4
|
+
data.tar.gz: d5a28a2c529ff2b59652bd0c1ba1f9d649c7c24fd02521905c6d2cbe6b1eeae3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 792c138d327b5d305ebe273dfd5a97d70eb8104102557d5ae6452a26d5cc64e1db7720c3de5e2c556a78b22561c53778579909ddef7f4615e1c447f041fa921c
|
|
7
|
+
data.tar.gz: 9f6479ceabbd5f6fc24585a2134a0f4e1270dbd988922d4d387e5695055b811655999b3d95c2a9b31c960cf0c22aa884d5bf94eae93ca6fb97868bbec0de3aab
|
data/lib/simplecov/color.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
-
|
|
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
|
|
31
|
-
# downstream code merges
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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;
|
|
63
|
-
#
|
|
64
|
-
#
|
|
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
|
|
73
|
+
return point_at_end(node.predicate.location) if empty_arm_collapses?(node, type)
|
|
68
74
|
|
|
69
|
-
|
|
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`
|
|
75
|
-
#
|
|
76
|
-
# `
|
|
77
|
-
#
|
|
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 =
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
134
|
-
return 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)
|
|
135
162
|
|
|
136
|
-
|
|
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
|
|
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]
|
data/lib/simplecov/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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:
|