sec_id 5.2.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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +31 -0
  4. data/MIGRATION.md +101 -0
  5. data/README.md +245 -17
  6. data/lib/sec_id/active_model.rb +144 -0
  7. data/lib/sec_id/base.rb +64 -2
  8. data/lib/sec_id/bic/country_codes.rb +46 -0
  9. data/lib/sec_id/bic.rb +123 -0
  10. data/lib/sec_id/cei.rb +19 -7
  11. data/lib/sec_id/cfi/attribute_set.rb +49 -0
  12. data/lib/sec_id/cfi/classification.rb +107 -0
  13. data/lib/sec_id/cfi/field.rb +56 -0
  14. data/lib/sec_id/cfi/tables.rb +1033 -0
  15. data/lib/sec_id/cfi.rb +138 -208
  16. data/lib/sec_id/cik.rb +13 -0
  17. data/lib/sec_id/concerns/checkable.rb +2 -2
  18. data/lib/sec_id/concerns/generatable.rb +71 -0
  19. data/lib/sec_id/concerns/normalizable.rb +1 -0
  20. data/lib/sec_id/concerns/validatable.rb +14 -3
  21. data/lib/sec_id/cusip.rb +18 -7
  22. data/lib/sec_id/deep_freeze.rb +18 -0
  23. data/lib/sec_id/detector.rb +29 -31
  24. data/lib/sec_id/dti.rb +104 -0
  25. data/lib/sec_id/figi.rb +18 -0
  26. data/lib/sec_id/fisn.rb +31 -0
  27. data/lib/sec_id/iban/country_rules.rb +7 -6
  28. data/lib/sec_id/iban.rb +19 -1
  29. data/lib/sec_id/isin.rb +20 -15
  30. data/lib/sec_id/lei.rb +13 -0
  31. data/lib/sec_id/occ.rb +21 -0
  32. data/lib/sec_id/railtie.rb +14 -0
  33. data/lib/sec_id/scanner.rb +18 -27
  34. data/lib/sec_id/sedol.rb +16 -0
  35. data/lib/sec_id/valoren.rb +13 -0
  36. data/lib/sec_id/version.rb +2 -1
  37. data/lib/sec_id/wkn.rb +16 -0
  38. data/lib/sec_id.rb +31 -7
  39. data/sec_id.gemspec +11 -5
  40. data/sig/manifest.yaml +8 -0
  41. data/sig/sec_id/base.rbs +78 -0
  42. data/sig/sec_id/bic/country_codes.rbs +5 -0
  43. data/sig/sec_id/bic.rbs +30 -0
  44. data/sig/sec_id/cei.rbs +26 -0
  45. data/sig/sec_id/cfi/attribute_set.rbs +68 -0
  46. data/sig/sec_id/cfi/classification.rbs +23 -0
  47. data/sig/sec_id/cfi/field.rbs +337 -0
  48. data/sig/sec_id/cfi/tables.rbs +68 -0
  49. data/sig/sec_id/cfi.rbs +56 -0
  50. data/sig/sec_id/cik.rbs +19 -0
  51. data/sig/sec_id/concerns/checkable.rbs +41 -0
  52. data/sig/sec_id/concerns/generatable.rbs +26 -0
  53. data/sig/sec_id/concerns/normalizable.rbs +34 -0
  54. data/sig/sec_id/concerns/validatable.rbs +42 -0
  55. data/sig/sec_id/cusip.rbs +29 -0
  56. data/sig/sec_id/deep_freeze.rbs +6 -0
  57. data/sig/sec_id/detector.rbs +29 -0
  58. data/sig/sec_id/dti.rbs +27 -0
  59. data/sig/sec_id/errors.rbs +28 -0
  60. data/sig/sec_id/figi.rbs +31 -0
  61. data/sig/sec_id/fisn.rbs +24 -0
  62. data/sig/sec_id/iban/country_rules.rbs +10 -0
  63. data/sig/sec_id/iban.rbs +54 -0
  64. data/sig/sec_id/isin.rbs +40 -0
  65. data/sig/sec_id/lei.rbs +33 -0
  66. data/sig/sec_id/occ.rbs +45 -0
  67. data/sig/sec_id/scanner.rbs +44 -0
  68. data/sig/sec_id/sedol.rbs +34 -0
  69. data/sig/sec_id/valoren.rbs +29 -0
  70. data/sig/sec_id/version.rbs +3 -0
  71. data/sig/sec_id/wkn.rbs +15 -0
  72. data/sig/sec_id.rbs +58 -0
  73. metadata +55 -7
  74. data/lib/sec_id/concerns/identifier_metadata.rb +0 -56
