simplecov 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/lib/simplecov/cli/coverage.rb +5 -3
  3. data/lib/simplecov/cli/diff.rb +2 -1
  4. data/lib/simplecov/cli/dotfile.rb +9 -4
  5. data/lib/simplecov/cli/merge.rb +5 -3
  6. data/lib/simplecov/cli/report.rb +11 -6
  7. data/lib/simplecov/cli/run.rb +1 -1
  8. data/lib/simplecov/cli/serve.rb +17 -7
  9. data/lib/simplecov/cli/uncovered.rb +2 -1
  10. data/lib/simplecov/combine/branches_combiner.rb +2 -1
  11. data/lib/simplecov/combine/results_combiner.rb +2 -1
  12. data/lib/simplecov/configuration/coverage.rb +6 -2
  13. data/lib/simplecov/configuration/coverage_criteria.rb +3 -1
  14. data/lib/simplecov/configuration/formatting.rb +17 -7
  15. data/lib/simplecov/configuration/thresholds.rb +13 -9
  16. data/lib/simplecov/configuration.rb +22 -13
  17. data/lib/simplecov/coverage_statistics.rb +4 -1
  18. data/lib/simplecov/coverage_violations.rb +2 -1
  19. data/lib/simplecov/defaults.rb +3 -3
  20. data/lib/simplecov/directive.rb +8 -3
  21. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +1 -1
  22. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +1 -1
  23. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +1 -1
  24. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +1 -1
  25. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +3 -3
  26. data/lib/simplecov/exit_codes.rb +12 -0
  27. data/lib/simplecov/exit_handling.rb +3 -3
  28. data/lib/simplecov/file_list.rb +12 -5
  29. data/lib/simplecov/formatter/base.rb +2 -1
  30. data/lib/simplecov/formatter/html_formatter.rb +13 -7
  31. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +9 -3
  32. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +1 -1
  33. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +1 -1
  34. data/lib/simplecov/formatter/json_formatter.rb +4 -2
  35. data/lib/simplecov/formatter/multi_formatter.rb +7 -2
  36. data/lib/simplecov/formatter/simple_formatter.rb +3 -1
  37. data/lib/simplecov/process.rb +22 -0
  38. data/lib/simplecov/result_adapter.rb +2 -1
  39. data/lib/simplecov/result_processing.rb +4 -4
  40. data/lib/simplecov/simulate_coverage.rb +2 -2
  41. data/lib/simplecov/source_file/line.rb +2 -2
  42. data/lib/simplecov/source_file/method.rb +1 -1
  43. data/lib/simplecov/source_file/method_builder.rb +3 -1
  44. data/lib/simplecov/source_file/source_loader.rb +2 -2
  45. data/lib/simplecov/source_file.rb +2 -2
  46. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +158 -0
  47. data/lib/simplecov/static_coverage_extractor/visitor.rb +9 -45
  48. data/lib/simplecov/version.rb +1 -1
  49. data/lib/simplecov.rb +24 -11
  50. data/sig/simplecov.rbs +1638 -0
  51. metadata +5 -3
@@ -27,8 +27,8 @@ module SimpleCov
27
27
  # returns the `{line:, branch:, method:}` Hash; pass a criterion symbol
28
28
  # (`:line` / `:branch` / `:method`) to get that one CoverageStatistics.
29
29
  def coverage_statistics(criterion = nil)
30
- @coverage_statistics ||= compute_coverage_statistics
31
- criterion ? @coverage_statistics[criterion] : @coverage_statistics
30
+ stats = (@coverage_statistics ||= compute_coverage_statistics)
31
+ criterion ? stats[criterion] : stats
32
32
  end
33
33
 
34
34
  def coverage_statistics_by_file
@@ -67,7 +67,11 @@ module SimpleCov
67
67
 
68
68
  # Finds the least covered file and returns that file's name
69
69
  def least_covered_file
70
- min_by(&:covered_percent).filename
70
+ # `covered_percent` is nil only for an unmeasured criterion, and :line
71
+ # is always measured, so the `|| 0.0` arm never fires at runtime; it
72
+ # (and the cast) exist to satisfy min_by's Comparable requirement.
73
+ least_covered = min_by { |file| file.covered_percent || 0.0 }
74
+ (_ = least_covered).filename
71
75
  end
72
76
 
73
77
  # Returns the overall amount of relevant lines of code across all files in this list
