simplecov 0.22.0 → 1.0.0.rc1

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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -1
  3. data/LICENSE +1 -1
  4. data/README.md +1009 -511
  5. data/doc/alternate-formatters.md +0 -5
  6. data/doc/commercial-services.md +5 -5
  7. data/exe/simplecov +11 -0
  8. data/lib/minitest/simplecov_plugin.rb +13 -5
  9. data/lib/simplecov/autostart.rb +11 -0
  10. data/lib/simplecov/cli/clean.rb +47 -0
  11. data/lib/simplecov/cli/coverage.rb +91 -0
  12. data/lib/simplecov/cli/diff.rb +151 -0
  13. data/lib/simplecov/cli/dotfile.rb +100 -0
  14. data/lib/simplecov/cli/merge.rb +116 -0
  15. data/lib/simplecov/cli/open.rb +50 -0
  16. data/lib/simplecov/cli/report.rb +84 -0
  17. data/lib/simplecov/cli/run.rb +36 -0
  18. data/lib/simplecov/cli/serve.rb +139 -0
  19. data/lib/simplecov/cli/uncovered.rb +107 -0
  20. data/lib/simplecov/cli.rb +150 -0
  21. data/lib/simplecov/color.rb +74 -0
  22. data/lib/simplecov/combine/branches_combiner.rb +3 -2
  23. data/lib/simplecov/combine/files_combiner.rb +7 -1
  24. data/lib/simplecov/combine/lines_combiner.rb +19 -17
  25. data/lib/simplecov/combine/methods_combiner.rb +26 -0
  26. data/lib/simplecov/combine/results_combiner.rb +5 -4
  27. data/lib/simplecov/command_guesser.rb +46 -32
  28. data/lib/simplecov/configuration/coverage.rb +171 -0
  29. data/lib/simplecov/configuration/coverage_criteria.rb +156 -0
  30. data/lib/simplecov/configuration/filters.rb +195 -0
  31. data/lib/simplecov/configuration/formatting.rb +119 -0
  32. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  33. data/lib/simplecov/configuration/merging.rb +74 -0
  34. data/lib/simplecov/configuration/thresholds.rb +174 -0
  35. data/lib/simplecov/configuration.rb +79 -405
  36. data/lib/simplecov/coverage_statistics.rb +12 -9
  37. data/lib/simplecov/coverage_violations.rb +148 -0
  38. data/lib/simplecov/defaults.rb +27 -20
  39. data/lib/simplecov/directive.rb +162 -0
  40. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  41. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
  42. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
  43. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
  45. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
  46. data/lib/simplecov/exit_codes.rb +3 -0
  47. data/lib/simplecov/exit_handling.rb +158 -0
  48. data/lib/simplecov/file_list.rb +61 -17
  49. data/lib/simplecov/filter.rb +69 -24
  50. data/lib/simplecov/formatter/base.rb +101 -0
  51. data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
  52. data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
  53. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  54. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  55. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
  56. data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
  57. data/lib/simplecov/formatter/html_formatter.rb +79 -0
  58. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +84 -0
  59. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +127 -0
  60. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
  61. data/lib/simplecov/formatter/json_formatter.rb +77 -0
  62. data/lib/simplecov/formatter/multi_formatter.rb +4 -5
  63. data/lib/simplecov/formatter/simple_formatter.rb +9 -11
  64. data/lib/simplecov/formatter.rb +4 -0
  65. data/lib/simplecov/last_run.rb +10 -3
  66. data/lib/simplecov/lines_classifier.rb +26 -13
  67. data/lib/simplecov/load_global_config.rb +9 -4
  68. data/lib/simplecov/parallel_adapters/base.rb +51 -0
  69. data/lib/simplecov/parallel_adapters/generic.rb +42 -0
  70. data/lib/simplecov/parallel_adapters/parallel_tests.rb +77 -0
  71. data/lib/simplecov/parallel_adapters.rb +83 -0
  72. data/lib/simplecov/parallel_coordination.rb +95 -0
  73. data/lib/simplecov/process.rb +20 -14
  74. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  75. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  76. data/lib/simplecov/profiles/rails.rb +24 -10
  77. data/lib/simplecov/profiles/root_filter.rb +6 -5
  78. data/lib/simplecov/profiles/strict.rb +32 -0
  79. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  80. data/lib/simplecov/profiles.rb +32 -3
  81. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  82. data/lib/simplecov/result/source_file_builder.rb +51 -0
  83. data/lib/simplecov/result.rb +97 -19
  84. data/lib/simplecov/result_adapter.rb +68 -6
  85. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  86. data/lib/simplecov/result_merger/resultset_file.rb +38 -0
  87. data/lib/simplecov/result_merger/resultset_store.rb +50 -0
  88. data/lib/simplecov/result_merger.rb +46 -90
  89. data/lib/simplecov/result_processing.rb +162 -0
  90. data/lib/simplecov/simulate_coverage.rb +54 -8
  91. data/lib/simplecov/source_file/branch.rb +1 -3
  92. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  93. data/lib/simplecov/source_file/builder_context.rb +28 -0
  94. data/lib/simplecov/source_file/line.rb +7 -2
  95. data/lib/simplecov/source_file/line_builder.rb +43 -0
  96. data/lib/simplecov/source_file/method.rb +52 -0
  97. data/lib/simplecov/source_file/method_builder.rb +58 -0
  98. data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
  99. data/lib/simplecov/source_file/skip_chunks.rb +77 -0
  100. data/lib/simplecov/source_file/source_loader.rb +63 -0
  101. data/lib/simplecov/source_file/statistics.rb +57 -0
  102. data/lib/simplecov/source_file.rb +66 -232
  103. data/lib/simplecov/static_coverage_extractor/visitor.rb +193 -0
  104. data/lib/simplecov/static_coverage_extractor.rb +111 -0
  105. data/lib/simplecov/useless_results_remover.rb +16 -7
  106. data/lib/simplecov/version.rb +1 -1
  107. data/lib/simplecov-html.rb +4 -0
  108. data/lib/simplecov.rb +131 -377
  109. data/lib/simplecov_json_formatter.rb +4 -0
  110. data/schemas/coverage-v1.0.schema.json +300 -0
  111. data/schemas/coverage.schema.json +300 -0
  112. metadata +88 -56
  113. data/lib/simplecov/default_formatter.rb +0 -20
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require_relative "result_merger/legacy_format_adapter"
4
+ require_relative "result_merger/resultset_file"
5
+ require_relative "result_merger/resultset_store"
4
6
 
