simplecov 1.0.3 → 1.1.0

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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -1439
  3. data/lib/simplecov/atomic_file.rb +70 -0
  4. data/lib/simplecov/cli/clean.rb +43 -3
  5. data/lib/simplecov/cli/command_helpers.rb +55 -0
  6. data/lib/simplecov/cli/coverage.rb +21 -28
  7. data/lib/simplecov/cli/coverage_file.rb +65 -0
  8. data/lib/simplecov/cli/diff.rb +43 -48
  9. data/lib/simplecov/cli/dotfile.rb +12 -6
  10. data/lib/simplecov/cli/merge.rb +12 -10
  11. data/lib/simplecov/cli/open.rb +3 -5
  12. data/lib/simplecov/cli/report.rb +31 -23
  13. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  14. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  15. data/lib/simplecov/cli/serve.rb +30 -94
  16. data/lib/simplecov/cli/uncovered.rb +20 -25
  17. data/lib/simplecov/cli.rb +15 -2
  18. data/lib/simplecov/combine/branches_combiner.rb +43 -19
  19. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  20. data/lib/simplecov/combine/identity_interner.rb +30 -0
  21. data/lib/simplecov/combine/interned_counts.rb +30 -0
  22. data/lib/simplecov/combine/lines_combiner.rb +48 -20
  23. data/lib/simplecov/combine/methods_combiner.rb +36 -12
  24. data/lib/simplecov/combine/results_combiner.rb +12 -37
  25. data/lib/simplecov/combine.rb +5 -23
  26. data/lib/simplecov/command_guesser.rb +66 -9
  27. data/lib/simplecov/configuration/coverage.rb +12 -15
  28. data/lib/simplecov/configuration/coverage_criteria.rb +34 -38
  29. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  30. data/lib/simplecov/configuration/filters.rb +11 -44
  31. data/lib/simplecov/configuration/formatting.rb +6 -3
  32. data/lib/simplecov/configuration/groups.rb +42 -0
  33. data/lib/simplecov/configuration/merging.rb +8 -7
  34. data/lib/simplecov/configuration/thresholds.rb +14 -13
  35. data/lib/simplecov/configuration.rb +13 -43
  36. data/lib/simplecov/coverage_json.rb +24 -0
  37. data/lib/simplecov/coverage_statistics.rb +1 -1
  38. data/lib/simplecov/coverage_violations.rb +15 -5
  39. data/lib/simplecov/defaults.rb +7 -3
  40. data/lib/simplecov/directive.rb +1 -1
  41. data/lib/simplecov/exit_codes/check.rb +33 -0
  42. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
  43. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +3 -16
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
  45. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
  46. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +4 -17
  47. data/lib/simplecov/exit_codes.rb +1 -0
  48. data/lib/simplecov/exit_handling.rb +11 -41
  49. data/lib/simplecov/file_list.rb +5 -10
  50. data/lib/simplecov/filter.rb +43 -9
  51. data/lib/simplecov/formatter/base.rb +11 -0
  52. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  53. data/lib/simplecov/formatter/html_formatter/public/index.html +35 -5
  54. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  55. data/lib/simplecov/formatter/html_formatter.rb +67 -47
  56. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -56
  57. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
  58. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
  59. data/lib/simplecov/formatter/json_formatter.rb +5 -48
  60. data/lib/simplecov/formatter/multi_formatter.rb +1 -1
  61. data/lib/simplecov/formatter/simple_formatter.rb +8 -5
  62. data/lib/simplecov/formatter.rb +5 -0
  63. data/lib/simplecov/group_names.rb +32 -0
  64. data/lib/simplecov/last_run.rb +16 -9
  65. data/lib/simplecov/lines_classifier.rb +29 -8
  66. data/lib/simplecov/load_global_config.rb +5 -2
  67. data/lib/simplecov/parallel_adapters/base.rb +17 -0
  68. data/lib/simplecov/parallel_adapters/generic.rb +2 -2
  69. data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
  70. data/lib/simplecov/parallel_coordination.rb +6 -1
  71. data/lib/simplecov/parallel_result_merger.rb +230 -0
  72. data/lib/simplecov/report_deferral.rb +49 -0
  73. data/lib/simplecov/report_stamp.rb +28 -0
  74. data/lib/simplecov/result.rb +40 -10
  75. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  76. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  77. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  78. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  79. data/lib/simplecov/result_merger.rb +69 -42
  80. data/lib/simplecov/result_processing.rb +82 -43
  81. data/lib/simplecov/run_identity.rb +77 -0
  82. data/lib/simplecov/simulate_coverage.rb +35 -11
  83. data/lib/simplecov/source_file/method.rb +7 -1
  84. data/lib/simplecov/source_file/ruby_data_parser.rb +6 -2
  85. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  86. data/lib/simplecov/source_file/source_loader.rb +23 -7
  87. data/lib/simplecov/source_file/statistics.rb +24 -16
  88. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +203 -13
  89. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +19 -30
  90. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  91. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  92. data/lib/simplecov/static_coverage_extractor/value_position.rb +6 -14
  93. data/lib/simplecov/static_coverage_extractor/visitor.rb +23 -36
  94. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  95. data/lib/simplecov/version.rb +1 -1
  96. data/lib/simplecov.rb +11 -5
  97. data/schemas/coverage-v1.0.schema.json +2 -2
  98. data/schemas/coverage.schema.json +2 -2
  99. data/sig/simplecov.rbs +200 -66
  100. metadata +28 -14
  101. data/doc/alternate-formatters.md +0 -66
  102. data/doc/commercial-services.md +0 -25
  103. data/doc/editor-integration.md +0 -18
  104. data/lib/simplecov/combine/files_combiner.rb +0 -70
  105. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  106. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  107. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  108. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  109. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