@@ -137,7 +141,10 @@ module SimpleCov
137
141
  # enabled set so disabled criteria don't surface in totals, JSON,
138
142
  # or the HTML report.
139
143
  def compute_coverage_statistics_by_file
140
- seed = enabled_criteria_for_reporting.to_h { |criterion| [criterion, []] }
144
+ seed = enabled_criteria_for_reporting.to_h do |criterion|
145
+ bucket = [] #: Array[CoverageStatistics]
146
+ [criterion, bucket]
147
+ end
141
148
  @files.each_with_object(seed) do |file, together|
142
149
  file.coverage_statistics.each do |criterion, stats|
143
150
  together[criterion] << stats if together.key?(criterion)
@@ -153,7 +160,7 @@ module SimpleCov
153
160
  # criterion is enabled; the JRuby-gated branch/method criteria are
154
161
  # reported when they pass their own engine-support check.
155
162
  def enabled_criteria_for_reporting
156
- criteria = []
163
+ criteria = [] #: Array[SimpleCov::criterion]
157
164
  criteria << :line if SimpleCov.coverage_criterion_enabled?(:line) ||
158
165
  SimpleCov.coverage_criterion_enabled?(:oneshot_line)
159
166
  criteria << :branch if SimpleCov.branch_coverage?
@@ -45,7 +45,8 @@ module SimpleCov
45
45
  # confusing than the absolute form. See issue #197.
46
46
  def displayable_output_path
47
47
  directory = relative_or_absolute_output_path
48
- entry_point_filename ? File.join(directory, entry_point_filename) : directory
48
+ entry_point = entry_point_filename
49
+ entry_point ? File.join(directory, entry_point) : directory
49
50
  end
50
51
 
51
52
  def relative_or_absolute_output_path
@@ -31,8 +31,10 @@ module SimpleCov
31
31
 
32
32
  copy_static_assets
33
33
  # stderr, not stdout: this is a status message, not the program's
34
- # output. Keeps the line out of pipelines like `rspec -f json`.
35
- warn output_message(result) unless @silent
34
+ # output. Keeps the line out of pipelines like `rspec -f json`. And
35
+ # $stderr.puts, not `warn`: a status line should not reach
36
+ # `Warning.warn` hooks or vanish under `-W0` (see #1225).
37
+ $stderr.puts output_message(result) unless @silent # rubocop:disable Style/StderrPuts
36
38
  end
37
39
 
38
40
  # Generate HTML from a pre-existing coverage.json file without
@@ -65,14 +67,18 @@ module SimpleCov
65
67
  # from opening the existing path for writing.
66
68
  def atomic_write(dest, content)
67
69
  temp = "#{dest}.#{Process.pid}.#{rand(2**32).to_s(36)}"
68
- File.binwrite(temp, content)
69
- File.rename(temp, dest)
70
- ensure
71
- FileUtils.rm_f(temp)
70
+ begin
71
+ File.binwrite(temp, content)
72
+ File.rename(temp, dest)
73
+ ensure
74
+ FileUtils.rm_f(temp)
75
+ end
72
76
  end
73
77
 
74
78
  def public_dir
75
- File.join(__dir__, "html_formatter/public/")
79
+ # `.to_s` collapses `__dir__`'s nil arm (only possible under eval,
80
+ # which can't happen for a file on disk) so the path is a String.
81
+ File.join(__dir__.to_s, "html_formatter/public/")
76
82
  end
77
83
  end
78
84
  end
@@ -42,14 +42,20 @@ module SimpleCov
42
42
  end
43
43
 
44
44
  def record_by_file(violation)
45
- file_bucket = bucket(:minimum_coverage_by_file)[violation.fetch(:project_filename)] ||= {}
45
+ by_file = bucket(:minimum_coverage_by_file)
46
+ file_bucket = by_file[violation.fetch(:project_filename)] ||= {} #: Hash[untyped, untyped]
46
47
  file_bucket[key_for(violation)] = expected_actual(violation)
47
48
  end
48
49
 
49
50
  def format_minimum_by_group
50
51
  violations = SimpleCov::CoverageViolations.minimum_by_group(@result, SimpleCov.minimum_coverage_by_group)
52
+ return if violations.empty?
53
+
54
+ # `bucket` lazily creates the errors key, so only touch it when
55
+ # there is a violation to record.
56
+ by_group = bucket(:minimum_coverage_by_group)
51
57
  violations.each do |violation|
