fat_core 3.0.0 → 4.0.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/.ruby-version +1 -1
- data/.yardopts +5 -1
- data/README.md +124 -7
- data/Rakefile +17 -1
- data/bin/console +6 -7
- data/bin/easters +1 -1
- data/fat_core.gemspec +3 -2
- data/lib/fat_core/all.rb +1 -4
- data/lib/fat_core/array.rb +4 -1
- data/lib/fat_core/bigdecimal.rb +19 -0
- data/lib/fat_core/date.rb +913 -298
- data/lib/fat_core/hash.rb +98 -15
- data/lib/fat_core/kernel.rb +13 -0
- data/lib/fat_core/nil.rb +16 -2
- data/lib/fat_core/numeric.rb +84 -32
- data/lib/fat_core/range.rb +311 -109
- data/lib/fat_core/string.rb +246 -161
- data/lib/fat_core/symbol.rb +28 -4
- data/lib/fat_core/version.rb +2 -1
- data/spec/lib/{big_decimal_spec.rb → bigdecimal_spec.rb} +1 -1
- data/spec/lib/date_spec.rb +1 -1
- data/spec/lib/numeric_spec.rb +1 -1
- data/spec/lib/range_spec.rb +8 -6
- data/spec/lib/string_spec.rb +72 -58
- data/spec/spec_helper.rb +3 -2
- metadata +9 -10
- data/lib/core_extensions/date/fat_core.rb +0 -6
- data/lib/fat_core/big_decimal.rb +0 -12
data/lib/fat_core/string.rb
CHANGED
@@ -1,119 +1,52 @@
|
|
1
|
+
require 'bigdecimal'
|
1
2
|
require 'damerau-levenshtein'
|
2
3
|
require 'active_support/core_ext/regexp'
|
3
4
|
|
4
5
|
module FatCore
|
5
6
|
module String
|
7
|
+
# @group Transforming
|
8
|
+
# :section: Transforming
|
9
|
+
|
6
10
|
# Remove leading and trailing white space and compress internal runs of
|
7
11
|
# white space to a single space.
|
12
|
+
#
|
13
|
+
# @example
|
14
|
+
# ' hello world\n '.clean #=> 'hello world'
|
15
|
+
#
|
16
|
+
# @return [String]
|
8
17
|
def clean
|
9
18
|
strip.squeeze(' ')
|
10
19
|
end
|
11
20
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
# See if self contains colon- or space-separated words that include
|
22
|
-
# the colon- or space-separated words of other. Return the matched
|
23
|
-
# portion of self. Other cannot be a regex embedded in a string.
|
24
|
-
def fuzzy_match(other)
|
25
|
-
# Remove periods, commas, and apostrophes
|
26
|
-
other = other.gsub(/[\*.,']/, '')
|
27
|
-
target = gsub(/[\*.,']/, '')
|
28
|
-
matchers = other.split(/[: ]+/)
|
29
|
-
regexp_string = matchers.map { |m| ".*?#{Regexp.escape(m)}.*?" }.join('[: ]')
|
30
|
-
regexp_string.sub!(/^\.\*\?/, '')
|
31
|
-
regexp_string.sub!(/\.\*\?$/, '')
|
32
|
-
regexp = /#{regexp_string}/i
|
33
|
-
matched_text =
|
34
|
-
if (match = regexp.match(target))
|
35
|
-
match[0]
|
36
|
-
end
|
37
|
-
matched_text
|
38
|
-
end
|
39
|
-
|
40
|
-
# Here are instance methods for the class that includes Matchable
|
41
|
-
# This tries to convert the receiver object into a string, then
|
42
|
-
# matches against the given matcher, either via regex or a fuzzy
|
43
|
-
# string matcher.
|
44
|
-
def matches_with(str)
|
45
|
-
if str.nil?
|
46
|
-
nil
|
47
|
-
elsif str =~ %r{^\s*/}
|
48
|
-
re = str.to_regexp
|
49
|
-
$& if to_s =~ re
|
50
|
-
else
|
51
|
-
to_s.fuzzy_match(str)
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
|
-
def blank?
|
56
|
-
!!self =~ /\A\s*\z/
|
57
|
-
end
|
58
|
-
|
59
|
-
# Convert a string of the form '/.../Iixm' to a regular expression. However,
|
60
|
-
# make the regular expression case-insensitive by default and extend the
|
61
|
-
# modifier syntax to allow '/I' to indicate case-sensitive.
|
62
|
-
def to_regexp
|
63
|
-
if self =~ %r{^\s*/([^/]*)/([Iixm]*)\s*$}
|
64
|
-
body = $1
|
65
|
-
opts = $2
|
66
|
-
flags = Regexp::IGNORECASE
|
67
|
-
unless opts.blank?
|
68
|
-
flags = 0 if opts.include?('I')
|
69
|
-
flags |= Regexp::IGNORECASE if opts.include?('i')
|
70
|
-
flags |= Regexp::EXTENDED if opts.include?('x')
|
71
|
-
flags |= Regexp::MULTILINE if opts.include?('m')
|
72
|
-
end
|
73
|
-
flags = nil if flags.zero?
|
74
|
-
Regexp.new(body, flags)
|
75
|
-
else
|
76
|
-
Regexp.new(self)
|
77
|
-
end
|
78
|
-
end
|
79
|
-
|
80
|
-
# Convert to symbol "Hello World" -> :hello_world
|
21
|
+
# Convert to a lower-case symbol with all white space converted to a single
|
22
|
+
# '_' and all non-alphanumerics deleted, such that the string will work as
|
23
|
+
# an unquoted Symbol.
|
24
|
+
#
|
25
|
+
# @example
|
26
|
+
# "Hello World" -> :hello_world
|
27
|
+
# "Hello*+World" -> :helloworld
|
28
|
+
#
|
29
|
+
# @return [Symbol] self converted to a Symbol
|
81
30
|
def as_sym
|
82
|
-
|
83
|
-
.gsub(
|
31
|
+
clean
|
32
|
+
.gsub(/\s+/, '_')
|
33
|
+
.gsub(/[^_A-Za-z0-9]/, '')
|
34
|
+
.downcase.to_sym
|
84
35
|
end
|
85
36
|
|
37
|
+
# Return self unmodified. This method is here so to comply with the API of
|
38
|
+
# Symbol#as_string so that it can be applied to a variable that is either a
|
39
|
+
# String or a Symbol.
|
40
|
+
#
|
41
|
+
# @return [String] self unmodified
|
86
42
|
def as_string
|
87
43
|
self
|
88
44
|
end
|
89
45
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
return false
|
95
|
-
end
|
96
|
-
|
97
|
-
# If the string is a number, add grouping commas to the whole number part.
|
98
|
-
def commify
|
99
|
-
# Break the number into parts
|
100
|
-
return self unless clean =~ /\A(-)?(\d*)((\.)?(\d*))?\z/
|
101
|
-
neg = $1 || ''
|
102
|
-
whole = $2
|
103
|
-
frac = $5
|
104
|
-
# Place the commas in the whole part only
|
105
|
-
whole = whole.reverse
|
106
|
-
whole.gsub!(/([0-9]{3})/, '\\1,')
|
107
|
-
whole.gsub!(/,$/, '')
|
108
|
-
whole.reverse!
|
109
|
-
# Reassemble
|
110
|
-
if frac.blank?
|
111
|
-
neg + whole
|
112
|
-
else
|
113
|
-
neg + whole + '.' + frac
|
114
|
-
end
|
115
|
-
end
|
116
|
-
|
46
|
+
# Return a string wrapped to `width` characters with lines following the
|
47
|
+
# first indented by `hang` characters.
|
48
|
+
#
|
49
|
+
# @return [String] self wrapped
|
117
50
|
def wrap(width = 70, hang = 0)
|
118
51
|
result = ''
|
119
52
|
first_line = true
|
@@ -140,6 +73,14 @@ module FatCore
|
|
140
73
|
result.strip
|
141
74
|
end
|
142
75
|
|
76
|
+
# Return self with special TeX characters replaced with control-sequences
|
77
|
+
# that output the literal value of the special characters instead. It
|
78
|
+
# handles _, $, &, %, #, {, }, \, ^, ~, <, and >.
|
79
|
+
#
|
80
|
+
# @example
|
81
|
+
# '$100 & 20#'.tex_quote #=> '\\$100 \\& 20\\#'
|
82
|
+
#
|
83
|
+
# @return [String] self quoted
|
143
84
|
def tex_quote
|
144
85
|
r = dup
|
145
86
|
r = r.gsub(/[{]/, 'XzXzXobXzXzX')
|
@@ -155,20 +96,49 @@ module FatCore
|
|
155
96
|
r.gsub('XzXzXcbXzXzX', '\\}')
|
156
97
|
end
|
157
98
|
|
158
|
-
# Convert a string
|
159
|
-
#
|
160
|
-
|
161
|
-
|
99
|
+
# Convert a string representing a date with only digits, hyphens, or slashes
|
100
|
+
# to a Date.
|
101
|
+
#
|
102
|
+
# @example
|
103
|
+
# "20090923".as_date.iso -> "2009-09-23"
|
104
|
+
# "2009/09/23".as_date.iso -> "2009-09-23"
|
105
|
+
# "2009-09-23".as_date.iso -> "2009-09-23"
|
106
|
+
# "2009-9-23".as_date.iso -> "2009-09-23"
|
107
|
+
#
|
108
|
+
# @return [Date] the translated Date
|
109
|
+
def as_date
|
110
|
+
::Date.new($1.to_i, $2.to_i, $3.to_i) if self =~ %r{(\d\d\d\d)[-/]?(\d\d?)[-/]?(\d\d?)}
|
162
111
|
end
|
163
112
|
|
164
|
-
|
165
|
-
|
113
|
+
# Return self capitalized according to the conventions for capitalizing
|
114
|
+
# titles of books or articles. Tries to follow the rules of the University
|
115
|
+
# of Chicago's *A Manual of Style*, Section 7.123, except to the extent that
|
116
|
+
# doing so requires knowing the parts of speech of words in the title. Also
|
117
|
+
# tries to use sensible capitalization for things such as postal address
|
118
|
+
# abbreviations, like P.O Box, Ave., Cir., etc. Considers all-consonant
|
119
|
+
# words of 3 or more characters as acronyms to be kept all uppercase, e.g.,
|
120
|
+
# ddt => DDT, and words that are all uppercase in the input are kept that
|
121
|
+
# way, e.g. IBM stays IBM. Thus, if the source string is all uppercase, you
|
122
|
+
# should lowercase the whole string before using #entitle, otherwise is will
|
123
|
+
# not have the intended effect.
|
124
|
+
#
|
125
|
+
# @example 'now is the time for all good men' #=> 'Now Is the Time for All
|
126
|
+
# Good Men' 'how in the world does IBM do it?'.entitle #=> "How in the
|
127
|
+
# World Does IBM Do It?" 'how in the world does ibm do it?'.entitle #=>
|
128
|
+
# "How in the World Does Ibm Do It?" 'ne by nw'.entitle #=> 'NE by NW' 'my
|
129
|
+
# life: a narcissistic tale' => 'My Life: A Narcissistic Tale'
|
130
|
+
#
|
131
|
+
# @return [String]
|
132
|
+
def entitle
|
133
|
+
little_words = %w[a an the at for up and but
|
134
|
+
or nor in on under of from as by to]
|
166
135
|
newwords = []
|
136
|
+
capitalize_next = false
|
167
137
|
words = split(/\s+/)
|
168
|
-
|
169
|
-
num_words = words.length
|
138
|
+
last_k = words.size - 1
|
170
139
|
words.each_with_index do |w, k|
|
171
|
-
|
140
|
+
first = (k == 0)
|
141
|
+
last = (k == last_k)
|
172
142
|
if w =~ %r{c/o}i
|
173
143
|
# Care of
|
174
144
|
newwords.push('c/o')
|
@@ -194,96 +164,211 @@ module FatCore
|
|
194
164
|
elsif w =~ /^[^aeiouy]*$/i && w.size > 2
|
195
165
|
# All consonants and at least 3 chars, probably abbr
|
196
166
|
newwords.push(w.upcase)
|
167
|
+
elsif w =~ /^[A-Z0-9]+\z/
|
168
|
+
# All uppercase and numbers, keep as is
|
169
|
+
newwords.push(w)
|
197
170
|
elsif w =~ /^(\w+)-(\w+)$/i
|
198
171
|
# Hyphenated double word
|
199
172
|
newwords.push($1.capitalize + '-' + $2.capitalize)
|
173
|
+
elsif capitalize_next
|
174
|
+
# Last word ended with a ':'
|
175
|
+
newwords.push(w.capitalize)
|
176
|
+
capitalize_next = false
|
200
177
|
elsif little_words.include?(w.downcase)
|
201
178
|
# Only capitalize at beginning or end
|
202
|
-
newwords.push(
|
179
|
+
newwords.push(first || last ? w.capitalize : w.downcase)
|
203
180
|
else
|
204
181
|
# All else
|
205
182
|
newwords.push(w.capitalize)
|
206
183
|
end
|
207
|
-
|
184
|
+
# Capitalize following a ':'
|
185
|
+
capitalize_next = true if newwords.last =~ /:\s*\z/
|
208
186
|
end
|
209
|
-
|
187
|
+
newwords.join(' ')
|
210
188
|
end
|
211
189
|
|
212
|
-
|
213
|
-
|
190
|
+
# @group Matching
|
191
|
+
# :section: Matching
|
192
|
+
|
193
|
+
# Return the Damerau-Levenshtein distance between self an another string
|
194
|
+
# using a transposition block size of 1 and quitting if a max distance of 10
|
195
|
+
# is reached.
|
196
|
+
#
|
197
|
+
# @param other [#to_s] string to compute self's distance from
|
198
|
+
# @return [Integer] the distance between self and other
|
199
|
+
def distance(other)
|
200
|
+
DamerauLevenshtein.distance(self, other.to_s, 1, 10)
|
214
201
|
end
|
215
202
|
|
216
|
-
#
|
217
|
-
#
|
218
|
-
#
|
219
|
-
#
|
220
|
-
#
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
end
|
236
|
-
|
237
|
-
def console_yellow
|
238
|
-
colorize(self, "\001\e[1m\e[33m\002")
|
239
|
-
end
|
240
|
-
|
241
|
-
def console_dark_yellow
|
242
|
-
colorize(self, "\001\e[33m\002")
|
243
|
-
end
|
244
|
-
|
245
|
-
def console_blue
|
246
|
-
colorize(self, "\001\e[1m\e[34m\002")
|
203
|
+
# Test whether self matches the `matcher` treating `matcher` as a
|
204
|
+
# case-insensitive regular expression if it is of the form '/.../' or as a
|
205
|
+
# string to #fuzzy_match against otherwise.
|
206
|
+
#
|
207
|
+
# @param matcher [String] regexp if looks like /.../; #fuzzy_match pattern otherwise
|
208
|
+
# @return [nil] if no match
|
209
|
+
# @return [String] the matched portion of self, with punctuation stripped in
|
210
|
+
# case of #fuzzy_match
|
211
|
+
# @see #fuzzy_match #fuzzy_match for the specifics of string matching
|
212
|
+
# @see #to_regexp #to_regexp for conversion of `matcher` to regular expression
|
213
|
+
def matches_with(matcher)
|
214
|
+
if matcher.nil?
|
215
|
+
nil
|
216
|
+
elsif matcher =~ %r{^\s*/}
|
217
|
+
re = matcher.to_regexp
|
218
|
+
$& if to_s =~ re
|
219
|
+
else
|
220
|
+
to_s.fuzzy_match(matcher)
|
221
|
+
end
|
247
222
|
end
|
248
223
|
|
249
|
-
|
250
|
-
|
224
|
+
# Return the matched portion of self, minus punctuation characters, if self
|
225
|
+
# matches the string `matcher` using the following notion of matching:
|
226
|
+
#
|
227
|
+
# 1. Remove all periods, commas, apostrophes, and asterisks (the punctuation
|
228
|
+
# characters) from both self and `matcher`,
|
229
|
+
# 2. Treat ':' in the matcher as the equivalent of '.*' in a regular
|
230
|
+
# expression, that is, match anything in self,
|
231
|
+
# 3. Ignore case in the match
|
232
|
+
# 4. Match if any part of self matches `matcher`
|
233
|
+
#
|
234
|
+
# @example
|
235
|
+
# "St. Luke's Hospital".fuzzy_match('st lukes') #=> 'St Lukes'
|
236
|
+
# "St. Luke's Hospital".fuzzy_match('luk:hosp') #=> 'Lukes Hosp'
|
237
|
+
# "St. Luke's Hospital".fuzzy_match('st:spital') #=> 'St Lukes Hospital'
|
238
|
+
# "St. Luke's Hospital".fuzzy_match('st:laks') #=> nil
|
239
|
+
#
|
240
|
+
# @param matcher [String] pattern to test against where ':' is wildcard
|
241
|
+
# @return [String] the unpunctuated part of self that matched
|
242
|
+
# @return [nil] if self did not match matcher
|
243
|
+
def fuzzy_match(matcher)
|
244
|
+
# Remove periods, asterisks, commas, and apostrophes
|
245
|
+
matcher = matcher.gsub(/[\*.,']/, '')
|
246
|
+
target = gsub(/[\*.,']/, '')
|
247
|
+
matchers = matcher.split(/[: ]+/)
|
248
|
+
regexp_string = matchers.map { |m| ".*?#{Regexp.escape(m)}.*?" }.join('[: ]')
|
249
|
+
regexp_string.sub!(/^\.\*\?/, '')
|
250
|
+
regexp_string.sub!(/\.\*\?$/, '')
|
251
|
+
regexp = /#{regexp_string}/i
|
252
|
+
matched_text =
|
253
|
+
if (match = regexp.match(target))
|
254
|
+
match[0]
|
255
|
+
end
|
256
|
+
matched_text
|
251
257
|
end
|
252
258
|
|
253
|
-
|
254
|
-
|
259
|
+
# Convert a string of the form '/.../Iixm' to a regular expression. However,
|
260
|
+
# make the regular expression case-insensitive by default and extend the
|
261
|
+
# modifier syntax to allow '/I' to indicate case-sensitive. Without the
|
262
|
+
# surrounding '/', do not make the Regexp case insensitive, just translate
|
263
|
+
# it to a Regexp with Regexp.new.
|
264
|
+
#
|
265
|
+
# @example
|
266
|
+
# '/Hello/'.to_regexp #=> /Hello/i
|
267
|
+
# '/Hello/I'.to_regexp #=> /Hello/
|
268
|
+
# 'Hello'.to_regexp #=> /Hello/
|
269
|
+
#
|
270
|
+
# @return [Regexp]
|
271
|
+
def to_regexp
|
272
|
+
if self =~ %r{^\s*/([^/]*)/([Iixm]*)\s*$}
|
273
|
+
body = $1
|
274
|
+
opts = $2
|
275
|
+
flags = Regexp::IGNORECASE
|
276
|
+
unless opts.blank?
|
277
|
+
flags = 0 if opts.include?('I')
|
278
|
+
flags |= Regexp::IGNORECASE if opts.include?('i')
|
279
|
+
flags |= Regexp::EXTENDED if opts.include?('x')
|
280
|
+
flags |= Regexp::MULTILINE if opts.include?('m')
|
281
|
+
end
|
282
|
+
flags = nil if flags.zero?
|
283
|
+
Regexp.new(body, flags)
|
284
|
+
else
|
285
|
+
Regexp.new(self)
|
286
|
+
end
|
255
287
|
end
|
256
288
|
|
257
|
-
|
258
|
-
|
289
|
+
# @group Numbers
|
290
|
+
# :section: Numbers
|
291
|
+
|
292
|
+
# Return whether self is convertible into a valid number.
|
293
|
+
#
|
294
|
+
# @example
|
295
|
+
# '6465321'.number? #=> true
|
296
|
+
# '6465321.271828'.number? #=> true
|
297
|
+
# '76 trombones' #=> false
|
298
|
+
# '2.77e7' #=> true
|
299
|
+
# '+12_534' #=> true
|
300
|
+
#
|
301
|
+
# @return [Boolean] does self represent a valid number
|
302
|
+
def number?
|
303
|
+
Float(self)
|
304
|
+
true
|
305
|
+
rescue ArgumentError
|
306
|
+
return false
|
259
307
|
end
|
260
308
|
|
261
|
-
|
262
|
-
|
263
|
-
|
309
|
+
# If the string is a valid number, return a string that adds grouping commas
|
310
|
+
# to the whole number part; otherwise, return self.
|
311
|
+
#
|
312
|
+
# @example
|
313
|
+
# 'hello'.commas #=> 'hello'
|
314
|
+
# '+4654656.33e66'.commas #=> '+4,654,656.33e66'
|
315
|
+
#
|
316
|
+
# @return [String] self if not a valid number
|
317
|
+
# @return [String] commified number as a String
|
318
|
+
def commas(places = nil)
|
319
|
+
numeric_re = /\A([-+])?([\d_]*)((\.)?([\d_]*))?([eE][+-]?[\d_]+)?\z/
|
320
|
+
return self unless clean =~ numeric_re
|
321
|
+
|
322
|
+
# Round if places given
|
323
|
+
num = BigDecimal(self)
|
324
|
+
str =
|
325
|
+
if places.nil?
|
326
|
+
num.whole? ? num.to_i.to_s : num.to_f.to_s
|
327
|
+
else
|
328
|
+
num.to_f.round(places).to_s
|
329
|
+
end
|
264
330
|
|
265
|
-
|
266
|
-
|
267
|
-
|
331
|
+
# Break the number into parts
|
332
|
+
str =~ numeric_re
|
333
|
+
sig = $1 || ''
|
334
|
+
whole = $2 ? $2.delete('_') : ''
|
335
|
+
frac = $5 || ''
|
336
|
+
exp = $6 || ''
|
268
337
|
|
269
|
-
|
270
|
-
|
271
|
-
|
338
|
+
# Place the commas in the whole part only
|
339
|
+
whole = whole.reverse
|
340
|
+
whole.gsub!(/([0-9]{3})/, '\\1,')
|
341
|
+
whole.gsub!(/,$/, '')
|
342
|
+
whole.reverse!
|
272
343
|
|
273
|
-
|
274
|
-
|
344
|
+
# Reassemble
|
345
|
+
if frac.blank?
|
346
|
+
sig + whole + exp
|
347
|
+
else
|
348
|
+
sig + whole + '.' + frac + exp
|
349
|
+
end
|
275
350
|
end
|
276
351
|
|
277
352
|
module ClassMethods
|
353
|
+
# @group Generating
|
354
|
+
# :section: Generating
|
355
|
+
|
356
|
+
# Return a random string composed of all lower-case letters of length
|
357
|
+
# `size`
|
278
358
|
def random(size = 8)
|
279
359
|
('a'..'z').cycle.take(size).shuffle.join
|
280
360
|
end
|
281
361
|
end
|
282
362
|
|
363
|
+
# @private
|
283
364
|
def self.included(base)
|
284
365
|
base.extend(ClassMethods)
|
285
366
|
end
|
286
367
|
end
|
287
368
|
end
|
288
369
|
|
289
|
-
|
370
|
+
class String
|
371
|
+
include FatCore::String
|
372
|
+
# @!parse include FatCore::String
|
373
|
+
# @!parse extend FatCore::String::ClassMethods
|
374
|
+
end
|