fat_core 7.1.3 → 7.3.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.
data/Rakefile CHANGED
@@ -24,16 +24,12 @@ RSpec::Core::RakeTask.new(:spec, :tag) do |t|
24
24
  t.rspec_opts = '--tag ~online -f d'
25
25
  end
26
26
 
27
+ require "gem_docs"
28
+ GemDocs.install
29
+
27
30
  ########################################################################
28
31
  # Rubocop tasks
29
32
  ########################################################################
30
- # Option A (recommended): Keep using Bundler and run rubocop via `bundle exec`.
31
- # This wrapper task ensures the rubocop run uses the gems from your Gemfile,
32
- # even when you invoke `rake rubocop` (no need to remember `bundle exec rake`).
33
- #
34
- # You can pass extra RuboCop CLI flags with the RUBOCOP_OPTS environment variable:
35
- # RUBOCOP_OPTS="--format simple" rake rubocop
36
-
37
33
  desc "Run rubocop under `bundle exec`"
38
34
  task :rubocop do
39
35
  opts = (ENV['RUBOCOP_OPTS'] || '').split
data/lib/fat_core/all.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../fat_core'
3
4
  require_relative 'array'
4
5
  require_relative 'bigdecimal'
5
6
  require_relative 'enumerable'
@@ -8,6 +8,9 @@ require_relative 'numeric'
8
8
 
9
9
  module FatCore
10
10
  module String
11
+ UPPERS = ('A'..'Z').to_a
12
+ REGEXP_META_CHARACTERS = "\\$()*+.<>?[]^{|}".chars.freeze
13
+
11
14
  # @group Transforming
12
15
  # :section: Transforming
13
16
 
@@ -93,22 +96,29 @@ module FatCore
93
96
  r.gsub('XzXzXcbXzXzX', '\\}')
94
97
  end
95
98
 
96
- UPPERS = ('A'..'Z').to_a
97
- REGEXP_META_CHARACTERS = "\\$()*+.<>?[]^{|}".chars.freeze
98
-
99
- private
99
+ # Rather than truncate a String to make it fit a given length, this method
100
+ # removes characters from the middle of the string to make it fit the
101
+ # given size. This is often preferable to truncating at the end or
102
+ # beginning of a String because the most important information is often at
103
+ # the start or end of a String. By default the missing middle is
104
+ # indicated by a single '~' character, but you can set it to any string,
105
+ # even the empty string with the `ellipsis:` parameter.
106
+ def gut(max_size, ellipsis: '~', squeeze: nil)
107
+ return self if size <= max_size
100
108
 
101
- def upper?
102
- UPPERS.include?(self[0])
103
- end
104
-
105
- # Return true if all the letters in self are upper case
106
- def all_upper?
107
- tr('^A-Za-z', '').split('').all? { |c| ('A'..'Z').to_a.include? c }
109
+ s =
110
+ if squeeze
111
+ tr(squeeze, '')
112
+ else
113
+ self
114
+ end
115
+ chars_to_cut = (s.size - max_size) + ellipsis.size
116
+ chars_to_keep = s.size - chars_to_cut
117
+ start_chars = chars_to_keep / 2 + (chars_to_keep.odd? ? 1 : 0)
118
+ end_chars = chars_to_keep - start_chars
119
+ s[0..start_chars - 1] + ellipsis + s[-end_chars..-1]
108
120
  end
109
121
 
110
- public
111
-
112
122
  # Return self capitalized according to the conventions for capitalizing
113
123
  # titles of books or articles. Tries to follow the rules of the University
114
124
  # of Chicago's *A Manual of Style*, Section 7.123, except to the extent that
@@ -215,33 +225,32 @@ module FatCore
215
225
  DamerauLevenshtein.distance(self, other.to_s, 1, 10)
216
226
  end
217
227
 
218
- # Return the matched portion of self, minus punctuation characters, if self
219
- # matches the string `matcher` using the following notion of matching:
228
+ # Return the matched portion of self, with fuzzy-match punctuation normalized,
229
+ # if self matches `matcher` using the following rules:
220
230
  #