@@ -17,9 +17,13 @@ module SecID
17
17
  # @example Normalize a Valoren to 9 digits
18
18
  # SecID::Valoren.normalize('3886335') #=> '003886335'
19
19
  class Valoren < Base
20
+ # Human-readable name of the standard.
20
21
  FULL_NAME = 'Valoren Number'
22
+ # Valid length(s) of a normalized identifier.
21
23
  ID_LENGTH = (5..9)
24
+ # A representative valid identifier.
22
25
  EXAMPLE = '3886335'
26
+ # Pattern matching the identifier's permitted character set.
23
27
  VALID_CHARS_REGEX = /\A[0-9]+\z/
24
28
 
25
29
  # Regular expression for parsing Valoren components.
@@ -77,5 +81,14 @@ module SecID
77
81
  def to_s
78
82
  full_id
79
83
  end
84
+
85
+ # Generates a random Valoren: an integer rendered without leading zeros.
86
+ #
87
+ # @param random [Random] source of randomness
88
+ # @return [String] a 5-9 digit Valoren
89
+ def self.generate_body(random)
90
+ random.rand(10_000..999_999_999).to_s
91
+ end
92
+ private_class_method :generate_body
80
93
  end
81
94
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecID
4
- VERSION = '5.2.0'
4
+ # Current gem version.
5
+ VERSION = '6.1.0'
5
6
  end
data/lib/sec_id/wkn.rb CHANGED
@@ -13,9 +13,13 @@ module SecID
13
13
  # SecID::WKN.valid?('514000') #=> true
14
14
  # SecID::WKN.valid?('CBK100') #=> true
15
15
  class WKN < Base
16
+ # Human-readable name of the standard.
16
17
  FULL_NAME = 'Wertpapierkennnummer'
18
+ # Valid length(s) of a normalized identifier.
17
19
  ID_LENGTH = 6
20
+ # A representative valid identifier.
18
21
  EXAMPLE = '514000'
22
+ # Pattern matching the identifier's permitted character set.
19
23
  VALID_CHARS_REGEX = /\A[0-9A-HJ-NP-Z]+\z/
20
24
 
21
25
  # Regular expression for parsing WKN components.
@@ -24,6 +28,9 @@ module SecID
24
28
  (?<identifier>[0-9A-HJ-NP-Z]{6})
25
29
  \z/x
26
30
 
31
+ # Characters valid in a WKN (alphanumeric excluding I and O).
32
+ GENERATE_CHARSET = ALPHANUMERIC.grep(VALID_CHARS_REGEX).freeze
33
+
27
34
  # @param wkn [String] the WKN string to parse
28
35
  def initialize(wkn)
29
36
  wkn_parts = parse(wkn)
@@ -40,5 +47,14 @@ module SecID
40
47
 
41
48
  ISIN.new("#{country_code}000#{identifier}").restore!
42
49
  end
50
+
51
+ # Generates a random WKN: 6 characters excluding I and O.
52
+ #
53
+ # @param random [Random] source of randomness
54
+ # @return [String] a 6-character WKN
55
+ def self.generate_body(random)
56
+ random_string(GENERATE_CHARSET, 6, random: random)
57
+ end
58
+ private_class_method :generate_body
43
59
  end
44
60
  end
data/lib/sec_id.rb CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  require 'sec_id/version'
4
4
 
5
+ # Toolkit for securities identifiers — validate, normalize, parse, detect, convert,
6
+ # generate, and classify ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN, CIK, OCC, WKN,
7
+ # Valoren, CFI, FISN, BIC, and DTI.
8
+ #
9
+ # @see https://github.com/svyatov/sec_id
5
10
  module SecID
