simplecov 1.0.1 → 1.0.3

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: ddfad3987b6f04c755c08c2b251d111ca75eff0e14aef5156e9d30f049087e96
4
+ data.tar.gz: 97efb84c53398cc92a3e2106d2428351f3f1edc37cc4a7c34abeb2859503a0fd
5
5
  SHA512:
6
- metadata.gz: 13bdde3f26f987d864caf3c2a861c479cac8e03c12fcf12752aad70b0ca55793e1dc529490d5ad9fc6250c780d6131fd78264ddaf0ca3158ae4cea5ffdb3b3b5
7
- data.tar.gz: 21239f67622ae18b306783d6a355dd728247128ece00935601334b82f4f7f8948150768d0ac949c1db5c859c23187b4a9f568b7a45a49bf5a7a1ab40a8178951
6
+ metadata.gz: ac211a4d93fe0db4e87f4ddddff178566e84ef2d04234ffba49382da236668f718ef43d2b68b01977e1aa17dec86823dbc70cb8adf7eb116a9f24f06a99d78c2
7
+ data.tar.gz: 5259cbb289b03d111995433363370d3e91729da55c9aef5834a0626cfd11fc93fa6614fab3f764e8faaa667439a6716cb1e842f343c58875fd83c023977f6c99
data/README.md CHANGED
@@ -1042,6 +1042,20 @@ SimpleCov.formatter = SimpleCov::Formatter::HTMLFormatter
1042
1042
  `SimpleCov.result.format!` then invokes `SimpleCov::Formatter::YourFormatter.new.format(result)`, where `result` is a
1043
1043
  `SimpleCov::Result`. Do whatever you wish with it.
1044
1044
 
1045
+ ### Passing options to formatters
1046
+
1047
+ Anywhere a formatter class is accepted, a ready-built instance works too — that's how you reach constructor options.
1048
+ The built-in HTML and JSON formatters take `silent: true` to suppress the "Coverage report generated" status line on
1049
+ stderr, and `output_dir:` to write the report somewhere other than `SimpleCov.coverage_path`:
1050
+
1051
+ ```ruby
1052
+ SimpleCov.start do
1053
+ formatter SimpleCov::Formatter::HTMLFormatter.new(silent: true)
1054
+ end
1055
+ ```
1056
+
1057
+ Instances mix freely with classes in `formatters` lists as well.
1058
+
1045
1059
  ### Using multiple formatters
1046
1060
 
1047
1061
  As of SimpleCov 0.9 you can specify multiple result formats. The HTML and JSON formatters are built in; other
@@ -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,36 @@ 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 the location, ignoring the class and name elements —
20
+ # because Ruby records one entry per defined method: the same
21
+ # `define_method` block defined onto different classes, or under
22
+ # different names, in different processes arrives with different
23
+ # receivers or names for the same source method, and matching on the
24
+ # full key would keep both, letting a never-called copy's 0 shadow a
25
+ # covered method after merge (issue #1234). Combining sums the hit
26
+ # counts for matching methods and preserves methods that only appear
27
+ # in one result.
18
28
  #
19
29
  # @return [Hash]
20
30
  #
21
31
  def combine(coverage_a, coverage_b)
22
- coverage_a.merge(coverage_b) { |_key, a_count, b_count| a_count + b_count }
32
+ merged = {} #: Hash[untyped, [untyped, Integer]]
33
+ [coverage_a, coverage_b].each_with_object(merged) do |coverage, memo|
34
+ coverage.each do |key, count|
35
+ method_key = source_identity(key)
36
+ retained_key, existing = memo[method_key] || [key, 0]
37
+ memo[method_key] = [retained_key, existing + count]
38
+ end
39
+ end
40
+
41
+ merged.values.to_h
42
+ end
43
+
44
+ def source_identity(key)
45
+ _class_name, _method_name, *location = SourceFile::RubyDataParser.call(key)
46
+ location
23
47
  end
24
48
  end
25
49
  end
@@ -9,11 +9,16 @@ module SimpleCov
9
9
  attr_writer :formatter, :print_error_status
10
10
 
11
11
  #
12
- # Gets or sets the configured formatter. Pass `false` (or `nil`) to
13
- # opt out of formatting entirely worker processes in big parallel
14
- # CI setups (see #964) only need their `.resultset.json` on disk so
15
- # a final `SimpleCov.collate` job can produce the report; running
16
- # them without a formatter saves the per-job HTML/multi-formatter
12
+ # Gets or sets the configured formatter. Accepts a formatter class
13
+ # (instantiated fresh for every report) or a ready-built instance,
14
+ # which is how constructor options are passed — e.g.
15
+ # `formatter SimpleCov::Formatter::HTMLFormatter.new(silent: true)`
16
+ # to suppress the "Coverage report generated" status line (see
17
+ # #1240). Pass `false` (or `nil`) to opt out of formatting
18
+ # entirely — worker processes in big parallel CI setups (see #964)
19
+ # only need their `.resultset.json` on disk so a final
20
+ # `SimpleCov.collate` job can produce the report; running them
21
+ # without a formatter saves the per-job HTML/multi-formatter
17
22
  # overhead.