@@ -13,13 +13,27 @@ module SimpleCov
13
13
  # so all results in all files specified will be merged. Pass
14
14
  # `ignore_timeout: false` to honor it.
15
15
  #
16
- def collate(result_filenames, profile = nil, ignore_timeout: true, &)
16
+ # `processes:` above 1 fans the merge out across that many forked worker
17
+ # processes, for a collate big enough that reading and parsing the
18
+ # resultsets dominates it. The report is identical either way, not merely
19
+ # equivalent — the workers visit the resultsets in the order the
20
+ # single-process merge visits them — and one process never forks at all.
21
+ # The count is deliberately not clamped to the machine's core count nor
22
+ # gated on some minimum number of resultsets: how many processes a collate
23
+ # job can afford is the caller's call, not SimpleCov's. Anything below 1 is
24
+ # taken as 1, so a count computed from arithmetic that can reach zero needs
25
+ # no guarding. Defaults to `SIMPLECOV_CONCURRENCY`, or 1 when that is
26
+ # unset. See `SimpleCov::ParallelResultMerger`.
27
+ #
28
+ def collate(result_filenames, profile = nil, processes: ENV.fetch("SIMPLECOV_CONCURRENCY", 1).to_i,
29
+ ignore_timeout: true, &)
17
30
  raise ArgumentError, "There are no reports to be merged" if result_filenames.empty?
18
31
 
19
32
  initial_setup(profile, &)
20
33
 
21
34
  # Use the ResultMerger to produce a single, merged result, ready to use.
22
- @result = ResultMerger.merge_and_store(*result_filenames, ignore_timeout: ignore_timeout)
35
+ @result = ParallelResultMerger.merge_and_store(*result_filenames, processes: [1, processes].max,
36
+ ignore_timeout: ignore_timeout)
23
37
 
24
38
  @collating_result = true
25
39
  run_exit_tasks!
@@ -28,24 +42,22 @@ module SimpleCov
28
42
  end
29
43
 
30
44
  #