6
11
  # Base error class for all SecID errors.
7
12
  class Error < StandardError; end
@@ -52,7 +57,7 @@ module SecID
52
57
  # @return [Boolean]
53
58
  # @raise [ArgumentError] if any key in types is unknown
54
59
  def valid?(str, types: nil)
55
- return detect(str).any? if types.nil?
60
+ return detector.matches?(str) if types.nil?
56
61
 
57
62
  types.any? { |key| self[key].valid?(str) }
58
63
  end
@@ -80,7 +85,7 @@ module SecID
80
85
  # @return [Hash] hash with :input and :candidates keys
81
86
  def explain(str, types: nil)
82
87
  input = str.to_s.strip
83
- target_keys = types || identifier_list.map { |k| k.short_name.downcase.to_sym }
88
+ target_keys = types || identifier_list.map(&:type_key)
84
89
  candidates = target_keys.map do |key|
85
90
  instance = self[key].new(input)
86
91
  { type: key, valid: instance.valid?, errors: instance.errors.details }
@@ -120,20 +125,32 @@ module SecID
120
125
  result || raise(InvalidFormatError, parse_error_message(str, types))
121
126
  end
122
127
 
128
+ # Generates a new, format-valid identifier instance of the given type.
129
+ #
130
+ # @note Generated identifiers are valid in format only — they are not real,
131
+ # registered securities.
132
+ #
133
+ # @param key [Symbol] identifier type (e.g. :isin, :cusip)
134
+ # @param random [Random] seedable source for reproducible output
135
+ # @return [SecID::Base] a generated, format-valid instance of the type mapped to `key`
136
+ # (e.g. {SecID::ISIN} for `:isin`)
137
+ # @raise [ArgumentError] if key is unknown
138
+ def generate(key, random: Random.new)
139
+ self[key].generate(random: random)
140
+ end
141
+
123
142
  private
124
143
 
125
144
  # @return [void]
126
145
  def register_identifier(klass)
127
- key = klass.name.split('::').last.downcase.to_sym
128
- identifier_map[key] = klass
146
+ identifier_map[klass.type_key] = klass
129
147
  identifier_list << klass
130
148
  @detector = nil
131
149
  @scanner = nil
132
150
  end
133
151
 
134
152
  def parse_any(str)
135
- key = detect(str).first
136
- key && self[key].new(str)
153
+ detector.first_match(str)
137
154
  end
138
155
 
139
156
  def parse_from(str, types)
@@ -178,11 +195,12 @@ module SecID
178
195
  end
179
196
  end
180
197
 
198
+ require 'sec_id/deep_freeze'
181
199
  require 'sec_id/errors'
182
- require 'sec_id/concerns/identifier_metadata'
183
200
  require 'sec_id/concerns/normalizable'
184
201
  require 'sec_id/concerns/validatable'
185
202
  require 'sec_id/concerns/checkable'
203
+ require 'sec_id/concerns/generatable'
186
204
  require 'sec_id/base'
187
205
  require 'sec_id/detector'
188
206
  require 'sec_id/scanner'
@@ -199,3 +217,9 @@ require 'sec_id/valoren'
199
217
  require 'sec_id/cei'
200
218
  require 'sec_id/cfi'
201
219
  require 'sec_id/fisn'
220
+ require 'sec_id/bic'
221
+ require 'sec_id/dti'
222
+
223
+ # Auto-activate the ActiveModel validator inside Rails; a no-op everywhere else (keeps the
224
+ # default require zero-dependency). See SecID::Railtie and lib/sec_id/active_model.rb.
225
+ require 'sec_id/railtie' if defined?(Rails::Railtie)
data/sec_id.gemspec CHANGED
@@ -10,19 +10,25 @@ Gem::Specification.new do |spec|
10
10
  spec.authors = ['Leonid Svyatov']
11
11
  spec.email = ['leonid@svyatov.ru']
12
12
 
