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.
- checksums.yaml +4 -4
- data/lib/simplecov/cli/coverage.rb +5 -3
- data/lib/simplecov/cli/diff.rb +2 -1
- data/lib/simplecov/cli/dotfile.rb +9 -4
- data/lib/simplecov/cli/merge.rb +5 -3
- data/lib/simplecov/cli/report.rb +11 -6
- data/lib/simplecov/cli/run.rb +1 -1
- data/lib/simplecov/cli/serve.rb +17 -7
- data/lib/simplecov/cli/uncovered.rb +2 -1
- data/lib/simplecov/combine/branches_combiner.rb +2 -1
- data/lib/simplecov/combine/results_combiner.rb +2 -1
- data/lib/simplecov/configuration/coverage.rb +6 -2
- data/lib/simplecov/configuration/coverage_criteria.rb +3 -1
- data/lib/simplecov/configuration/formatting.rb +17 -7
- data/lib/simplecov/configuration/thresholds.rb +13 -9
- data/lib/simplecov/configuration.rb +22 -13
- data/lib/simplecov/coverage_statistics.rb +4 -1
- data/lib/simplecov/coverage_violations.rb +2 -1
- data/lib/simplecov/defaults.rb +3 -3
- data/lib/simplecov/directive.rb +8 -3
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +1 -1
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +1 -1
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +1 -1
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +1 -1
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +3 -3
- data/lib/simplecov/exit_codes.rb +12 -0
- data/lib/simplecov/exit_handling.rb +3 -3
- data/lib/simplecov/file_list.rb +12 -5
- data/lib/simplecov/formatter/base.rb +2 -1
- data/lib/simplecov/formatter/html_formatter.rb +13 -7
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +9 -3
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +1 -1
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +1 -1
- data/lib/simplecov/formatter/json_formatter.rb +4 -2
- data/lib/simplecov/formatter/multi_formatter.rb +7 -2
- data/lib/simplecov/formatter/simple_formatter.rb +3 -1
- data/lib/simplecov/process.rb +22 -0
- data/lib/simplecov/result_adapter.rb +2 -1
- data/lib/simplecov/result_processing.rb +4 -4
- data/lib/simplecov/simulate_coverage.rb +2 -2
- data/lib/simplecov/source_file/line.rb +2 -2
- data/lib/simplecov/source_file/method.rb +1 -1
- data/lib/simplecov/source_file/method_builder.rb +3 -1
- data/lib/simplecov/source_file/source_loader.rb +2 -2
- data/lib/simplecov/source_file.rb +2 -2
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +158 -0
- data/lib/simplecov/static_coverage_extractor/visitor.rb +9 -45
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov.rb +24 -11
- data/sig/simplecov.rbs +1638 -0
- metadata +5 -3
data/lib/simplecov/file_list.rb
CHANGED
|
@@ -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 ?
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/simplecov/process.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
result, not_loaded_files = add_not_loaded_files(
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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 ?
|
|
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 =
|
|
96
|
-
else_loc = if_like_else_location(node)
|
|
97
|
-
@branches[build_tuple(type, node
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/simplecov/version.rb
CHANGED