52
- group_bucket = bucket(:minimum_coverage_by_group)[violation.fetch(:group_name)] ||= {}
58
+ group_bucket = by_group[violation.fetch(:group_name)] ||= {} #: Hash[untyped, untyped]
53
59
  group_bucket[key_for(violation)] = expected_actual(violation)
54
60
  end
55
61
  end
@@ -68,7 +74,7 @@ module SimpleCov
68
74
  end
69
75
 
70
76
  def bucket(name)
71
- @errors[name] ||= {}
77
+ @errors[name] ||= {} #: Hash[untyped, untyped]
72
78
  end
73
79
 
74
80
  def key_for(violation)
@@ -95,7 +95,7 @@ module SimpleCov
95
95
  end
96
96
 
97
97
  def format_coverage_statistics(statistics)
98
- result = {}
98
+ result = {} #: Hash[Symbol, untyped]
99
99
  result[:lines] = format_line_statistic(statistics[:line]) if statistics[:line]
100
100
  result[:branches] = format_single_statistic(statistics[:branch]) if statistics[:branch]
101
101
  result[:methods] = format_single_statistic(statistics[:method]) if statistics[:method]
@@ -13,7 +13,7 @@ module SimpleCov
13
13
  end
14
14
 
15
15
  def call
16
- result = @include_source ? format_source_code : {}
16
+ result = @include_source ? format_source_code : {} #: Hash[Symbol, untyped]
17
17
  result.merge!(line_coverage_section) if line_coverage_enabled?
18
18
  result.merge!(branch_coverage_section) if SimpleCov.branch_coverage?
19
19
  result.merge!(method_coverage_section) if SimpleCov.method_coverage?
@@ -28,8 +28,10 @@ module SimpleCov
28
28
  warn_if_concurrent_overwrite(path, result)
29
29
  File.write(path, JSON.pretty_generate(self.class.build_hash(result)))
30
30
  # stderr, not stdout: this is a status message, not the program's
31
- # output. Keeps the line out of pipelines like `rspec -f json`.
32
- warn output_message(result) unless @silent
31
+ # output. Keeps the line out of pipelines like `rspec -f json`. And
32
+ # $stderr.puts, not `warn`: a status line should not reach
33
+ # `Warning.warn` hooks or vanish under `-W0` (see #1225).
34
+ $stderr.puts output_message(result) unless @silent # rubocop:disable Style/StderrPuts
33
35
  end
34
36
 
35
37
  private
@@ -12,16 +12,21 @@ module SimpleCov
12
12
  formatters.map do |formatter|
13
13
  formatter.new.format(result)
14
14
  rescue StandardError => e
15
- warn("Formatter #{formatter} failed with #{e.class}: #{e.message} (#{e.backtrace.first})")
15
+ warn("Formatter #{formatter} failed with #{e.class}: #{e.message} (#{(_ = e.backtrace).first})")
16
16
  nil
17
17
  end
18
18
  end
19
19
  end
20
20
 
21
21
  def self.new(formatters = nil)
22
+ # Normalize eagerly and capture the list in the closure. Array()
23
+ # is pure for every accepted input shape (nil, single formatter,
24
+ # array, or another MultiFormatter class), so this is equivalent
25
+ # to the historical lazy per-instance memoization.
26
+ formatter_list = Array(formatters)
22
27
  Class.new do
23
28
  define_method :formatters do
24
- @formatters ||= Array(formatters)
29
+ formatter_list
25
30
  end
26
31
  include InstanceMethods
27
32
  end
@@ -15,7 +15,9 @@ module SimpleCov
15
15
 
16
16
  def format_group(name, files)
17
17
  header = "Group: #{name}\n#{'=' * 40}\n"
18
- body = files.map { |file| "#{file.filename} (coverage: #{file.covered_percent.floor(2)}%)\n" }.join
18
+ # `covered_percent` is nilable across criteria, but line stats are
19
+ # always measured, so the no-argument call can't return nil here.
20
+ body = files.map { |file| "#{file.filename} (coverage: #{(_ = file.covered_percent).floor(2)}%)\n" }.join
19
21
  "#{header}#{body}\n"
20
22
  end
21
23
  end
@@ -9,7 +9,24 @@
9
9
  # correctly with other libraries doing the same — they each prepend
10
10
  # their own module and chain via `super`.