221
- # 1. Remove leading and trailing whitespace in the subject and the matcher
222
- # and collapse its internal whitespace to a single space,
223
- # 2. In the subject string replace periods and commas with a space (so
224
- # they still act as word separators) but remove apostrophes, and
225
- # asterisks so the user need not remember whether they were used when
226
- # forming the matcher.
227
- # 3. In the matcher, make any period, comma, asterisk, or apostrophe
228
- # optional for the same reason.
229
- # 4. Treat internal ':stuff' or ' :stuff' in the matcher as the equivalent
230
- # of /\bstuff.*/ in a regular expression, that is, match any word
231
- # starting with stuff in self,
232
- # 5. Treat internal 'stuff: ' in the matcher as the equivalent
233
- # of /.*stuff\b/ in a regular expression, that is, match any word
234
- # ending with stuff in self,
235
- # 6. A colon with no spaces around it is treated as belonging to the
236
- # following word, requiring it to start with it, so 'some:stuff'
237
- # requires 'some' anywhere followed by a word beginning with 'stuff',
238
- # i.e., /some.*\bstuff/i,
239
- # 7. Treat leading ':' in the matcher as anchoring the match to the
240
- # beginning of the target string,
241
- # 8. Treat ending ':' in the matcher as anchoring the match to the
242
- # end of the target string,
243
- # 9. Require each component to match some part of self, and
244
- # 10. Ignore case in the match
231
+ # 1. Remove leading and trailing whitespace in the subject and matcher and
232
+ # collapse internal whitespace to a single space.
233
+ # 2. In ordinary fuzzy matching, treat periods and commas between non-space
234
+ # characters as word separators. Otherwise ignore periods and commas.
235
+ # Ignore apostrophes and asterisks, so the matcher need not reproduce
236
+ # those punctuation characters exactly.
237
+ # 3. Escape regular-expression metacharacters in an ordinary matcher. A
238
+ # matcher enclosed in '/' characters is instead treated as a regular
239
+ # expression and matched against the original, unnormalized subject.
240
+ # 4. Treat internal ':stuff' or ' :stuff' in an ordinary matcher as the
241
+ # equivalent of /\bstuff.*/, that is, match a word beginning with stuff.
242
+ # 5. Treat internal 'stuff: ' as the equivalent of /stuff\b.*/, that is,
243
+ # require stuff to end at a word boundary and then match anything after it.
244
+ # 6. A colon with no spaces around it belongs to the following component, so
245
+ # 'some:stuff' requires 'some' followed somewhere by a word beginning with
246
+ # 'stuff', i.e. /some.*\bstuff/i.
247
+ # 7. Treat a leading ':' as anchoring the match to the beginning of the
248
+ # normalized subject.
249
+ # 8. Treat a trailing ':' as anchoring the match to the end of the normalized
250
+ # subject.
251
+ # 9. Require each component of an ordinary matcher to match some part of the
252
+ # subject, in order.
253
+ # 10. Ignore case in both fuzzy and regular-expression matching.
245
254
  #
246
255
  # @example
247
256
  # "St. Luke's Hospital".fuzzy_match('st lukes') #=> 'St Lukes'
@@ -251,28 +260,23 @@ module FatCore
251
260
  # "St. Luke's Hospital".fuzzy_match('st:laks') #=> nil
252
261
  # "St. Luke's Hospital".fuzzy_match(':lukes') #=> nil
253
262
  # "St. Luke's Hospital".fuzzy_match('lukes:hospital:') #=> 'Lukes Hospital'
263
+ # "Amazon.com".fuzzy_match('Amazon.com') #=> 'Amazon com'
264
+ # "Price is $12.50".fuzzy_match('/\$12\.50/') #=> '$12.50'
254
265
  #
255
- # @param matcher [String] pattern to test against where ':' is wildcard
256
- # @return [String] the unpunctuated part of self that matched
257
- # @return [nil] if self did not match matcher
266
+ # @param matcher [String] fuzzy matcher, or a regular expression enclosed in /
267
+ # @return [String] the portion of the subject that matched
268
+ # @return [nil] if the subject did not match
258
269
  def fuzzy_match(matcher)
