simplecov-rspec 0.4.4 → 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.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class RSpec
5
+ # Resolves the `list_uncovered:` option (plus its `LIST_UNCOVERED` ENV override)
6
+ # into a normalized Array<Symbol>, in a fixed line/branch/method order.
7
+ #
8
+ # @api private
9
+ #
10
+ module ListUncoveredOption
11
+ # The coverage criteria this gem knows how to report on
12
+ ALL_CRITERIA = %i[line branch method].freeze
13
+
14
+ # LIST_UNCOVERED environment variable values that mean "every criterion"
15
+ ALL_ENV_VALUES = %w[yes on true 1 all].freeze
16
+
17
+ # LIST_UNCOVERED environment variable values that mean "no criteria"
18
+ NONE_ENV_VALUES = %w[false no off 0].freeze
19
+
20
+ module_function
21
+
22
+ # Resolve the effective criteria, applying the ENV override if present
23
+ # @param value [false, :all, Symbol, Array<Symbol>] the `list_uncovered:` argument
24
+ # @param env [Hash] the environment variables
25
+ # @param env_var [String] the ENV var name that overrides `value`
26
+ # @return [Array<Symbol>]
27
+ # @raise [ArgumentError] if value is not one of the accepted forms, or names an unknown criterion
28
+ # @example
29
+ # ListUncoveredOption.resolve(:all, env: {}, env_var: 'LIST_UNCOVERED') # => [:line, :branch, :method]
30
+ def resolve(value, env:, env_var:)
31
+ value = from_env(env.fetch(env_var)) if env.key?(env_var)
32
+ ALL_CRITERIA & normalize(value)
33
+ end
34
+
35
+ # Parse a raw LIST_UNCOVERED environment variable value
36
+ # @param raw [String] the raw LIST_UNCOVERED environment variable value
37
+ # @return [false, :all, Array<Symbol>]
38
+ # @example
39
+ # ListUncoveredOption.from_env('line,branch') # => [:line, :branch]
40
+ def from_env(raw)
41
+ raw = raw.strip.downcase
42
+ return false if raw.empty? || NONE_ENV_VALUES.include?(raw)
43
+ return :all if ALL_ENV_VALUES.include?(raw)
44
+
45
+ raw.split(',').map { |criterion| criterion.strip.to_sym }
46
+ end
47
+
48
+ # Normalize a `list_uncovered:`-style value into an Array of Symbols
49
+ # @param value [false, true, :all, Symbol, Array<Symbol>]
50
+ # @return [Array<Symbol>]
51
+ # @example
52
+ # ListUncoveredOption.normalize(:branch) # => [:branch]
53
+ def normalize(value)
54
+ case value
55
+ when nil, false then []
56
+ when :all then ALL_CRITERIA
57
+ when Symbol then validate([value])
58
+ when Array then validate(value)
59
+ else
60
+ raise ArgumentError,
61
+ "list_uncovered must be false, :all, a Symbol, or an Array of Symbols; got #{value.inspect}"
62
+ end
63
+ end
64
+
65
+ # Raise unless every given criterion is one this gem knows how to report on
66
+ # @param criteria [Array<Symbol>]
67
+ # @return [Array<Symbol>]
68
+ # @raise [ArgumentError] if criteria contains anything outside ALL_CRITERIA
69
+ # @example
70
+ # ListUncoveredOption.validate([:line]) # => [:line]
71
+ def validate(criteria)
72
+ invalid = criteria - ALL_CRITERIA
73
+ return criteria if invalid.empty?
74
+
75
+ raise ArgumentError, "Unknown coverage criterion #{invalid.inspect}; must be one of #{ALL_CRITERIA.inspect}"
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class RSpec
5
+ # Formats the "uncovered lines/branches/methods" report printed after a run: either
6
+ # a full listing of every uncovered item, or a per-criterion count with a hint on
7
+ # how to see the details.
8
+ #
9
+ # @api private
10
+ #
11
+ class UncoveredReport
12
+ # Maps a coverage criterion to its [singular, plural] noun, for report formatting
13
+ # @api private
14
+ CRITERION_LABELS = {
15
+ line: %w[line lines],
16
+ branch: %w[branch branches],
17
+ method: %w[method methods]
18
+ }.freeze
19
+
20
+ # Build a report for the given result, criteria, and detail level
21
+ # @param result [SimpleCov::Result] the SimpleCov result to report on
22
+ # @param criteria [Array<Symbol>] which criteria (:line, :branch, :method) to report
23
+ # @param detail [Boolean] list individual items, or just a count per criterion
24
+ # @param detail_env_var [String] the ENV var name to suggest for switching to detail
25
+ # @param files [Array<String>, nil] absolute paths to report on, or nil for every file
26
+ # @example
27
+ # UncoveredReport.new(result: SimpleCov.result, criteria: [:line], detail: true, detail_env_var: 'X')
28
+ def initialize(result:, criteria:, detail:, detail_env_var:, files: nil)
29
+ @result = result
30
+ @criteria = criteria
31
+ @detail = detail
32
+ @detail_env_var = detail_env_var
33
+ @files = files
34
+ end
35
+
36
+ # The formatted report text, or an empty string if there is nothing to report
37
+ #
38
+ # A scoped report (one where `files` is not nil) always produces text, even when
39
+ # every file it covers is fully covered. Silence there would be indistinguishable
40
+ # from a clean run of the whole suite, which is the opposite of what asking for a
41
+ # scope means.
42
+ #
43
+ # @return [String]
44
+ def to_s = files.nil? ? uncovered_text : scoped_text
45
+
46
+ private
47
+
48
+ # The SimpleCov result being reported on
49
+ # @return [SimpleCov::Result]
50
+ # @api private
51
+ attr_reader :result
52
+
53
+ # Which criteria (:line, :branch, :method) to report on
54
+ # @return [Array<Symbol>]
55
+ # @api private
56
+ attr_reader :criteria
57
+
58
+ # Whether to list individual items, or just a count per criterion
59
+ # @return [Boolean]
60
+ # @api private
61
+ attr_reader :detail
62
+
63
+ # The ENV var name to suggest for switching to detail
64
+ # @return [String]
65
+ # @api private
66
+ attr_reader :detail_env_var
67
+
68
+ # The absolute paths to report on, or nil to report on every file in the result
69
+ # @return [Array<String>, nil]
70
+ # @api private
71
+ attr_reader :files
72
+
73
+ # The result's files, narrowed to `files` when a scope was given
74
+ # @return [Array<SimpleCov::SourceFile>]
75
+ def reported_files
76
+ @reported_files ||= files.nil? ? result.files : result.files.select { |file| files.include?(file.filename) }
77
+ end
78
+
79
+ # The uncovered items or counts, in the requested level of detail
80
+ # @return [String]
81
+ def uncovered_text = detail ? detailed_text : summary_text
82
+
83
+ # The report for a scope, which is always non-empty
84
+ #
85
+ # Names how much of the result the scope covered, then one section per criterion,
86
+ # then a closing statement.
87
+ #
88
+ # @return [String]
89
+ def scoped_text
90
+ return "#{scope_text}\n\n#{nothing_reported_text}" if reported_files.empty?
91
+
92
+ [scope_text, *criterion_blocks, closing_text].compact.join("\n\n")
93
+ end
94
+
95
+ # Why a scope produced nothing to report on
96
+ #
97
+ # Three different mistakes end up here, and only one of them is "your pattern
98
+ # matched nothing". Saying that when the pattern matched a real file that
99
+ # SimpleCov never saw sends the reader looking for a typo that isn't there.
100
+ #
101
+ # @return [String]
102
+ def nothing_reported_text
103
+ return 'No files were requested, so no coverage was reported.' if files.empty?
104
+ return 'No files matched, so no coverage was reported.' if matched_files.empty?
105
+
106
+ untracked_text
107
+ end
108
+
109
+ # The requested paths that name a file on disk
110
+ #
111
+ # A pattern that globbed successfully expands to paths that exist; one that
112
+ # matched nothing is kept as a literal path that does not.
113
+ #
114
+ # @return [Array<String>]
115
+ def matched_files = @matched_files ||= files.select { |path| File.file?(path) }
116
+
117
+ # The report for files that exist but are absent from the coverage result
118
+ # @return [String]
119
+ def untracked_text
120
+ count = matched_files.count
121
+ "#{count} #{pluralize(count, 'file matched, but it is not', 'files matched, but none are')} in the " \
122
+ "coverage result. #{pluralize(count, 'It', 'They')} may not have been loaded by this run, or may " \
123
+ 'be excluded by a SimpleCov filter.'
124
+ end
125
+
126
+ # The line stating how much of the result the scope covered
127
+ #
128
+ # Reported alongside the total so that a scoped report showing nothing uncovered
129
+ # cannot be misread as the whole suite being fully covered. Marked off as a section
130
+ # header because SimpleCov prints its own project-wide summary just above, on the
131
+ # same stream, counting different things.
132
+ #
133
+ # @return [String]
134
+ def scope_text
135
+ "-- Reporting uncovered #{noun_list(criteria)} for " \
136
+ "#{reported_files.count} of #{result.files.count} #{pluralize(result.files.count, 'file', 'files')} --"
137
+ end
138
+
139
+ # The body of a scoped report: what the files cover, then what they miss
140
+ #
141
+ # A criterion's coverage sits directly above its own listing, and criteria with
142
+ # nothing to list share a block, so that a blank line always means "next criterion"
143
+ # and never separates a heading from what it heads.
144
+ #
145
+ # @return [Array<String>]
146
+ def criterion_blocks
147
+ criteria.map { |criterion| [coverage_text(criterion), missing_text(criterion)] }
148
+ .chunk_while { |(_, missing), (_, next_missing)| missing.nil? && next_missing.nil? }
149
+ .map { |block| block.flatten.compact.join("\n") }
150
+ end
151
+
152
+ # A criterion's coverage across the scoped files
153
+ #
154
+ # Deliberately not labelled the way SimpleCov labels its own project-wide summary.
155
+ # The two appear within a few lines of each other and count different things, so
156
+ # sharing a label would make the narrower number look like a restatement of the
157
+ # broader one.
158
+ #
159
+ # @param criterion [Symbol]
160
+ # @return [String]
161
+ def coverage_text(criterion)
162
+ covered = covered_count(criterion)
163
+ total = covered + uncovered_count(criterion)
164
+ "Scoped #{criterion} coverage: #{covered} / #{total} (#{percent_text(covered, total)})"
165
+ end
166
+
167
+ # A covered-of-total ratio, formatted the way SimpleCov formats its own
168
+ #
169
+ # Truncated rather than rounded, through SimpleCov's own helper, because its
170
+ # project-wide summary prints a few lines above this one over the same kind of
171
+ # ratio. Two percentages differing in the last digit would read as a bug in one of
172
+ # them. A criterion with nothing to cover is 100%, which is also what SimpleCov says.
173
+ #
174
+ # @param covered [Integer] the number of covered items
175
+ # @param total [Integer] the number of items that could be covered
176
+ #
177
+ # @return [String]
178
+ def percent_text(covered, total)
179
+ percent = total.zero? ? 100.0 : covered * 100.0 / total
180
+ "#{format('%.2f', ::SimpleCov.round_coverage(percent))}%"
181
+ end
182
+
183
+ # A criterion's uncovered items or count
184
+ #
185
+ # Nil when the scoped files leave nothing uncovered for it.
186
+ #
187
+ # @param criterion [Symbol]
188
+ # @return [String, nil]
189
+ def missing_text(criterion)
190
+ return section(criterion) if detail
191
+
192
+ count = uncovered_count(criterion)
193
+ count.positive? ? "#{header(criterion, count)}." : nil
194
+ end
195
+
196
+ # The closing statement: that nothing is uncovered, or how to see what is
197
+ # @return [String, nil]
198
+ def closing_text
199
+ return nothing_uncovered_text if missing_criteria.empty?
200
+ return nil if detail
201
+
202
+ "Run with #{detail_env_var}=true to see the uncovered #{noun_list(missing_criteria)}."
203
+ end
204
+
205
+ # The criteria the scoped files leave something uncovered for
206
+ # @return [Array<Symbol>]
207
+ def missing_criteria = criteria.reject { |criterion| uncovered_count(criterion).zero? }
208
+
209
+ # The statement that a scope turned up no uncovered items
210
+ # @return [String]
211
+ def nothing_uncovered_text
212
+ "No uncovered #{noun_list(criteria)} in #{pluralize(reported_files.count, 'this file', 'these files')}."
213
+ end
214
+
215
+ # The count of covered items for a single criterion across the reported files
216
+ # @param criterion [Symbol]
217
+ # @return [Integer]
218
+ def covered_count(criterion)
219
+ plural = CRITERION_LABELS.fetch(criterion).last
220
+ reported_files.sum { |file| file.public_send(:"covered_#{plural}").count }
221
+ end
222
+
223
+ # The full listing, one blank-line-separated section per criterion
224
+ # @return [String]
225
+ def detailed_text
226
+ criteria.filter_map { |criterion| section(criterion) }.join("\n\n")
227
+ end
228
+
229
+ # A criterion's report section, or nil when nothing is uncovered
230
+ # @param criterion [Symbol]
231
+ # @return [String, nil]
232
+ def section(criterion)
233
+ items = uncovered_items(criterion)
234
+ return nil if items.empty?
235
+
236
+ ["#{header(criterion, items.count)}:", *items.map { |item| " #{item}" }].join("\n")
237
+ end
238
+
239
+ # The per-criterion counts, plus a hint on how to see the details
240
+ # @return [String]
241
+ def summary_text
242
+ summarized = criteria.filter_map do |criterion|
243
+ count = uncovered_count(criterion)
244
+ [criterion, count] if count.positive?
245
+ end
246
+ return '' if summarized.empty?
247
+
248
+ counts = summarized.map { |criterion, count| "#{header(criterion, count)}." }.join("\n")
249
+ "#{counts}\n\nRun with #{detail_env_var}=true to see the uncovered #{noun_list(summarized.map(&:first))}."
250
+ end
251
+
252
+ # The "N lines are not covered by tests" header for a criterion
253
+ # @param criterion [Symbol]
254
+ # @param count [Integer]
255
+ # @return [String]
256
+ def header(criterion, count)
257
+ singular, plural = CRITERION_LABELS.fetch(criterion)
258
+ "#{count} #{pluralize(count, "#{singular} is", "#{plural} are")} not covered by tests"
259
+ end
260
+
261
+ # The formatted, uncovered items for a single criterion, across all files
262
+ # @param criterion [Symbol]
263
+ # @return [Array<String>]
264
+ def uncovered_items(criterion)
265
+ reported_files.flat_map { |file| items_for(file, criterion) }
266
+ end
267
+
268
+ # The count of uncovered items for a single criterion across all files
269
+ #
270
+ # @param criterion [Symbol]
271
+ # @return [Integer]
272
+ def uncovered_count(criterion)
273
+ reported_files.sum { |file| count_for(file, criterion) }
274
+ end
275
+
276
+ # The formatted, uncovered items of one criterion within a single file
277
+ # @param file [SimpleCov::SourceFile]
278
+ # @param criterion [Symbol]
279
+ # @return [Array<String>]
280
+ def items_for(file, criterion) = send(:"#{criterion}_items", file)
281
+
282
+ # The count of uncovered items of one criterion within a single file
283
+ # @param file [SimpleCov::SourceFile]
284
+ # @param criterion [Symbol]
285
+ # @return [Integer]
286
+ def count_for(file, criterion) = send(:"#{criterion}_count", file)
287
+
288
+ # The formatted, uncovered lines within a single file
289
+ # @param file [SimpleCov::SourceFile]
290
+ # @return [Array<String>]
291
+ def line_items(file) = file.missed_lines.map { |line| "#{project_path(file)}:#{line.number}" }
292
+
293
+ # The formatted, uncovered branches within a single file
294
+ # @param file [SimpleCov::SourceFile]
295
+ # @return [Array<String>]
296
+ def branch_items(file) = file.missed_branches.map { |branch| branch_text(file, branch) }
297
+
298
+ # The formatted, uncovered methods within a single file
299
+ # @param file [SimpleCov::SourceFile]
300
+ # @return [Array<String>]
301
+ def method_items(file)
302
+ file.missed_methods.map { |method| "#{project_path(file)}:#{method.start_line} #{method}" }
303
+ end
304
+
305
+ # The count of uncovered lines within a single file
306
+ # @param file [SimpleCov::SourceFile]
307
+ # @return [Integer]
308
+ def line_count(file) = file.missed_lines.count
309
+
310
+ # The count of uncovered branches within a single file
311
+ # @param file [SimpleCov::SourceFile]
312
+ # @return [Integer]
313
+ def branch_count(file) = file.missed_branches.count
314
+
315
+ # The count of uncovered methods within a single file
316
+ # @param file [SimpleCov::SourceFile]
317
+ # @return [Integer]
318
+ def method_count(file) = file.missed_methods.count
319
+
320
+ # A single formatted, uncovered branch
321
+ # @param file [SimpleCov::SourceFile]
322
+ # @param branch [SimpleCov::SourceFile::Branch]
323
+ # @return [String]
324
+ def branch_text(file, branch) = "#{project_path(file)}:#{branch.report_line} (#{branch.type} branch)"
325
+
326
+ # The path to a source file, relative to the project root
327
+ # @param file [SimpleCov::SourceFile]
328
+ # @return [String]
329
+ def project_path(file) = File.join('.', file.project_filename)
330
+
331
+ # Join plural nouns into a friendly list, e.g. "lines and branches"
332
+ # @param criteria_list [Array<Symbol>]
333
+ # @return [String]
334
+ def noun_list(criteria_list)
335
+ nouns = criteria_list.map { |criterion| CRITERION_LABELS.fetch(criterion).last }
336
+ return nouns.first if nouns.size == 1
337
+
338
+ "#{nouns[0..-2].join(', ')} and #{nouns.last}"
339
+ end
340
+
341
+ # Return the singular or plural form of a word based on the count
342
+ # @param count [Integer] the count
343
+ # @param singular [String] the singular form of the phrase
344
+ # @param plural [String] the plural form of the phrase
345
+ # @return [String]
346
+ def pluralize(count, singular, plural) = count == 1 ? singular : plural
347
+ end
348
+ end
349
+ end
@@ -3,6 +3,6 @@
3
3
  module Simplecov
4
4
  class Rspec
5
5
  # This gem's version
6
- VERSION = '0.4.4'
6
+ VERSION = '1.1.0'
7
7
  end
8
8
  end