11
11
 
12
+ # Reopened here for the fork-related at_exit plumbing that only matters
13
+ # when subprocess support is loaded.
12
14
  module SimpleCov
15
+ class << self
16
+ # Forked children inherit at_exit state that is wrong for them:
17
+ # @at_exit_hook_installed may describe a hook the parent already
18
+ # consumed before forking (Minitest autorun runs the suite inside the
19
+ # parent's at_exit, after SimpleCov's own hook has fired), and
20
+ # external_at_exit may point at a Minitest.after_run deferral that is
21
+ # pid-pinned to the parent. Reset both so the at_fork proc's
22
+ # SimpleCov.start installs a fresh hook that actually fires at child
23
+ # exit. See issue #1227.
24
+ def reset_inherited_at_exit_state!
25
+ @at_exit_hook_installed = false
26
+ self.external_at_exit = false
27
+ end
28
+ end
29
+
13
30
  # Prepended onto Process's singleton class so every fork — direct or
14
31
  # via Kernel#fork / IO.popen — re-runs SimpleCov's at_fork callback in
15
32
  # the child.
@@ -27,6 +44,11 @@ module SimpleCov
27
44
  # the user installed, so `final_result_process?` can keep forked
28
45
  # workers from each producing the final report. See issue #1171.
29
46
  SimpleCov.mark_forked_subprocess!
47
+ # Without this, a child forked under Minitest autorun measures
48
+ # coverage and then silently discards it: every inherited exit
49
+ # route is dead in the child (see the method's comment). A custom
50
+ # at_fork runs afterwards and can still override. See issue #1227.
51
+ SimpleCov.reset_inherited_at_exit_state!
30
52
  SimpleCov.at_fork.call(::Process.pid)
31
53
  end
32
54
  pid
@@ -63,7 +63,8 @@ module SimpleCov
63
63
  methods = cover_statistic[:methods]
64
64
  return unless methods
65
65
 
66
- cover_statistic[:methods] = methods.each_with_object({}) do |(key, count), normalized|
66
+ normalized_methods = {} #: Hash[untyped, untyped]
67
+ cover_statistic[:methods] = methods.each_with_object(normalized_methods) do |(key, count), normalized|
67
68
  normalized_key = key.dup
68
69
  normalized_key[0] = key[0].to_s
69
70
  .gsub(ADDRESS_PATTERN, ADDRESS_PLACEHOLDER)
@@ -66,7 +66,7 @@ module SimpleCov
66
66
 
67
67
  # Applies the configured filters to the given array of SimpleCov::SourceFile items
68
68
  def filtered(files)
69
- result = files.clone
69
+ result = files.to_a.dup
70
70
  filters.each do |filter|
71
71
  result = result.reject { |source_file| filter.matches?(source_file) }
72
72
  end
@@ -170,9 +170,9 @@ module SimpleCov
170
170
  # off); with merging on the merged result reports dropped source files,
171
171
  # so the per-process slice stays quiet to avoid one warning per worker.
172
172
  def process_coverage_result(report:)
173
- @result = SimpleCov::UselessResultsRemover.call(Coverage.result)
174
- @result = SimpleCov::ResultAdapter.call(@result)
175
- result, not_loaded_files = add_not_loaded_files(@result)
173
+ raw = SimpleCov::UselessResultsRemover.call(Coverage.result)
174
+ adapted = SimpleCov::ResultAdapter.call(raw)
175
+ result, not_loaded_files = add_not_loaded_files(adapted)
176
176
  @result = SimpleCov::Result.new(result, not_loaded_files: not_loaded_files, report: report)
177
177
  end
178
178
  end
@@ -39,8 +39,8 @@ module SimpleCov
39
39
  source_lines = read_lines(absolute_path)
40
40
  lines = coverage_stub(absolute_path, source_lines) ||
41
41
  LinesClassifier.new.classify(source_lines)
42
- synthesized = StaticCoverageExtractor.call(source_lines.join) ||
43
- {"branches" => {}, "methods" => {}}
42
+ empty = {"branches" => {}, "methods" => {}} #: Hash[String, Hash[untyped, untyped]]
43
+ synthesized = StaticCoverageExtractor.call(source_lines.join) || empty
44
44
 