18
23
  #
19
24
  def formatter(formatter = :__no_arg__)
@@ -40,7 +45,8 @@ module SimpleCov
40
45
 
41
46
  # Sets the configured formatters. Equivalent to `formatters [...]`.
42
47
  # Accepts a single formatter as well as an Array, matching the pre-1.0 behavior
43
- # where `MultiFormatter.new` normalized its input.
48
+ # where `MultiFormatter.new` normalized its input. Elements may be
49
+ # formatter classes or ready-built instances; see `formatter`.
44
50
  def formatters=(formatters)
45
51
  formatters = Array(formatters)
46
52
  @formatter = formatters.empty? ? nil : SimpleCov::Formatter::MultiFormatter.new(formatters)
@@ -10,7 +10,7 @@ module SimpleCov
10
10
  module InstanceMethods
11
11
  def format(result)
12
12
  formatters.map do |formatter|
13
- formatter.new.format(result)
13
+ Formatter.instance_for(formatter).format(result)
14
14
  rescue StandardError => e
15
15
  warn("Formatter #{formatter} failed with #{e.class}: #{e.message} (#{(_ = e.backtrace).first})")
16
16
  nil
@@ -6,6 +6,13 @@ module SimpleCov
6
6
  # and can be wired up via `SimpleCov.formatter=`.
7
7
  # TODO: Documentation on how to build your own formatters
8
8
  module Formatter
9
+ # Formatters can be configured either as classes (instantiated
10
+ # fresh for every report) or as ready-built instances — the only
11
+ # way to reach constructor options like
12
+ # `HTMLFormatter.new(silent: true)`. See #1240.
13
+ def self.instance_for(formatter)
14
+ formatter.is_a?(Class) ? formatter.new : formatter
15
+ end
9
16
  end
10
17
  end
11
18
 
@@ -102,7 +102,7 @@ module SimpleCov
102
102
  formatter = SimpleCov.formatter
103
103
  return nil if formatter.nil?
104
104
 
105
- formatter.new.format(self)
105
+ Formatter.instance_for(formatter).format(self)
106
106
  end
107
107
 
108
108
  # Defines when this result has been created. Defaults to Time.now
@@ -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,85 @@ 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 DEFINED METHOD, not per
64
+ # source location: a block handed to `define_method` /
65
+ # `define_singleton_method` from a shared code path yields a separate
66
+ # `[receiver, name, location]` entry for every class it's defined on
67
+ # (a module's `included` hook defining onto each descendant) AND for
68
+ # every name it's defined under (a builder looping `define_method key`
69
+ # over a container), all pointing at the same source. A file-based
70
+ # report can only express "was the method at this location ever
71
+ # executed", so entries are aggregated by location alone, summing
72
+ # hits — otherwise each receiver or name whose generated copy never
73
+ # ran shows as a phantom uncovered method on a line whose line
74
+ # coverage is 100%. Regular `def`s map one location to one name, so
75
+ # they are unaffected. The first entry's (normalized) key is kept for
76
+ # display. See issue #1234.
62
77
  def normalize_method_keys(cover_statistic)
63
78
  methods = cover_statistic[:methods]
64
79
  return unless methods
65
80
 
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
81
+ aggregated = {} #: Hash[untyped, [untyped, Integer]]
82
+ methods.each_with_object(aggregated) do |(key, count), memo|
83
+ location = key[2..] #: Array[untyped]
84
+ retained_key, existing = memo[location] || [normalize_method_key(key), 0]
85
+ memo[location] = [retained_key, existing + count]
75
86
  end