5
7
  module SimpleCov
6
8
  #
@@ -10,13 +12,8 @@ module SimpleCov
10
12
  #
11
13
  module ResultMerger
12
14
  class << self
13
- # The path to the .resultset.json cache file
14
15
  def resultset_path
15
- File.join(SimpleCov.coverage_path, ".resultset.json")
16
- end
17
-
18
- def resultset_writelock
19
- File.join(SimpleCov.coverage_path, ".resultset.json.lock")
16
+ ResultsetStore.resultset_path
20
17
  end
21
18
 
22
19
  def merge_and_store(*file_paths, ignore_timeout: false)
@@ -44,50 +41,37 @@ module SimpleCov
44
41
  end
45
42
 
46
43
  def valid_results(file_path, ignore_timeout: false)
47
- results = parse_file(file_path)
48
- merge_valid_results(results, ignore_timeout: ignore_timeout)
49
- end
50
-
51
- def parse_file(path)
52
- data = read_file(path)
53
- parse_json(data)
54
- end
55
-
56
- def read_file(path)
57
- return unless File.exist?(path)
58
-
59
- data = File.read(path)
60
- return if data.nil? || data.length < 2
61
-
62
- data
63
- end
64
-
65
- def parse_json(content)
66
- return {} unless content
67
-
68
- JSON.parse(content) || {}
69
- rescue StandardError
70
- warn "[SimpleCov]: Warning! Parsing JSON content of resultset file failed"
71
- {}
44
+ merge_valid_results(ResultsetFile.parse(file_path), ignore_timeout: ignore_timeout)
72
45
  end
73
46
 
74
47
  def merge_valid_results(results, ignore_timeout: false)
75
- results = results.select { |_command_name, data| within_merge_timeout?(data) } unless ignore_timeout
48
+ results = drop_expired_results(results) unless ignore_timeout
76
49
 
77
50
  command_plus_coverage = results.map do |command_name, data|