45
45
  {
46
46
  "lines" => lines,
@@ -37,12 +37,12 @@ module SimpleCov
37
37
 
38
38
  # Returns true if this is a line that should have been covered, but was not
39
39
  def missed?
40
- !never? && !skipped? && coverage.zero?
40
+ !never? && !skipped? && coverage.to_i.zero?
41
41
  end
42
42
 
43
43
  # Returns true if this is a line that has been covered
44
44
  def covered?
45
- !never? && !skipped? && coverage.positive?
45
+ !never? && !skipped? && coverage.to_i.positive?
46
46
  end
47
47
 
48
48
  # Returns true if this line is not relevant for coverage
@@ -34,7 +34,7 @@ module SimpleCov
34
34
  end
35
35
 
36
36
  def lines
37
- @lines ||= start_line && end_line ? source_file.lines[(start_line - 1)..(end_line - 1)] : []
37
+ @lines ||= start_line && end_line ? source_file.lines[(start_line - 1)..(end_line - 1)] || [] : []
38
38
  end
39
39
 
40
40
  # Whether this method's source range intersects the given inclusive line range.
@@ -12,7 +12,9 @@ module SimpleCov
12
12
  end
13
13
 
14
14
  def call
15
- methods = @source_file.coverage_data.fetch("methods", {}).filter_map do |info, hit_count|
15
+ none = {} #: Hash[untyped, Integer]
16
+ raw_methods = @source_file.coverage_data.fetch("methods", none)
17
+ methods = raw_methods.filter_map do |info, hit_count|
16
18
  info = RubyDataParser.call(info)
17
19
  next if eval_generated_to_ignore?(info)
18
20
 
@@ -14,12 +14,12 @@ module SimpleCov
14
14
  module_function
15
15
 
16
16
  def call(filename)
17
- lines = []
17
+ lines = [] #: Array[String]
18
18
  # The default encoding is UTF-8
19
19
  File.open(filename, "rb:UTF-8") do |file|
20
20
  current_line = file.gets
21
21
 
22
- if shebang?(current_line)
22
+ if current_line && shebang?(current_line)
23
23
  lines << current_line
24
24
  current_line = file.gets
25
25
  end
@@ -51,8 +51,8 @@ module SimpleCov
51
51
  # returns the `{line:, branch:, method:}` Hash; pass a criterion symbol
52
52
  # (`:line` / `:branch` / `:method`) to get that one CoverageStatistics.
53
53
  def coverage_statistics(criterion = nil)
54
- @coverage_statistics ||= Statistics.new(self).call
55
- criterion ? @coverage_statistics[criterion] : @coverage_statistics
54
+ stats = (@coverage_statistics ||= Statistics.new(self).call)
55
+ criterion ? stats[criterion] : stats
56
56
  end
57
57
 
58
58
  # Returns all source lines for this file as instances of SimpleCov::SourceFile::Line,
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # The source ranges Ruby's Coverage assigns to branch conditions and
6
+ # arms, resolved from Prism nodes. Simulated entries only ever merge
7
+ # with real entries produced by the running Ruby, and CRuby 3.4
8
+ # changed several of these conventions, so every resolver here emits
9
+ # whichever shape this Ruby's Coverage uses. See issue #1226.
10
+ module LocationConventions
11
+ LEGACY_COVERAGE_LOCATIONS = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")
12
+
13
+ # A zero-width stand-in for Prism locations, for the arms Coverage
14
+ # anchors to a point rather than a range.
15
+ PointLocation = Data.define(:start_line, :start_column, :end_line, :end_column)
16
+
17
+ private
18
+
19
+ # simplecov:disable
20
+ # Which arm of each conditional below runs is fixed by the running
21
+ # Ruby's version, so no single process can cover both sides, and
22
+ # the legacy-only helpers are unreachable on modern Rubies (and
23
+ # vice versa). The "runtime tuple equivalence" spec exercises this
24
+ # module against real Coverage output on every CI Ruby.
25
+
26
+ # The range Coverage assigns to an if-like node itself. Modern
27
+ # CRuby uses the node's full source range for every form; 3.2/3.3
28
+ # end an `elsif` clause's range at its last content instead of the
29
+ # shared `end` keyword the clause doesn't own.
30
+ def if_like_location(node, type)
31
+ return node.location unless LEGACY_COVERAGE_LOCATIONS && type == :if && elsif_node?(node)
32
+
33
+ content_end = legacy_content_end(node)
34
+ PointLocation.new(
35
+ start_line: node.location.start_line, start_column: node.location.start_column,
36
+ end_line: content_end.end_line, end_column: content_end.end_column
37
+ )
38
+ end
39
+
40
+ def elsif_node?(node)
41
+ keyword = node.if_keyword_loc
42
+ !keyword.nil? && keyword.slice == "elsif"
43
+ end
44
+
45
+ # Where an if/elsif chain's content ends, for the legacy range
46
+ # convention: the deepest trailing clause's statements, or that
47
+ # clause's predicate / `else` keyword when its body is empty.
48
+ def legacy_content_end(node)
49
+ tail = node
50
+ while tail.is_a?(::Prism::IfNode)
51
+ sub = tail.public_send(IF_NODE_SUBSEQUENT_METHOD)
52
+ break unless sub
53
+
54
+ tail = sub
55
+ end
56
+ return (tail.statements || tail.predicate).location if tail.is_a?(::Prism::IfNode)
57
+
58
+ tail.statements ? tail.statements.location : tail.else_keyword_loc
59
+ end
60
+
61
+ # 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.
65
+ def if_like_then_location(node, type)
66
+ return node.statements.location if node.statements
67
+ return if_like_location(node, type) if LEGACY_COVERAGE_LOCATIONS || type != :if
68
+
69
+ point_at_end(node.predicate.location)
70
+ end
71
+
72
+ # Resolve the source range Coverage attributes to a real-or-synthetic
73
+ # `: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).
78
+ def if_like_else_location(node, type)
79
+ sub = node.is_a?(::Prism::IfNode) ? node.public_send(IF_NODE_SUBSEQUENT_METHOD) : node.else_clause
80
+ return if_like_location(node, type) unless sub
81
+ # An `elsif` arrives as a nested IfNode. Coverage attributes the
82
+ # outer else arm to the clause's own range, not its then body
83
+ # (which is what created phantom unmergeable arms).
84
+ return if_like_location(sub, :if) if sub.is_a?(::Prism::IfNode)
85
+ return sub.statements.location if sub.statements
86
+
87
+ # Empty explicit else: a modern `if` uses the else..end span,
88
+ # while `unless` and legacy Rubies use the condition's range.
89
+ return sub.location if !LEGACY_COVERAGE_LOCATIONS && type == :if
90
+
91
+ if_like_location(node, type)
92
+ end
93
+
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).
99
+ def case_arm_location(case_node, when_node, when_type)
100
+ return when_node.statements.location if when_node.statements
101
+ return when_node.location unless LEGACY_COVERAGE_LOCATIONS
102
+ return point_at_end(when_node.pattern.location) if when_type == :in
103
+
104
+ tail_end = legacy_case_tail_end(case_node, when_node)
105
+ PointLocation.new(
106
+ start_line: when_node.location.start_line, start_column: when_node.location.start_column,
107
+ end_line: tail_end.end_line, end_column: tail_end.end_column
108
+ )
109
+ end
110
+
111
+ # The last body content in the case after `when_node`, falling
112
+ # back to the clause's final condition value.
113
+ def legacy_case_tail_end(case_node, when_node)
114
+ following_case_content(case_node, when_node).last ||
115
+ (when_node.conditions.last || when_node).location
116
+ end
117
+
118
+ def following_case_content(case_node, when_node)
119
+ clauses = case_node.conditions
120
+ 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
125
+ end
126
+
127
+ # Resolve the source range Coverage attributes to a synthetic-or-real
128
+ # `:else` arm of a case construct: the body of an explicit else,
129
+ # the case's full range when no else is present, and — for an
130
+ # explicit else with an empty body — the else..end span on modern
131
+ # Rubies or the case's full range on legacy ones.
132
+ 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
135
+
136
+ LEGACY_COVERAGE_LOCATIONS ? node.location : node.else_clause.location
137
+ end
138
+
139
+ # An empty loop body falls back to the loop's range on modern
140
+ # Rubies and collapses to a point at the predicate's end on legacy
141
+ # ones.
142
+ def loop_body_location(node)
143
+ return node.statements.location if node.statements
144
+ return point_at_end(node.predicate.location) if LEGACY_COVERAGE_LOCATIONS
145
+
146
+ node.location
147
+ end
148
+
149
+ def point_at_end(location)
150
+ PointLocation.new(
151
+ start_line: location.end_line, start_column: location.end_column,
152
+ end_line: location.end_line, end_column: location.end_column
153
+ )
154
+ end
155
+ # simplecov:enable
156
+ end
157
+ end
158
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "location_conventions"
3
4
  require_relative "method_collector"