13
- spec.summary = 'A Ruby toolkit for securities identifiers — validate, parse, normalize, detect, and convert.'
14
- spec.description = 'Validate, normalize, parse, and convert securities identifiers. Auto-detect identifier ' \
15
- 'type from any string. Calculate and restore check digits. Supports ISIN, CUSIP, CEI, ' \
16
- 'SEDOL, FIGI, LEI, IBAN, CIK, OCC, WKN, Valoren, CFI, and FISN.'
13
+ spec.summary = 'A Ruby toolkit for securities identifiers — validate, parse, normalize, detect, convert, ' \
14
+ 'generate, and classify.'
15
+ spec.description = 'Validate, normalize, parse, convert, generate, and classify securities identifiers. ' \
16
+ 'Auto-detect identifier type from any string. Calculate and restore check digits. Generate ' \
17
+ 'format-valid identifiers as test fixtures. Decode CFI codes to full ISO 10962:2021 ' \
18
+ 'classifications. Includes an opt-in ActiveModel/Rails validator that adds no runtime ' \
19
+ 'dependency. Supports ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN, CIK, OCC, WKN, Valoren, ' \
20
+ 'CFI, FISN, BIC, and DTI.'
17
21
  spec.homepage = 'https://github.com/svyatov/sec_id'
18
22
  spec.license = 'MIT'
19
23
 
20
24
  spec.required_ruby_version = '>= 3.2.0'
21
25
 
22
26
  spec.require_paths = ['lib']
23
- spec.files = Dir['lib/**/*.rb'] + %w[CHANGELOG.md LICENSE.txt MIGRATION.md README.md sec_id.gemspec]
27
+ spec.files = Dir['lib/**/*.rb'] + Dir['sig/**/*'] +
28
+ %w[.yardopts CHANGELOG.md LICENSE.txt MIGRATION.md README.md sec_id.gemspec]
24
29
 
25
30
  spec.metadata['rubygems_mfa_required'] = 'true'
31
+ spec.metadata['documentation_uri'] = 'https://rubydoc.info/gems/sec_id'
26
32
  spec.metadata['source_code_uri'] = 'https://github.com/svyatov/sec_id'
27
33
  spec.metadata['changelog_uri'] = 'https://github.com/svyatov/sec_id/blob/main/CHANGELOG.md'
28
34
  spec.metadata['bug_tracker_uri'] = 'https://github.com/svyatov/sec_id/issues'