87
+ cover_statistic[:methods] = aggregated.values.to_h
88
+ end
89
+
90
+ def normalize_method_key(key)
91
+ normalized_key = key.dup
92
+ normalized_key[0] = class_display_name(key[0])
93
+ .gsub(ADDRESS_PATTERN, ADDRESS_PLACEHOLDER)
94
+ .sub(SINGLETON_WRAPPER_PATTERN, '\1')
95
+ normalized_key
96
+ end
97
+
98
+ # Rendering a class name can execute user code: a singleton class's
99
+ # `to_s` renders its attached object via `#inspect`, which a module can
100
+ # shadow with an incompatible signature (Liquid::Utils defines
101
+ # `inspect(value, max_depth = 2)` as a module_function, so rendering
102
+ # `#<Class:Liquid::Utils>` raises ArgumentError). A coverage report
103
+ # must never crash the host suite over that, so on failure rebuild the
104
+ # singleton wrapper from `Module#name` via bound methods (which cannot
105
+ # be shadowed), falling back to the address form, which
106
+ # ADDRESS_PATTERN then normalizes. See issue #1236.
107
+ def class_display_name(klass)
108
+ klass.to_s
109
+ rescue StandardError
110
+ singleton_wrapper_name(klass) || Object.instance_method(:to_s).bind_call(klass)
111
+ end
112
+
113
+ def singleton_wrapper_name(klass)
114
+ return nil unless klass.is_a?(Class) && klass.singleton_class?
115
+
116
+ attached = klass.attached_object
117
+ # simplecov:disable branch — CRuby only reaches the rescue via a
118
+ # Module/Class attached object (instance singletons render from the
119
+ # class name chain without calling user inspect), so the non-Module
120
+ # arm is defensive.
121
+ name = Module.instance_method(:name).bind_call(attached) if attached.is_a?(Module)
122
+ # simplecov:enable branch
123
+ name && "#<Class:#{name}>"
124
+ end
125
+
126
+ # Ruby's eval coverage records a fresh set of branch entries for every
127
+ # COMPILE of an eval'd string: a template rendered through multiple view
128
+ # classes (e.g. hanami-view compiles each template once per view) yields
129
+ # several `[:if, id, location]` conditions at identical coordinates in
130
+ # the same file, each counting only the renders that flowed through that
131
+ # compile. Reported as-is they inflate the branch denominator and turn a
132
+ # side covered under a different compile into a phantom miss (issue
133
+ # #1235). Aggregate them by (type, location) — combining a branches hash
134
+ # with an empty one dedups within it, since BranchesCombiner keys arms
135
+ # on location identity. Regular (non-eval) source can never produce two
136
+ # conditions at the same location, so this is a no-op outside eval.
137
+ def aggregate_duplicated_branches(cover_statistic)
138
+ branches = cover_statistic[:branches]
139
+ return unless branches
140
+
141
+ cover_statistic[:branches] = Combine::BranchesCombiner.combine(branches, {})
76
142
  end
77
143
 
78
144
  def adapt_oneshot_lines_if_needed(file_name, cover_statistic)
@@ -15,10 +15,28 @@ module SimpleCov
15
15
 
16
16
  # Tests use the real data structures (except for integration tests)
17
17
  # so no need to put them through here.
18
+ #
19
+ # String parses are memoized: `Combine::BranchesCombiner` and
20
+ # `Combine::MethodsCombiner` derive a merge identity from every key of
21
+ # both sides on every pairwise merge, so collating N resultsets parses
22
+ # each key string N-1 times — and Ripper dominates the wall time of a
23
+ # large collate.
24
+ # Key strings repeat across folds and within report building, while the
25
+ # set of unique keys is bounded by the project's branch and method
26
+ # count, so a permanent cache stays small. Cached arrays are frozen
27
+ # element-wise (String class names included, not just the tuple):
28
+ # every caller destructures without mutating, and sharing one array
29
+ # across callers must stay that way. The cache key needs no such
30
+ # care — `Hash#[]=` dups and freezes String keys on its own.
18
31
  def call(structure)
19
32
  return structure if structure.is_a?(Array)
20
33
 
21
- parse_array_string(structure.to_s)
34
+ string = structure.to_s
35
+ parse_cache[string] ||= parse_array_string(string).each(&:freeze).freeze
36
+ end
37
+
38
+ def parse_cache
39
+ @parse_cache ||= {} #: Hash[String, untyped]
22
40
  end
23
41
 
24
42
  # Parse a string like '[:if, 0, 3, 4, 3, 21]' or
@@ -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.3"
5
5
  end
data/sig/simplecov.rbs CHANGED
@@ -1217,13 +1217,18 @@ end
1217
1217
  module SimpleCov
1218
1218
  # Namespace for result formatters. A formatter is any class whose
1219
1219
  # instances respond to `#format(result)` and that can be instantiated
1220
- # with no arguments; wire one up via `SimpleCov.formatter=` or
1220
+ # with no arguments, or a ready-built instance responding to
1221
+ # `#format(result)`; wire one up via `SimpleCov.formatter=` or
1221
1222
  # `SimpleCov.formatters=`.
1222
1223
  module Formatter
1223
1224
  # Structural interface for custom formatter instances.
1224
1225
  interface _Formatter
1225
1226
  def format: (SimpleCov::Result result) -> untyped
1226
1227
  end
1228
+
1229
+ # Normalizes a configured formatter — class or ready-built
1230
+ # instance — into an instance. See #1240.
1231
+ def self.instance_for: (untyped formatter) -> untyped
1227
1232
  end
1228
1233
  end
1229
1234
 
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.3
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.3
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.3
158
160
  rubygems_mfa_required: 'true'
159
161
  rdoc_options: []
160
162
  require_paths:
@@ -170,7 +172,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
170
172
  - !ruby/object:Gem::Version
171
173
  version: '0'
172
174
  requirements: []
173
- rubygems_version: 4.0.16
175
+ rubygems_version: 4.0.17
174
176
  specification_version: 4
175
177
  summary: Code coverage for Ruby
176
178
  test_files: []