4
5
 
5
6
  module SimpleCov
@@ -29,6 +30,9 @@ module SimpleCov
29
30
  # Method tuples and the class/module nesting that names them are
30
31
  # collected by this mixin; this class focuses on branch extraction.
31
32
  include MethodCollector
33
+ # Source-range resolution, including the per-Ruby-version Coverage
34
+ # conventions. See issue #1226.
35
+ include LocationConventions
32
36
 
33
37
  attr_reader :branches, :methods
34
38
 
@@ -92,9 +96,9 @@ module SimpleCov
92
96
  # optional else/elsif) but expose the trailing arm under different
93
97
  # accessors. `if_like_else_location` hides that split.
94
98
  def emit_if_like(node, type)
95
- then_loc = arm_location(node.statements, node.location)
96
- else_loc = if_like_else_location(node)
97
- @branches[build_tuple(type, node.location)] = {
99
+ then_loc = if_like_then_location(node, type)
100
+ else_loc = if_like_else_location(node, type)
101
+ @branches[build_tuple(type, if_like_location(node, type))] = {
98
102
  build_tuple(:then, then_loc) => 0,
99
103
  build_tuple(:else, else_loc) => 0
100
104
  }
@@ -108,58 +112,18 @@ module SimpleCov
108
112
  }
109
113
  end