31
- # Returns the result for the current coverage run, merging it across test suites
32
- # from cache using SimpleCov::ResultMerger if use_merging is activated (default)
45
+ # Returns the result for the current coverage run. With merging enabled,
46
+ # every process stores its own slice, but only the finalization owner reads
47
+ # and caches the merged result.
33
48
  #
34
49
  def result
35
50
  return @result if result?
36
51
 
37
52
  use_merging = merging
38
53
 
39
- # Collect our coverage result. When merging is off there is no merge
40
- # step, so this per-process result is the final one and reports any
41
- # dropped source files; otherwise the merged result does the reporting.
42
- process_coverage_result(report: !use_merging) if defined?(Coverage) && Coverage.running?
54
+ collect_own_coverage(standalone: !use_merging)
43
55
 
44
56
  # If we're using merging of results, store the current result
45
57
  # first (if there is one), then merge the results and return those
46
58
  if use_merging
47
59
  SimpleCov::ResultMerger.store_result(@result) if result?
48
- return @result unless finalize_merge?
60
+ return @result unless merge_finalization_owner?
49
61
 
50
62
  wait_for_other_processes
51
63
  @result = SimpleCov::ResultMerger.merged_result
@@ -54,6 +66,16 @@ module SimpleCov
54
66
  @result
55
67
  end
56
68
 
69
+ # Build this process's slice of the coverage result. `standalone` is true
70
+ # when no merge step follows, which makes this the final result: it is the
71
+ # one that reports dropped source files, and the one that injects unloaded
72
+ # files. When a merge does follow, both jobs belong to the merged result.
73
+ def collect_own_coverage(standalone:)
74
+ return unless defined?(Coverage) && Coverage.running?
75
+
76
+ process_coverage_result(report: standalone, inject_unloaded: standalone)
77
+ end
78
+
57
79
  # Returns nil if the result has not been computed, otherwise the result.
58
80
  def result?
59
81
  defined?(@result) && @result
@@ -79,7 +101,7 @@ module SimpleCov
79
101
  # construction). Files matched by no group fall into the implicit
80
102
  # "Ungrouped" bucket.
81
103
  def grouped(files, groups: SimpleCov.groups)
82
- return {} if groups.empty?
104
+ return {} if GroupNames.validate!(groups.keys).empty?
83
105
 
84
106
  grouped = groups.transform_values do |filter|
85
107
  SimpleCov::FileList.new(files.select { |source_file| filter.matches?(source_file) })
@@ -87,7 +109,7 @@ module SimpleCov
87
109
 
88
110
  in_group = grouped_file_set(grouped)
89
111
  ungrouped = files.reject { |source_file| in_group.include?(source_file) }
90
- grouped["Ungrouped"] = SimpleCov::FileList.new(ungrouped) if ungrouped.any?
112
+ grouped[GroupNames::UNGROUPED] = SimpleCov::FileList.new(ungrouped) if ungrouped.any?
91
113
 
92
114
  grouped
93
115
  end
@@ -115,6 +137,29 @@ module SimpleCov
115
137
  coverage.floor(2)
116
138
  end
117
139
 
140
+ # Add simulated coverage for each of `candidate_paths` the result doesn't
141
+ # already carry, and return it with the set of paths added.
142
+ #
143
+ # @api private — the seam `SimpleCov::ResultMerger` injects through. Public
144
+ # only because the merge runs in another object, on behalf of processes
145
+ # whose configuration it may not share, so it supplies the paths itself.
146
+ def inject_unloaded_files(result, candidate_paths, synthesize: nil, lines: nil)
147
+ return [result, Set.new] if candidate_paths.empty?
148
+
149
+ # Synthesizing branch and method tuples means parsing every tracked file
150
+ # that wasn't loaded, which is about half the cost of simulating one.
151
+ # Nothing reads those tuples when neither criterion is enabled —
152
+ # `Combine::CoverageAccumulator` only combines them per criterion, and the
153
+ # statistics drop them the same way — so skip the parse. The same goes
154
+ # for line data, which a branch-only or method-only run neither reports
155
+ # nor receives from `Coverage` for the files it loaded. See #1250.
156
+ UnloadedFileInjector.call(
157
+ result, candidate_paths,
158
+ synthesize: synthesize.nil? ? branch_coverage? || method_coverage? : synthesize,
159
+ lines: lines.nil? ? line_coverage? : lines
160
+ )
161
+ end
162
+
118
163
  private
