sec_id 6.0.0 → 6.1.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: c629207aa12be217cf0de6bcb9f7429731da6f33e28b4829c21e789c01246d70
4
- data.tar.gz: 18c299442df23cba47816bf3f6ebc7d274299f4e87cb82430018fd3e1ce11f8b
3
+ metadata.gz: 2c705053b66b513f6cbac21702cd4548f5f446669f9251db569d62693698ff64
4
+ data.tar.gz: b792e03a67c57ead4064c392925771033c35b68fd984ad93aa5d932e37d1ea21
5
5
  SHA512:
6
- metadata.gz: 7210e01bab67effdb53376fcd6d4de7ecdf17c61ef505db439a8a08c5ab5e29960b48e0ef57000bbc68c9bd0feb8f123ad1fb397df25104414205a5720f21ef9
7
- data.tar.gz: 8325b5b97cd4e1edc9d4e2eae1537e1aa7f02d48d73ad8cda502df4075efe33ffa86ba1ed625c4e45b7cdd748606ac5ed32e509c54be128f310fc8d7b4ca978f
6
+ metadata.gz: 51880f7c27744b9700e01bca80a6f3a9a436d76a843921fdc97a401e12ef0acb2011c4f67a19034c30ea90e59638de0b66cb8a949b32d2d896e28a4f7957c4ba
7
+ data.tar.gz: 780d1cd07415046d2c5dfda3b6f548785ae0e244bdb294876440521f90475562000beaf493c88109d609e9c65fa83b94ad67c8509084174a84e52e6be9268543
data/CHANGELOG.md CHANGED
@@ -8,6 +8,13 @@ and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [6.1.0] - 2026-07-10
12
+
13
+ ### Added
14
+
15
+ - `SecID::Base#deconstruct_keys` — every identifier now destructures in `case/in` patterns, exposing the same parsed fields `#to_h` reports under `:components`. The `keys` argument is ignored, no new keys are introduced, and validity is not part of the protocol: `SecID.parse` returning `nil` remains the validity guard, and an instance built from unparseable input binds `nil` for each component. No `deconstruct` (array-pattern) method is defined
16
+ - `SecID::Base.type_key` — the memoized class-level registry symbol for an identifier type, joining the existing `short_name` / `full_name` / `id_length` metadata surface. It round-trips through the registry: `SecID[SecID::ISIN.type_key] == SecID::ISIN`. It is now the single authority for that symbol; the registry, `#to_h`, `SecID.explain`, the ActiveModel validator, the detector, and the scanner all read it instead of each deriving it from the class name
17
+
11
18
  ## [6.0.0] - 2026-07-08
12
19
 
13
20
  ### Added