78
- [[command_name], adapt_result(data.fetch("coverage"))]
51
+ [[command_name], LegacyFormatAdapter.call(data.fetch("coverage"))]
79
52
  end
80
53
 
81
54
  # one file itself _might_ include multiple test runs
82
55
  merge_coverage(*command_plus_coverage)
83
56
  end
84
57
 
58
+ def drop_expired_results(results)
59
+ fresh, expired = results.partition { |_command_name, data| within_merge_timeout?(data) }
60
+ return results if expired.empty?
61
+
62
+ warn_about_expired_results(expired.map(&:first))
63
+ fresh.to_h
64
+ end
65
+
85
66
  def within_merge_timeout?(data)
86
- time_since_result_creation(data) < SimpleCov.merge_timeout
67
+ (Time.now - Time.at(data.fetch("timestamp"))) < SimpleCov.merge_timeout
87
68
  end
88
69
 
89
- def time_since_result_creation(data)
90
- Time.now - Time.at(data.fetch("timestamp"))
70
+ def warn_about_expired_results(expired_command_names)
71
+ warn "[SimpleCov]: Excluded #{expired_command_names.size} result(s) older than " \
72
+ "merge_timeout (#{SimpleCov.merge_timeout}s) from the merged report: " \
73
+ "#{expired_command_names.sort.join(', ')}. " \
74
+ "Increase SimpleCov.merge_timeout to include them."
91
75
  end
92
76
 
93
77
  def create_result(command_names, coverage)
@@ -104,9 +88,7 @@ module SimpleCov
104
88
  results.reduce do |(memo_command, memo_coverage), (command, coverage)|
105
89
  # timestamp is dropped here, which is intentional (we merge it, it gets a new time stamp as of now)
106
90
  merged_coverage = Combine.combine(Combine::ResultsCombiner, memo_coverage, coverage)
107
- merged_command = memo_command + command
108
-
109
- [merged_command, merged_coverage]
91
+ [memo_command + command, merged_coverage]
110
92
  end
111
93
  end
112
94
 
@@ -115,79 +97,53 @@ module SimpleCov
115
97
  # SimpleCov::Result with merged coverage data and the command_name
116
98
  # for the result consisting of a join on all source result's names
117
99
  def merged_result
118
- # conceptually this is just doing `merge_results(resultset_path)`
119
- # it's more involved to make syre `synchronize_resultset` is only used around reading
120
- resultset_hash = read_resultset
121
- command_names, coverage = merge_valid_results(resultset_hash)
122
-
100
+ command_names, coverage = merge_valid_results(read_resultset)
123
101
  create_result(command_names, coverage)
124
102
  end
125
103
 
126
104
  def read_resultset
127
- resultset_content =
128
- synchronize_resultset do
129
- read_file(resultset_path)
130
- end
131
-
132
- parse_json(resultset_content)
105
+ content = synchronize_resultset { ResultsetFile.read(resultset_path) }
106
+ ResultsetFile.decode(content)
133
107
  end
134
108
 
135
109
  # Saves the given SimpleCov::Result in the resultset cache
136
- def store_result(result)
110
+ def store_result(result) # rubocop:disable Naming/PredicateMethod
137
111
  synchronize_resultset do
138
112
  # Ensure we have the latest, in case it was already cached
139
113
  new_resultset = read_resultset
140
114
 
141
115
  # A single result only ever has one command_name, see `SimpleCov::Result#to_hash`
142
116
  command_name, data = result.to_hash.first
143
- new_resultset[command_name] = data
144
- File.open(resultset_path, "w+") do |f_|
145
- f_.puts JSON.pretty_generate(new_resultset)
146
- end
117
+ new_resultset[command_name] = merged_entry(new_resultset[command_name], data)
118
+
119
+ ResultsetStore.write(new_resultset)
147
120
  end
148
121
  true
149
122
  end
150
123
 