119
164
 
120
165
  def initial_setup(profile, &block)
@@ -126,14 +171,15 @@ module SimpleCov
126
171
  grouped.values.each_with_object(Set.new) { |file_list, set| set.merge(file_list) }
127
172
  end
128
173
 
129
- # Finds files that were to be tracked but were not loaded, and
130
- # initializes their line-by-line coverage to zero (or nil for
131
- # comments / whitespace).
132
- def add_not_loaded_files(result)
133
- globs = unloaded_file_discovery_globs
134
- return [result, Set.new] if globs.empty?
135
-
136
- inject_unloaded_files(result.dup, discover_unloaded_paths(globs))
174
+ # Every path this process was told to track, whether or not it loaded them.
175
+ # Recorded on the result (and from there into the resultset) so that a merge
176
+ # in another process can inject the ones nobody loaded without needing this
177
+ # process's `cover` / `track_files` configuration. A standalone `collate`
178
+ # never ran `SimpleCov.start` and so has none of its own. See #1250.
179
+ def tracked_file_paths
180
+ UnloadedFileInjector.discover(
181
+ unloaded_file_discovery_globs, root: root, reject: filters.select(&:path_only?)
182
+ )
137
183
  end
138
184
 
139
185
  # Globs to expand on disk when injecting unloaded files into the
@@ -144,36 +190,29 @@ module SimpleCov
144
190
  [tracked_files, *cover_globs].compact
145
191
  end
146
192
 
147
- # Expand the given globs relative to SimpleCov.root, not Dir.pwd —
148
- # test runners that chdir (or CI scripts that invoke the suite
149
- # from a subdir) would otherwise silently miss the unloaded-file
150
- # injection and produce a different file set per environment. See
151
- # issue #1106.
152
- def discover_unloaded_paths(globs)
153
- globs.flat_map { |glob| Dir.glob(glob, base: root) }.uniq
154
- end
155
-
156
- def inject_unloaded_files(result, candidate_paths)
157
- not_loaded_files = candidate_paths.each_with_object(Set.new) do |file, set|
158
- absolute_path = File.expand_path(file, root)
159
- next if result.key?(absolute_path)
160
-
161
- result[absolute_path] = SimulateCoverage.call(absolute_path)
162
- set << absolute_path
163
- end
164
-
165
- [result, not_loaded_files]
166
- end
167
-
168
193
  # Run all the steps that handle processing the raw coverage result.
169
194
  # `report:` is true only when this slice is the final result (merging
170
195
  # off); with merging on the merged result reports dropped source files,
171
196
  # so the per-process slice stays quiet to avoid one warning per worker.
172
- def process_coverage_result(report:)
197
+ #
198
+ # `inject_unloaded:` is likewise false when a merge step follows. Only the
199
+ # union of every process's loaded files says what was really never loaded,
200
+ # so injecting here means each worker simulates nearly the whole project
201
+ # and all but one of those passes is merged away. See #1250.
202
+ def process_coverage_result(report:, inject_unloaded: true)
173
203
  raw = SimpleCov::UselessResultsRemover.call(Coverage.result)
174
204
  adapted = SimpleCov::ResultAdapter.call(raw)
