tins 1.61.0 → 1.62.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/CHANGES.md +67 -0
- data/lib/tins/attempt.rb +47 -9
- data/lib/tins/dslkit.rb +91 -52
- data/lib/tins/duration.rb +342 -53
- data/lib/tins/enum_joined.rb +68 -0
- data/lib/tins/enum_squeeze.rb +33 -0
- data/lib/tins/null.rb +1 -1
- data/lib/tins/version.rb +7 -2
- data/lib/tins/xt/enum_joined.rb +7 -0
- data/lib/tins/xt.rb +1 -0
- data/tests/duration_test.rb +313 -0
- data/tests/enum_joined_test.rb +59 -0
- data/tins.gemspec +7 -7
- metadata +10 -4
data/lib/tins/duration.rb
CHANGED
|
@@ -1,74 +1,289 @@
|
|
|
1
1
|
# frozen_string_literal: false
|
|
2
2
|
|
|
3
|
+
require 'tins/enum_joined'
|
|
4
|
+
|
|
3
5
|
module Tins
|
|
4
6
|
# A class to represent durations with support for formatting and parsing time
|
|
5
7
|
# intervals.
|
|
6
8
|
class Duration
|
|
7
9
|
include Comparable
|
|
8
10
|
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
+
# Raised when a duration string cannot be parsed by any of the
|
|
12
|
+
# provided templates.
|
|
11
13
|
#
|
|
12
|
-
# @
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
# @see .parse
|
|
15
|
+
class ParserError < ArgumentError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Resolves a {HUMAN_CONNECTORS} preset to its connector hash.
|
|
15
19
|
#
|
|
16
|
-
#
|
|
20
|
+
# Shared by {#to_human} and {.parse_human} so that both
|
|
21
|
+
# accept the same +connector:+ keyword argument without
|
|
22
|
+
# duplicating the nil / Symbol / Hash dispatch.
|
|
17
23
|
#
|
|
18
|
-
# @
|
|
24
|
+
# @see HUMAN_CONNECTORS
|
|
25
|
+
# @see STYLE_DEFAULT_CONNECTOR
|
|
26
|
+
module ConnectorResolver
|
|
27
|
+
# Resolves a connector specification to a +Hash+ with keys
|
|
28
|
+
# +:words+, +:two_words+, and +:last_word+.
|
|
29
|
+
#
|
|
30
|
+
# @param connector [Symbol, Hash, nil] A preset name from
|
|
31
|
+
# {HUMAN_CONNECTORS}, a custom hash, or +nil+ (derive from
|
|
32
|
+
# +style+ via {STYLE_DEFAULT_CONNECTOR}).
|
|
33
|
+
# @param style [Symbol, Hash] The label style passed alongside,
|
|
34
|
+
# used only when +connector+ is +nil+.
|
|
35
|
+
#
|
|
36
|
+
# @return [Hash] The resolved connector hash.
|
|
37
|
+
#
|
|
38
|
+
# @raise [ArgumentError] If +connector+ is neither +nil+, a
|
|
39
|
+
# +Symbol+, nor a +Hash+.
|
|
40
|
+
#
|
|
41
|
+
# @see HUMAN_CONNECTORS
|
|
42
|
+
# @see STYLE_DEFAULT_CONNECTOR
|
|
43
|
+
def resolve_connector(connector, style)
|
|
44
|
+
case connector
|
|
45
|
+
when nil
|
|
46
|
+
key = style.is_a?(Symbol) ? style : :abbrev
|
|
47
|
+
HUMAN_CONNECTORS.fetch(STYLE_DEFAULT_CONNECTOR.fetch(key))
|
|
48
|
+
when Symbol then HUMAN_CONNECTORS.fetch(connector)
|
|
49
|
+
when Hash then connector
|
|
50
|
+
else raise ArgumentError,
|
|
51
|
+
"connector must be a Symbol or Hash, got #{connector.class}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
private_constant :ConnectorResolver
|
|
56
|
+
include ConnectorResolver
|
|
57
|
+
|
|
58
|
+
# Built-in label styles for {#to_human}. Keys are the unit symbols
|
|
59
|
+
# (+:days+, +:hours+, +:minutes+, +:seconds+); values are the literal
|
|
60
|
+
# strings appended directly after each unit's numeric value.
|
|
19
61
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
62
|
+
# +:words+ uses spelled-out units (the default); +:abbrev+ uses
|
|
63
|
+
# single-letter suffixes. A caller may instead pass their own +Hash+ with the
|
|
64
|
+
# same keys to {#to_human} to override either built-in, e.g. for another
|
|
65
|
+
# language.
|
|
23
66
|
#
|
|
24
|
-
# @
|
|
25
|
-
|
|
67
|
+
# @see #to_human
|
|
68
|
+
HUMAN_STYLES = {
|
|
69
|
+
abbrev: { days: 'd', hours: 'h', minutes: 'm', seconds: 's' }.freeze,
|
|
70
|
+
words: {
|
|
71
|
+
days: ' days', hours: ' hours',
|
|
72
|
+
minutes: ' minutes', seconds: ' seconds'
|
|
73
|
+
}.freeze
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
# Named connector presets for {#to_human} and {.parse_human}.
|
|
26
77
|
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
78
|
+
# Each entry maps to a +Hash+ with three keys:
|
|
79
|
+
# +:words+ (3+ items), +:two_words+ (exactly 2), and
|
|
80
|
+
# +:last_word+ (before the final item, 3+ only).
|
|
29
81
|
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# - `%m` - Minutes component (integer)
|
|
35
|
-
# - `%s` - Seconds component (integer)
|
|
36
|
-
# - `%f` - Fractional seconds component (decimal)
|
|
37
|
-
# - `%%` - Literal percent character
|
|
82
|
+
# +:none+ produces compact output (`"1m30s"`); +:spaces+
|
|
83
|
+
# separates with single spaces (`"1 minutes 30 seconds"`);
|
|
84
|
+
# +:prose+ uses grammatical connectors
|
|
85
|
+
# (`"1 minutes, and 30 seconds"`).
|
|
38
86
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
87
|
+
# {#to_human} and {.parse_human} accept a +connector:+
|
|
88
|
+
# keyword argument that is either a +Symbol+ naming one of
|
|
89
|
+
# these presets or a +Hash+ with the three keys for
|
|
90
|
+
# full control. When +connector:+ is +nil+ (the default),
|
|
91
|
+
# the preset is derived from +style:+ via
|
|
92
|
+
# {STYLE_DEFAULT_CONNECTOR}.
|
|
93
|
+
#
|
|
94
|
+
# @see HUMAN_STYLES
|
|
95
|
+
# @see STYLE_DEFAULT_CONNECTOR
|
|
96
|
+
# @see #to_human
|
|
97
|
+
# @see .parse_human
|
|
98
|
+
HUMAN_CONNECTORS = {
|
|
99
|
+
none: { words: '', two_words: '', last_word: '' }.freeze,
|
|
100
|
+
spaces: { words: ' ', two_words: ' ', last_word: ' ' }.freeze,
|
|
101
|
+
prose: {
|
|
102
|
+
words: ', ', two_words: ' and ', last_word: ', and '
|
|
103
|
+
}.freeze
|
|
104
|
+
}.freeze
|
|
105
|
+
|
|
106
|
+
# Maps each built-in {HUMAN_STYLES} key to its default
|
|
107
|
+
# {HUMAN_CONNECTORS} preset. Used when +connector:+ is
|
|
108
|
+
# not explicitly provided.
|
|
109
|
+
#
|
|
110
|
+
# @see HUMAN_CONNECTORS
|
|
111
|
+
# @see HUMAN_STYLES
|
|
112
|
+
STYLE_DEFAULT_CONNECTOR = { abbrev: :none, words: :prose }.freeze
|
|
113
|
+
|
|
114
|
+
class << self
|
|
115
|
+
include ConnectorResolver
|
|
116
|
+
|
|
117
|
+
# Parses a duration string against one or more templates,
|
|
118
|
+
# returning the total number of seconds.
|
|
119
|
+
#
|
|
120
|
+
# Templates are tried in order; the first one that matches wins.
|
|
121
|
+
# If no template matches, a {ParserError} is raised.
|
|
122
|
+
#
|
|
123
|
+
# @param [String] string The duration string to parse.
|
|
124
|
+
# @param [String, Array<String>, nil] template A single template
|
|
125
|
+
# string, an array of template strings tried in order, or +nil+
|
|
126
|
+
# (the built-in default `'%S%d+%h:%m:%s.%f'`).
|
|
127
|
+
#
|
|
128
|
+
# @return [Integer, Float] The number of (fractional) seconds.
|
|
129
|
+
#
|
|
130
|
+
# @raise [ParserError] If no template matches the string.
|
|
131
|
+
#
|
|
132
|
+
# @see #from_string
|
|
133
|
+
# @see #format
|
|
134
|
+
#
|
|
135
|
+
# @example Default template
|
|
136
|
+
# Tins::Duration.parse('6+05:04:03') # => 536643
|
|
137
|
+
# Tins::Duration.parse('6+05:04:03.21') # => 536643.21
|
|
138
|
+
#
|
|
139
|
+
# @example Parsing negative durations
|
|
140
|
+
# Tins::Duration.parse('-6+05:04:03') # => -536643
|
|
141
|
+
#
|
|
142
|
+
# @example Custom template
|
|
143
|
+
# Tins::Duration.parse('05:04:03.21', template: '%h:%m:%s.%f')
|
|
144
|
+
# # => 18243.21
|
|
145
|
+
#
|
|
146
|
+
# @example Multiple templates (first match wins)
|
|
147
|
+
# Tins::Duration.parse('1m 30s',
|
|
148
|
+
# template: [ '%m minutes %s seconds', '%S%d+%h:%m:%s.%f' ]
|
|
149
|
+
# )
|
|
150
|
+
def parse(string, template: nil)
|
|
151
|
+
templates = Array(template).dup
|
|
152
|
+
templates.empty? and templates << '%S%d+%h:%m:%s.%f'
|
|
153
|
+
templates.each do |template|
|
|
154
|
+
result = parse_template(string, template:) and return result
|
|
155
|
+
rescue ParserError
|
|
156
|
+
end
|
|
157
|
+
raise ParserError, "#{string.inspect} did not match any of the " \
|
|
158
|
+
"templates in #{template.inspect}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Parses a duration string against a single template.
|
|
162
|
+
#
|
|
163
|
+
# Supported directives:
|
|
164
|
+
# - `%S` - Sign indicator (optional negative sign)
|
|
165
|
+
# - `%d` - Days component (integer)
|
|
166
|
+
# - `%h` - Hours component (integer)
|
|
167
|
+
# - `%m` - Minutes component (integer)
|
|
168
|
+
# - `%s` - Seconds component (integer)
|
|
169
|
+
# - `%f` - Fractional seconds component (decimal)
|
|
170
|
+
# - `%%` - Literal percent character
|
|
171
|
+
#
|
|
172
|
+
# The parser is greedy and consumes as much of the input string as
|
|
173
|
+
# possible for each directive. If a directive expects a specific
|
|
174
|
+
# format but doesn't find it, a {ParserError} is raised.
|
|
175
|
+
def parse_template(string, template:)
|
|
176
|
+
s, t = string.to_s.dup, template.dup
|
|
177
|
+
d, sd = 0, 1
|
|
178
|
+
loop do
|
|
179
|
+
t.sub!(/\A(%[Sdhmsf%]|.)/) { |directive|
|
|
180
|
+
case directive
|
|
181
|
+
when '%S' then s.sub!(/\A-?/) { sd *= -1 if _1 == ?-; '' }
|
|
182
|
+
when '%d' then s.sub!(/\A\d+/) { d += 86_400 * _1.to_i; '' }
|
|
183
|
+
when '%h' then s.sub!(/\A\d+/) { d += 3_600 * _1.to_i; '' }
|
|
184
|
+
when '%m' then s.sub!(/\A\d+/) { d += 60 * _1.to_i; '' }
|
|
185
|
+
when '%s' then s.sub!(/\A\d+/) { d += _1.to_i; '' }
|
|
186
|
+
when '%f' then s.sub!(/\A\d+/) { d += Float(?. + _1); '' }
|
|
187
|
+
when '%%' then
|
|
188
|
+
if s[0] == ?%
|
|
189
|
+
s[0] = ''
|
|
190
|
+
else
|
|
191
|
+
raise ParserError, "expected %s, got #{s.inspect}"
|
|
192
|
+
end
|
|
64
193
|
else
|
|
65
|
-
|
|
194
|
+
if directive == s[0]
|
|
195
|
+
s[0] = ''
|
|
196
|
+
else
|
|
197
|
+
raise ParserError, "expected #{t.inspect}, got #{s.inspect}"
|
|
198
|
+
end
|
|
66
199
|
end
|
|
200
|
+
''
|
|
201
|
+
} or break
|
|
202
|
+
end
|
|
203
|
+
sd * d
|
|
204
|
+
end
|
|
205
|
+
private :parse_template
|
|
206
|
+
|
|
207
|
+
# Creates a new {Duration} instance by parsing the given string
|
|
208
|
+
# with {#parse}.
|
|
209
|
+
#
|
|
210
|
+
# @param [String] string The duration string to parse.
|
|
211
|
+
# @param [String, nil] template for the duration format, see
|
|
212
|
+
# {#format}. Default: +nil+ (falls back to the built-in format).
|
|
213
|
+
#
|
|
214
|
+
# @return [Duration] A new {Duration} instance.
|
|
215
|
+
#
|
|
216
|
+
# @raise [Duration::ParserError] If the string doesn't match the
|
|
217
|
+
# expected template format.
|
|
218
|
+
#
|
|
219
|
+
# @see .parse
|
|
220
|
+
#
|
|
221
|
+
# @example Basic usage
|
|
222
|
+
# Tins::Duration.from_string('6+05:04:03.21')
|
|
223
|
+
# # => #<Tins::Duration: 6+05:04:03.210>
|
|
224
|
+
#
|
|
225
|
+
# @example Custom template
|
|
226
|
+
# Tins::Duration.from_string('05:04:03', template: '%h:%m:%s')
|
|
227
|
+
# # => #<Tins::Duration: 0+05:04:03.000>
|
|
228
|
+
def from_string(string, template: nil)
|
|
229
|
+
new(parse(string, template:))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Parses a human-readable duration string (the inverse of {#to_human})
|
|
233
|
+
# and returns a new {Duration} instance.
|
|
234
|
+
#
|
|
235
|
+
# All 15 possible +to_human+ output shapes (every subset of 1–4 units,
|
|
236
|
+
# in canonical order) are generated as templates for the given +style+,
|
|
237
|
+
# joined with the supplied connectors, and tried in order against
|
|
238
|
+
# {#parse}. The first template that matches wins.
|
|
239
|
+
#
|
|
240
|
+
# @param [String] string The human-readable duration string.
|
|
241
|
+
# @param style [Symbol, Hash] Built-in label style (+:abbrev+ or
|
|
242
|
+
# +:words+, see {HUMAN_STYLES}) or a custom +Hash+. Default: +:words+.
|
|
243
|
+
# @param connector [Symbol, Hash, nil] Connector preset
|
|
244
|
+
# ({HUMAN_CONNECTORS}) or a custom +Hash+ with keys
|
|
245
|
+
# +:words+, +:two_words+, +:last_word+.
|
|
246
|
+
# Default: +nil+ (derived from +style:+).
|
|
247
|
+
#
|
|
248
|
+
# @return [Duration] A new {Duration} instance.
|
|
249
|
+
#
|
|
250
|
+
# @raise [ArgumentError] If +style+ is neither a +Symbol+ nor a +Hash+.
|
|
251
|
+
# @raise [ParserError] If no generated template matches the string.
|
|
252
|
+
#
|
|
253
|
+
# @see #to_human
|
|
254
|
+
# @see .parse
|
|
255
|
+
#
|
|
256
|
+
# @example Spelled-out units (default)
|
|
257
|
+
# Tins::Duration.parse_human('1 minutes and 30 seconds')
|
|
258
|
+
# # => #<Tins::Duration: 00:01:30>
|
|
259
|
+
#
|
|
260
|
+
# @example Abbreviated
|
|
261
|
+
# Tins::Duration.parse_human('1m30s', style: :abbrev)
|
|
262
|
+
# # => #<Tins::Duration: 00:01:30>
|
|
263
|
+
#
|
|
264
|
+
# @example Round-trip with #to_human
|
|
265
|
+
# d = Tins::Duration.new(3725)
|
|
266
|
+
# Tins::Duration.parse_human(d.to_human).to_f # => 3725.0
|
|
267
|
+
def parse_human(string, style: :words, connector: nil)
|
|
268
|
+
labels =
|
|
269
|
+
case style
|
|
270
|
+
when Hash then HUMAN_STYLES[:abbrev].merge(style)
|
|
271
|
+
when Symbol then HUMAN_STYLES.fetch(style)
|
|
272
|
+
else raise ArgumentError,
|
|
273
|
+
"style must be a Symbol or Hash, got #{style.class}"
|
|
67
274
|
end
|
|
68
|
-
|
|
69
|
-
|
|
275
|
+
conn = resolve_connector(connector, style)
|
|
276
|
+
units = [ [ :days, '%d' ], [ :hours, '%h' ],
|
|
277
|
+
[ :minutes, '%m' ], [ :seconds, '%s' ] ]
|
|
278
|
+
templates = (1..4).reverse_each.flat_map do |n|
|
|
279
|
+
units.combination(n).map do |combo|
|
|
280
|
+
parts = combo.map { |k, d| "#{d}#{labels[k]}" }
|
|
281
|
+
.extend(EnumJoined)
|
|
282
|
+
parts.joined(**conn)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
new(parse(string, template: templates))
|
|
70
286
|
end
|
|
71
|
-
sd * d
|
|
72
287
|
end
|
|
73
288
|
|
|
74
289
|
# Initializes a new Duration object with the specified number of seconds.
|
|
@@ -91,9 +306,15 @@ module Tins
|
|
|
91
306
|
}
|
|
92
307
|
end
|
|
93
308
|
|
|
94
|
-
#
|
|
309
|
+
# Returns the duration's magnitude in seconds as a floating-point
|
|
310
|
+
# number.
|
|
311
|
+
#
|
|
312
|
+
# A duration represents the time span between two points; the span
|
|
313
|
+
# itself is always non-negative. The sign is a direction qualifier,
|
|
314
|
+
# tracked separately — see {#negative?}.
|
|
95
315
|
#
|
|
96
|
-
# @return [Float]
|
|
316
|
+
# @return [Float] The magnitude of the duration in seconds.
|
|
317
|
+
# Always non-negative.
|
|
97
318
|
def to_f
|
|
98
319
|
@original_seconds.to_f
|
|
99
320
|
end
|
|
@@ -156,6 +377,74 @@ module Tins
|
|
|
156
377
|
@fractional_seconds > 0
|
|
157
378
|
end
|
|
158
379
|
|
|
380
|
+
# Returns a human-readable string representation of the duration.
|
|
381
|
+
# By default, uses spelled-out units (e.g. `1 minutes and 30 seconds`);
|
|
382
|
+
# pass +style: :abbrev+ for compact single-letter suffixes
|
|
383
|
+
# (e.g. `1m30s`), or pass your own +Hash+ keyed by the unit symbols
|
|
384
|
+
# (+:days+, +:hours+, +:minutes+, +:seconds+) for arbitrary wording.
|
|
385
|
+
#
|
|
386
|
+
# Fractional seconds are truncated (not rounded).
|
|
387
|
+
#
|
|
388
|
+
# @param max [Integer] Maximum number of time units to include,
|
|
389
|
+
# starting from the largest. Default: +nil+ (all units).
|
|
390
|
+
# @param connector [Symbol, Hash, nil] Connector preset
|
|
391
|
+
# ({HUMAN_CONNECTORS}) or a custom +Hash+ with keys
|
|
392
|
+
# +:words+, +:two_words+, +:last_word+.
|
|
393
|
+
# Default: +nil+ (derived from +style:+).
|
|
394
|
+
# @param style [Symbol, Hash] Built-in label style (+:abbrev+ or
|
|
395
|
+
# +:words+, see {HUMAN_STYLES}) or a custom +Hash+ mapping the unit
|
|
396
|
+
# symbols to label strings. A +Hash+ is merged onto the
|
|
397
|
+
# +:abbrev+ defaults, so omitting a key keeps the single-letter label.
|
|
398
|
+
# Default: +:words+.
|
|
399
|
+
#
|
|
400
|
+
# @raise [ArgumentError] If +style+ is neither a +Symbol+ nor a +Hash+.
|
|
401
|
+
#
|
|
402
|
+
# @return [String] Human-readable duration string.
|
|
403
|
+
#
|
|
404
|
+
# @see Tins::EnumJoined#joined
|
|
405
|
+
#
|
|
406
|
+
# @example Basic usage (default +:words+ style)
|
|
407
|
+
# Tins::Duration.new(90).to_human
|
|
408
|
+
# # => "1 minutes and 30 seconds"
|
|
409
|
+
# Tins::Duration.new(3725).to_human
|
|
410
|
+
# # => "1 hours, 2 minutes, and 5 seconds"
|
|
411
|
+
# Tins::Duration.new(86_400).to_human # => "1 days"
|
|
412
|
+
# Tins::Duration.new(0).to_human # => "0 seconds"
|
|
413
|
+
#
|
|
414
|
+
# @example Abbreviated units
|
|
415
|
+
# Tins::Duration.new(90).to_human(style: :abbrev)
|
|
416
|
+
# # => "1m30s"
|
|
417
|
+
#
|
|
418
|
+
# @example Custom labels (e.g. another language)
|
|
419
|
+
# Tins::Duration.new(90).to_human(
|
|
420
|
+
# style: { minutes: ' Min.', seconds: ' Sek.' }
|
|
421
|
+
# )
|
|
422
|
+
# # => "1 Min.30 Sek."
|
|
423
|
+
#
|
|
424
|
+
# @example Negative durations
|
|
425
|
+
# Tins::Duration.new(-90).to_human
|
|
426
|
+
# # => "-1 minutes and 30 seconds"
|
|
427
|
+
def to_human(max: nil, style: :words, connector: nil)
|
|
428
|
+
labels =
|
|
429
|
+
case style
|
|
430
|
+
when Hash then HUMAN_STYLES[:abbrev].merge(style)
|
|
431
|
+
when Symbol then HUMAN_STYLES.fetch(style)
|
|
432
|
+
else raise ArgumentError,
|
|
433
|
+
"style must be a Symbol or Hash, got #{style.class}"
|
|
434
|
+
end
|
|
435
|
+
conn = resolve_connector(connector, style)
|
|
436
|
+
parts = [
|
|
437
|
+
[ @days, :days ], [ @hours, :hours ],
|
|
438
|
+
[ @minutes, :minutes ], [ @seconds, :seconds ]
|
|
439
|
+
].select { |v, _| v > 0 }
|
|
440
|
+
parts << [ 0, :seconds ] if parts.empty?
|
|
441
|
+
parts = parts.first(max) if max
|
|
442
|
+
items = parts.map { |v, k| "#{v}#{labels[k]}" }
|
|
443
|
+
.extend(EnumJoined)
|
|
444
|
+
result = items.joined(**conn)
|
|
445
|
+
@negative ? "-#{result}" : result
|
|
446
|
+
end
|
|
447
|
+
|
|
159
448
|
# Formats the duration according to the given template and precision.
|
|
160
449
|
#
|
|
161
450
|
# The template string supports the following directives:
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Tins
|
|
2
|
+
# Adds +joined+ to any +Enumerable+, providing human-friendly
|
|
3
|
+
# sentence-style joining with configurable connectors, similar
|
|
4
|
+
# to Rails' +Array#to_sentence+.
|
|
5
|
+
#
|
|
6
|
+
# @example Polite usage (include into your own class):
|
|
7
|
+
# require 'tins/enum_joined'
|
|
8
|
+
#
|
|
9
|
+
# class MyList
|
|
10
|
+
# include Enumerable
|
|
11
|
+
# include Tins::EnumJoined
|
|
12
|
+
#
|
|
13
|
+
# def each
|
|
14
|
+
# %w[foo bar baz].each { |x| yield x }
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# MyList.new.joined # => "foo, bar, and baz"
|
|
19
|
+
#
|
|
20
|
+
# @example Rude usage (mixin into ::Enumerable globally):
|
|
21
|
+
# require 'tins/xt/enum_joined'
|
|
22
|
+
#
|
|
23
|
+
# [1, 2, 3].joined # => "1, 2, and 3"
|
|
24
|
+
# Set['a', 'b', 'c'].joined # => "a, b, and c"
|
|
25
|
+
module EnumJoined
|
|
26
|
+
# Join all elements into a human-readable sentence.
|
|
27
|
+
#
|
|
28
|
+
# Uses three connectors, mirroring Rails' +Array#to_sentence+:
|
|
29
|
+
#
|
|
30
|
+
# | elements | connectors used |
|
|
31
|
+
# |----------|-------------------------------|
|
|
32
|
+
# | 0 | (none) — returns `""` |
|
|
33
|
+
# | 1 | (none) — returns the element |
|
|
34
|
+
# | 2 | +two_words+ |
|
|
35
|
+
# | 3+ | +words+ + +last_word+ |
|
|
36
|
+
#
|
|
37
|
+
# @param [String] words
|
|
38
|
+
# Separator between all items except the last two.
|
|
39
|
+
# Default: `', '`.
|
|
40
|
+
# @param [String] two_words
|
|
41
|
+
# Separator between the only two items.
|
|
42
|
+
# Default: `' and '`.
|
|
43
|
+
# @param [String] last_word
|
|
44
|
+
# Separator before the last item (3+ items only).
|
|
45
|
+
# Default: `', and '` (Oxford comma).
|
|
46
|
+
#
|
|
47
|
+
# @return [String] the joined sentence
|
|
48
|
+
#
|
|
49
|
+
# @example Default connectors
|
|
50
|
+
# [1, 2].joined # => "1 and 2"
|
|
51
|
+
# [1, 2, 3].joined # => "1, 2, and 3"
|
|
52
|
+
#
|
|
53
|
+
# @example Custom connectors
|
|
54
|
+
# [1, 2, 3].joined(words: ' / ', last_word: ' / ')
|
|
55
|
+
# # => "1 / 2 / 3"
|
|
56
|
+
def joined(words: ', ', two_words: ' and ', last_word: ', and ')
|
|
57
|
+
items = map(&:to_s)
|
|
58
|
+
case items.size
|
|
59
|
+
when 0 then ''
|
|
60
|
+
when 1 then items.first
|
|
61
|
+
when 2 then items.first + two_words + items.last
|
|
62
|
+
else
|
|
63
|
+
head, tail = items[0..-2], items.last
|
|
64
|
+
head.join(words) + last_word + tail
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/tins/enum_squeeze.rb
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
module Tins
|
|
2
|
+
# Adds +squeeze+ and +squeeze!+ to any +Enumerable+, providing
|
|
3
|
+
# the same "collapse consecutive duplicates" semantics as
|
|
4
|
+
# +String#squeeze+ but for generic collections.
|
|
5
|
+
#
|
|
6
|
+
# Two elements are considered duplicates when +item == previous+.
|
|
7
|
+
# You can optionally restrict squeezing to a subset of elements by
|
|
8
|
+
# passing case-equality matchers (+===+) or a predicate block.
|
|
9
|
+
#
|
|
10
|
+
# @example Polite usage (include into your own class):
|
|
11
|
+
# require 'tins/enum_squeeze'
|
|
12
|
+
#
|
|
13
|
+
# class Repeater
|
|
14
|
+
# include Enumerable
|
|
15
|
+
# include Tins::EnumSqueeze
|
|
16
|
+
#
|
|
17
|
+
# def each
|
|
18
|
+
# [1, 1, 2, 2, 3].each { |i| yield i }
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Repeater.new.squeeze # => [1, 2, 3]
|
|
23
|
+
#
|
|
24
|
+
# @example Rude usage (mixin into ::Enumerable globally):
|
|
25
|
+
# require 'tins/xt/enum_squeeze'
|
|
26
|
+
#
|
|
27
|
+
# # Now any custom Enumerable gains squeeze/squeeze!:
|
|
28
|
+
# enum = (1..5).to_a # Array already has its own #squeeze,
|
|
29
|
+
# # so this is more useful for custom Enumerables:
|
|
30
|
+
# class Repeater
|
|
31
|
+
# include Enumerable
|
|
32
|
+
# def each = [1, 1, 2, 2, 3].each { |i| yield i }
|
|
33
|
+
# end
|
|
34
|
+
# Repeater.new.squeeze # => [1, 2, 3]
|
|
2
35
|
module EnumSqueeze
|
|
3
36
|
A_THING_THAT_IS_NOT_A_THING = Object.new.freeze
|
|
4
37
|
|
data/lib/tins/null.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Tins
|
|
|
22
22
|
#
|
|
23
23
|
# # You can use the null object:
|
|
24
24
|
# user = find_user(id) || Tins::NULL
|
|
25
|
-
# user.name # => "" (instead of nil)
|
|
25
|
+
# user.name.to_s # => "" (instead of nil)
|
|
26
26
|
#
|
|
27
27
|
# @example With NullPlus for debugging
|
|
28
28
|
# user = find_user(id) || Tins::NullPlus.new(value: "Unknown", caller: caller)
|
data/lib/tins/version.rb
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
# Version information for the Tins gem.
|
|
2
|
+
#
|
|
3
|
+
# This module is auto-generated by GemHadar and exposes the
|
|
4
|
+
# current release version as a set of constants. Do not edit it
|
|
5
|
+
# by hand; it is rewritten on every `rake version` invocation.
|
|
1
6
|
module Tins
|
|
2
|
-
# Tins
|
|
3
|
-
VERSION = '1.
|
|
7
|
+
# The current version of the Tins gem as a String.
|
|
8
|
+
VERSION = '1.62.0'
|
|
4
9
|
VERSION_ARRAY = VERSION.split('.').map(&:to_i) # :nodoc:
|
|
5
10
|
VERSION_MAJOR = VERSION_ARRAY[0] # :nodoc:
|
|
6
11
|
VERSION_MINOR = VERSION_ARRAY[1] # :nodoc:
|