data/README.md CHANGED
@@ -11,6 +11,7 @@
11
11
  - [Text Scanning](#text-scanning) - find identifiers in freeform text
12
12
  - [Debugging Detection](#debugging-detection) - understand why strings match or don't
13
13
  - [Structured Validation](#structured-validation) - detailed error codes and messages
14
+ - [Pattern Matching](#pattern-matching) - destructure identifiers with `case/in`
14
15
  - [Generating Test Fixtures](#generating-test-fixtures) - produce valid identifiers for tests
15
16
  - [ISIN](#isin) - International Securities Identification Number
16
17
  - [CUSIP](#cusip) - Committee on Uniform Securities Identification Procedures
@@ -75,6 +76,7 @@ All identifier classes provide `valid?`, `errors`, `validate`, `validate!` metho
75
76
  **All identifiers** support hash serialization:
76
77
  - `#to_h` - returns a hash with `:type`, `:full_id`, `:normalized`, `:valid`, and `:components` keys
77
78
  - `#as_json` - same as `#to_h`, for JSON serialization compatibility (Rails, `JSON.generate`, etc.)
79
+ - `#deconstruct_keys` - exposes `:components` for [pattern matching](#pattern-matching) with `case/in`
78
80
 
79
81
  ```ruby
80
82
  SecID::ISIN.new('US5949181045').to_h
@@ -119,6 +121,7 @@ SecID.identifiers # => [SecID::ISIN, SecID::CUSIP, ...]
119
121
  SecID.identifiers.map(&:short_name) # => ["ISIN", "CUSIP", "SEDOL", ...]
120
122
 
121
123
  # Query metadata
124
+ SecID::ISIN.type_key # => :isin (SecID[SecID::ISIN.type_key] == SecID::ISIN)
122
125
  SecID::ISIN.short_name # => "ISIN"
123
126
  SecID::ISIN.full_name # => "International Securities Identification Number"
124
127
  SecID::ISIN.id_length # => 12
@@ -261,6 +264,48 @@ SecID::FIGI.new('BSG000BLNNH6').validate!
261
264
  isin = SecID::ISIN.validate!('US5949181045') # => #<SecID::ISIN>
262
265
  ```
263
266
 
267
+ ### Pattern Matching
268
+
269
+ Every identifier destructures in `case/in` via its parsed components. Since `SecID.parse` returns `nil` for
270
+ anything invalid, an `in nil` branch is a complete validity guard:
271
+
272
+ ```ruby
273
+ case SecID.parse(input)
274
+ in SecID::ISIN[country_code: 'US', nsin:] then "US security #{nsin}"
275
+ in SecID::ISIN[country_code:] then "foreign security from #{country_code}"
276
+ in SecID::OCC[underlying:, type:, strike_mills:] then "#{type} option on #{underlying}"
277
+ in nil then 'not a valid identifier'
278
+ end
279
+ ```
280
+
281
+ `SecID::Match` is a `Data`, so scan results destructure one layer up and nest into the identifier they wrap:
282
+
283
+ ```ruby
284
+ SecID.extract('AAPL ISIN US0378331005').each do |match|
285
+ case match
286
+ in { type: :isin, identifier: SecID::ISIN[country_code: 'US'] } then puts 'US ISIN found'
287
+ else next
288
+ end
289
+ end
290
+ ```
291
+
292
+ The keys are exactly the `:components` of `#to_h`, and destructuring never consults
293
+ `valid?`. An instance built directly from unparseable input therefore binds `nil` for every component, the same shape
294
+ `to_h` reports:
295
+
296
+ ```ruby
297
+ SecID::ISIN.new('GARBAGE') => { country_code:, nsin: }
298
+ country_code # => nil
299
+ nsin # => nil
300
+ ```
301
+
302
+ CIK, WKN, and Valoren have no sub-fields: they match a bare `in SecID::CIK` but no keyed pattern. Array patterns
303
+ (`deconstruct`) are not supported.
304
+
305
+ Watch the `:type` key: on a `SecID::Match` it is the registry symbol (`:occ`), while on an `OCC` instance it is the
306
+ OSI option type (`'C'` or `'P'`), so `in SecID::OCC[type: :occ]` never matches. Other types have no `:type` component
307
+ at all — `#to_h`'s envelope keys (`:type`, `:full_id`, `:normalized`, `:valid`) are not part of the pattern surface.
308
+
264
309
  ### Generating Test Fixtures
265
310
 
266
311
  Generate syntactically valid identifiers — with correct check digits where applicable — for use as test fixtures. Available per class and via the central dispatcher:
@@ -93,7 +93,7 @@ class SecIdValidator < ActiveModel::EachValidator
93
93
  #
94
94
  # @return [Array<Symbol>]
95
95
  def candidate_types
96
- configured_types || SecID.identifiers.map { |klass| klass.short_name.downcase.to_sym }
96
+ configured_types || SecID.identifiers.map(&:type_key)
97
97
  end
98
98
 
99
99
  # sec_id's specific failure reason, only when `details: true` and a single `type:` is set;
data/lib/sec_id/base.rb CHANGED
@@ -50,6 +50,22 @@ module SecID
50
50
  attr_reader :identifier
51
51
 
52
52
  class << self
53
+ # The type's registry symbol: `SecID[SecID::ISIN.type_key] == SecID::ISIN`.
54
+ #
55
+ # @return [Symbol] the registry key (e.g. :isin, :cusip)
56
+ def type_key = @type_key ||= short_name.downcase.to_sym
57
+
58
+ # Composite sort key ranking detection specificity: check-digit types first,
59
+ # then narrower length range, then registration order. A class the registry
60
+ # never saw sorts last.
61
+ #
62
+ # @api private
63
+ # @return [Array] frozen [check-digit rank, length specificity, registration order]
64
+ def detection_priority
65
+ @detection_priority ||=
66
+ [has_check_digit? ? 0 : 1, length_specificity, SecID.identifiers.index(self) || Float::INFINITY].freeze
67
+ end
68
+
53
69
  # @return [String] the unqualified class name (e.g. "ISIN", "CUSIP")
54
70
  def short_name = @short_name ||= name.split('::').last
55
71
 
@@ -114,7 +130,7 @@ module SecID
114
130
  # @return [Hash] hash with :type, :full_id, :normalized, :valid, and :components keys
115
131
  def to_h
116
132
  {
117
- type: self.class.short_name.downcase.to_sym,
133
+ type: self.class.type_key,
118
134
  full_id: full_id,
119
135
  normalized: valid? ? normalized : nil,
120
136
  valid: valid?,
@@ -129,6 +145,20 @@ module SecID
129
145
  to_h
130
146
  end
131
147
 
148
+ # Exposes the parsed components for `case/in` destructuring. Validity is not part of
149
+ # the protocol: components of unparseable input are `nil`, and `SecID.parse` returning
150
+ # `nil` is the validity guard.
151
+ #
152
+ # @param _keys [Array<Symbol>, nil] the keys the pattern requests; ignored
153
+ # @return [Hash] the parsed components
154
+ #
155
+ # @example
156
+ # case SecID.parse('US5949181045')
157
+ # in SecID::ISIN[country_code:, nsin:] then [country_code, nsin]
158
+ # in nil then :invalid
159
+ # end #=> ['US', '594918104']
160
+ def deconstruct_keys(_keys) = components
161
+
132
162
  protected
133
163
 
134
164
  # @return [String]
@@ -58,7 +58,7 @@ module SecID
58
58
 
59
59
  candidates = filter_candidates(input.upcase)
60
60
  matches = candidates.filter_map { |klass| (i = klass.new(input)).valid? ? i : nil }
61
- matches.min_by { |instance| @priority_for[instance.class] }
61
+ matches.min_by { |instance| instance.class.detection_priority }
62
62
  end
63
63
 
64
64
  private
@@ -81,16 +81,14 @@ module SecID
81
81
  # @return [Array<Symbol>]
82
82
  def validate_and_sort(input, candidates)
83
83
  matches = candidates.select { |klass| klass.valid?(input) }
84
- matches.sort_by! { |klass| @priority_for[klass] }
85
- matches.map! { |klass| @key_for[klass] }
84
+ matches.sort_by!(&:detection_priority)
85
+ matches.map!(&:type_key)
86
86
  end
87
87
 
88
88
  # @return [void]
89
89
  def precompute
90
90
  build_discriminator_sets
91
91
  build_length_table
92
- build_priority_table
93
- build_key_table
94
92
  end
95
93
 
96
94
  # Classifies types by which special characters their VALID_CHARS_REGEX accepts.
@@ -114,27 +112,6 @@ module SecID
114
112
  @candidates_by_length.each_value(&:freeze)
115
113
  end
116
114
 
117
- # Builds composite sort keys: check-digit types first, then smaller range, then load order.
118
- #
119
- # @return [void]
120
- def build_priority_table
121
- @priority_for = {}
122
- @classes.each_with_index do |klass, index|
123
- check_digit_rank = klass.has_check_digit? ? 0 : 1
124
- @priority_for[klass] = [check_digit_rank, klass.length_specificity, index].freeze
125
- end
126
- @priority_for.freeze
127
- end
128
-
129
- # Maps each class to its registry symbol key.
130
- #
131
- # @return [void]
132
- def build_key_table
133
- @key_for = {}
134
- @classes.each { |klass| @key_for[klass] = klass.short_name.downcase.to_sym }
135
- @key_for.freeze
136
- end
137
-
138
115
  # Stage 1: route strings with special characters to the only types that accept them.
139
116
  # Returns nil if no special chars found (fall through to stage 2).
140
117
  #
@@ -51,12 +51,24 @@ module SecID
51
51
  private
52
52
 
53
53
  # @return [void]
54
- def precompute # rubocop:disable Metrics/AbcSize
55
- build_key_table
56
- build_priority_table
54
+ def precompute
55
+ build_candidate_partitions
56
+ build_length_table
57
+ end
58
+
59
+ # Splits the registered classes by which CANDIDATE_RE group routes to them.
60
+ #
61
+ # @return [void]
62
+ def build_candidate_partitions
57
63
  @fisn_classes = @classes.select { |k| k.short_name == 'FISN' }
58
64
  @occ_classes = @classes.select { |k| k.short_name == 'OCC' }
59
65
  @simple_classes = @classes - @fisn_classes - @occ_classes
66
+ end
67
+
68
+ # Builds a Hash mapping each possible length to the classes that accept it.
69
+ #
70
+ # @return [void]
71
+ def build_length_table
60
72
  @candidates_by_length = Hash.new { |h, k| h[k] = [] }
61
73
  @classes.each do |klass|
62
74
  klass.length_values.each { |len| @candidates_by_length[len] << klass }
@@ -64,23 +76,6 @@ module SecID
64
76
  @candidates_by_length.each_value(&:freeze)
65
77
  end
66
78
 
67
- # @return [void]
68
- def build_key_table
69
- @key_for = {}
70
- @classes.each { |klass| @key_for[klass] = klass.short_name.downcase.to_sym }
71
- @key_for.freeze
72
- end
73
-
74
- # @return [void]
75
- def build_priority_table
76
- @priority_for = {}
77
- @classes.each_with_index do |klass, index|
78
- check_digit_rank = klass.has_check_digit? ? 0 : 1
79
- @priority_for[klass] = [check_digit_rank, klass.length_specificity, index].freeze
80
- end
81
- @priority_for.freeze
82
- end
83
-
84
79
  # @param input [String]
85
80
  # @param target_classes [Array<Class>]
86
81
  # @return [void]
@@ -123,7 +118,7 @@ module SecID
123
118
  return unless best
124
119
 
125
120
  end_pos = start_pos + raw.length
126
- Match.new(type: @key_for[best], raw: raw, range: start_pos...end_pos, identifier: best.new(cleaned))
121
+ Match.new(type: best.type_key, raw: raw, range: start_pos...end_pos, identifier: best.new(cleaned))
127
122
  end
128
123
 
129
124
  # @return [Class, nil]
@@ -134,7 +129,7 @@ module SecID
134
129
  return if candidates.empty?
135
130
 
136
131
  validated = candidates.select { |k| cleaned.match?(k::VALID_CHARS_REGEX) && k.valid?(cleaned) }
137
- validated.min_by { |k| @priority_for[k] }
132
+ validated.min_by(&:detection_priority)
138
133
  end
139
134
  end
140
135
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SecID
4
4
  # Current gem version.
5
- VERSION = '6.0.0'
5
+ VERSION = '6.1.0'
6
6
  end
data/lib/sec_id.rb CHANGED
@@ -85,7 +85,7 @@ module SecID
85
85
  # @return [Hash] hash with :input and :candidates keys
86
86
  def explain(str, types: nil)
87
87
  input = str.to_s.strip
88
- target_keys = types || identifier_list.map { |k| k.short_name.downcase.to_sym }
88
+ target_keys = types || identifier_list.map(&:type_key)
89
89
  candidates = target_keys.map do |key|
90
90
  instance = self[key].new(input)
91
91
  { type: key, valid: instance.valid?, errors: instance.errors.details }
@@ -143,8 +143,7 @@ module SecID
143
143
 
144
144
  # @return [void]
145
145
  def register_identifier(klass)
146
- key = klass.name.split('::').last.downcase.to_sym
147
- identifier_map[key] = klass
146
+ identifier_map[klass.type_key] = klass
148
147
  identifier_list << klass
149
148
  @detector = nil
150
149
  @scanner = nil
data/sig/sec_id/base.rbs CHANGED
@@ -33,6 +33,9 @@ module SecID
33
33
  # RBS forbids narrowing an inherited ivar's type.
34
34
  def identifier: () -> String?
35
35
 
36
+ def self.type_key: () -> Symbol
37
+ # `@api private` in YARD, but public here: Detector and Scanner call it from outside.
38
+ def self.detection_priority: () -> Array[Integer | Float | nil]
36
39
  def self.short_name: () -> String
37
40
  def self.full_name: () -> String
38
41
  def self.id_length: () -> (Integer | ::Range[Integer] | ::Array[Integer])
@@ -49,6 +52,7 @@ module SecID
49
52
  def hash: () -> Integer
50
53
  def to_h: () -> Hash[Symbol, untyped]
51
54
  def as_json: (*untyped) -> Hash[Symbol, untyped]
55
+ def deconstruct_keys: (::Array[Symbol]? _keys) -> Hash[Symbol, untyped]
52
56
 
53
57
  # Check-digit surface. Only the types that `include Checkable` implement these
54
58
  # (they raise/NoMethodError otherwise); declared on Base so `Generatable#generate`
@@ -68,5 +72,7 @@ module SecID
68
72
  @identifier: untyped
69
73
  self.@short_name: String
70
74
  self.@has_check_digit: bool
75
+ self.@type_key: Symbol
76
+ self.@detection_priority: Array[Integer | Float | nil]
71
77
  end
72
78
  end
@@ -3,8 +3,6 @@ module SecID
3
3
  #
4
4
  # @api private
5
5
  class Detector
6
- type priority = Array[Integer | Float | nil]
7
-
8
6
  def initialize: (Array[singleton(Base)] identifier_list) -> void
9
7
  def call: (SecID::input str) -> Array[Symbol]
10
8
  def matches?: (SecID::input str) -> bool
@@ -17,8 +15,6 @@ module SecID
17
15
  def precompute: () -> void
18
16
  def build_discriminator_sets: () -> void
19
17
  def build_length_table: () -> void
20
- def build_priority_table: () -> void
21
- def build_key_table: () -> void
22
18
  def stage1_special_chars: (String upcased) -> Array[singleton(Base)]?
23
19
  def stage2_length: (Integer length) -> Array[singleton(Base)]
24
20
  def stage3_charset: (String upcased, Array[singleton(Base)] candidates) -> Array[singleton(Base)]
@@ -29,7 +25,5 @@ module SecID
29
25
  @space_only_types: Array[singleton(Base)]
30
26
  @special_types: Array[singleton(Base)]
31
27
  @candidates_by_length: Hash[Integer, Array[singleton(Base)]]
32
- @priority_for: Hash[singleton(Base), priority]
33
- @key_for: Hash[singleton(Base), Symbol]
34
28
  end
35
29
  end
@@ -28,8 +28,8 @@ module SecID
28
28
  private
29
29
 
30
30
  def precompute: () -> void
31
- def build_key_table: () -> void
32
- def build_priority_table: () -> void
31
+ def build_candidate_partitions: () -> void
32
+ def build_length_table: () -> void
33
33
  def scan_text: (String input, Array[singleton(Base)] target_classes) { (Match) -> void } -> void
34
34
  def identify_candidate: (untyped match_data, Array[singleton(Base)] target_classes) -> Match?
35
35
  def try_classes: (String raw, String cleaned, Integer start_pos, Array[singleton(Base)] classes) -> Match?
@@ -40,7 +40,5 @@ module SecID
40
40
  @occ_classes: Array[singleton(Base)]
41
41
  @simple_classes: Array[singleton(Base)]
42
42
  @candidates_by_length: Hash[Integer, Array[singleton(Base)]]
43
- @priority_for: Hash[singleton(Base), Array[Integer | Float | nil]]
44
- @key_for: Hash[singleton(Base), Symbol]
45
43
  end
46
44
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sec_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.0
4
+ version: 6.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leonid Svyatov