175
- result, not_loaded_files = add_not_loaded_files(adapted)
176
- @result = SimpleCov::Result.new(result, not_loaded_files: not_loaded_files, report: report)
205
+ # What this process was told to track and did not load. Subtracting what
206
+ # it loaded matters because `Result#to_hash` serializes coverage after
207
+ # filtering: a file this process loaded and then filtered out would
208
+ # otherwise be recorded as tracked while absent from the stored coverage,
209
+ # and the merge would simulate it back in as never-loaded. See #1250.
210
+ tracked = tracked_file_paths - adapted.keys
211
+ result, not_loaded = inject_unloaded ? inject_unloaded_files(adapted, tracked) : [adapted, Set.new]
212
+ @result = SimpleCov::Result.new(
213
+ result, not_loaded_files: not_loaded, tracked_files: tracked,
214
+ run_id: SimpleCov.run_id, worker_id: SimpleCov.worker_id, report: report
215
+ )
177
216
  end
178
217
  end
179
218
  end
@@ -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
@@ -33,25 +33,46 @@ module SimpleCov
33
33
  # can't be parsed, fall back to the old empty hashes — old behavior,
34
34
  # old tradeoff.
35
35
  #
36
+ # Pass `synthesize: false` to skip the static analysis and return the
37
+ # empty hashes directly. Callers use it when neither branch nor method
38
+ # coverage is enabled, since nothing will read the tuples and the Prism
39
+ # parse is about half the cost of simulating a file. See #1250.
40
+ #
41
+ # Pass `lines: false` to omit the `"lines"` key entirely, mirroring what
42
+ # `Coverage.result` reports for a file loaded under a branch-only or
43
+ # method-only run. Emitting zeroed lines there would make a simulated
44
+ # file indistinguishable from one a sibling process actually loaded once
45
+ # the two are merged.
46
+ #
36
47
  # @return [Hash]
37
48
  #
38
- def call(absolute_path)
49
+ def call(absolute_path, synthesize: true, lines: true)
39
50
  source_lines = read_lines(absolute_path)
40
- lines = coverage_stub(absolute_path, source_lines) ||
41
- LinesClassifier.new.classify(source_lines)
51
+ simulated = synthesized_tuples(source_lines, synthesize)
52
+ return simulated unless lines
53
+
54
+ classified = coverage_stub(absolute_path, source_lines) ||
55
+ LinesClassifier.new.classify(source_lines)
56
+ {"lines" => classified}.merge(simulated)
57
+ end
58
+
59
+ # The branch and method tuples for a file, or empty hashes when the static
60
+ # analysis is skipped (nothing enabled reads them) or unavailable (no Prism,
61
+ # or the file doesn't parse).
62
+ def synthesized_tuples(source_lines, synthesize)
42
63
  empty = {"branches" => {}, "methods" => {}} #: Hash[String, Hash[untyped, untyped]]
43
- synthesized = StaticCoverageExtractor.call(source_lines.join) || empty
64
+ synthesized = (StaticCoverageExtractor.call(source_lines.join) if synthesize) || empty
44
65
 
45
- {
46
- "lines" => lines,
47
- "branches" => synthesized["branches"],
48
- "methods" => synthesized["methods"]
49
- }
66
+ {"branches" => synthesized["branches"], "methods" => synthesized["methods"]}
50
67
  end
51
68
 
69
+ # SystemCallError, not just ENOENT: a `track_files` glob can sweep
70
+ # up an unreadable file or a directory named like a Ruby file
71
+ # (EACCES, EISDIR), and simulation must degrade to "empty file"
72
+ # rather than crash the merge or report step.
52
73
  def read_lines(path)
53
74
  File.readlines(path)
54
- rescue Errno::ENOENT
75
+ rescue SystemCallError
55
76
  []
56
77
  end
57
78
 
@@ -68,7 +89,10 @@ module SimpleCov
68
89
  classifier_output = LinesClassifier.new.classify(source_lines)
69
90
  stub.each_index { |idx| stub[idx] = nil if classifier_output[idx].nil? }
70
91
  stub
