string_pattern 2.4.0 → 2.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47172de723c22b8175a1119aa51c065056ec4eab029cfb989b4fa4c9bc20d845
4
- data.tar.gz: c9e9996c3f5ec59813b157a9f818c10723fb1b9f95a1ed871ba80219d04bbe6e
3
+ metadata.gz: 8769f150212e86d1c798377384ec17edccc22ba4c09d4b15d75bdac5ea1772b0
4
+ data.tar.gz: '0249dda55facee2d54e3a9229ef3bfb5e587eeac6adc72849c6a374fa15eb9dd'
5
5
  SHA512:
6
- metadata.gz: 840a84447c523a01e21657fa772c190388a1401e41d254e5340fe9bd081eb0740ba200161c0a29e06dcfa841632c26d51346da53bf2c5a8f93ad3459aa455930
7
- data.tar.gz: f53b2b1ef110f8e20b95e7e5bafac00067bbc782e2817fa3292cbd4b60492454b171133b80b8808e737606c83dd8f2df59d5d57447bcc5cc225d6239c0edb7e3
6
+ metadata.gz: a6247d3b5b23256e06bdd1a4079197f238b856fd8f492878bfca79373abd257cf7b5b6002a6bcf363fc21ce5402222fc7c666002c0c2bb05ae429d4f9b9ce8bf
7
+ data.tar.gz: e72ce0732bb53fcc97b96623d43b76d73b774c2c3033c9ef4143edb53935120d734d4cbb7677e3db26cb0504047c2827576a3dbf068894f47a7092d1c9e586c2
data/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [2.5.0] - 2026-09-23
8
+
9
+ ### Added
10
+ - Added `StringPattern.with` to apply configuration for the duration of a block, and `StringPattern.reset!` to restore defaults and clear caches.
11
+ - Added `StringPattern::VERSION`.
12
+ - Added full-string `Regexp` validation via `StringPattern.validate`, `StringPattern.valid?`, and `Regexp#validate` / `#val`.
13
+ - Added `StringPattern.explain` with the failing segment index for pattern arrays.
14
+ - Added `StringPattern.iban` / `valid_iban?` (ISO 13616) and `StringPattern.luhn` / `valid_luhn?`.
15
+ - Added `block_list_regexp` to opt into the previous regular-expression block list.
16
+ - `StringPattern.sample` forwards `seed:` and other generate options.
17
+
18
+ ### Changed
19
+ - Generation uses its own `Random` instance. `seed:` no longer calls `srand`, so it does not change `Kernel#rand`. Sequences for a given seed may differ from 2.4.0.
20
+ - `block_list` matches literals (case-insensitive) unless `block_list_regexp` is true.
21
+ - Spanish word generation loads all `data/spanish/palabras*.json` files instead of one random file.
22
+ - Invalid patterns and impossible generation honor `raise_on_error` on every failure path.
23
+ - Required Ruby version is 3.0 or newer.
24
+
25
+ ## [2.4.0] - 2026-02-11
26
+
27
+ ### Added
28
+ - Added `StringPattern.valid_email?` helper and centralized email format checks.
29
+ - Added `StringPattern.valid?(text:, pattern:)` for boolean validation.
30
+ - Added `StringPattern.sample(pattern, n)` for batch generation of distinct values.
31
+ - Added `StringPattern.uuid` and `StringPattern.valid_uuid?`.
32
+ - Added support for `seed:` in generation for reproducible outputs.
33
+ - Added support for `block_list` as a `Proc`.
34
+ - Added `StringPattern::InvalidPatternError` and `StringPattern::GenerationImpossibleError`.
35
+ - Added `StringPattern.logger` and `StringPattern.raise_on_error` configuration.
36
+ - Added `spec/string/pattern/analyze_spec.rb`.
37
+ - Added GitHub Actions CI workflow.
38
+
39
+ ### Changed
40
+ - Fixed email-domain comparison bug in email validation/generation paths.
41
+ - Replaced internal direct `puts` calls with centralized logging (`StringPattern.log_message`).
42
+ - Updated README with analyze/validation/error-handling/new-features documentation.
43
+ - Updated CI target Ruby versions (3.0, 3.1, 3.2, 3.3).
44
+
45
+ ## [2.3.0] - 2025-xx-xx
46
+
47
+ ### Changed
48
+ - Previous release notes not yet backfilled.
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # StringPattern
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/string_pattern.svg)](https://rubygems.org/gems/string_pattern)
4
- [![Build Status](https://travis-ci.com/MarioRuiz/string_pattern.svg?branch=master)](https://github.com/MarioRuiz/string_pattern)
4
+ [![CI](https://github.com/MarioRuiz/string_pattern/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/MarioRuiz/string_pattern/actions/workflows/ci.yml)
5
5
  [![Coverage Status](https://coveralls.io/repos/github/MarioRuiz/string_pattern/badge.svg?branch=master)](https://coveralls.io/github/MarioRuiz/string_pattern?branch=master)
6
6
  ![Gem](https://img.shields.io/gem/dt/string_pattern)
7
7
  ![GitHub commit activity](https://img.shields.io/github/commit-activity/y/MarioRuiz/string_pattern)
@@ -503,6 +503,51 @@ StringPattern.block_list = ->(s) { s.include?("forbidden") }
503
503
  StringPattern.block_list_enabled = true
504
504
  ```
505
505
 
506
+ Entries in `block_list` are matched as case-insensitive literals. Set `StringPattern.block_list_regexp = true` when an entry should be a regular expression, which was the behavior before 2.5.0.
507
+
508
+ #### Scoped configuration
509
+
510
+ `StringPattern.with` applies settings for one block and restores the previous values afterwards, including when the block raises:
511
+
512
+ ```ruby
513
+ StringPattern.with(dont_repeat: true, raise_on_error: true) do
514
+ "6:N".gen
515
+ end
516
+ ```
517
+
518
+ `StringPattern.reset!` restores the default configuration and clears the pattern cache and the generated-value cache.
519
+
520
+ #### Validate a regular expression
521
+
522
+ `valid?` and `validate` accept a `Regexp` and match the whole string. The same methods are available on the regexp itself:
523
+
524
+ ```ruby
525
+ StringPattern.valid?(text: "abc", pattern: /[a-z]+/) # => true
526
+ StringPattern.valid?(text: "abc1", pattern: /[a-z]+/) # => false
527
+ /[a-z]+/.validate("abc") # => []
528
+ /[a-z]+/.val("abc1") # => [:value]
529
+ ```
530
+
531
+ #### Explain a validation
532
+
533
+ ```ruby
534
+ StringPattern.explain(text: "ab", pattern: "6:N")
535
+ # => { valid: false, errors: [:min_length, :length, :value, :string_set_not_allowed], message: "invalid: ...", index: nil }
536
+
537
+ StringPattern.explain(text: "333111", pattern: ["3:n", "3:x"])
538
+ # => { valid: false, errors: [...], message: "segment 1 does not match: ...", index: 1 }
539
+ ```
540
+
541
+ #### IBAN and Luhn
542
+
543
+ International checksums for test data. `iban` requires a country code and only generates countries whose length is known (ES, DE, FR, GB, PT, IT, NL, BE). The BBAN is random. `valid_iban?` accepts any well-formed IBAN. `luhn` builds a digit string with a valid Luhn check digit and does not represent a real card.
544
+
545
+ ```ruby
546
+ StringPattern.iban(country: "DE") # => "DE..."
547
+ StringPattern.valid_iban?("DE89 3704 0044 0532 0130 00") # => true
548
+ StringPattern.luhn(length: 16) # => "16 digits"
549
+ StringPattern.valid_luhn?("79927398713") # => true
550
+ ```
506
551
 
507
552
  ## Contributing
508
553
 
@@ -78,6 +78,13 @@ class Regexp
78
78
 
79
79
  alias gen generate
80
80
 
81
+ # Validates +string_to_validate+ against this regular expression as a full-string match.
82
+ def validate(string_to_validate, expected_errors: [], not_expected_errors: [], **synonyms)
83
+ StringPattern.validate(text: string_to_validate, pattern: self, expected_errors: expected_errors, not_expected_errors: not_expected_errors, **synonyms)
84
+ end
85
+
86
+ alias val validate
87
+
81
88
  # adds method to convert a Regexp to StringPattern
82
89
  # returns an array of string patterns or just one string pattern
83
90
  def to_sp
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StringPattern
4
+ # IBAN lengths we can generate. Validation accepts any well-formed IBAN.
5
+ IBAN_LENGTHS = {
6
+ "ES" => 24,
7
+ "DE" => 22,
8
+ "FR" => 27,
9
+ "GB" => 22,
10
+ "PT" => 25,
11
+ "IT" => 27,
12
+ "NL" => 18,
13
+ "BE" => 16
14
+ }.freeze
15
+
16
+ # Generates a random IBAN for +country+ (ISO 13616). The BBAN is random, not a real bank account.
17
+ # @param country [String, Symbol] two-letter country code
18
+ # @return [String]
19
+ # @raise [ArgumentError] when the country is not in {IBAN_LENGTHS}
20
+ def self.iban(country:)
21
+ code = country.to_s.upcase
22
+ length = IBAN_LENGTHS[code]
23
+ raise ArgumentError, "unknown IBAN country: #{country.inspect}" unless length
24
+
25
+ rng = Random.new
26
+ alphabet = [*("0".."9"), *("A".."Z")]
27
+ bban = Array.new(length - 4) { alphabet.sample(random: rng) }.join
28
+ "#{code}#{iban_check_digits(code, bban)}#{bban}"
29
+ end
30
+
31
+ # Returns true when +str+ is a well-formed IBAN (format and mod-97), ignoring spaces.
32
+ # @param str [String]
33
+ # @return [Boolean]
34
+ def self.valid_iban?(str)
35
+ return false unless str.is_a?(String)
36
+
37
+ compact = str.gsub(/\s+/, "").upcase
38
+ return false unless compact.match?(/\A[A-Z]{2}\d{2}[A-Z0-9]+\z/)
39
+ return false unless (15..34).cover?(compact.length)
40
+
41
+ rearranged = compact[4..] + compact[0, 4]
42
+ iban_mod97(iban_to_digits(rearranged)) == 1
43
+ end
44
+
45
+ # Generates a digit string of +length+ that satisfies the Luhn checksum.
46
+ # @param length [Integer] total number of digits, at least 2
47
+ # @return [String]
48
+ # @raise [ArgumentError] when length is below 2
49
+ def self.luhn(length:)
50
+ length = Integer(length)
51
+ raise ArgumentError, "length must be >= 2" if length < 2
52
+
53
+ rng = Random.new
54
+ digits = Array.new(length - 1) { rng.rand(10) }
55
+ digits << luhn_check_digit(digits)
56
+ digits.join
57
+ end
58
+
59
+ # Returns true when +str+ is a digit string with a valid Luhn checksum.
60
+ # @param str [String]
61
+ # @return [Boolean]
62
+ def self.valid_luhn?(str)
63
+ return false unless str.is_a?(String) && str.match?(/\A\d{2,}\z/)
64
+
65
+ luhn_sum(str.chars.map(&:to_i)) % 10 == 0
66
+ end
67
+
68
+ def self.iban_check_digits(country, bban)
69
+ remainder = iban_mod97(iban_to_digits("#{bban}#{country}00"))
70
+ format("%02d", 98 - remainder)
71
+ end
72
+
73
+ def self.iban_to_digits(value)
74
+ value.each_char.map { |char|
75
+ if char >= "A" && char <= "Z"
76
+ (char.ord - 55).to_s
77
+ else
78
+ char
79
+ end
80
+ }.join
81
+ end
82
+
83
+ def self.iban_mod97(digit_string)
84
+ remainder = 0
85
+ digit_string.scan(/.{1,9}/).each do |chunk|
86
+ remainder = "#{remainder}#{chunk}".to_i % 97
87
+ end
88
+ remainder
89
+ end
90
+
91
+ def self.luhn_check_digit(payload)
92
+ (10 - (luhn_sum(payload + [0]) % 10)) % 10
93
+ end
94
+
95
+ def self.luhn_sum(digits)
96
+ digits.reverse.each_with_index.sum do |digit, index|
97
+ value = digit.to_i
98
+ if index.odd?
99
+ value *= 2
100
+ value -= 9 if value > 9
101
+ end
102
+ value
103
+ end
104
+ end
105
+ private_class_method :iban_check_digits, :iban_to_digits, :iban_mod97, :luhn_check_digit, :luhn_sum
106
+ end
@@ -64,8 +64,11 @@ class StringPattern
64
64
  # the generated string
65
65
  ###############################################
66
66
  def StringPattern.generate(pattern, expected_errors: [], **synonyms)
67
- seed_given = synonyms.key?(:seed)
68
- saved_rng = seed_given ? srand(synonyms[:seed]) : nil
67
+ rng = synonyms.delete(:_rng)
68
+ if rng.nil?
69
+ base = synonyms.key?(:seed) ? Random.new(synonyms[:seed]) : Random.new
70
+ rng = StringPattern::RandomSource.new(base)
71
+ end
69
72
  tries = 0
70
73
  begin
71
74
  good_result = true
@@ -81,30 +84,32 @@ class StringPattern
81
84
  if pattern.kind_of?(Array)
82
85
  pattern.each { |pat|
83
86
  if pat.kind_of?(Array) # for the case it is one of the values
84
- pat = pat.sample
87
+ pat = pat.sample(random: rng)
85
88
  end
86
89
 
87
90
  if pat.kind_of?(Symbol)
88
91
  if pat.to_s.scan(/^!?\d+-?\d*:.+/).size > 0
89
- string << StringPattern.generate(pat.to_s, expected_errors: expected_errors)
92
+ string << StringPattern.generate(pat.to_s, expected_errors: expected_errors, _rng: rng)
90
93
  else
91
94
  string << pat.to_s
92
95
  end
93
96
  elsif pat.kind_of?(String)
94
97
  if @optimistic and pat.to_s.scan(/^!?\d+-?\d*:.+/).size > 0
95
- string << StringPattern.generate(pat.to_s, expected_errors: expected_errors)
98
+ string << StringPattern.generate(pat.to_s, expected_errors: expected_errors, _rng: rng)
96
99
  else
97
100
  string << pat
98
101
  end
99
102
  else
100
- StringPattern.log_message("StringPattern.generate: it seems you supplied wrong array of patterns: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}")
101
- return ""
103
+ return StringPattern.fail_generation("StringPattern.generate: it seems you supplied wrong array of patterns: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}", StringPattern::InvalidPatternError)
102
104
  end
103
105
  }
104
106
  return string
105
107
  elsif pattern.kind_of?(String) or pattern.kind_of?(Symbol)
106
108
  patt = StringPattern.analyze(pattern).clone
107
- return "" unless patt.kind_of?(Struct)
109
+ unless patt.kind_of?(Struct)
110
+ raise StringPattern::InvalidPatternError, "pattern argument not valid on StringPattern.generate: #{pattern.inspect}" if @raise_on_error
111
+ return ""
112
+ end
108
113
 
109
114
  min_length = patt.min_length.clone
110
115
  max_length = patt.max_length.clone
@@ -122,17 +127,18 @@ class StringPattern
122
127
  }
123
128
  unless excluded_data.size == 0
124
129
  if (required_chars.flatten & excluded_data.flatten).size > 0
125
- StringPattern.log_message("pattern argument not valid on StringPattern.generate, a character cannot be required and excluded at the same time: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}")
126
- return ""
130
+ return StringPattern.fail_generation("pattern argument not valid on StringPattern.generate, a character cannot be required and excluded at the same time: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}", StringPattern::InvalidPatternError)
127
131
  end
128
132
  end
129
133
  end
130
134
 
131
135
  string_set_not_allowed = Array.new
132
136
  elsif pattern.kind_of?(Regexp)
133
- return generate(pattern.to_sp, expected_errors: expected_errors)
137
+ return generate(pattern.to_sp, expected_errors: expected_errors, _rng: rng)
134
138
  else
135
- StringPattern.log_message("pattern argument not valid on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}")
139
+ message = "pattern argument not valid on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}"
140
+ raise StringPattern::InvalidPatternError, message if @raise_on_error
141
+ StringPattern.log_message(message)
136
142
  return pattern.to_s
137
143
  end
138
144
 
@@ -141,8 +147,8 @@ class StringPattern
141
147
  if symbol_type[0..0] == "!"
142
148
  deny_pattern = true
143
149
  possible_errors = [:length, :value, :string_set_not_allowed]
144
- (rand(possible_errors.size) + 1).times {
145
- expected_errors << possible_errors.sample
150
+ (rng.rand(possible_errors.size) + 1).times {
151
+ expected_errors << possible_errors.sample(random: rng)
146
152
  }
147
153
  expected_errors.uniq!
148
154
  if symbol_type[1..1] == "0"
@@ -171,21 +177,18 @@ class StringPattern
171
177
 
172
178
  unless deny_pattern
173
179
  if required_data.size == 0 and expected_errors_left.include?(:required_data)
174
- StringPattern.log_message("required data not supplied on pattern so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}")
175
- return ""
180
+ return StringPattern.fail_generation("required data not supplied on pattern so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}", StringPattern::GenerationImpossibleError)
176
181
  end
177
182
 
178
183
  if excluded_data.size == 0 and expected_errors_left.include?(:excluded_data)
179
- StringPattern.log_message("excluded data not supplied on pattern so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}")
180
- return ""
184
+ return StringPattern.fail_generation("excluded data not supplied on pattern so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}", StringPattern::GenerationImpossibleError)
181
185
  end
182
186
 
183
187
  if expected_errors_left.include?(:string_set_not_allowed)
184
188
  string_set_not_allowed = all_characters_set - string_set
185
189
 
186
190
  if string_set_not_allowed.size == 0
187
- StringPattern.log_message("all characters are allowed so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}")
188
- return ""
191
+ return StringPattern.fail_generation("all characters are allowed so it won't be possible to generate a wrong string. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}", StringPattern::GenerationImpossibleError)
189
192
  end
190
193
  end
191
194
  end
@@ -194,12 +197,12 @@ class StringPattern
194
197
  expected_errors_left.include?(:max_length) or
195
198
  expected_errors_left.include?(:length)
196
199
  if expected_errors_left.include?(:min_length) or
197
- (min_length > 0 and expected_errors_left.include?(:length) and rand(2) == 0)
200
+ (min_length > 0 and expected_errors_left.include?(:length) and rng.rand(2) == 0)
198
201
  if min_length > 0
199
202
  if allow_empty
200
- length = rand(min_length).to_i
203
+ length = rng.rand(min_length).to_i
201
204
  else
202
- length = rand(min_length - 1).to_i + 1
205
+ length = rng.rand(min_length - 1).to_i + 1
203
206
  end
204
207
  if required_data.size > length and required_data.size < min_length
205
208
  length = required_data.size
@@ -207,22 +210,21 @@ class StringPattern
207
210
  expected_errors_left.delete(:length)
208
211
  expected_errors_left.delete(:min_length)
209
212
  else
210
- StringPattern.log_message("min_length is 0 so it won't be possible to generate a wrong string smaller than 0 characters. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}")
211
- return ""
213
+ return StringPattern.fail_generation("min_length is 0 so it won't be possible to generate a wrong string smaller than 0 characters. StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}", StringPattern::GenerationImpossibleError)
212
214
  end
213
215
  elsif expected_errors_left.include?(:max_length) or expected_errors_left.include?(:length)
214
- length = max_length + 1 + rand(max_length).to_i
216
+ length = max_length + 1 + rng.rand(max_length).to_i
215
217
  expected_errors_left.delete(:length)
216
218
  expected_errors_left.delete(:max_length)
217
219
  end
218
220
  else
219
- if allow_empty and rand(7) == 1
221
+ if allow_empty and rng.rand(7) == 1
220
222
  length = 0
221
223
  else
222
224
  if max_length == min_length
223
225
  length = min_length
224
226
  else
225
- length = min_length + rand(max_length - min_length + 1)
227
+ length = min_length + rng.rand(max_length - min_length + 1)
226
228
  end
227
229
  end
228
230
  end
@@ -253,28 +255,27 @@ class StringPattern
253
255
  if symbol_type != "@" and symbol_type != "!@" and length != 0 and string_set.size != 0
254
256
  if string_set.size != 0
255
257
  1.upto(length) { |i|
256
- string << string_set.sample.to_s
258
+ string << string_set.sample(random: rng).to_s
257
259
  }
258
260
  end
259
261
  if required_data.size > 0
260
262
  positions_to_set = (0..(string.size - 1)).to_a
261
263
  required_data.each { |rd|
262
264
  if (string.chars & rd).size > 0
263
- rd_to_set = (string.chars & rd).sample
265
+ rd_to_set = (string.chars & rd).sample(random: rng)
264
266
  else
265
- rd_to_set = rd.sample
267
+ rd_to_set = rd.sample(random: rng)
266
268
  end
267
269
  if ((0...string.length).find_all { |i| string[i, 1] == rd_to_set }).size == 0
268
270
  if positions_to_set.size == 0
269
- StringPattern.log_message("pattern not valid on StringPattern.generate, not possible to generate a valid string: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}")
270
- return ""
271
+ return StringPattern.fail_generation("pattern not valid on StringPattern.generate, not possible to generate a valid string: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}", StringPattern::GenerationImpossibleError)
271
272
  else
272
- k = positions_to_set.sample
273
+ k = positions_to_set.sample(random: rng)
273
274
  string[k] = rd_to_set
274
275
  positions_to_set.delete(k)
275
276
  end
276
277
  else
277
- k = ((0...string.length).find_all { |i| string[i, 1] == rd_to_set }).sample
278
+ k = ((0...string.length).find_all { |i| string[i, 1] == rd_to_set }).sample(random: rng)
278
279
  positions_to_set.delete(k)
279
280
  end
280
281
  }
@@ -282,7 +283,7 @@ class StringPattern
282
283
  excluded_data.each { |ed|
283
284
  if (string.chars & ed).size > 0
284
285
  (string.chars & ed).each { |s|
285
- string.gsub!(s, string_set.sample)
286
+ string.gsub!(s, string_set.sample(random: rng))
286
287
  }
287
288
  end
288
289
  }
@@ -291,28 +292,27 @@ class StringPattern
291
292
  string_set_not_allowed = all_characters_set - string_set if string_set_not_allowed.size == 0
292
293
 
293
294
  if string_set_not_allowed.size == 0
294
- StringPattern.log_message("Not possible to generate a non valid string on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}")
295
- return ""
295
+ return StringPattern.fail_generation("Not possible to generate a non valid string on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}", StringPattern::GenerationImpossibleError)
296
296
  end
297
- (rand(string.size) + 1).times {
298
- string[rand(string.size)] = (all_characters_set - string_set).sample
297
+ (rng.rand(string.size) + 1).times {
298
+ string[rng.rand(string.size)] = (all_characters_set - string_set).sample(random: rng)
299
299
  }
300
300
  expected_errors_left.delete(:value)
301
301
  end
302
302
 
303
303
  if expected_errors_left.include?(:required_data) and required_data.size > 0
304
- (rand(required_data.size) + 1).times {
305
- chars_to_remove = required_data.sample
304
+ (rng.rand(required_data.size) + 1).times {
305
+ chars_to_remove = required_data.sample(random: rng)
306
306
  chars_to_remove.each { |char_to_remove|
307
- string.gsub!(char_to_remove, (string_set - chars_to_remove).sample)
307
+ string.gsub!(char_to_remove, (string_set - chars_to_remove).sample(random: rng))
308
308
  }
309
309
  }
310
310
  expected_errors_left.delete(:required_data)
311
311
  end
312
312
 
313
313
  if expected_errors_left.include?(:excluded_data) and excluded_data.size > 0
314
- (rand(string.size) + 1).times {
315
- string[rand(string.size)] = excluded_data.sample.sample
314
+ (rng.rand(string.size) + 1).times {
315
+ string[rng.rand(string.size)] = excluded_data.sample(random: rng).sample(random: rng)
316
316
  }
317
317
  expected_errors_left.delete(:excluded_data)
318
318
  end
@@ -320,67 +320,21 @@ class StringPattern
320
320
  if expected_errors_left.include?(:string_set_not_allowed)
321
321
  string_set_not_allowed = all_characters_set - string_set if string_set_not_allowed.size == 0
322
322
  if string_set_not_allowed.size > 0
323
- (rand(string.size) + 1).times {
324
- string[rand(string.size)] = string_set_not_allowed.sample
323
+ (rng.rand(string.size) + 1).times {
324
+ string[rng.rand(string.size)] = string_set_not_allowed.sample(random: rng)
325
325
  }
326
326
  expected_errors_left.delete(:string_set_not_allowed)
327
327
  end
328
328
  end
329
329
  elsif (symbol_type == "W" or symbol_type == "P" or symbol_type == "w" or symbol_type == "p") and length > 0
330
- words = []
331
- words_short = []
332
- if symbol_type == "W"
333
- if @words_camel.empty?
334
- require "pathname"
335
- require "json"
336
- filename = File.join Pathname(File.dirname(__FILE__)), "../../../data", "english/nouns.json"
337
- nouns = JSON.parse(File.read(filename))
338
- filename = File.join Pathname(File.dirname(__FILE__)), "../../../data", "english/adjs.json"
339
- adjs = JSON.parse(File.read(filename))
340
- nouns = nouns.map(&:to_camel_case)
341
- adjs = adjs.map(&:to_camel_case)
342
- @words_camel = adjs + nouns
343
- @words_camel_short = @words_camel.sample(2000)
344
- end
345
- words = @words_camel
346
- words_short = @words_camel_short
347
- elsif symbol_type == "w"
348
- if @words.empty?
349
- require "pathname"
350
- require "json"
351
- filename = File.join Pathname(File.dirname(__FILE__)), "../../../data", "english/nouns.json"
352
- nouns = JSON.parse(File.read(filename))
353
- filename = File.join Pathname(File.dirname(__FILE__)), "../../../data", "english/adjs.json"
354
- adjs = JSON.parse(File.read(filename))
355
- @words = adjs + nouns
356
- @words_short = @words.sample(2000)
357
- end
358
- words = @words
359
- words_short = @words_short
360
- elsif symbol_type == "P"
361
- if @palabras_camel.empty?
362
- require "pathname"
363
- require "json"
364
- filename = File.join Pathname(File.dirname(__FILE__)), "../../../data", "spanish/palabras#{rand(12)}.json"
365
- palabras = JSON.parse(File.read(filename))
366
- palabras = palabras.map(&:to_camel_case)
367
- @palabras_camel = palabras
368
- @palabras_camel_short = @palabras_camel.sample(2000)
369
- end
370
- words = @palabras_camel
371
- words_short = @palabras_camel_short
372
- elsif symbol_type == "p"
373
- if @palabras.empty?
374
- require "pathname"
375
- require "json"
376
- filename = File.join Pathname(File.dirname(__FILE__)), "../../../data", "spanish/palabras#{rand(12)}.json"
377
- palabras = JSON.parse(File.read(filename))
378
- @palabras = palabras
379
- @palabras_short = @palabras.sample(2000)
380
- end
381
- words = @palabras
382
- words_short = @palabras_short
330
+ if symbol_type == "W" || symbol_type == "w"
331
+ load_english_words(camel: symbol_type == "W")
332
+ words = symbol_type == "W" ? @words_camel : @words
333
+ else
334
+ load_spanish_words(camel: symbol_type == "P")
335
+ words = symbol_type == "P" ? @palabras_camel : @palabras
383
336
  end
337
+ words_short = words.sample(2000, random: rng)
384
338
 
385
339
  wordr = ""
386
340
  wordr_array = []
@@ -394,16 +348,16 @@ class StringPattern
394
348
  end
395
349
  if symbol_type == "w" or symbol_type == "p"
396
350
  length = length - 1 if wordr_array.size > 0
397
- res = (words_short.select { |word| word.length <= length && word.length != length - 1 && word.length != length - 2 && word.length != length - 3 }).sample.to_s
351
+ res = (words_short.select { |word| word.length <= length && word.length != length - 1 && word.length != length - 2 && word.length != length - 3 }).sample(random: rng).to_s
398
352
  unless res.to_s == ""
399
353
  wordr_array << res
400
354
  wordr = wordr_array.join(@word_separator)
401
355
  end
402
356
  else
403
- wordr += (words_short.select { |word| word.length <= length && word.length != length - 1 && word.length != length - 2 && word.length != length - 3 }).sample.to_s
357
+ wordr += (words_short.select { |word| word.length <= length && word.length != length - 1 && word.length != length - 2 && word.length != length - 3 }).sample(random: rng).to_s
404
358
  end
405
359
  if (tries % 100) == 0
406
- words_short = words.sample(2000)
360
+ words_short = words.sample(2000, random: rng)
407
361
  end
408
362
  end
409
363
  good_result = true
@@ -436,60 +390,60 @@ class StringPattern
436
390
  at_sign = "@"
437
391
 
438
392
  if expected_errors_left.include?(:value)
439
- if rand(2) == 1
440
- extension = (all_characters_set - ["."]).sample.dup
393
+ if rng.rand(2) == 1
394
+ extension = (all_characters_set - ["."]).sample(random: rng).dup
441
395
  expected_errors_left.delete(:value)
442
396
  expected_errors_left.delete(:required_data)
443
397
  end
444
398
 
445
- if rand(2) == 1
446
- 1.upto(rand(7)) { |i|
447
- extension << alpha_set.sample.downcase
399
+ if rng.rand(2) == 1
400
+ 1.upto(rng.rand(7)) { |i|
401
+ extension << alpha_set.sample(random: rng).downcase
448
402
  }
449
403
 
450
- (rand(extension.size) + 1).times {
451
- extension[rand(extension.size)] = (string_set - alpha_set - ["."]).sample
404
+ (rng.rand(extension.size) + 1).times {
405
+ extension[rng.rand(extension.size)] = (string_set - alpha_set - ["."]).sample(random: rng)
452
406
  }
453
407
 
454
408
  expected_errors_left.delete(:value)
455
409
  else
456
- 1.upto(rand(3) + 2) { |i|
457
- extension << alpha_set.sample.downcase
410
+ 1.upto(rng.rand(3) + 2) { |i|
411
+ extension << alpha_set.sample(random: rng).downcase
458
412
  }
459
413
  end
460
414
 
461
- if rand(2) == 1
462
- at_sign = (string_set - ["@"]).sample.dup
415
+ if rng.rand(2) == 1
416
+ at_sign = (string_set - ["@"]).sample(random: rng).dup
463
417
  expected_errors_left.delete(:value)
464
418
  expected_errors_left.delete(:required_data)
465
419
  end
466
420
  else
467
421
  if length > 6
468
- 1.upto(rand(3) + 2) { |i|
469
- extension << alpha_set.sample.downcase
422
+ 1.upto(rng.rand(3) + 2) { |i|
423
+ extension << alpha_set.sample(random: rng).downcase
470
424
  }
471
425
  else
472
426
  1.upto(2) { |i|
473
- extension << alpha_set.sample.downcase
427
+ extension << alpha_set.sample(random: rng).downcase
474
428
  }
475
429
  end
476
430
  end
477
431
  length_e = length - extension.size - 1
478
- length1 = rand(length_e - 1) + 1
432
+ length1 = rng.rand(length_e - 1) + 1
479
433
  length2 = length_e - length1
480
- 1.upto(length1) { |i| string << string_set.sample }
434
+ 1.upto(length1) { |i| string << string_set.sample(random: rng) }
481
435
 
482
436
  string << at_sign
483
437
 
484
438
  domain = ""
485
439
  domain_set = alpha_set + NUMBER_SET.clone + ["."] + ["-"]
486
440
  1.upto(length2) { |i|
487
- domain << domain_set.sample.downcase
441
+ domain << domain_set.sample(random: rng).downcase
488
442
  }
489
443
 
490
- if expected_errors.include?(:value) and rand(2) == 1 and domain.size > 0
491
- (rand(domain.size) + 1).times {
492
- domain[rand(domain.size)] = (all_characters_set - domain_set).sample
444
+ if expected_errors.include?(:value) and rng.rand(2) == 1 and domain.size > 0
445
+ (rng.rand(domain.size) + 1).times {
446
+ domain[rng.rand(domain.size)] = (all_characters_set - domain_set).sample(random: rng)
493
447
  }
494
448
  expected_errors_left.delete(:value)
495
449
  end
@@ -497,8 +451,8 @@ class StringPattern
497
451
  string << domain << extension
498
452
 
499
453
  if expected_errors_left.include?(:value) or expected_errors_left.include?(:string_set_not_allowed)
500
- (rand(string.size) + 1).times {
501
- string[rand(string.size)] = string_set_not_allowed.sample
454
+ (rng.rand(string.size) + 1).times {
455
+ string[rng.rand(string.size)] = string_set_not_allowed.sample(random: rng)
502
456
  }
503
457
  expected_errors_left.delete(:value)
504
458
  expected_errors_left.delete(:string_set_not_allowed)
@@ -529,10 +483,7 @@ class StringPattern
529
483
  end
530
484
  end until good_result or tries > 100
531
485
  unless good_result
532
- msg = "Not possible to generate an email on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}"
533
- raise StringPattern::GenerationImpossibleError, msg if @raise_on_error
534
- StringPattern.log_message(msg)
535
- return ""
486
+ return StringPattern.fail_generation("Not possible to generate an email on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}", StringPattern::GenerationImpossibleError)
536
487
  end
537
488
  end
538
489
  if @dont_repeat
@@ -563,10 +514,20 @@ class StringPattern
563
514
  if @block_list.respond_to?(:call)
564
515
  good_result = false if @block_list.call(string)
565
516
  elsif @block_list.is_a?(Array)
566
- @block_list.each do |bl|
567
- if string.match?(/#{bl}/i)
568
- good_result = false
569
- break
517
+ if @block_list_regexp
518
+ @block_list.each do |bl|
519
+ if string.match?(/#{bl}/i)
520
+ good_result = false
521
+ break
522
+ end
523
+ end
524
+ else
525
+ downcased = string.downcase
526
+ @block_list.each do |bl|
527
+ if downcased.include?(bl.to_s.downcase)
528
+ good_result = false
529
+ break
530
+ end
570
531
  end
571
532
  end
572
533
  end
@@ -575,12 +536,42 @@ class StringPattern
575
536
  unless good_result
576
537
  msg = "Not possible to generate the string on StringPattern.generate: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}"
577
538
  msg += "\nTake in consideration if you are using StringPattern.dont_repeat=true that you don't try to generate more strings that are possible to be generated"
578
- raise StringPattern::GenerationImpossibleError, msg if @raise_on_error
579
- StringPattern.log_message(msg)
580
- return ""
539
+ return StringPattern.fail_generation(msg, StringPattern::GenerationImpossibleError)
581
540
  end
582
541
  return string
583
- ensure
584
- srand(saved_rng) if saved_rng
542
+ end
543
+
544
+ def self.fail_generation(message, error_class)
545
+ raise error_class, message if @raise_on_error
546
+ log_message(message)
547
+ ""
548
+ end
549
+
550
+ def self.word_data_path(*parts)
551
+ File.join(__dir__, "..", "..", "..", "data", *parts)
552
+ end
553
+
554
+ def self.load_english_words(camel: false)
555
+ require "json"
556
+ if @words.nil? || @words.empty?
557
+ nouns = JSON.parse(File.read(word_data_path("english", "nouns.json")))
558
+ adjs = JSON.parse(File.read(word_data_path("english", "adjs.json")))
559
+ @words = adjs + nouns
560
+ end
561
+ if camel && (@words_camel.nil? || @words_camel.empty?)
562
+ @words_camel = @words.map(&:to_camel_case)
563
+ end
564
+ end
565
+
566
+ def self.load_spanish_words(camel: false)
567
+ require "json"
568
+ if @palabras.nil? || @palabras.empty?
569
+ @palabras = (0..11).flat_map do |i|
570
+ JSON.parse(File.read(word_data_path("spanish", "palabras#{i}.json")))
571
+ end
572
+ end
573
+ if camel && (@palabras_camel.nil? || @palabras_camel.empty?)
574
+ @palabras_camel = @palabras.map(&:to_camel_case)
575
+ end
585
576
  end
586
577
  end
@@ -32,6 +32,11 @@ class StringPattern
32
32
  if (not_expected_errors.include?(:min_length) or not_expected_errors.include?(:max_length)) and !not_expected_errors.include?(:length)
33
33
  not_expected_errors.push(:length)
34
34
  end
35
+ if pattern.is_a?(Regexp)
36
+ return false if text_to_validate.nil?
37
+ detected_errors = StringPattern.regexp_full_match?(pattern, text_to_validate.to_s) ? [] : [:value]
38
+ return StringPattern.validation_decision(detected_errors, expected_errors, not_expected_errors)
39
+ end
35
40
  if pattern.kind_of?(Array) and pattern.size == 1
36
41
  pattern = pattern[0]
37
42
  elsif pattern.kind_of?(Array) and pattern.size > 1
@@ -133,7 +138,9 @@ class StringPattern
133
138
  required_chars << rd if rd.size == 1
134
139
  }
135
140
  if (required_chars.flatten & excluded_data.flatten).size > 0
136
- StringPattern.log_message("pattern argument not valid on StringPattern.validate, a character cannot be required and excluded at the same time: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}")
141
+ message = "pattern argument not valid on StringPattern.validate, a character cannot be required and excluded at the same time: #{pattern.inspect}, expected_errors: #{expected_errors.inspect}"
142
+ raise StringPattern::InvalidPatternError, message if @raise_on_error
143
+ StringPattern.log_message(message)
137
144
  return ""
138
145
  end
139
146
  end
@@ -190,18 +197,99 @@ class StringPattern
190
197
  end
191
198
  end
192
199
 
193
- if expected_errors.size == 0 and not_expected_errors.size == 0
194
- return detected_errors.uniq
200
+ StringPattern.validation_decision(detected_errors, expected_errors, not_expected_errors)
201
+ end
202
+
203
+ def self.validation_decision(detected_errors, expected_errors, not_expected_errors)
204
+ if expected_errors.size == 0 && not_expected_errors.size == 0
205
+ detected_errors.uniq
206
+ elsif (expected_errors & detected_errors) == expected_errors && (not_expected_errors & detected_errors).empty?
207
+ true
195
208
  else
196
- if expected_errors & detected_errors == expected_errors
197
- if (not_expected_errors & detected_errors).size > 0
198
- return false
199
- else
200
- return true
201
- end
202
- else
203
- return false
204
- end
209
+ false
210
+ end
211
+ end
212
+
213
+ # True when +text+ matches the whole +regexp+, not a substring.
214
+ def self.regexp_full_match?(regexp, text)
215
+ return false unless text.is_a?(String)
216
+ anchored = Regexp.new("\\A(?:#{regexp.source})\\z", regexp.options)
217
+ anchored.match?(text)
218
+ rescue RegexpError
219
+ false
220
+ end
221
+
222
+ # Describes why +text+ does or does not match +pattern+.
223
+ # @return [Hash] :valid, :errors, :message, and :index (segment index for arrays)
224
+ def self.explain(text: nil, pattern: nil, **synonyms)
225
+ text = synonyms[:text_to_validate] if text.nil? && synonyms.key?(:text_to_validate)
226
+ text = synonyms[:validate] if text.nil? && synonyms.key?(:validate)
227
+
228
+ if pattern.is_a?(Array) && pattern.size > 1
229
+ return explain_array(text, pattern)
230
+ end
231
+
232
+ errors = validate(text: text, pattern: pattern)
233
+ if errors == true || (errors.is_a?(Array) && errors.empty?)
234
+ { valid: true, errors: [], message: "valid", index: nil }
235
+ elsif errors.is_a?(Array)
236
+ { valid: false, errors: errors, message: "invalid: #{errors.join(', ')}", index: nil }
237
+ else
238
+ { valid: false, errors: [:value], message: "invalid", index: nil }
239
+ end
240
+ end
241
+
242
+ def self.explain_array(text, patterns)
243
+ ok, index, errors = match_segments(text.to_s, patterns, 0)
244
+ if ok
245
+ { valid: true, errors: [], message: "valid", index: nil }
246
+ else
247
+ { valid: false, errors: errors, message: "segment #{index} does not match: #{errors.join(', ')}", index: index }
248
+ end
249
+ end
250
+
251
+ def self.match_segments(text, patterns, index)
252
+ if patterns.empty?
253
+ return [true, nil, []] if text.empty?
254
+ return [false, index, [:length]]
255
+ end
256
+
257
+ if patterns.size == 1
258
+ errors = validate(text: text, pattern: patterns[0])
259
+ return [true, nil, []] if errors == true || (errors.is_a?(Array) && errors.empty?)
260
+ errs = errors.is_a?(Array) ? errors : [:value]
261
+ return [false, index, errs]
262
+ end
263
+
264
+ info = segment_bounds(patterns[0])
265
+ return [false, index, [:value]] if info.nil?
266
+
267
+ best = nil
268
+ (info[:min]..info[:max]).each do |n|
269
+ next if n > text.length
270
+ errors = validate(text: text[0, n], pattern: patterns[0])
271
+ next unless errors == true || (errors.is_a?(Array) && errors.empty?)
272
+ ok, idx, errs = match_segments(text[n..], patterns[1..], index + 1)
273
+ return [true, nil, []] if ok
274
+ best = [idx, errs] if best.nil? || idx > best[0]
275
+ end
276
+
277
+ return [false, best[0], best[1]] if best
278
+
279
+ errors = validate(text: text, pattern: patterns[0])
280
+ errs = errors.is_a?(Array) ? errors : [:value]
281
+ errs = [:value] if errs.empty?
282
+ [false, index, errs]
283
+ end
284
+
285
+ def self.segment_bounds(pat)
286
+ pattern_like = pat.to_s.scan(/(\d+)-(\d+):(.+)/).size > 0 || pat.to_s.scan(/^!?(\d+):(.+)/).size > 0
287
+ if pat.is_a?(String) && (!optimistic || !pattern_like)
288
+ { min: pat.length, max: pat.length }
289
+ elsif pat.is_a?(Symbol) || pat.is_a?(String)
290
+ analyzed = analyze(pat, silent: true)
291
+ return nil unless analyzed.is_a?(Struct)
292
+ { min: analyzed.min_length, max: analyzed.max_length }
205
293
  end
206
294
  end
207
295
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StringPattern
4
+ VERSION = "2.5.0"
5
+ end
@@ -1,6 +1,8 @@
1
1
  SP_ADD_TO_RUBY = true if !defined?(SP_ADD_TO_RUBY)
2
+ require_relative "string_pattern/version"
2
3
  require_relative "string/pattern/add_to_ruby" if SP_ADD_TO_RUBY
3
4
  require_relative "string/pattern/analyze"
5
+ require_relative "string/pattern/checksum"
4
6
  require_relative "string/pattern/email"
5
7
  require_relative "string/pattern/generate"
6
8
  require_relative "string/pattern/validate"
@@ -41,13 +43,36 @@ class StringPattern
41
43
  # Raised when generation is impossible (e.g. exhausted by dont_repeat) and {raise_on_error} is true.
42
44
  GenerationImpossibleError = Class.new(StandardError)
43
45
 
46
+ # Wraps Random so +rand+ matches Kernel#rand for negative limits and zero.
47
+ # Array#sample calls +rand+ with a positive integer.
48
+ class RandomSource
49
+ def initialize(random)
50
+ @random = random
51
+ end
52
+
53
+ def rand(limit = nil)
54
+ return @random.rand if limit.nil?
55
+ if limit.is_a?(Integer) && limit.negative?
56
+ @random.rand(-limit)
57
+ elsif limit == 0
58
+ @random.rand
59
+ else
60
+ @random.rand(limit)
61
+ end
62
+ end
63
+ end
64
+
44
65
  class << self
45
66
  # @return [String, nil] When set, warning messages are sent here instead of +puts+.
46
67
  attr_accessor :logger
47
68
  # @return [Boolean] When true, invalid patterns or impossible generation raise instead of returning "".
48
69
  attr_accessor :raise_on_error
49
- attr_accessor :national_chars, :optimistic, :dont_repeat, :cache, :cache_values, :default_infinite, :word_separator, :block_list, :block_list_enabled
70
+ attr_accessor :national_chars, :optimistic, :dont_repeat, :cache, :cache_values, :default_infinite, :word_separator, :block_list, :block_list_enabled, :block_list_regexp
50
71
  end
72
+ SCOPE_KEYS = %i[
73
+ dont_repeat block_list block_list_enabled block_list_regexp
74
+ national_chars optimistic word_separator raise_on_error logger
75
+ ].freeze
51
76
  @national_chars = (("a".."z").to_a + ("A".."Z").to_a).join
52
77
  @optimistic = true
53
78
  @cache = Hash.new()
@@ -59,6 +84,7 @@ class StringPattern
59
84
  @block_list = []
60
85
  @logger = nil
61
86
  @raise_on_error = false
87
+ @block_list_regexp = false
62
88
  NUMBER_SET = ("0".."9").to_a
63
89
  SPECIAL_SET = [" ", "~", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "-", "_", "+", "=", "{", "}", "[", "]", "'", ";", ":", "?", ">", "<", "`", "|", "/", '"']
64
90
  ALPHA_SET_LOWER = ("a".."z").to_a
@@ -101,25 +127,78 @@ class StringPattern
101
127
  end
102
128
 
103
129
  # Generates up to +n+ distinct strings for +pattern+. Uses a temporary dont_repeat state.
130
+ # Keyword arguments, including +seed:+, are forwarded to {generate}. A seed advances one
131
+ # Random across the whole sample so successive values differ.
104
132
  # @param pattern [String, Symbol, Array, Regexp]
105
133
  # @param n [Integer]
106
134
  # @return [Array<String>]
107
- def self.sample(pattern, n)
135
+ def self.sample(pattern, n, seed: nil, **options)
108
136
  return [] if n <= 0
137
+
138
+ rng = StringPattern::RandomSource.new(seed.nil? ? Random.new : Random.new(seed))
109
139
  old_dont = @dont_repeat
110
140
  old_cache = @cache_values.dup
141
+ started = true
111
142
  @dont_repeat = true
112
143
  @cache_values = {}
113
144
  results = []
114
145
  n.times do
115
- s = generate(pattern)
146
+ s = generate(pattern, _rng: rng, **options)
116
147
  break if s.nil? || s.empty?
117
148
  results << s
118
149
  end
119
150
  results
120
151
  ensure
121
- @dont_repeat = old_dont
122
- @cache_values = old_cache
152
+ if started
153
+ @dont_repeat = old_dont
154
+ @cache_values = old_cache
155
+ end
156
+ end
157
+
158
+ # Runs +block+ with the given settings and restores the previous values afterwards.
159
+ # @return [Object] the block's result
160
+ def self.with(**options)
161
+ unknown = options.keys - SCOPE_KEYS
162
+ raise ArgumentError, "unknown option: #{unknown.join(', ')}" unless unknown.empty?
163
+
164
+ saved = {}
165
+ SCOPE_KEYS.each { |key| saved[key] = instance_variable_get("@#{key}") }
166
+ options.each do |key, value|
167
+ if key == :national_chars
168
+ self.national_chars = value
169
+ else
170
+ instance_variable_set("@#{key}", value)
171
+ end
172
+ end
173
+ yield
174
+ ensure
175
+ if saved
176
+ saved.each do |key, value|
177
+ if key == :national_chars
178
+ self.national_chars = value unless @national_chars == value
179
+ else
180
+ instance_variable_set("@#{key}", value)
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ # Restores configuration defaults and clears pattern and generated-value caches.
187
+ # @return [void]
188
+ def self.reset!
189
+ self.national_chars = (("a".."z").to_a + ("A".."Z").to_a).join
190
+ @optimistic = true
191
+ @dont_repeat = false
192
+ @default_infinite = 10
193
+ @word_separator = "_"
194
+ @block_list_enabled = false
195
+ @block_list = []
196
+ @block_list_regexp = false
197
+ @logger = nil
198
+ @raise_on_error = false
199
+ @cache = {}
200
+ @cache_values = {}
201
+ nil
123
202
  end
124
203
 
125
204
  # Generates a random UUID v4 (e.g. "550e8400-e29b-41d4-a716-446655440000").
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: string_pattern
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.0
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Ruiz
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-02-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: regexp_parser
@@ -39,10 +38,12 @@ email: marioruizs@gmail.com
39
38
  executables: []
40
39
  extensions: []
41
40
  extra_rdoc_files:
41
+ - CHANGELOG.md
42
42
  - LICENSE
43
43
  - README.md
44
44
  files:
45
45
  - ".yardopts"
46
+ - CHANGELOG.md
46
47
  - LICENSE
47
48
  - README.md
48
49
  - data/english/adjs.json
@@ -61,15 +62,16 @@ files:
61
62
  - data/spanish/palabras9.json
62
63
  - lib/string/pattern/add_to_ruby.rb
63
64
  - lib/string/pattern/analyze.rb
65
+ - lib/string/pattern/checksum.rb
64
66
  - lib/string/pattern/email.rb
65
67
  - lib/string/pattern/generate.rb
66
68
  - lib/string/pattern/validate.rb
67
69
  - lib/string_pattern.rb
70
+ - lib/string_pattern/version.rb
68
71
  homepage: https://github.com/MarioRuiz/string_pattern
69
72
  licenses:
70
73
  - MIT
71
74
  metadata: {}
72
- post_install_message:
73
75
  rdoc_options: []
74
76
  require_paths:
75
77
  - lib
@@ -77,15 +79,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
77
79
  requirements:
78
80
  - - ">="
79
81
  - !ruby/object:Gem::Version
80
- version: '0'
82
+ version: '3.0'
81
83
  required_rubygems_version: !ruby/object:Gem::Requirement
82
84
  requirements:
83
85
  - - ">="
84
86
  - !ruby/object:Gem::Version
85
87
  version: '0'
86
88
  requirements: []
87
- rubygems_version: 3.4.19
88
- signing_key:
89
+ rubygems_version: 4.0.6
89
90
  specification_version: 4
90
91
  summary: 'Generate easily random strings following a simple pattern or regular expression.
91
92
  ''10-20:Xn/x/''.generate #>qBstvc6JN8ra. Also generate words in English or Spanish.