151
- # Ensure only one process is reading or writing the resultset at any
152
- # given time
153
- def synchronize_resultset
154
- # make it reentrant
155
- return yield if defined?(@resultset_locked) && @resultset_locked
156
-
157
- begin
158
- @resultset_locked = true
159
- File.open(resultset_writelock, "w+") do |f|
160
- f.flock(File::LOCK_EX)
161
- yield
162
- end
163
- ensure
164
- @resultset_locked = false
165
- end
166
- end
124
+ # If an entry with the same command_name was written AFTER our process
125
+ # started, a sibling test runner (typically a subprocess our parent
126
+ # process shelled out to) wrote it. Combine coverage data rather than
127
+ # overwriting, so an empty parent-process result doesn't clobber the
128
+ # subprocess's real data. See https://github.com/simplecov-ruby/simplecov/issues/581.
129
+ def merged_entry(existing, incoming)
130
+ return incoming unless concurrent_runner_entry?(existing)
167
131
 
168
- # We changed the format of the raw result data in simplecov, as people are likely
169
- # to have "old" resultsets lying around (but not too old so that they're still
170
- # considered we can adapt them).
171
- # See https://github.com/simplecov-ruby/simplecov/pull/824#issuecomment-576049747
172
- def adapt_result(result)
173
- if pre_simplecov_0_18_result?(result)
174
- adapt_pre_simplecov_0_18_result(result)
175
- else
176
- result
177
- end
132
+ incoming.merge(
133
+ "coverage" => Combine.combine(Combine::ResultsCombiner, existing["coverage"], incoming["coverage"])
134
+ )
178
135
  end
179
136
 
180
- # pre 0.18 coverage data pointed from file directly to an array of line coverage
181
- def pre_simplecov_0_18_result?(result)
182
- _key, data = result.first
137
+ def concurrent_runner_entry?(entry)
138
+ return false unless entry.is_a?(Hash)
183
139
 
184
- data.is_a?(Array)
140
+ timestamp = entry["timestamp"]
141
+ process_start = SimpleCov.process_start_time
142
+ timestamp && process_start && timestamp.to_i >= process_start.to_i
185
143
  end
186
144
 
187
- def adapt_pre_simplecov_0_18_result(result)
188
- result.transform_values do |line_coverage_data|
189
- {"lines" => line_coverage_data}
190
- end
145
+ def synchronize_resultset(&)
146
+ ResultsetStore.synchronize(&)
191
147
  end
192
148
  end