71
- rescue Errno::ENOENT, SyntaxError
92
+ rescue SystemCallError, SyntaxError
93
+ # SystemCallError for the same reason as read_lines above:
94
+ # line_stub reads the file itself, so EACCES/EISDIR surface here
95
+ # too.
72
96
  nil
73
97
  end
74
98
  end
@@ -18,10 +18,16 @@ module SimpleCov
18
18
  !skipped? && coverage.positive?
19
19
  end
20
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.
21
27
  def skipped?
22
28
  return @skipped if defined?(@skipped)
23
29
 
24
- @skipped = lines.all?(&:skipped?)
30
+ @skipped = lines.empty?
25
31
  end
26
32
 
27
33
  # Flag the method as skipped directly, without going through its lines.
@@ -97,9 +97,13 @@ module SimpleCov
97
97
  # Method coverage keys can contain inspect-format class references
98
98
  # like `#<Class:Foo>` or `#<Class:0x...>`, which aren't valid Ruby
99
99
  # syntax. Wrap them in quotes so Ripper can parse the surrounding
100
- # array literal; downstream we treat them as opaque strings.
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.
101
105
  def quote_inspected_class_segments(str)
102
- str.gsub(/#<[^>]*>/) { |segment| %("#{segment.gsub('"', '\\"')}") }
106
+ str.gsub(/#<(?:[^<>]|\g<0>)*>/) { |segment| %("#{segment.gsub('"', '\\"')}") }
103
107
  end
104
108
  end
105
109
  end
@@ -22,19 +22,16 @@ module SimpleCov
22
22
  @src = src
23
23
  end
24
24
 
25
- # `:method` ignores nocov chunks (Ruby's Coverage doesn't tie
26
- # method entries to line ranges); `:line` / `:branch` honor both
27
- # the nocov chunks and the per-criterion directive ranges.
25
+ # Every criterion honors the deprecated all-criteria `# :nocov:`
26
+ # chunks plus its own per-criterion directive ranges. Methods
27
+ # included: they carry a source range, and nocov has always meant
28
+ # "exclude everything here".
28
29
  def for(criterion)
29
- if criterion == :method
30
- directive_chunks.fetch(:method)
31
- else
32
- nocov_chunks + directive_chunks.fetch(criterion)
33
- end
30
+ nocov_chunks + directive_chunks.fetch(criterion)
34
31
  end
35
32
 
36
- # no_cov_chunks is zero indexed to work directly with the array
37
- # holding the lines.
33
+ # Ranges of 1-based line numbers (see `with_index(1)` below);
34
+ # consumers subtract 1 to index into the zero-based lines array.
38
35
  def nocov_chunks
39
36
  @nocov_chunks ||= build_nocov_chunks
40
37
  end
@@ -17,17 +17,27 @@ module SimpleCov
17
17
  lines = [] #: Array[String]
18
18
  # The default encoding is UTF-8
19
19
  File.open(filename, "rb:UTF-8") do |file|
20
- current_line = file.gets
20
+ current_line = scrub_invalid(file.gets)
21
21
 
22
22
  if current_line && shebang?(current_line)
23
23
  lines << current_line
24
- current_line = file.gets
24
+ current_line = scrub_invalid(file.gets)
25
25
  end
26
26
 
27
27
  read_lines(file, lines, current_line)
28
28
  end
29
29
  end
30
30
 
31
+ # A line read as UTF-8 can still carry invalid bytes (a Latin-1
32
+ # source file without a magic comment, say). Replace them before
33
+ # any regex sees the line: the shebang and magic-comment checks
34
+ # would otherwise raise ArgumentError and take the report down.
35
+ def scrub_invalid(line)
36
+ return line if line.nil? || line.valid_encoding?
37
+
38
+ line.scrub
39
+ end
40
+
31
41
  def shebang?(line)
32
42
  SHEBANG_REGEX.match?(line)
33
43
  end
@@ -47,14 +57,20 @@ module SimpleCov
47
57
  end
48
58
  end
49
59
 
50
- # invalid/undef replace are technically not really necessary but
51
- # nice to have and work around a JRuby incompatibility. Setting
52
- # these options on `file.set_encoding` doesn't seem to work
53
- # properly, so it has to be done here.
60
+ # Guarantee every line leaves the loader as valid UTF-8, replacing
61
+ # what can't be represented: transcode non-UTF-8 lines (setting
62
+ # invalid/undef options on `file.set_encoding` doesn't work
63
+ # properly, and this also works around a JRuby incompatibility)
64
+ # and scrub UTF-8-tagged lines that carry invalid bytes, which
65
+ # would otherwise raise from every regex the classifier runs.
54
66
  def ensure_remove_undefs(file_lines)
55
67
  file_lines.each do |line|
56
68
  # simplecov:disable — defensive: only fires for non-UTF-8 source files
57
- line.encode!("UTF-8", invalid: :replace, undef: :replace) unless line.encoding == Encoding::UTF_8
69
+ if line.encoding == Encoding::UTF_8
70
+ line.scrub! unless line.valid_encoding?
71
+ else
72
+ line.encode!("UTF-8", invalid: :replace, undef: :replace)
73
+ end
58
74
  # simplecov:enable
59
75
  end
60
76
  end
@@ -22,35 +22,43 @@ module SimpleCov
22
22
  private
23
23
 
24
24
  def line_statistics
25
- sf = @source_file
26
25
  {
27
- line: CoverageStatistics.new(
28
- total_strength: sf.lines.sum { |line| line.coverage.to_i },
29
- covered: sf.covered_lines.size,
30
- missed: sf.missed_lines.size,
31
- omitted: sf.never_lines.size
26
+ line: coverage_statistics(
27
+ @source_file.covered_lines,
28
+ @source_file.missed_lines,
29
+ omitted: @source_file.never_lines.size
32
30
  )
33
31
  }
34
32
  end
35
33
 
34
+ # Files added via track_files but never loaded have no branch/method
35
+ # data. Report 0% instead of the empty-set default of 100% (see #902).
36
36
  def branch_statistics
37
37
  sf = @source_file
38
- # Files added via track_files but never loaded/required have no
39
- # branch data. Report 0% instead of misleading 100% (see #902).
40
- if sf.not_loaded? && sf.covered_branches.empty? && sf.missed_branches.empty?
41
- return {branch: CoverageStatistics.new(covered: 0, missed: 0, percent: 0.0)}
42
- end
38
+ covered = sf.covered_branches
39
+ missed = sf.missed_branches
40
+ percent = 0.0 if sf.not_loaded? && covered.empty? && missed.empty?
43
41
 
44
- {branch: CoverageStatistics.new(covered: sf.covered_branches.size, missed: sf.missed_branches.size)}
42
+ {branch: coverage_statistics(covered, missed, percent: percent)}
45
43
  end
46
44
 
47
45
  def method_statistics
48
46
  sf = @source_file
49
- if sf.not_loaded? && sf.covered_methods.empty? && sf.missed_methods.empty?
50
- return {method: CoverageStatistics.new(covered: 0, missed: 0, percent: 0.0)}
51
- end
47
+ covered = sf.covered_methods
48
+ missed = sf.missed_methods
49
+ percent = 0.0 if sf.not_loaded? && covered.empty? && missed.empty?
50
+
51
+ {method: coverage_statistics(covered, missed, percent: percent)}
52
+ end
52
53
 
53
- {method: CoverageStatistics.new(covered: sf.covered_methods.size, missed: sf.missed_methods.size)}
54
+ def coverage_statistics(covered, missed, omitted: 0, percent: nil)
55
+ CoverageStatistics.new(
56
+ total_strength: covered.sum { |item| item.coverage.to_i },
57
+ covered: covered.size,
58
+ missed: missed.size,
59
+ omitted: omitted,
60
+ percent: percent
61
+ )
54
62
  end
55
63
  end
56
64
  end