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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.rubocop.yml +7 -1
- data/CHANGELOG.md +21 -0
- data/README.md +259 -38
- data/lib/simplecov-rspec/described_source_files.rb +76 -0
- data/lib/simplecov-rspec/list_uncovered_files_option.rb +130 -0
- data/lib/simplecov-rspec/list_uncovered_option.rb +79 -0
- data/lib/simplecov-rspec/uncovered_report.rb +349 -0
- data/lib/simplecov-rspec/version.rb +1 -1
- data/lib/simplecov-rspec.rb +288 -179
- data/release-please-config.json +2 -2
- metadata +36 -17
|
@@ -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
|