193
149
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Result-building façade: turns the raw `Coverage.result` hash into a
4
+ # `SimpleCov::Result`, applies filters and groups, drives merging
5
+ # across test suites via `SimpleCov::ResultMerger`, and exposes the
6
+ # `collate` entry point for stitching disparate resultsets together.
7
+ module SimpleCov
8
+ class << self
9
+ #
10
+ # Collate a series of SimpleCov result files into a single SimpleCov output.
11
+ #
12
+ # See README for usage. By default `collate` ignores the merge_timeout
13
+ # so all results in all files specified will be merged. Pass
14
+ # `ignore_timeout: false` to honor it.
15
+ #
16
+ def collate(result_filenames, profile = nil, ignore_timeout: true, &block)
17
+ raise ArgumentError, "There are no reports to be merged" if result_filenames.empty?
18
+
19
+ initial_setup(profile, &block)
20
+
21
+ # Use the ResultMerger to produce a single, merged result, ready to use.
22
+ @result = ResultMerger.merge_and_store(*result_filenames, ignore_timeout: ignore_timeout)
23
+
24
+ run_exit_tasks!
25
+ end
26
+
27
+ #
28
+ # Returns the result for the current coverage run, merging it across test suites
29
+ # from cache using SimpleCov::ResultMerger if use_merging is activated (default)
30
+ #
31
+ def result
32
+ return @result if result?
33
+
34
+ # Collect our coverage result
35
+ process_coverage_result if defined?(Coverage) && Coverage.running?
36
+
37
+ # If we're using merging of results, store the current result
38
+ # first (if there is one), then merge the results and return those
39
+ if merging
40
+ wait_for_other_processes
41
+ SimpleCov::ResultMerger.store_result(@result) if result?
42
+ @result = SimpleCov::ResultMerger.merged_result
43
+ end
44
+
45
+ @result
46
+ end
47
+
48
+ # Returns nil if the result has not been computed, otherwise the result.
49
+ def result?
50
+ defined?(@result) && @result
51
+ end
52
+
53
+ # Applies the configured filters to the given array of SimpleCov::SourceFile items
54
+ def filtered(files)
55
+ result = files.clone
56
+ filters.each do |filter|
57
+ result = result.reject { |source_file| filter.matches?(source_file) }
58
+ end
59
+ SimpleCov::FileList.new result
60
+ end
61
+
62
+ # Bin the given source files by group filter. `groups:` defaults to
63
+ # `SimpleCov.groups`; pass a Hash explicitly to bin against a
64
+ # different group config (e.g., the snapshot a Result captured at
65
+ # construction). Files matched by no group fall into the implicit
66
+ # "Ungrouped" bucket.
67
+ def grouped(files, groups: SimpleCov.groups)
68
+ return {} if groups.empty?
69
+
70
+ grouped = groups.transform_values do |filter|
71
+ SimpleCov::FileList.new(files.select { |source_file| filter.matches?(source_file) })
72
+ end
73
+
74
+ in_group = grouped_file_set(grouped)
75
+ ungrouped = files.reject { |source_file| in_group.include?(source_file) }
76
+ grouped["Ungrouped"] = SimpleCov::FileList.new(ungrouped) if ungrouped.any?
77
+
78
+ grouped
79
+ end
80
+
81
+ # Applies the profile of given name on SimpleCov configuration
82
+ def load_profile(name)
83
+ profiles.load(name)
84
+ end
85
+
86
+ # Clear out the previously cached .result. Primarily useful in testing.
87
+ def clear_result
88
+ @result = nil
89
+ end
90
+
91
+ # @api private — persist the per-criterion coverage percentages
92
+ # rounded down (see #679) so the next run can compute drift.
93
+ def write_last_run(result)
94
+ SimpleCov::LastRun.write(
95
+ result: result.coverage_statistics.transform_values { |stats| round_coverage(stats.percent) }
96
+ )
97
+ end
98
+
99
+ # @api private — round down to two decimals to be extra strict.
100
+ def round_coverage(coverage)
101
+ coverage.floor(2)
102
+ end
103
+
104
+ private
105
+
106
+ def initial_setup(profile, &block)
107
+ load_profile(profile) if profile
108
+ configure(&block) if block
109
+ end
110
+
111
+ def grouped_file_set(grouped)
112
+ grouped.values.each_with_object(Set.new) { |file_list, set| set.merge(file_list) }
113
+ end
114
+
115
+ # Finds files that were to be tracked but were not loaded, and
116
+ # initializes their line-by-line coverage to zero (or nil for
117
+ # comments / whitespace).
118
+ def add_not_loaded_files(result)
119
+ globs = unloaded_file_discovery_globs
120
+ return [result, Set.new] if globs.empty?
121
+
122
+ inject_unloaded_files(result.dup, discover_unloaded_paths(globs))
123
+ end
124
+
125
+ # Globs to expand on disk when injecting unloaded files into the
126
+ # result. Combines the legacy `track_files` glob (additive only)
127
+ # with every string glob declared via `cover` (also restrictive,
128
+ # but the restriction lives in `Result#apply_cover_filters!`).
129
+ def unloaded_file_discovery_globs
130
+ [tracked_files, *cover_globs].compact
131
+ end
132
+
133
+ # Expand the given globs relative to SimpleCov.root, not Dir.pwd —
134
+ # test runners that chdir (or CI scripts that invoke the suite
135
+ # from a subdir) would otherwise silently miss the unloaded-file
136
+ # injection and produce a different file set per environment. See
137
+ # issue #1106.
138
+ def discover_unloaded_paths(globs)
139
+ globs.flat_map { |glob| Dir.glob(glob, base: root) }.uniq
140
+ end
141
+
142
+ def inject_unloaded_files(result, candidate_paths)
143
+ not_loaded_files = candidate_paths.each_with_object(Set.new) do |file, set|
144
+ absolute_path = File.expand_path(file, root)
145
+ next if result.key?(absolute_path)
146
+
147
+ result[absolute_path] = SimulateCoverage.call(absolute_path)
148
+ set << absolute_path
149
+ end
150
+
151
+ [result, not_loaded_files]
152
+ end
153
+
154
+ # Run all the steps that handle processing the raw coverage result.
155
+ def process_coverage_result
156
+ @result = SimpleCov::UselessResultsRemover.call(Coverage.result)
157
+ @result = SimpleCov::ResultAdapter.call(@result)
158
+ result, not_loaded_files = add_not_loaded_files(@result)
159
+ @result = SimpleCov::Result.new(result, not_loaded_files: not_loaded_files)
160
+ end
161
+ end
162
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "static_coverage_extractor"
4
+
3
5
  module SimpleCov
