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.
@@ -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
- def distance(other, block_size: 1, max_distance: 10)
13
- dl = DamerauLevenshtein
14
- # NOTE: DL 'gives up after' max_distance, so the distance function
15
- # will return max_distance+1 if the distance is bigger than that.
16
- # Here we subtract 1 so the max_distance also becomes the max
17
- # return value.
18
- dl.distance(self, other, block_size, max_distance - 1)
19
- end
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
- strip.squeeze(' ').gsub(/\s+/, '_')
83
- .gsub(/[^_A-Za-z0-9]/, '').downcase.to_sym
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
- def number?
91
- Float(self)
92
- true
93
- rescue ArgumentError
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 with an all-digit date to an iso string
159
- # E.g., "20090923" -> "2009-09-23"
160
- def digdate2iso
161
- sub(/(\d\d\d\d)(\d\d)(\d\d)/, '\1-\2-\3')
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
- def entitle!
165
- little_words = %w(a an the and or in on under of from as by to)
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
- first_word = true
169
- num_words = words.length
138
+ last_k = words.size - 1
170
139
  words.each_with_index do |w, k|
171
- last_word = (k + 1 == num_words)
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(first_word || last_word ? w.capitalize : w.downcase)
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
- first_word = false
184
+ # Capitalize following a ':'
185
+ capitalize_next = true if newwords.last =~ /:\s*\z/
208
186
  end
209
- self[0..-1] = newwords.join(' ')
187
+ newwords.join(' ')
210
188
  end
211
189
 
212
- def entitle
213
- dup.entitle!
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
- # Thanks to Eugene at stackoverflow for the following.
217
- # http://stackoverflow.com/questions/8806643/
218
- # colorized-output-breaks-linewrapping-with-readline
219
- # These color strings without confusing readline about the length of
220
- # the prompt string in the shell. (Unlike the rainbow routines)
221
- def console_red
222
- colorize(self, "\001\e[1m\e[31m\002")
223
- end
224
-
225
- def console_dark_red
226
- colorize(self, "\001\e[31m\002")
227
- end
228
-
229
- def console_green
230
- colorize(self, "\001\e[1m\e[32m\002")
231
- end
232
-
233
- def console_dark_green
234
- colorize(self, "\001\e[32m\002")
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
- def console_dark_blue
250
- colorize(self, "\001\e[34m\002")
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
- def console_purple
254
- colorize(self, "\001\e[1m\e[35m\002")
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
- def console_cyan
258
- colorize(self, "\001\e[1m\e[36m\002")
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
- def console_def
262
- colorize(self, "\001\e[1m\002")
263
- end
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
- def console_bold
266
- colorize(self, "\001\e[1m\002")
267
- end
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
- def console_blink
270
- colorize(self, "\001\e[5m\002")
271
- end
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
- def colorize(text, color_code)
274
- "#{color_code}#{text}\001\e[0m\002"
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
- String.include FatCore::String
370
+ class String
371
+ include FatCore::String
372
+ # @!parse include FatCore::String
373
+ # @!parse extend FatCore::String::ClassMethods
374
+ end