simplecov 0.22.0 → 1.1.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/LICENSE +1 -1
- data/README.md +71 -916
- data/exe/simplecov +11 -0
- data/lib/minitest/simplecov_plugin.rb +13 -5
- data/lib/simplecov/atomic_file.rb +70 -0
- data/lib/simplecov/autostart.rb +11 -0
- data/lib/simplecov/cli/clean.rb +87 -0
- data/lib/simplecov/cli/command_helpers.rb +55 -0
- data/lib/simplecov/cli/coverage.rb +86 -0
- data/lib/simplecov/cli/coverage_file.rb +65 -0
- data/lib/simplecov/cli/diff.rb +147 -0
- data/lib/simplecov/cli/dotfile.rb +111 -0
- data/lib/simplecov/cli/merge.rb +120 -0
- data/lib/simplecov/cli/open.rb +48 -0
- data/lib/simplecov/cli/report.rb +97 -0
- data/lib/simplecov/cli/run.rb +36 -0
- data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
- data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
- data/lib/simplecov/cli/serve.rb +85 -0
- data/lib/simplecov/cli/uncovered.rb +103 -0
- data/lib/simplecov/cli.rb +163 -0
- data/lib/simplecov/color.rb +83 -0
- data/lib/simplecov/combine/branches_combiner.rb +54 -7
- data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
- data/lib/simplecov/combine/identity_interner.rb +30 -0
- data/lib/simplecov/combine/interned_counts.rb +30 -0
- data/lib/simplecov/combine/lines_combiner.rb +53 -23
- data/lib/simplecov/combine/methods_combiner.rb +74 -0
- data/lib/simplecov/combine/results_combiner.rb +13 -36
- data/lib/simplecov/combine.rb +5 -23
- data/lib/simplecov/command_guesser.rb +104 -33
- data/lib/simplecov/configuration/coverage.rb +172 -0
- data/lib/simplecov/configuration/coverage_criteria.rb +154 -0
- data/lib/simplecov/configuration/eval_coverage.rb +41 -0
- data/lib/simplecov/configuration/filters.rb +164 -0
- data/lib/simplecov/configuration/formatting.rb +138 -0
- data/lib/simplecov/configuration/groups.rb +42 -0
- data/lib/simplecov/configuration/ignored_entries.rb +63 -0
- data/lib/simplecov/configuration/merging.rb +165 -0
- data/lib/simplecov/configuration/thresholds.rb +179 -0
- data/lib/simplecov/configuration.rb +83 -420
- data/lib/simplecov/coverage_json.rb +24 -0
- data/lib/simplecov/coverage_statistics.rb +16 -10
- data/lib/simplecov/coverage_violations.rb +159 -0
- data/lib/simplecov/defaults.rb +34 -23
- data/lib/simplecov/deprecation.rb +45 -0
- data/lib/simplecov/directive.rb +167 -0
- data/lib/simplecov/exit_codes/check.rb +33 -0
- data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
- data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +18 -65
- data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +32 -0
- data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -36
- data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +30 -0
- data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -33
- data/lib/simplecov/exit_codes.rb +20 -0
- data/lib/simplecov/exit_handling.rb +128 -0
- data/lib/simplecov/file_list.rb +70 -24
- data/lib/simplecov/filter.rb +105 -26
- data/lib/simplecov/formatter/base.rb +118 -0
- data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
- data/lib/simplecov/formatter/html_formatter/public/index.html +87 -0
- data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
- data/lib/simplecov/formatter/html_formatter.rb +105 -0
- data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +88 -0
- data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +118 -0
- data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +93 -0
- data/lib/simplecov/formatter/json_formatter.rb +46 -0
- data/lib/simplecov/formatter/multi_formatter.rb +12 -8
- data/lib/simplecov/formatter/simple_formatter.rb +14 -11
- data/lib/simplecov/formatter.rb +16 -0
- data/lib/simplecov/group_names.rb +32 -0
- data/lib/simplecov/last_run.rb +18 -4
- data/lib/simplecov/lines_classifier.rb +48 -15
- data/lib/simplecov/load_global_config.rb +12 -4
- data/lib/simplecov/parallel_adapters/base.rb +78 -0
- data/lib/simplecov/parallel_adapters/generic.rb +45 -0
- data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
- data/lib/simplecov/parallel_adapters.rb +85 -0
- data/lib/simplecov/parallel_coordination.rb +134 -0
- data/lib/simplecov/parallel_result_merger.rb +230 -0
- data/lib/simplecov/process.rb +52 -12
- data/lib/simplecov/profiles/bundler_filter.rb +1 -1
- data/lib/simplecov/profiles/hidden_filter.rb +1 -1
- data/lib/simplecov/profiles/rails.rb +24 -10
- data/lib/simplecov/profiles/root_filter.rb +6 -5
- data/lib/simplecov/profiles/strict.rb +32 -0
- data/lib/simplecov/profiles/test_frameworks.rb +1 -4
- data/lib/simplecov/profiles.rb +32 -3
- data/lib/simplecov/report_deferral.rb +49 -0
- data/lib/simplecov/report_stamp.rb +28 -0
- data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
- data/lib/simplecov/result/source_file_builder.rb +51 -0
- data/lib/simplecov/result.rb +145 -26
- data/lib/simplecov/result_adapter.rb +139 -8
- data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
- data/lib/simplecov/result_merger/resultset_file.rb +74 -0
- data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
- data/lib/simplecov/result_merger/resultset_store.rb +53 -0
- data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
- data/lib/simplecov/result_merger.rb +109 -114
- data/lib/simplecov/result_processing.rb +218 -0
- data/lib/simplecov/run_identity.rb +77 -0
- data/lib/simplecov/simulate_coverage.rb +83 -12
- data/lib/simplecov/source_file/branch.rb +1 -3
- data/lib/simplecov/source_file/branch_builder.rb +114 -0
- data/lib/simplecov/source_file/builder_context.rb +28 -0
- data/lib/simplecov/source_file/line.rb +9 -4
- data/lib/simplecov/source_file/line_builder.rb +43 -0
- data/lib/simplecov/source_file/method.rb +58 -0
- data/lib/simplecov/source_file/method_builder.rb +60 -0
- data/lib/simplecov/source_file/ruby_data_parser.rb +110 -0
- data/lib/simplecov/source_file/skip_chunks.rb +72 -0
- data/lib/simplecov/source_file/source_loader.rb +79 -0
- data/lib/simplecov/source_file/statistics.rb +65 -0
- data/lib/simplecov/source_file.rb +66 -232
- data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
- data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
- data/lib/simplecov/static_coverage_extractor/method_collector.rb +62 -0
- data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
- data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
- data/lib/simplecov/static_coverage_extractor/visitor.rb +183 -0
- data/lib/simplecov/static_coverage_extractor.rb +110 -0
- data/lib/simplecov/unloaded_file_injector.rb +75 -0
- data/lib/simplecov/useless_results_remover.rb +16 -7
- data/lib/simplecov/version.rb +1 -1
- data/lib/simplecov-html.rb +4 -0
- data/lib/simplecov.rb +185 -380
- data/lib/simplecov_json_formatter.rb +4 -0
- data/sig/simplecov.rbs +1777 -0
- metadata +111 -62
- data/CHANGELOG.md +0 -191
- data/doc/alternate-formatters.md +0 -71
- data/doc/commercial-services.md +0 -25
- data/doc/editor-integration.md +0 -18
- data/lib/simplecov/combine/files_combiner.rb +0 -24
- data/lib/simplecov/default_formatter.rb +0 -20
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
# Identifies one test invocation and each top-level parallel worker within
|
|
7
|
+
# it. Forked subprocesses inherit both values from their parent worker.
|
|
8
|
+
module RunIdentity
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Returns the run id together with its provenance: `true` when the id
|
|
12
|
+
# alone proves same-run membership (explicitly configured, derived from
|
|
13
|
+
# parallel_tests' per-invocation pid file, or freshly random), `false`
|
|
14
|
+
# when it was inferred from the parent pid, which consecutive runs
|
|
15
|
+
# launched by the same long-lived parent process share.
|
|
16
|
+
def generate
|
|
17
|
+
explicit = ENV.fetch("SIMPLECOV_RUN_ID", nil)
|
|
18
|
+
return [explicit, true] if explicit && !explicit.empty?
|
|
19
|
+
|
|
20
|
+
pid_file = ENV.fetch("PARALLEL_PID_FILE", nil)
|
|
21
|
+
return ["parallel-tests:#{File.basename(pid_file)}", true] if pid_file && !pid_file.empty?
|
|
22
|
+
|
|
23
|
+
return ["parallel-parent:#{Process.ppid}", false] if SimpleCov::ParallelAdapters.current
|
|
24
|
+
|
|
25
|
+
[SecureRandom.uuid, true]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def worker_id
|
|
29
|
+
explicit = ENV.fetch("SIMPLECOV_WORKER_ID", nil)
|
|
30
|
+
return explicit if explicit && !explicit.empty?
|
|
31
|
+
|
|
32
|
+
number = ENV.fetch("TEST_ENV_NUMBER", nil)
|
|
33
|
+
return number.empty? ? "1" : number if number
|
|
34
|
+
|
|
35
|
+
Process.pid.to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Whether the current run id alone proves same-run membership. Decided
|
|
39
|
+
# where the id is generated, never re-inferred from the id's shape, so
|
|
40
|
+
# an explicit SIMPLECOV_RUN_ID that happens to look like an inferred
|
|
41
|
+
# one is still trusted.
|
|
42
|
+
def authoritative?
|
|
43
|
+
materialize_current
|
|
44
|
+
@authoritative
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def current
|
|
48
|
+
materialize_current
|
|
49
|
+
@current
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def materialize_current
|
|
53
|
+
@current, @authoritative = generate unless defined?(@current)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def current_worker_id
|
|
57
|
+
@current_worker_id ||= worker_id
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def prepare
|
|
61
|
+
SimpleCov::ParallelAdapters.current
|
|
62
|
+
SimpleCov.run_id
|
|
63
|
+
SimpleCov.worker_id
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Mixed into SimpleCov after ParallelAdapters has loaded.
|
|
67
|
+
module Accessors
|
|
68
|
+
def run_id
|
|
69
|
+
SimpleCov::RunIdentity.current
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def worker_id
|
|
73
|
+
SimpleCov::RunIdentity.current_worker_id
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "coverage"
|
|
4
|
+
require_relative "static_coverage_extractor"
|
|
5
|
+
|
|
3
6
|
module SimpleCov
|
|
4
7
|
#
|
|
5
8
|
# Responsible for producing file coverage metrics.
|
|
@@ -8,22 +11,90 @@ module SimpleCov
|
|
|
8
11
|
module_function
|
|
9
12
|
|
|
10
13
|
#
|
|
11
|
-
# Simulate
|
|
12
|
-
#
|
|
14
|
+
# Simulate a file coverage report for a file that was tracked but never
|
|
15
|
+
# required. Returns the same hash shape as `Coverage.result` (lines,
|
|
16
|
+
# branches, methods).
|
|
17
|
+
#
|
|
18
|
+
# The line classification comes from `Coverage.line_stub` — the same
|
|
19
|
+
# classification the runtime would have produced if the file had been
|
|
20
|
+
# required — overlaid with SimpleCov's `# :nocov:` toggles and
|
|
21
|
+
# `# simplecov:disable line` directive ranges, which `Coverage` doesn't
|
|
22
|
+
# know about. This keeps "relevant lines" identical whether a file was
|
|
23
|
+
# loaded or just tracked, fixing the multi-line statement discrepancy
|
|
24
|
+
# in https://github.com/simplecov-ruby/simplecov/issues/654.
|
|
25
|
+
#
|
|
26
|
+
# Branches and methods are enumerated by static analysis (via
|
|
27
|
+
# `StaticCoverageExtractor`, which uses Prism). Earlier behavior left
|
|
28
|
+
# both as empty hashes, which made unloaded files invisible to the
|
|
29
|
+
# branch/method denominators while their lines DID count — so a
|
|
30
|
+
# `track_files`/`cover` glob that picked up files without specs
|
|
31
|
+
# silently inflated branch% relative to line%. See
|
|
32
|
+
# https://github.com/simplecov-ruby/simplecov/issues/1059. When Prism
|
|
33
|
+
# isn't loadable (Ruby < 3.3 without the prism gem) or the file
|
|
34
|
+
# can't be parsed, fall back to the old empty hashes — old behavior,
|
|
35
|
+
# old tradeoff.
|
|
36
|
+
#
|
|
37
|
+
# Pass `synthesize: false` to skip the static analysis and return the
|
|
38
|
+
# empty hashes directly. Callers use it when neither branch nor method
|
|
39
|
+
# coverage is enabled, since nothing will read the tuples and the Prism
|
|
40
|
+
# parse is about half the cost of simulating a file. See #1250.
|
|
13
41
|
#
|
|
14
|
-
#
|
|
42
|
+
# Pass `lines: false` to omit the `"lines"` key entirely, mirroring what
|
|
43
|
+
# `Coverage.result` reports for a file loaded under a branch-only or
|
|
44
|
+
# method-only run. Emitting zeroed lines there would make a simulated
|
|
45
|
+
# file indistinguishable from one a sibling process actually loaded once
|
|
46
|
+
# the two are merged.
|
|
15
47
|
#
|
|
16
48
|
# @return [Hash]
|
|
17
49
|
#
|
|
18
|
-
def call(absolute_path)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
50
|
+
def call(absolute_path, synthesize: true, lines: true)
|
|
51
|
+
source_lines = read_lines(absolute_path)
|
|
52
|
+
simulated = synthesized_tuples(source_lines, synthesize)
|
|
53
|
+
return simulated unless lines
|
|
54
|
+
|
|
55
|
+
classified = coverage_stub(absolute_path, source_lines) ||
|
|
56
|
+
LinesClassifier.new.classify(source_lines)
|
|
57
|
+
{"lines" => classified}.merge(simulated)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The branch and method tuples for a file, or empty hashes when the static
|
|
61
|
+
# analysis is skipped (nothing enabled reads them) or unavailable (no Prism,
|
|
62
|
+
# or the file doesn't parse).
|
|
63
|
+
def synthesized_tuples(source_lines, synthesize)
|
|
64
|
+
empty = {"branches" => {}, "methods" => {}} #: Hash[String, Hash[untyped, untyped]]
|
|
65
|
+
synthesized = (StaticCoverageExtractor.call(source_lines.join) if synthesize) || empty
|
|
66
|
+
|
|
67
|
+
{"branches" => synthesized["branches"], "methods" => synthesized["methods"]}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# SystemCallError, not just ENOENT: a `track_files` glob can sweep
|
|
71
|
+
# up an unreadable file or a directory named like a Ruby file
|
|
72
|
+
# (EACCES, EISDIR), and simulation must degrade to "empty file"
|
|
73
|
+
# rather than crash the merge or report step.
|
|
74
|
+
def read_lines(path)
|
|
75
|
+
File.readlines(path)
|
|
76
|
+
rescue SystemCallError
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Combine `Coverage.line_stub` (which gets multi-line statements right)
|
|
81
|
+
# with `LinesClassifier` (which knows about `# :nocov:` toggles and
|
|
82
|
+
# `# simplecov:disable line` ranges). Returns nil — and the caller
|
|
83
|
+
# falls back to `LinesClassifier` alone — when `Coverage` can't read
|
|
84
|
+
# or parse the file, or when the runtime doesn't expose `line_stub`
|
|
85
|
+
# (JRuby and TruffleRuby).
|
|
86
|
+
def coverage_stub(path, source_lines)
|
|
87
|
+
return nil unless Coverage.respond_to?(:line_stub)
|
|
88
|
+
|
|
89
|
+
stub = Coverage.line_stub(path)
|
|
90
|
+
classifier_output = LinesClassifier.new.classify(source_lines)
|
|
91
|
+
stub.each_index { |idx| stub[idx] = nil if classifier_output[idx].nil? }
|
|
92
|
+
stub
|
|
93
|
+
rescue SystemCallError, SyntaxError
|
|
94
|
+
# SystemCallError for the same reason as read_lines above:
|
|
95
|
+
# line_stub reads the file itself, so EACCES/EISDIR surface here
|
|
96
|
+
# too.
|
|
97
|
+
nil
|
|
27
98
|
end
|
|
28
99
|
end
|
|
29
100
|
end
|
|
@@ -8,7 +8,6 @@ module SimpleCov
|
|
|
8
8
|
class Branch
|
|
9
9
|
attr_reader :start_line, :end_line, :coverage, :type
|
|
10
10
|
|
|
11
|
-
# rubocop:disable Metrics/ParameterLists
|
|
12
11
|
def initialize(start_line:, end_line:, coverage:, inline:, type:)
|
|
13
12
|
@start_line = start_line
|
|
14
13
|
@end_line = end_line
|
|
@@ -17,7 +16,6 @@ module SimpleCov
|
|
|
17
16
|
@type = type
|
|
18
17
|
@skipped = false
|
|
19
18
|
end
|
|
20
|
-
# rubocop:enable Metrics/ParameterLists
|
|
21
19
|
|
|
22
20
|
def inline?
|
|
23
21
|
@inline
|
|
@@ -33,7 +31,7 @@ module SimpleCov
|
|
|
33
31
|
end
|
|
34
32
|
|
|
35
33
|
#
|
|
36
|
-
# Check if
|
|
34
|
+
# Check if branch missed or not
|
|
37
35
|
#
|
|
38
36
|
# @return [Boolean]
|
|
39
37
|
#
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
class SourceFile
|
|
5
|
+
# Builds the `SourceFile::Branch` objects for a source file from
|
|
6
|
+
# the raw branch data Ruby's Coverage library reports. Applies the
|
|
7
|
+
# `ignore_branches :eval_generated` / `:implicit_else` filters and
|
|
8
|
+
# marks branches inside `# simplecov:disable` / `# :nocov:` chunks
|
|
9
|
+
# as skipped.
|
|
10
|
+
class BranchBuilder
|
|
11
|
+
def initialize(source_file)
|
|
12
|
+
@source_file = source_file
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
coverage_branch_data = @source_file.coverage_data["branches"] || {}
|
|
17
|
+
branches = coverage_branch_data.flat_map do |condition, coverage_branches|
|
|
18
|
+
next [] if eval_generated_condition_to_ignore?(condition)
|
|
19
|
+
|
|
20
|
+
build_branches_from(condition, coverage_branches)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
process_skipped(branches)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Detect a Coverage-reported branch condition that originates from
|
|
29
|
+
# `eval`/`module_eval`/`class_eval`/`instance_eval` rather than from
|
|
30
|
+
# the file's literal source. Coverage attributes such branches to the
|
|
31
|
+
# caller's `__FILE__`/`__LINE__`, so a Rails `delegate :foo, to: :bar`
|
|
32
|
+
# call surfaces inside the source file as if there were branches at
|
|
33
|
+
# the `delegate` line. Prism never sees those branches in the static
|
|
34
|
+
# source, so a condition whose start_line isn't in the real-source
|
|
35
|
+
# branch set must be eval-generated. Only consulted when the user has
|
|
36
|
+
# opted in via `SimpleCov.ignore_branches :eval_generated`. See #1046.
|
|
37
|
+
def eval_generated_condition_to_ignore?(condition)
|
|
38
|
+
return false unless SimpleCov.ignored_branch?(:eval_generated)
|
|
39
|
+
|
|
40
|
+
positions = @source_file.real_source_positions
|
|
41
|
+
# simplecov:disable branch — nil branch fires only when Prism is unavailable
|
|
42
|
+
return false unless positions
|
|
43
|
+
|
|
44
|
+
# simplecov:enable branch
|
|
45
|
+
|
|
46
|
+
_type, _id, start_line, * = RubyDataParser.call(condition)
|
|
47
|
+
!positions[:branches].include?(start_line)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_branches_from(condition, branches)
|
|
51
|
+
# the format handed in from the coverage data is like this:
|
|
52
|
+
#
|
|
53
|
+
# [:then, 4, 6, 6, 6, 10]
|
|
54
|
+
#
|
|
55
|
+
# which is [type, id, start_line, start_col, end_line, end_col]
|
|
56
|
+
_condition_type, _condition_id, *condition_range = RubyDataParser.call(condition)
|
|
57
|
+
|
|
58
|
+
branches.filter_map do |branch_data, hit_count|
|
|
59
|
+
build_branch(RubyDataParser.call(branch_data), hit_count, condition_range)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_branch(branch_data, hit_count, condition_range)
|
|
64
|
+
type, _id, start_line, start_col, end_line, end_col = branch_data
|
|
65
|
+
return nil if implicit_else_to_ignore?(type, [start_line, start_col, end_line, end_col], condition_range)
|
|
66
|
+
|
|
67
|
+
SourceFile::Branch.new(
|
|
68
|
+
start_line: start_line,
|
|
69
|
+
end_line: end_line,
|
|
70
|
+
coverage: hit_count,
|
|
71
|
+
inline: start_line == condition_range.first,
|
|
72
|
+
type: type
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Detect synthetic `:else` branches that Ruby's Coverage library reports
|
|
77
|
+
# for constructs with no literal `else` keyword in source (`case/in` /
|
|
78
|
+
# `case/when` without else, `||=`, `&&=`, `if`/`unless` without else,
|
|
79
|
+
# and the postfix `return if cond` shape). The signal is structural:
|
|
80
|
+
# a synthetic else reuses its parent condition's *full source range*
|
|
81
|
+
# (start_line, start_col, end_line, end_col all identical), while an
|
|
82
|
+
# explicit `else` arm carries a narrower range — its own keyword/body
|
|
83
|
+
# position rather than the whole conditional. Comparing the full range
|
|
84
|
+
# (not just `start_line`) is what distinguishes a ternary's explicit
|
|
85
|
+
# else on the same line as the condition — `arg == 42 ? :yes : :no`,
|
|
86
|
+
# where the else's columns differ from the parent's — from a postfix
|
|
87
|
+
# `return if cond` where the synthetic else inherits the full range.
|
|
88
|
+
# Only consulted when the user has opted in via
|
|
89
|
+
# `SimpleCov.ignore_branches :implicit_else`. See #1033.
|
|
90
|
+
def implicit_else_to_ignore?(type, branch_range, condition_range)
|
|
91
|
+
return false unless type == :else
|
|
92
|
+
return false unless SimpleCov.ignored_branch?(:implicit_else)
|
|
93
|
+
|
|
94
|
+
branch_range == condition_range
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def process_skipped(branches)
|
|
98
|
+
chunks = @source_file.skip_chunks_for(:branch)
|
|
99
|
+
return branches if chunks.empty?
|
|
100
|
+
|
|
101
|
+
# A non-inline branch's source range starts on its arm body (e.g. the
|
|
102
|
+
# `:yes` line of `if cond / :yes / else / :no / end`), but `report_line`
|
|
103
|
+
# is the condition line above it — that's where the user sees the
|
|
104
|
+
# branch in the report and where they would naturally place an inline
|
|
105
|
+
# `# simplecov:disable branch` directive. Honour both.
|
|
106
|
+
branches.each do |branch|
|
|
107
|
+
branch.skipped! if chunks.any? { |chunk| branch.overlaps_with?(chunk) || chunk.include?(branch.report_line) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
branches
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
class SourceFile
|
|
5
|
+
# Helpers shared by the per-criterion builders (LineBuilder /
|
|
6
|
+
# BranchBuilder / MethodBuilder). Mixed into SourceFile so each
|
|
7
|
+
# builder can ask the file for its skip-chunk ranges and Prism-derived
|
|
8
|
+
# real source positions without duplicating the memoization.
|
|
9
|
+
module BuilderContext
|
|
10
|
+
# Skip-chunk lookup for the named criterion (`:line`, `:branch`,
|
|
11
|
+
# `:method`).
|
|
12
|
+
def skip_chunks_for(criterion)
|
|
13
|
+
(@skip_chunks ||= SkipChunks.new(filename, src)).for(criterion)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Memoized set of real source positions (branch start lines, method
|
|
17
|
+
# name+line pairs) extracted via Prism. Returns nil when Prism is
|
|
18
|
+
# unavailable or parsing fails, signaling callers to keep every
|
|
19
|
+
# Coverage entry (no false drops). The `defined?` guard preserves a
|
|
20
|
+
# nil memoization across calls.
|
|
21
|
+
def real_source_positions
|
|
22
|
+
return @real_source_positions if defined?(@real_source_positions)
|
|
23
|
+
|
|
24
|
+
@real_source_positions = StaticCoverageExtractor.real_source_positions(src.join)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -25,7 +25,9 @@ module SimpleCov
|
|
|
25
25
|
def initialize(src, line_number, coverage)
|
|
26
26
|
raise ArgumentError, "Only String accepted for source" unless src.is_a?(String)
|
|
27
27
|
raise ArgumentError, "Only Integer accepted for line_number" unless line_number.is_a?(Integer)
|
|
28
|
-
|
|
28
|
+
unless coverage.is_a?(Integer) || coverage.nil?
|
|
29
|
+
raise ArgumentError, "Only Integer and nil accepted for coverage"
|
|
30
|
+
end
|
|
29
31
|
|
|
30
32
|
@src = src
|
|
31
33
|
@line_number = line_number
|
|
@@ -35,12 +37,12 @@ module SimpleCov
|
|
|
35
37
|
|
|
36
38
|
# Returns true if this is a line that should have been covered, but was not
|
|
37
39
|
def missed?
|
|
38
|
-
!never? && !skipped? && coverage.zero?
|
|
40
|
+
!never? && !skipped? && coverage.to_i.zero?
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
# Returns true if this is a line that has been covered
|
|
42
44
|
def covered?
|
|
43
|
-
!never? && !skipped? && coverage.positive?
|
|
45
|
+
!never? && !skipped? && coverage.to_i.positive?
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
# Returns true if this line is not relevant for coverage
|
|
@@ -65,7 +67,10 @@ module SimpleCov
|
|
|
65
67
|
return "skipped" if skipped?
|
|
66
68
|
return "never" if never?
|
|
67
69
|
return "missed" if missed?
|
|
68
|
-
|
|
70
|
+
|
|
71
|
+
# simplecov:disable — defensive: covered? is the only state left after the three above
|
|
72
|
+
"covered" if covered?
|
|
73
|
+
# simplecov:enable
|
|
69
74
|
end
|
|
70
75
|
end
|
|
71
76
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
class SourceFile
|
|
5
|
+
# Builds the `SourceFile::Line` objects for a source file from the
|
|
6
|
+
# raw line-coverage array. Each line carries its source text, its
|
|
7
|
+
# 1-based line number, and the Coverage hit count (or nil for
|
|
8
|
+
# never-counted lines). Applies `# simplecov:disable` /
|
|
9
|
+
# `# :nocov:` block ranges via `skipped!`.
|
|
10
|
+
class LineBuilder
|
|
11
|
+
def initialize(source_file)
|
|
12
|
+
@source_file = source_file
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
lines = build_lines
|
|
17
|
+
mark_skipped(lines, @source_file.skip_chunks_for(:line))
|
|
18
|
+
lines
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# When `:line` coverage is disabled, the Ruby Coverage module
|
|
24
|
+
# doesn't emit "lines" data, so look up `nil` (never-counted) for
|
|
25
|
+
# every position. The source rows are still useful — e.g. for the
|
|
26
|
+
# HTML report's source view — even without per-line hits.
|
|
27
|
+
def build_lines
|
|
28
|
+
line_coverage = @source_file.coverage_data["lines"] || []
|
|
29
|
+
@source_file.src.map.with_index(1) do |src, i|
|
|
30
|
+
SourceFile::Line.new(src, i, line_coverage[i - 1])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The array the lines are kept in is 0-based whereas the line
|
|
35
|
+
# numbers in the chunks are 1-based (more understandable elsewhere),
|
|
36
|
+
# so each range needs to be shifted down by one to slice into the
|
|
37
|
+
# `lines` array.
|
|
38
|
+
def mark_skipped(lines, chunks)
|
|
39
|
+
chunks.each { |chunk| lines[(chunk.begin - 1)..(chunk.end - 1)].each(&:skipped!) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
class SourceFile
|
|
5
|
+
# Represents a single method detected in coverage data.
|
|
6
|
+
# Provides helpers similar to Branch for coverage status.
|
|
7
|
+
class Method
|
|
8
|
+
attr_reader :source_file, :coverage, :class_name, :method_name,
|
|
9
|
+
:start_line, :start_col, :end_line, :end_col
|
|
10
|
+
|
|
11
|
+
def initialize(source_file, info, coverage)
|
|
12
|
+
@source_file = source_file
|
|
13
|
+
@class_name, @method_name, @start_line, @start_col, @end_line, @end_col = info
|
|
14
|
+
@coverage = coverage
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def covered?
|
|
18
|
+
!skipped? && coverage.positive?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Criterion-level skips (nocov chunks, `# simplecov:disable` /
|
|
22
|
+
# `# simplecov:disable method` regions) arrive via `skipped!` from
|
|
23
|
+
# MethodBuilder. Deliberately NOT derived from the lines' skip
|
|
24
|
+
# state: a line-only directive around a def must not remove the
|
|
25
|
+
# method from method totals. Without line info there is nothing to
|
|
26
|
+
# report against, so such a method stays skipped.
|
|
27
|
+
def skipped?
|
|
28
|
+
return @skipped if defined?(@skipped)
|
|
29
|
+
|
|
30
|
+
@skipped = lines.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Flag the method as skipped directly, without going through its lines.
|
|
34
|
+
def skipped!
|
|
35
|
+
@skipped = true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def missed?
|
|
39
|
+
!skipped? && coverage.zero?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def lines
|
|
43
|
+
@lines ||= start_line && end_line ? source_file.lines[(start_line - 1)..(end_line - 1)] || [] : []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Whether this method's source range intersects the given inclusive line range.
|
|
47
|
+
def overlaps_with?(line_range)
|
|
48
|
+
return false unless start_line && end_line
|
|
49
|
+
|
|
50
|
+
start_line <= line_range.end && end_line >= line_range.begin
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_s
|
|
54
|
+
"#{class_name}##{method_name}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleCov
|
|
4
|
+
class SourceFile
|
|
5
|
+
# Builds the `SourceFile::Method` objects for a source file from
|
|
6
|
+
# the raw method-coverage data. Applies the
|
|
7
|
+
# `ignore_methods :eval_generated` filter and marks methods inside
|
|
8
|
+
# `# simplecov:disable` / `# :nocov:` chunks as skipped.
|
|
9
|
+
class MethodBuilder
|
|
10
|
+
def initialize(source_file)
|
|
11
|
+
@source_file = source_file
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
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|
|
|
18
|
+
info = RubyDataParser.call(info)
|
|
19
|
+
next if eval_generated_to_ignore?(info)
|
|
20
|
+
|
|
21
|
+
SourceFile::Method.new(@source_file, info, hit_count)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
process_skipped(methods)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# See `BranchBuilder#eval_generated_condition_to_ignore?` for the
|
|
30
|
+
# rationale. Coverage reports an eval'd `def` at the eval caller's
|
|
31
|
+
# line and name, so a method whose `(name, start_line)` is absent
|
|
32
|
+
# from the real-source `def` set is eval-generated. Only consulted
|
|
33
|
+
# when the user has opted in via
|
|
34
|
+
# `SimpleCov.ignore_methods :eval_generated`. See #1046.
|
|
35
|
+
def eval_generated_to_ignore?(info)
|
|
36
|
+
return false unless SimpleCov.ignored_method?(:eval_generated)
|
|
37
|
+
|
|
38
|
+
positions = @source_file.real_source_positions
|
|
39
|
+
# simplecov:disable branch — nil branch fires only when Prism is unavailable
|
|
40
|
+
return false unless positions
|
|
41
|
+
|
|
42
|
+
# simplecov:enable branch
|
|
43
|
+
|
|
44
|
+
_class_name, name, start_line, * = info
|
|
45
|
+
!positions[:methods].include?([name, start_line])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def process_skipped(methods)
|
|
49
|
+
chunks = @source_file.skip_chunks_for(:method)
|
|
50
|
+
return methods if chunks.empty?
|
|
51
|
+
|
|
52
|
+
methods.each do |method|
|
|
53
|
+
method.skipped! if chunks.any? { |chunk| method.overlaps_with?(chunk) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
methods
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module SimpleCov
|
|
6
|
+
class SourceFile
|
|
7
|
+
# `Coverage.result` reports condition and method keys as Ruby
|
|
8
|
+
# arrays. When the resultset is round-tripped through JSON those
|
|
9
|
+
# array keys become their stringified inspect form, so this parser
|
|
10
|
+
# walks the literal back into a real Array without using `eval` (see
|
|
11
|
+
# #801). The grammar covers symbols, strings, integers, unary minus,
|
|
12
|
+
# and constant paths — every shape Coverage ever emits.
|
|
13
|
+
module RubyDataParser
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Tests use the real data structures (except for integration tests)
|
|
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.
|
|
31
|
+
def call(structure)
|
|
32
|
+
return structure if structure.is_a?(Array)
|
|
33
|
+
|
|
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]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Parse a string like '[:if, 0, 3, 4, 3, 21]' or
|
|
43
|
+
# '["ClassName", :method1, 2, 2, 5, 5]' back into a Ruby array.
|
|
44
|
+
def parse_array_string(str)
|
|
45
|
+
# Try plain Ripper first; only pre-quote `#<...>` inspect segments
|
|
46
|
+
# if the input isn't already valid Ruby (otherwise we corrupt
|
|
47
|
+
# `"#<Class:Foo>"` strings that *are* valid Ruby literals — exactly
|
|
48
|
+
# the shape simplecov-on-simplecov method-coverage keys take).
|
|
49
|
+
sexp = Ripper.sexp(str) || Ripper.sexp(quote_inspected_class_segments(str))
|
|
50
|
+
# simplecov:disable — defensive: Ripper.sexp returning nil from both passes requires malformed input
|
|
51
|
+
array_node = sexp&.dig(1, 0)
|
|
52
|
+
# simplecov:enable
|
|
53
|
+
raise ArgumentError, "expected array literal: #{str.inspect}" unless array_node && array_node[0] == :array
|
|
54
|
+
|
|
55
|
+
Array(array_node[1]).map { |element| parse_element(element) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_element(node)
|
|
59
|
+
case node[0]
|
|
60
|
+
when :@int, :unary then parse_integer_node(node)
|
|
61
|
+
when :symbol_literal, :dyna_symbol then parse_symbol_node(node)
|
|
62
|
+
when :string_literal then unescape_ruby(string_literal_text(node[1]))
|
|
63
|
+
when :var_ref then node.dig(1, 1) # `Foo`
|
|
64
|
+
when :const_path_ref then "#{parse_element(node[1])}::#{node[2][1]}" # `Foo::Bar`
|
|
65
|
+
else
|
|
66
|
+
# simplecov:disable — defensive fallback for unexpected Ripper node shapes
|
|
67
|
+
raise ArgumentError, "unexpected element: #{node.inspect}"
|
|
68
|
+
# simplecov:enable
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_integer_node(node)
|
|
73
|
+
node[0] == :@int ? node[1].to_i : -node[2][1].to_i
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_symbol_node(node)
|
|
77
|
+
if node[0] == :symbol_literal
|
|
78
|
+
node.dig(1, 1, 1).to_sym
|
|
79
|
+
else
|
|
80
|
+
unescape_ruby(string_literal_text(node[1])).to_sym
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Concatenate the text fragments of a `:string_content` node. Ripper
|
|
85
|
+
# may emit zero, one, or many `:@tstring_content` children depending
|
|
86
|
+
# on the literal.
|
|
87
|
+
def string_literal_text(string_content)
|
|
88
|
+
Array(string_content[1..]).map { |child| child[1] }.join
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Undo the same backslash-prefix escapes the previous hand-rolled
|
|
92
|
+
# parser undid: `\X` → `X` for any X.
|
|
93
|
+
def unescape_ruby(raw)
|
|
94
|
+
raw.gsub(/\\(.)/) { ::Regexp.last_match(1) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Method coverage keys can contain inspect-format class references
|
|
98
|
+
# like `#<Class:Foo>` or `#<Class:0x...>`, which aren't valid Ruby
|
|
99
|
+
# syntax. Wrap them in quotes so Ripper can parse the surrounding
|
|
100
|
+
# array literal; downstream we treat them as opaque strings. The
|
|
101
|
+
# pattern recurses because singleton methods on instances nest one
|
|
102
|
+
# inspect segment inside another (`#<Class:#<Object:0x...>>`), and
|
|
103
|
+
# stopping at the first `>` leaves a dangling `>` that fails both
|
|
104
|
+
# Ripper passes and crashes the merge.
|
|
105
|
+
def quote_inspected_class_segments(str)
|
|
106
|
+
str.gsub(/#<(?:[^<>]|\g<0>)*>/) { |segment| %("#{segment.gsub('"', '\\"')}") }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|