4
6
  #
5
7
  # Responsible for producing file coverage metrics.
@@ -8,22 +10,66 @@ module SimpleCov
8
10
  module_function
9
11
 
10
12
  #
11
- # Simulate normal file coverage report on
12
- # ruby 2.5 and return similar hash with lines and branches keys
13
+ # Simulate a file coverage report for a file that was tracked but never
14
+ # required. Returns the same hash shape as `Coverage.result` (lines,
15
+ # branches, methods).
16
+ #
17
+ # The line classification comes from `Coverage.line_stub` — the same
18
+ # classification the runtime would have produced if the file had been
19
+ # required — overlaid with SimpleCov's `# :nocov:` toggles and
20
+ # `# simplecov:disable line` directive ranges, which `Coverage` doesn't
21
+ # know about. This keeps "relevant lines" identical whether a file was
22
+ # loaded or just tracked, fixing the multi-line statement discrepancy
23
+ # in https://github.com/simplecov-ruby/simplecov/issues/654.
13
24
  #
14
- # Happens when a file wasn't required but still tracked.
25
+ # Branches and methods are enumerated by static analysis (via
26
+ # `StaticCoverageExtractor`, which uses Prism). Earlier behavior left
27
+ # both as empty hashes, which made unloaded files invisible to the
28
+ # branch/method denominators while their lines DID count — so a
29
+ # `track_files`/`cover` glob that picked up files without specs
30
+ # silently inflated branch% relative to line%. See
31
+ # https://github.com/simplecov-ruby/simplecov/issues/1059. When Prism
32
+ # isn't loadable (Ruby < 3.3 without the prism gem) or the file
33
+ # can't be parsed, fall back to the old empty hashes — old behavior,
34
+ # old tradeoff.
15
35
  #
16
36
  # @return [Hash]
17
37
  #
18
38
  def call(absolute_path)
19
- lines = File.foreach(absolute_path)
39
+ source_lines = read_lines(absolute_path)
40
+ lines = coverage_stub(absolute_path, source_lines) ||
41
+ LinesClassifier.new.classify(source_lines)
42
+ synthesized = StaticCoverageExtractor.call(source_lines.join) ||
43
+ {"branches" => {}, "methods" => {}}
20
44
 
21
45
  {
22
- "lines" => LinesClassifier.new.classify(lines),
23
- # we don't want to parse branches ourselves...
24
- # requiring files can have side effects and we don't want to trigger that
25
- "branches" => {}
46
+ "lines" => lines,
47
+ "branches" => synthesized["branches"],
48
+ "methods" => synthesized["methods"]
26
49
  }
27
50
  end
51
+
52
+ def read_lines(path)
53
+ File.readlines(path)
54
+ rescue Errno::ENOENT
55
+ []
56
+ end
57
+
58
+ # Combine `Coverage.line_stub` (which gets multi-line statements right)
59
+ # with `LinesClassifier` (which knows about `# :nocov:` toggles and
60
+ # `# simplecov:disable line` ranges). Returns nil — and the caller
61
+ # falls back to `LinesClassifier` alone — when `Coverage` can't read
62
+ # or parse the file, or when the runtime doesn't expose `line_stub`
63
+ # (JRuby and TruffleRuby).
64
+ def coverage_stub(path, source_lines)
65
+ return nil unless Coverage.respond_to?(:line_stub)
66
+
67
+ stub = Coverage.line_stub(path)
68
+ classifier_output = LinesClassifier.new.classify(source_lines)
69
+ stub.each_index { |idx| stub[idx] = nil if classifier_output[idx].nil? }
70
+ stub
71
+ rescue Errno::ENOENT, SyntaxError
72
+ nil
73
+ end
28
74
  end
29
75
  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 branche missed or not
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
- raise ArgumentError, "Only Integer and nil accepted for coverage" unless coverage.is_a?(Integer) || coverage.nil?
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
@@ -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
- return "covered" if covered?
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