259
- # Make asterisks, periods, commas, and apostrophes optional
260
- matcher = matcher.clean.gsub(/[\*.,']/, '\0?')
261
- # Replace periods and commas with a space (so they are still word
262
- # separators, e.g. 'WWW.WOLFRAM' -> 'WWW WOLFRAM' and 'AMZON,INC.' ->
263
- # 'AMAZON INC') and remove asterisks and apostrophes
264
- target = gsub(/[.,]/, ' ').gsub(/[\*']/, '').clean
265
- regexp_string =
266
- if matcher.match?(/[: ]/)
267
- matcher.sub(/\A:/, "\\A").sub(/:\z/, "\\z")
268
- .gsub(/:\s+/, "\\b.*").gsub(':', ".*\\b")
269
- .gsub(/\s+/, ".*")
270
- else
271
- Regexp.escape(matcher)
272
- end
273
- regexp = /#{regexp_string}/i
270
+ if matcher.match?(%r{\A/.*/\z})
271
+ regexp = Regexp.new(matcher[1...-1], Regexp::IGNORECASE)
272
+ subject = self
273
+ else
274
+ subject = fuzzy_match_clean(self)
275
+ matcher = fuzzy_match_clean(matcher)
276
+ regexp = Regexp.new(fuzzy_match_regexp(matcher), Regexp::IGNORECASE)
277
+ end
274
278
  matched_text =
275
- if (match = regexp.match(target))
279
+ if (match = regexp.match(subject))
276
280
  match[0]
277
281
  end
278
282
  matched_text
@@ -373,6 +377,48 @@ module FatCore
373
377
  to_f.commas(places)
374
378
  end
375
379
 
380
+ private
381
+
382
+ def fuzzy_match_regexp(matcher)
383
+ start_anchor = matcher.start_with?(':')
384
+ end_anchor = matcher.end_with?(':')
385
+ matcher = matcher.delete_prefix(':') if start_anchor
386
+ matcher = matcher.delete_suffix(':') if end_anchor
387
+ parts = matcher.split(/(:\s+|:|\s+)/)
388
+ regexp_string = parts.map { |part|
389
+ case part
390
+ when /\A:\s+\z/
391
+ "\\b.*"
392
+ when ':'
393
+ ".*\\b"
394
+ when /\A\s+\z/
395
+ '.*'
396
+ else
397
+ Regexp.escape(part)
398
+ end
399
+ }.join
400
+ regexp_string = "\\A#{regexp_string}" if start_anchor
401
+ regexp_string = "#{regexp_string}\\z" if end_anchor
402
+ regexp_string
403
+ end
404
+
405
+ def fuzzy_match_clean(string)
406
+ string
407
+ .clean
408
+ .gsub(/(?<=\S)[.,](?=\S)/, ' ')
409
+ .gsub(/[.,*']/, '')
410
+ .clean
411
+ end
412
+
413
+ def upper?
414
+ UPPERS.include?(self[0])
415
+ end
416
+
417
+ # Return true if all the letters in self are upper case
418
+ def all_upper?
419
+ tr('^A-Za-z', '').split('').all? { |c| ('A'..'Z').to_a.include? c }
420
+ end
421
+
376
422
  module ClassMethods
377
423
  # @group Generating
378
424
  # :section: Generating
@@ -2,8 +2,8 @@
2
2
 
3
3
  module FatCore
4
4
  MAJOR = 7
5
- MINOR = 1
6
- PATCH = 3
5
+ MINOR = 3
6
+ PATCH = 0
7
7
 
8
8
  # FatCore version number
9
9
  VERSION = [MAJOR, MINOR, PATCH].compact.join('.')
data/lib/fat_core.rb CHANGED
@@ -3,5 +3,15 @@
3
3
  require 'active_support/core_ext/object/blank'
4
4
  require 'active_support/core_ext/object/deep_dup'
5
5
 
6
- require 'fat_core/version'
7
- require 'fat_core/patches'
6
+ # Gem Overview (extracted from README.org by gem_docs)
7
+ #
8
+ # * Introduction
9
+ # ~fat-core~ is somewhat of a grab bag of core class extensions that I have
10
+ # found useful across several projects. It's higgeldy-piggeldy nature reflects
11
+ # the fact that none of them are important enough to deserve a gem of their own,
12
+ # but nonetheless need to be collected in one place to reduce redundancy across
13
+ # projects and provide a focused place to develop and test them.
14
+ module FatCore
15
+ require 'fat_core/version'
16
+ require 'fat_core/patches'
17
+ end
@@ -158,6 +158,15 @@ the people, for the people, shall not perish from the earth."
158
158
  end
159
159
  end
160
160
 
161
+ it 'guts a string in the middle' do
162
+ expect('hello'.gut(10)).to eq('hello')
163
+ expect('helloworld'.gut(6)).to eq('hel~ld')
164
+ expect('Class A Common Stock'.gut(15)).to eq("Class A~n Stock")
165
+ expect('Class A Common Stock'.gut(15, ellipsis: '...')).to eq("Class ... Stock")
166
+ expect('Class A Common Stock'.gut(15, ellipsis: '')).to eq("Class A n Stock")
167
+ expect('Class A Common Stock'.gut(16, ellipsis: '', squeeze: ' ')).to eq("ClassAComonStock")
168
+ end
169
+
161
170
  it 'converts a string to a regular expression' do
162
171
  # Ignores case by default
163
172
  re = "/hello((\s+)(world))?/".as_regexp
@@ -261,6 +270,49 @@ the people, for the people, shall not perish from the earth."
261
270
  expect('Hello, world'.fuzzy_match('ox')).to be_falsy
262
271
  end
263
272
 
273
+ it 'matches an exact match' do
274
+ expect('Amazon.com'.fuzzy_match('Amazon.com')).to be_truthy
275
+ expect('Hello:world'.fuzzy_match('Hello:world')).to be_truthy
276
+ expect('2+2=4'.fuzzy_match('2+2=4')).to be_truthy
277
+ expect('Whodunnit?'.fuzzy_match('Whodunnit?')).to be_truthy
278
+ expect('(I like (lisp))'.fuzzy_match('(I like (lisp))')).to be_truthy
279
+ # expect('Price is $12.50'.fuzzy_match('Price is $12.50')).to be_truthy
280
+ expect('foo[bar]'.fuzzy_match('foo[bar]')).to be_truthy
281
+ expect('a^b'.fuzzy_match('a^b')).to be_truthy
282
+ expect('foo|bar'.fuzzy_match('foo|bar')).to be_truthy
283
+ expect('path\\name'.fuzzy_match('path\\name')).to be_truthy
284
+ expect('Acme Co., Inc.'.fuzzy_match('Acme Co., Inc.')).to be_truthy
285
+ expect("St. Luke's Hospital".fuzzy_match("St. Luke's Hospital")).to be_truthy
286
+ expect('E*TRADE'.fuzzy_match('E*TRADE')).to be_truthy
287
+ end
288
+
289
+ it 'fuzzy matches ordinary strings against themselves' do
290
+ [
291
+ 'Amazon.com',
292
+ 'Amazon, Inc.',
293
+ 'St. Luke\'s Hospital',
294
+ 'E*TRADE',
295
+ 'WWW.WOLFRAM.COM',
296
+ 'Smith, Jones & Brown',
297
+ 'Acme Co., Inc.',
298
+ 'Hello, world.',
299
+ ].each do |string|
300
+ expect(string.fuzzy_match(string)).to be_truthy
301
+ end
302
+ end
303
+
304
+ it 'escapes regexp special characters in ordinary matchers' do
305
+ expect('Price is $12.50'.fuzzy_match('Price is $12.50')).to be_truthy
306
+ expect('Use a+b'.fuzzy_match('Use a+b')).to be_truthy
307
+ expect('Maybe yes?'.fuzzy_match('Maybe yes?')).to be_truthy
308
+ expect('Pick (one)'.fuzzy_match('Pick (one)')).to be_truthy
309
+ end
310
+
311
+ it 'honors regexp special characters in slash-delimited matchers' do
312
+ expect('Hello world'.fuzzy_match('/^hello.*world$/')).to be_truthy
313
+ expect('Hello world'.fuzzy_match('/^world/')).to be_falsy
314
+ end
315
+
264
316
  it 'replaces periods and commas with a space so they are still word separators' do
265
317
  expect('WWW.WOLFRAM.COM'.fuzzy_match('wolf')).to be_truthy
266
318
  expect('AMAZON,INC'.fuzzy_match('amazon inc')).to be_truthy
@@ -281,10 +333,14 @@ the people, for the people, shall not perish from the earth."
281
333
  expect('Hello, what is with the world?'.fuzzy_match('at wi or')).to be_truthy
282
334
  end
283
335
 
336
+ it 'does not normalize the subject for regexp matchers' do
337
+ expect('Price is $12.50'.fuzzy_match('/\\$12\\.50/')).to eq('$12.50')
338
+ expect('Price is $12.50'.fuzzy_match('/\\$12 50/')).to be_nil
339
+ end
340
+
284
341
  it 'fuzzy matches colon-separated parts' do
285
342
  expect('Hello:world'.fuzzy_match('hel:wor')).to be_truthy
286
343
  expect('Hello:world'.fuzzy_match('hel :wor')).to be_truthy
287
- expect('Hello:world'.fuzzy_match('hel: wor')).to be_falsy
288
344
  expect('Hello:world'.fuzzy_match('hel:orld')).to be_falsy
289
345
  expect("Hello, 'world'".fuzzy_match('hel:wor')).to be_truthy
290
346
  expect('Hello "world"'.fuzzy_match('hel:world')).to be_truthy
@@ -301,6 +357,8 @@ the people, for the people, shall not perish from the earth."
301
357
  expect('Hello, what is with the world?'.fuzzy_match('llo: th: :wor')).to be_truthy
302
358
  expect('Hello:world'.fuzzy_match('llox: ')).to be_falsy
303
359
  expect('Hello, what=+&is (with) the world?'.fuzzy_match('at: ith: the')).to be_truthy
360
+ # A colon at the end of a word in the matcher should require a word-boundary there.
361
+ expect('Hello:world'.fuzzy_match('hel: wor')).to be_falsy
304
362
  end
305
363
 
306
364
  it 'requires end-anchor for ending colon' do
@@ -333,9 +391,8 @@ the people, for the people, shall not perish from the earth."
333
391
  expect('St Lukes, Inc.'.fuzzy_match('st luke inc')).to eq('St Lukes Inc')
334
392
  expect('St Lukes, Inc.'.fuzzy_match('st lukes, inc')).to eq('St Lukes Inc')
335
393
  expect('E*TRADE'.fuzzy_match('etrade')).to eq('ETRADE')
336
- # Does not recognize non-alphanumerics as start of string.
337
394
  expect('The 1 Dollar Store'.fuzzy_match('1 stor')).to be_truthy
338
- expect('The $1 Dollar Store'.fuzzy_match('$1 stor')).to be_falsy
395
+ expect('The $1 Dollar Store'.fuzzy_match('$1 stor')).to be_truthy
339
396
  end
340
397
 
341
398
  it 'performs examples in documentation' do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fat_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.1.3
4
+ version: 7.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel E. Doherty
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-11-28 00:00:00.000000000 Z
11
+ date: 2026-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -86,9 +86,11 @@ files:
86
86
  - ".simplecov"
87
87
  - ".travis.yml"
88
88
  - ".yardopts"
89
+ - CHANGELOG.md
89
90
  - CHANGELOG.org
90
91
  - Gemfile
91
92
  - LICENSE.txt
93
+ - README.md
92
94
  - README.org
93
95
  - Rakefile
94
96
  - TODO.org