110
114
 
111
- # Resolve the source range Coverage attributes to a real-or-synthetic
112
- # `:else` arm of an if-like construct. IfNode uses
113
- # `subsequent` / `consequent` depending on Prism version (resolved
114
- # to `IF_NODE_SUBSEQUENT_METHOD` at load time); UnlessNode uses
115
- # `else_clause`. When neither is present, the synthesized else
116
- # inherits the whole condition's range (matches Coverage's
117
- # convention).
118
- def if_like_else_location(node)
119
- sub = node.is_a?(::Prism::IfNode) ? node.public_send(IF_NODE_SUBSEQUENT_METHOD) : node.else_clause
120
- return node.location unless sub
121
-
122
- arm_location(else_body_of(sub), sub.location)
123
- end
124
-
125
115
  def emit_case_like(node, when_type)
126
116
  arms = node.conditions.to_h do |when_node|
127
- loc = arm_location(when_node.statements, when_node.location)
128
- [build_tuple(when_type, loc), 0]
117
+ [build_tuple(when_type, case_arm_location(node, when_node, when_type)), 0]
129
118
  end
130
119
  arms[build_tuple(:else, else_arm_location(node))] = 0
131
120
  @branches[build_tuple(:case, node.location)] = arms
132
121
  end
133
122
 
134
- # Resolve the source range Coverage attributes to a synthetic-or-real
135
- # `:else` arm of a case construct: the body of an explicit else,
136
- # or the case's full range when no else is present.
137
- def else_arm_location(node)
138
- return node.location unless node.else_clause
139
-
140
- arm_location(else_body_of(node.else_clause), node.else_clause.location)
141
- end
142
-
143
123
  def emit_loop(node, type)
144
124
  cond_tuple = build_tuple(type, node.location)
145
- body_loc = arm_location(node.statements, node.location)
146
- @branches[cond_tuple] = {build_tuple(:body, body_loc) => 0}
147
- end
148
-
149
- # Body location for an arm. Prism's `statements` is a StatementsNode
150
- # whose span covers the contained expressions; fall back to the
151
- # parent when the arm body is empty (e.g., `if cond then end`).
152
- def arm_location(statements, fallback_location)
153
- statements&.location || fallback_location
154
- end
155
-
156
- # simplecov:disable branch
157
- # The `else_node` fallback is defensive: every Prism node passed
158
- # in here in practice responds to `:statements`.
159
- def else_body_of(else_node)
160
- else_node.respond_to?(:statements) ? else_node.statements : else_node
125
+ @branches[cond_tuple] = {build_tuple(:body, loop_body_location(node)) => 0}
161
126
  end
162
- # simplecov:enable branch
163
127
 
164
128
  def build_tuple(type, location)
165
129
  id = @next_id
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleCov
4
- VERSION = "1.0.0"
4
+ VERSION = "1.0.1"
5
5
  end