string_pattern 2.3.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: 7b9ea7310d3fb561b37d8b0753360ddd5742188a2f71b68560a121d716d65083
4
- data.tar.gz: a0504e61ff66a749d7c0b0fcc36ad629474d793abd78bc0456bdd6a9ddeae7f7
3
+ metadata.gz: 8769f150212e86d1c798377384ec17edccc22ba4c09d4b15d75bdac5ea1772b0
4
+ data.tar.gz: '0249dda55facee2d54e3a9229ef3bfb5e587eeac6adc72849c6a374fa15eb9dd'
5
5
  SHA512:
6
- metadata.gz: 3ade8ead8f7434fb1893f62e44523245fb52776a24ffc9f9f548a16f790ec035f2411d223203886571decd7c7455b6f56cf41a7e62c491910762c210754e11f7
7
- data.tar.gz: bbb68322396f6017f6f6f1b6190b1e14cc0a3cea804efa66f6527f892be032fe5cebc321e39ea399e0af9f69ee6f5e4ef1c7a9241ac97541cf6d4aac3191c658
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,8 +1,13 @@
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
+ ![Gem](https://img.shields.io/gem/dt/string_pattern)
7
+ ![GitHub commit activity](https://img.shields.io/github/commit-activity/y/MarioRuiz/string_pattern)
8
+ ![GitHub last commit](https://img.shields.io/github/last-commit/MarioRuiz/string_pattern)
9
+ ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/MarioRuiz/string_pattern)
10
+
6
11
 
7
12
  With this gem, you can easily generate strings supplying a very simple pattern. Even generate random words in English or Spanish.
8
13
  Also, you can validate if a text fulfills a specific pattern or even generate a string following a pattern and returning the wrong length, value... for testing your applications. Perfect to be used in test data factories.
@@ -313,12 +318,9 @@ Examples:
313
318
 
314
319
  If you need to validate if a specific text is fulfilling the pattern you can use the validate method.
315
320
 
316
- If a string pattern supplied and no other parameters supplied the output will be an array with the errors detected.
317
-
321
+ When you supply a single pattern and do **not** supply `expected_errors` or `not_expected_errors`, the method returns an **array of error symbols**: an empty array `[]` when the text is valid, or one or more of `:min_length`, `:max_length`, `:length`, `:value`, `:string_set_not_allowed`, `:required_data`, `:excluded_data` when invalid.
318
322
 
319
- Possible output values, empty array (validation without errors detected) or one or more of: :min_length, :max_length, :length, :value, :string_set_not_allowed, :required_data, :excluded_data
320
-
321
- In case an array of patterns supplied it will return only true or false
323
+ When an array of patterns is supplied, the method returns only `true` or `false`.
322
324
 
323
325
  Examples:
324
326
 
@@ -438,6 +440,114 @@ StringPattern.block_list_enabled = true
438
440
  "2-20:Tn".gen #>AAñ34Ef99éNOP
439
441
  ```
440
442
 
443
+ #### StringPattern.analyze
444
+
445
+ To inspect how a pattern is parsed without generating or validating:
446
+
447
+ ```ruby
448
+ p = StringPattern.analyze("10-20:LN/x/")
449
+ # => #<Struct min_length=10, max_length=20, symbol_type="LN/x/", required_data=..., string_set=..., unique=false>
450
+ p.min_length # => 10
451
+ p.max_length # => 20
452
+ p.symbol_type # => "LN/x/"
453
+ ```
454
+
455
+ Useful for debugging or building tools on top of the pattern DSL. Invalid patterns return the pattern string; use `silent: true` to avoid logging.
456
+
457
+ #### Error handling and logging
458
+
459
+ By default, when generation is impossible (e.g. invalid pattern or `dont_repeat` exhausted), `generate` returns an empty string `""` and a message is printed. You can:
460
+
461
+ - Set `StringPattern.logger = Logger.new($stderr)` to send messages to a logger instead of `puts`.
462
+ - Set `StringPattern.raise_on_error = true` to raise `StringPattern::GenerationImpossibleError` or `StringPattern::InvalidPatternError` instead of returning `""`.
463
+
464
+ #### Reproducible generation (seed)
465
+
466
+ Pass `seed:` to get the same string for the same pattern in tests:
467
+
468
+ ```ruby
469
+ "10:N".gen(seed: 42) # => same result every time
470
+ ```
471
+
472
+ #### Batch generation (sample)
473
+
474
+ Generate up to `n` distinct strings without mutating the global dont_repeat cache:
475
+
476
+ ```ruby
477
+ StringPattern.sample("4:N", 10) # => array of 10 distinct 4-digit strings
478
+ ```
479
+
480
+ #### Boolean validation (valid?)
481
+
482
+ Check if text matches a pattern without building the full error list:
483
+
484
+ ```ruby
485
+ StringPattern.valid?(text: "user@domain.com", pattern: "14-40:@") # => true
486
+ ```
487
+
488
+ #### UUID
489
+
490
+ Generate a random UUID v4 or validate one:
491
+
492
+ ```ruby
493
+ StringPattern.uuid # => "550e8400-e29b-41d4-a716-446655440000"
494
+ StringPattern.valid_uuid?(some_str) # => true or false
495
+ ```
496
+
497
+ #### block_list as Proc
498
+
499
+ You can set `block_list` to a Proc for custom blocking:
500
+
501
+ ```ruby
502
+ StringPattern.block_list = ->(s) { s.include?("forbidden") }
503
+ StringPattern.block_list_enabled = true
504
+ ```
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
+ ```
441
551
 
442
552
  ## Contributing
443
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
@@ -106,7 +113,7 @@ class Regexp
106
113
  elsif token == :literal and text.size == 2
107
114
  text = text[1]
108
115
  else
109
- puts "Report token not controlled: type: #{type}, token: #{token}, text: '#{text}' [#{ts}..#{te}]"
116
+ StringPattern.log_message("Report token not controlled: type: #{type}, token: #{token}, text: '#{text}' [#{ts}..#{te}]")
110
117
  end
111
118
  end
112
119
 
@@ -165,7 +172,7 @@ class Regexp
165
172
  set_negate = false
166
173
  else
167
174
  pats += "]"
168
- end
175
+ end
169
176
 
170
177
  end
171
178
  elsif type == :group
@@ -190,7 +197,6 @@ class Regexp
190
197
  patg << pats
191
198
  pats = ""
192
199
  elsif patg.empty?
193
- # for the case the first element was not added to patg and was on pata fex: (a+|b|c)
194
200
  patg << pata.pop
195
201
  end
196
202
  end
@@ -299,11 +305,11 @@ class Regexp
299
305
  end
300
306
  if pats != ""
301
307
  if pata.empty?
302
- if pats[0] == "[" and pats[-1] == "]" #fex: /[12ab]/
308
+ if pats[0] == "[" and pats[-1] == "]"
303
309
  pata = ["1:#{pats}"]
304
310
  end
305
311
  else
306
- pata[-1] += pats[1] #fex: /allo/
312
+ pata[-1] += pats[1]
307
313
  end
308
314
  end
309
315
  if pata.size == 1 and pata[0].kind_of?(String)
@@ -325,7 +331,7 @@ module Kernel
325
331
  if pattern.is_a?(String) || pattern.is_a?(Array) || pattern.is_a?(Symbol) || pattern.is_a?(Regexp)
326
332
  StringPattern.generate(pattern, expected_errors: expected_errors, **synonyms)
327
333
  else
328
- puts " Kernel generate method: class not recognized:#{pattern.class}"
334
+ StringPattern.log_message(" Kernel generate method: class not recognized:#{pattern.class}")
329
335
  end
330
336
  end
331
337
 
@@ -1,8 +1,8 @@
1
1
  class StringPattern
2
- ###############################################
3
- # Analyze the pattern supplied and returns an object of Pattern structure including:
4
- # min_length, max_length, symbol_type, required_data, excluded_data, data_provided, string_set, all_characters_set
5
- ###############################################
2
+ # Analyzes a pattern string and returns a Pattern struct.
3
+ # @param pattern [String, Symbol] Pattern in format "length:type" or "min-max:type" (e.g. "10:N", "5-15:L")
4
+ # @param silent [Boolean] If true, invalid patterns do not log a message.
5
+ # @return [Struct, String] Pattern struct with min_length, max_length, symbol_type, required_data, excluded_data, data_provided, string_set, all_characters_set, unique; or the pattern string if invalid.
6
6
  def StringPattern.analyze(pattern, silent: false)
7
7
  #unless @cache[pattern.to_s].nil?
8
8
  # return Pattern.new(@cache[pattern.to_s].min_length.clone, @cache[pattern.to_s].max_length.clone,
@@ -16,7 +16,7 @@ class StringPattern
16
16
  min_length, symbol_type = pattern.to_s.scan(/^!?(\d+):(.+)/)[0]
17
17
  max_length = min_length
18
18
  if min_length.nil?
19
- puts "pattern argument not valid on StringPattern.generate: #{pattern.inspect}" unless silent
19
+ StringPattern.log_message("pattern argument not valid on StringPattern.generate: #{pattern.inspect}") unless silent
20
20
  return pattern.to_s
21
21
  end
22
22
  end
@@ -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
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class StringPattern
4
+ # Validates email format using the same rules as pattern type @:
5
+ # - Forbids consecutive/adjacent invalid sequences (.. __ -- etc.)
6
+ # - Local part: [a-z0-9]+([\+\._\-][a-z0-9])*
7
+ # - Domain part: [0-9a-z]+([\.-][a-z0-9])*
8
+ def self.valid_email?(string)
9
+ return false if string.nil? || !string.is_a?(String)
10
+ return false if string.index("@").to_i <= 0
11
+
12
+ wrong = %w(.. __ -- ._ _. .- -. _- -_ @. @_ @- .@ _@ -@ @@)
13
+ return false if Regexp.union(*wrong) === string
14
+
15
+ local = string[0..(string.index("@") - 1)]
16
+ domain = string[(string.index("@") + 1)..-1]
17
+ local_ok = local.scan(/([a-z0-9]+([\+\._\-][a-z0-9]|)*)/i).join == local
18
+ domain_ok = domain.scan(/([0-9a-z]+([\.-][a-z0-9]|)*)/i).join == domain
19
+ local_ok && domain_ok
20
+ end
21
+ end