minting 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'parse/separator_parser'
4
+
3
5
  module Mint
4
6
  # :nodoc:
5
7
  class Money
@@ -8,7 +10,15 @@ module Mint
8
10
  # Returns +nil+ when the input is invalid or currency cannot be determined.
9
11
  #
10
12
  # @param input [String] Amount input, optionally including a currency symbol or code
11
- # @param currency [String, Symbol, Currency, nil] ISO code when not present in +input+
13
+ # @param positional_currency [String, Currency, nil] deprecated positional
14
+ # fallback currency when none is present in +input+
15
+ # @param default_currency [String, Currency, nil] fallback currency when none is present in +input+
16
+ # An embedded currency code or symbol takes precedence over this argument.
17
+ # The positional form is deprecated; use +default_currency:+ instead.
18
+ # @param decimal [String, nil] decimal separator used by the input source.
19
+ # When omitted, it is inferred from +thousand:+ or separator positions.
20
+ # @param thousand [String, nil] thousands separator used by the input source.
21
+ # When omitted, it is inferred from +decimal:+ or separator positions.
12
22
  # @return [Money, nil]
13
23
  #
14
24
  # @example With explicit currency
@@ -18,63 +28,95 @@ module Mint
18
28
  # @example With symbol or code in the string
19
29
  # Money.parse('$19.99') #=> [USD 19.99]
20
30
  # Money.parse('USD 1,234.56') #=> [USD 1234.56]
21
- def self.parse(input, currency = nil)
31
+ # Money.parse('123,456', 'EUR', decimal: ',') #=> [EUR 123.46]
32
+ # Money.parse('123,456', 'USD', thousand: ',') #=> [USD 123456.00]
33
+ # @deprecated Pass the fallback currency as +default_currency:+.
34
+ def self.parse(input, positional_currency = nil, default_currency: nil, decimal: nil, thousand: nil)
35
+ if positional_currency
36
+ warn 'DEPRECATION: Money.parse positional currency is deprecated; use default_currency: instead.', uplevel: 1
37
+ end
22
38
  return nil unless input.is_a?(String)
23
39
 
24
40
  input = input.strip
25
41
  return nil if input.empty?
26
42
 
43
+ currency = default_currency || positional_currency
27
44
  currency = parse_currency(input, currency)
28
45
  return nil unless currency
29
46
 
30
- amount = parse_amount(input)
47
+ amount = parse_amount(input, currency, decimal:, thousand:)
31
48
  return nil unless amount
32
49
 
33
50
  amount = currency.normalize_amount(amount)
34
- new(amount, currency)
51
+ amount.zero? ? currency.zero : new(amount, currency)
35
52
  end
36
53
 
37
54
  # Like {.parse} but raises on failure.
38
55
  #
39
56
  # @param input [String] Amount input, optionally including a currency symbol or code
40
- # @param currency [String, Symbol, Currency, nil] ISO code when not present in +input+
57
+ # @param positional_currency [String, Currency, nil] deprecated positional
58
+ # fallback currency when none is present in +input+
59
+ # @param default_currency [String, Currency, nil] fallback currency when none is present in +input+
60
+ # An embedded currency code or symbol takes precedence over this argument.
61
+ # The positional form is deprecated; use +default_currency:+ instead.
62
+ # @param decimal [String, nil] decimal separator used by the input source.
63
+ # When omitted, it is inferred from +thousand:+ or separator positions.
64
+ # @param thousand [String, nil] thousands separator used by the input source.
65
+ # When omitted, it is inferred from +decimal:+ or separator positions.
41
66
  # @return [Money]
42
67
  # @raise [ArgumentError] when +input+ is invalid or currency cannot be determined
43
68
  #
44
69
  # @example
45
70
  # Money.parse!('19.99', 'USD') #=> [USD 19.99]
46
71
  # Money.parse!('garbage', 'USD') #=> ArgumentError
47
- def self.parse!(input, currency = nil)
72
+ # @deprecated Pass the fallback currency as +default_currency:+.
73
+ def self.parse!(input, positional_currency = nil, default_currency: nil, decimal: nil, thousand: nil)
74
+ if positional_currency
75
+ warn 'DEPRECATION: Money.parse! positional currency is deprecated; use default_currency: instead.', uplevel: 1
76
+ end
48
77
  raise ArgumentError, 'input must be a String' unless input.is_a?(String)
49
78
 
50
79
  input = input.strip
51
80
  raise ArgumentError, 'input cannot be empty' if input.empty?
52
81
 
82
+ currency = default_currency || positional_currency
53
83
  currency = parse_currency(input, currency)
54
84
  raise ArgumentError, "Currency [#{currency}] not found" unless currency
55
85
 
56
- amount = parse_amount(input)
86
+ amount = parse_amount(input, currency, decimal:, thousand:)
57
87
  raise ArgumentError, "Could not parse [#{input}]" unless amount
58
88
 
59
89
  amount = currency.normalize_amount(amount)
60
- new(amount, currency)
90
+ amount.zero? ? currency.zero : new(amount, currency)
61
91
  end
62
92
 
63
93
  class << self
64
94
  private
65
95
 
66
- # Extracts a numeric value from input that should only contain an amount.
67
- def parse_amount(input)
96
+ # Extracts one valid numeric value, allowing surrounding currency markers
97
+ # and uppercase annotation words (for example, "MAX 10.00 USD").
98
+ def parse_amount(input, currency, decimal: nil, thousand: nil)
68
99
  accounting_negative = input.start_with?('(') && input.end_with?(')')
100
+ return nil if (input.include?('(') || input.include?(')')) && !accounting_negative
69
101
 