data/sig/manifest.yaml ADDED
@@ -0,0 +1,8 @@
1
+ # Standard-library signatures these RBS files reference. Consumers' RBS/Steep
2
+ # load these automatically when they depend on sec_id. (The gem has no runtime
3
+ # gem dependencies, so none are auto-derived from the gemspec.)
4
+ #
5
+ # Set (ISIN, SEDOL, FIGI, Valoren, BIC) is a core class in rbs 4.0 — always
6
+ # loaded, so it needs no manifest entry.
7
+ dependencies:
8
+ - name: date # Date in OCC
@@ -0,0 +1,78 @@
1
+ module SecID
2
+ # Accepted raw input for parsing/validating an identifier: a String, an Integer
3
+ # (Valoren/CIK/CUSIP accept numeric input), or nil.
4
+ type input = String | Integer | nil
5
+
6
+ # The class-object surface the concern ClassMethods modules call through `self`
7
+ # once extended onto an identifier class (they construct instances via `new`).
8
+ interface _IdentifierClass
9
+ def new: (SecID::input) -> Base
10
+ end
11
+
12
+ # Base class for securities identifiers, providing a common interface for
13
+ # validation, normalization, parsing, and generation.
14
+ class Base
15
+ include Normalizable
16
+ include Validatable
17
+ include Generatable
18
+ extend Normalizable::ClassMethods
19
+ extend Validatable::ClassMethods
20
+ extend Generatable::ClassMethods
21
+
22
+ # Metadata constants each subclass overrides with its concrete shape. Declared
23
+ # here (as the widest shape) so the shared concern method bodies type-check.
24
+ FULL_NAME: String
25
+ ID_LENGTH: Integer | ::Range[Integer] | ::Array[Integer]
26
+ EXAMPLE: String
27
+ VALID_CHARS_REGEX: ::Regexp
28
+ ID_REGEX: ::Regexp
29
+
30
+ attr_reader full_id: String
31
+ # Reader is nilable (nil for invalid input, checked by `valid_format?`); the ivar is
32
+ # typed `untyped` because subclasses use `@identifier` as non-nil after validation and
33
+ # RBS forbids narrowing an inherited ivar's type.
34
+ def identifier: () -> String?
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]
39
+ def self.short_name: () -> String
40
+ def self.full_name: () -> String
41
+ def self.id_length: () -> (Integer | ::Range[Integer] | ::Array[Integer])
42
+ def self.length_values: () -> (::Array[Integer] | ::Range[Integer])
43
+ def self.length_specificity: () -> (Integer | Float | nil)
44
+ def self.example: () -> String
45
+ def self.has_check_digit?: () -> bool
46
+ def self.inherited: (singleton(Base) subclass) -> void
47
+
48
+ def initialize: (SecID::input _sec_id_number) -> void
49
+
50
+ def ==: (Base other) -> bool
51
+ alias eql? ==
52
+ def hash: () -> Integer
53
+ def to_h: () -> Hash[Symbol, untyped]
54
+ def as_json: (*untyped) -> Hash[Symbol, untyped]
55
+ def deconstruct_keys: (::Array[Symbol]? _keys) -> Hash[Symbol, untyped]
56
+
57
+ # Check-digit surface. Only the types that `include Checkable` implement these
58
+ # (they raise/NoMethodError otherwise); declared on Base so `Generatable#generate`
59
+ # and `Checkable::ClassMethods` type-check without narrowing on `has_check_digit?`.
60
+ def restore: () -> String
61
+ def restore!: () -> self
62
+ def calculate_check_digit: () -> (Integer | String)
63
+
64
+ def comparison_id: () -> String
65
+
66
+ private
67
+
68
+ def components: () -> Hash[Symbol, untyped]
69
+ def parse: (SecID::input sec_id_number) -> (::MatchData | ::Hash[Symbol, untyped])
70
+
71
+ @full_id: String
72
+ @identifier: untyped
73
+ self.@short_name: String
74
+ self.@has_check_digit: bool
75
+ self.@type_key: Symbol
76
+ self.@detection_priority: Array[Integer | Float | nil]
77
+ end
78
+ end
@@ -0,0 +1,5 @@
1
+ module SecID
2
+ class BIC < Base
3
+ COUNTRY_CODES: Set[String]
4
+ end
5
+ end
@@ -0,0 +1,30 @@
1
+ module SecID
2
+ # Business Identifier Code (BIC / SWIFT code).
3
+ class BIC < Base
4
+ FULL_NAME: String
5
+ ID_LENGTH: Array[Integer]
6
+ EXAMPLE: String
7
+ VALID_CHARS_REGEX: ::Regexp
8
+ ID_REGEX: ::Regexp
9
+
10
+ def self.countries: () -> Array[String]
11
+
12
+ attr_reader bank_code: String?
13
+ attr_reader country_code: String?
14
+ attr_reader location_code: String?
15
+ attr_reader branch_code: String?
16
+
17
+ def initialize: (SecID::input bic) -> void
18
+ private def self.generate_body: (Random random) -> String
19
+
20
+ private
21
+
22
+ def components: () -> Hash[Symbol, untyped]
23
+ def valid_format?: () -> bool
24
+ def detect_errors: () -> Array[Symbol]
25
+ def validation_message: (Symbol code) -> String?
26
+ def recognized_country?: () -> bool
27
+
28
+ self.@countries: Array[String]
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ module SecID
2
+ # CUSIP Entity Identifier (CEI).
3
+ class CEI < Base
4
+ include Checkable
5
+ extend Checkable::ClassMethods
6
+
7
+ FULL_NAME: String
8
+ ID_LENGTH: Integer
9
+ EXAMPLE: String
10
+ VALID_CHARS_REGEX: ::Regexp
11
+ ID_REGEX: ::Regexp
12
+
13
+ attr_reader prefix: String?
14
+ attr_reader numeric: String?
15
+ attr_reader entity_id: String?
16
+
17
+ def initialize: (SecID::input cei) -> void
18
+ def calculate_check_digit: () -> Integer
19
+ def self.check_digit: (SecID::input id) -> Integer
20
+ private def self.generate_body: (Random random) -> String
21
+
22
+ private
23
+
24
+ def components: () -> Hash[Symbol, untyped]
25
+ end
26
+ end
@@ -0,0 +1,68 @@
1
+ # Generated by `rake sig:cfi` from SecID::CFI::Tables. Do not edit by hand.
2
+ module SecID
3
+ class CFI < Base
4
+ # The ordered collection of decoded attribute Fields.
5
+ class AttributeSet
6
+ include Enumerable[Field]
7
+
8
+ # `fields` is typed `untyped` because `#initialize` calls
9
+ # `define_singleton_method(field.meaning)` and `field.meaning` is nilable.
10
+ def initialize: (untyped fields) -> void
11
+ def each: () { (Field) -> void } -> self
12
+ | () -> Enumerator[Field, self]
13
+ def []: (Symbol meaning) -> Field?
14
+ def empty?: () -> bool
15
+ def to_h: () -> Hash[untyped, untyped]
16
+ def as_json: (*untyped) -> Hash[Symbol, untyped]
17
+
18
+ # Per-meaning readers — the union of every attribute meaning. Each instance
19
+ # only answers the meanings actually present; an absent one raises
20
+ # NoMethodError at runtime. Use #[] for the presence-uncertain path.
21
+ def asset_class: () -> Field
22
+ def assets: () -> Field
23
+ def barrier_dependency: () -> Field
24
+ def calculation_frequency: () -> Field
25
+ def call_put: () -> Field
26
+ def closed_open: () -> Field
27
+ def components: () -> Field
28
+ def composition: () -> Field
29
+ def currency: () -> Field
30
+ def delivery: () -> Field
31
+ def distribution: () -> Field
32
+ def distribution_policy: () -> Field
33
+ def equity_type: () -> Field
34
+ def exercise_style: () -> Field
35
+ def form: () -> Field
36
+ def further_grouping: () -> Field
37
+ def guarantee: () -> Field
38
+ def income: () -> Field
39
+ def interest_rate_type: () -> Field
40
+ def interest_type: () -> Field
41
+ def investment_strategy: () -> Field
42
+ def issuer_type: () -> Field
43
+ def long_short: () -> Field
44
+ def notional: () -> Field
45
+ def option_style: () -> Field
46
+ def ownership_restrictions: () -> Field
47
+ def payment_status: () -> Field
48
+ def redemption: () -> Field
49
+ def repayment: () -> Field
50
+ def return_trigger: () -> Field
51
+ def return_type: () -> Field
52
+ def security_type: () -> Field
53
+ def standardized: () -> Field
54
+ def strategy_style: () -> Field
55
+ def termination: () -> Field
56
+ def type: () -> Field
57
+ def underlying: () -> Field
58
+ def underlying_assets: () -> Field
59
+ def underlying_fund_type: () -> Field
60
+ def valuation: () -> Field
61
+ def voting_right: () -> Field
62
+ def warrant_type: () -> Field
63
+ def weighting: () -> Field
64
+
65
+ @fields: Array[Field]
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,23 @@
1
+ module SecID
2
+ class CFI < Base
3
+ # The decoded ISO 10962:2021 classification of a valid CFI, built by {CFI#decode}.
4
+ class Classification
5
+ attr_reader category: Field
6
+ attr_reader group: Field
7
+ attr_reader attributes: AttributeSet
8
+
9
+ def initialize: (String category_code, String group_code, Array[String] letters) -> void
10
+ def to_s: () -> String
11
+ def to_h: () -> Hash[Symbol, untyped]
12
+ def as_json: (*untyped) -> Hash[Symbol, untyped]
13
+
14
+ private
15
+
16
+ def build_category: (String code) -> Field
17
+ def build_group: (String category_code, String code) -> Field
18
+ def build_attributes: (String category_code, String group_code, Array[String] letters) -> AttributeSet
19
+ def build_attribute: (untyped position, String letter) -> Field
20
+ def decode_value: (untyped value_map, String letter) -> [Symbol, String]
21
+ end
22
+ end
23
+ end