70
- numeric_input = input.gsub(/[^\d.,-]/, '')
71
- numeric = parse_separators(numeric_input)
102
+ numeric_input = accounting_negative ? input[1...-1] : input
103
+ numeric_input = remove_currency_markers(numeric_input, currency)
104
+ numeric_input = numeric_input.gsub(/\b[A-Z_]+\b/, ' ').delete('[]').strip
105
+ numeric_input.sub!(/\A([+-])\s+/, '\\1')
106
+ return nil unless numeric_input.match?(/\A[+-]?\d[\d.,]*\z/)
107
+
108
+ numeric = parse_separators(numeric_input, decimal:, thousand:)
72
109
  return nil unless numeric
73
110
 
74
111
  amount = Rational(numeric)
75
112
  accounting_negative ? -amount : amount
76
113
  end
77
114
 
115
+ def remove_currency_markers(input, currency)
116
+ markers = [currency.symbol, currency.disambiguate_symbol].compact.uniq
117
+ markers.reduce(input) { |result, marker| result.gsub(marker, ' ') }
118
+ end
119
+
78
120
  # Extracts currency from a string by matching ISO code or symbol.
79
121
  #
80
122
  # Scans all uppercase words and returns the first registered code, falling
@@ -92,9 +134,17 @@ module Mint
92
134
  Currency.resolve(currency)
93
135
  end
94
136
 
95
- # Converts locale-specific decimal/thousand separators into a plain decimal string.
96
- def parse_separators(numeric)
137
+ # Converts decimal/thousand separators into a plain decimal string.
138
+ # An explicit decimal separator resolves otherwise ambiguous values.
139
+ def parse_separators(numeric, decimal: nil, thousand: nil)
140
+ return SeparatorParser.parse(numeric, decimal, thousand) if decimal || thousand
141
+
142
+ parse_heuristic_separators(numeric)
143
+ end
144
+
145
+ def parse_heuristic_separators(numeric)
97
146
  return nil unless numeric.match?(/\d/)
147
+ return nil unless valid_numeric_syntax?(numeric)
98
148
 
99
149
  case classify_separators(numeric)
100
150
  when :decimal_period then numeric
@@ -122,6 +172,16 @@ module Mint
122
172
  else :thousands
123
173
  end
124
174
  end
175
+
176
+ def valid_numeric_syntax?(numeric)
177
+ unsigned = numeric.delete_prefix('-').delete_prefix('+')
178
+ unsigned.match?(/\A\d+\z/) ||
179
+ unsigned.match?(/\A\d+[.,]\d+\z/) ||
180
+ unsigned.match?(/\A\d+(?:,\d{3})+\.\d+\z/) ||
181
+ unsigned.match?(/\A\d+(?:\.\d{3})+,\d+\z/) ||
182
+ unsigned.match?(/\A\d+(?:,\d{3})+\z/) ||
183
+ unsigned.match?(/\A\d+(?:\.\d{3})+\z/)
184
+ end
125
185
  end
126
186
  end
127
187
  end
@@ -9,11 +9,10 @@ module Mint
9
9
  # Restores the previous mode (or default) when the block exits, even on
10
10
  # exception.
11
11
  #
12
- # Rounding-mode support is activated on first call. Once activated,
13
- # +Currency#normalize_amount+ dispatches through +Currency.rounding_mode+,
14
- # adding ~10–35&ns of overhead to every money creation or mutation.
15
- # When rounding modes are never used (the common case), the fast path
16
- # incurs zero overhead.
12
+ # Rounding-mode support is activated on first call. Before activation,
13
+ # +Currency#normalize_amount+ uses a direct +Rational#round+ fast path with
14
+ # no thread-local lookup. Once activated, normalization reads the
15
+ # thread-local mode so custom rounding remains isolated per thread.
17
16
  #
18
17
  # @param mode [Symbol] one of: +:up+, +:down+, +:even+
19
18
  # @yield block to execute with the rounding mode active
@@ -3,5 +3,5 @@
3
3
  # Root namespace for the Minting library.
4
4
  module Minting
5
5
  # Current version of the Minting gem.
6
- VERSION = '2.1.1'
6
+ VERSION = '2.3.0'
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minting
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.1
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gilson Ferraz
@@ -36,6 +36,7 @@ files:
36
36
  - bin/check-currencies
37
37
  - bin/console
38
38
  - bin/setup
39
+ - doc/agents/AGENTS.md
39
40
  - doc/agents/api_review-2026-06-15.md
40
41
  - doc/agents/copilot-instructions.md
41
42
  - doc/agents/expired/AGENTS.md
@@ -43,6 +44,8 @@ files:
43
44
  - doc/agents/expired/gemini_gem_evaluation.md
44
45
  - doc/agents/expired/recommendations.md
45
46
  - doc/agents/expired/rubocop-issues.md
47
+ - doc/api_review-2026-08-13.md
48
+ - doc/security-report.md
46
49
  - lib/minting.rb
47
50
  - lib/minting/aliases.rb
48
51
  - lib/minting/currency/currency.rb
@@ -76,6 +79,7 @@ files:
76
79
  - lib/minting/money/format/validator.rb
77
80
  - lib/minting/money/money.rb
78
81
  - lib/minting/money/parse.rb
82
+ - lib/minting/money/parse/separator_parser.rb
79
83
  - lib/minting/money/rounding.rb
80
84
  - lib/minting/version.rb
81
85
  - minting.gemspec
@@ -104,7 +108,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
104
108
  - !ruby/object:Gem::Version
105
109
  version: '0'
106
110
  requirements: []
107
- rubygems_version: 4.0.17
111
+ rubygems_version: 4.0.18
108
112
  specification_version: 4
109
113
  summary: Library to manipulate currency values
110
114
  test_files: []