minting 1.9.6 → 1.9.7

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.
@@ -3,24 +3,92 @@
3
3
  module Mint
4
4
  # :nodoc:
5
5
  class Money
6
- private
6
+ def self.compiled_formatters
7
+ @compiled_formatters ||= {}
8
+ end
9
+
10
+ # Build each sign template with subunit precision baked in, returning a
11
+ # frozen Hash of templates keyed by sign (:positive, :negative, :zero).
12
+ # Keeps %<symbol>s and %<currency>s as format args so width/padding specifiers work.
13
+ # @private
14
+ def self.compile_templates(format_hash, subunit)
15
+ [
16
+ format_hash[:positive] || DEFAULT_FORMAT,
17
+ format_hash[:negative],
18
+ format_hash[:zero]
19
+ ].map do |sign_format|
20
+ next unless sign_format
21
+
22
+ # Injects the currency's subunit precision into %<amount>f specifiers
23
+ # (e.g. '%<amount>f' → '%<amount>.2f' for USD), preserving any
24
+ # existing width/alignment specifier (e.g. '%<amount>+10f' stays).
25
+ sign_format = sign_format.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{subunit}f")
26
+
27
+ # For zero-subunit currencies (JPY, KRW, etc.), strip %<fractional>d
28
+ # specifiers entirely since there is no fractional part to display.
29
+ sign_format.gsub!(/%<fractional>[^%]*?d/, '') if subunit.zero?
30
+
31
+ sign_format
32
+ end
33
+ end
7
34
 
8
- # Resolves the format template and amount based on the amount's sign
9
- # (negative_format/zero_format may override both the template and negate the value)
35
+ # Builds and returns a lambda that formats amounts for a fixed
36
+ # [format_config, currency, decimal, thousand] combination.
37
+ # The lambda is cached by {.compiled_formatters}.
10
38
  # @private
11
- def resolve_format(format)
12
- negative_format = format[:negative]
13
- zero_format = format[:zero]
14
-
15
- if amount.negative? && negative_format
16
- [negative_format, -amount]
17
- elsif amount.zero? && zero_format
18
- [zero_format, amount]
19
- else
20
- [format[:positive] || '%<symbol>s%<amount>f', amount]
39
+ def self.compile_formatter(format_hash, currency, decimal, thousand) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
40
+ subunit = currency.subunit
41
+ has_decimal_substitution = decimal != '.'
42
+ escaped_decimal = Regexp.escape(decimal)
43
+ has_thousand_separator = thousand && !thousand.empty?
44
+
45
+ templates = compile_templates(format_hash, subunit)
46
+ positive_template, negative_template, zero_template = templates
47
+
48
+ # Detect whether templates use %<fractional> and/or %<d-symbol>
49
+ all_templates = templates.compact.join
50
+ needs_fractional = all_templates.include?('%<fractional>')
51
+ needs_integral = all_templates.include?('%<amount>') || all_templates.include?('%<integral>')
52
+ multiplier = currency.fractional_multiplier
53
+ symbol = currency.symbol
54
+
55
+ args = {
56
+ currency: currency.code,
57
+ dsymbol: currency.disambiguate_symbol || symbol,
58
+ symbol: symbol
59
+ }
60
+
61
+ lambda do |amount|
62
+ format_template = if negative_template && amount < 0
63
+ amount = -amount
64
+ negative_template
65
+ elsif zero_template && amount == 0
66
+ zero_template
67
+ else
68
+ positive_template
69
+ end
70
+
71
+ args[:amount] = amount
72
+ args[:integral] = amount.to_i
73
+ args[:fractional] = ((amount.abs % 1) * multiplier).to_i if needs_fractional
74
+
75
+ result = Kernel.format(format_template, **args)
76
+ result.gsub!(/(?<=\d)\.(?=\d)/, decimal) if has_decimal_substitution
77
+
78
+ if needs_integral && has_thousand_separator && (amount >= 1000 || amount <= -1000)
79
+ # Split on the decimal separator between digits only — symbols may contain '.' (e.g. د.إ).
80
+ parts = result.split(/(?<=\d)#{escaped_decimal}(?=\d)/, 2)
81
+ # Insert the thousands delimiter before each run of 3 digits from the right.
82
+ parts[0].gsub!(/(\d)(?=(?:\d{3})+(?:[^\d]|$))/) { Regexp.last_match(1) + thousand }
83
+ result = parts.join(decimal)
84
+ end
85
+
86
+ result
21
87
  end
22
88
  end
23
89
 
90
+ private
91
+
24
92
  # Validates that format hash contains only known keys.
25
93
  # @private
26
94
  def validate_format_hash(format)
@@ -46,52 +114,17 @@ module Mint
46
114
  end
47
115
  end
48
116
 
49
- def apply_thousand_separator(string, decimal:, thousand:)
50
- return string if !thousand || thousand.empty?
51
-
52
- # Apply thousands only to the integral portion, using the decimal as boundary
53
- parts = string.split(/(?<=\d)#{Regexp.escape(decimal)}(?=\d)/, 2)
54
- parts[0].gsub!(/(\d)(?=(?:\d{3})+(?:[^\d]|$))/, "\\1#{thousand}")
55
- parts.join(decimal)
56
- end
57
-
58
- # Applies a format template to produce a formatted string representation.
117
+ # Applies a format template to the money amount, returning a formatted string.
118
+ # Uses a cached compiled formatter lambda that pre-resolves currency-specific
119
+ # values (symbol, code, subunit) so per-call work is reduced to
120
+ # Kernel.format + optional separator substitutions.
59
121
  # @private
60
- #
61
122
  def format_amount(format, decimal:, thousand:)
62
- subunit = currency.subunit
63
- resolved_format, adjusted_amount = resolve_format(format)
64
-
65
- resolved_format = inject_subunit_precision(resolved_format, subunit)
66
-
67
- result = Kernel.format(resolved_format, {
68
- amount: adjusted_amount,
69
- currency: currency_code,
70
- symbol: currency.symbol,
71
- integral: adjusted_amount.to_i,
72
- fractional: fractional
73
- })
123
+ key = [format, currency_code, decimal, thousand].hash
74
124
 
75
- # Substitute decimal first, while the dot is still unambiguous
76
- result.gsub!(/(?<=\d)\.(?=\d)/, decimal) if decimal != '.'
125
+ formatter = Money.compiled_formatters[key] ||= Money.compile_formatter(format, currency, decimal, thousand)
77
126
 
78
- return result if adjusted_amount.abs < 1000
79
-
80
- # Apply thousands only to the integral portion, using the decimal as boundary
81
- apply_thousand_separator(result, decimal:, thousand:)
82
- end
83
-
84
- # Injects the currency's subunit precision into %<amount>f specifiers
85
- # (e.g. '%<amount>f' → '%<amount>.2f' for USD). For zero-subunit
86
- # currencies (e.g. JPY), strips %<fractional>d specifiers.
87
- # @private
88
- def inject_subunit_precision(template, subunit)
89
- cache ||= {}
90
- cache[[template, subunit]] ||= begin
91
- result = template.gsub(/%<amount>(\s*\+?\d*)f/, "%<amount>\\1.#{subunit}f")
92
- result.gsub!(/%<fractional>[^%]*?d/, '') if subunit.zero?
93
- result.freeze
94
- end
127
+ formatter.call(amount)
95
128
  end
96
129
  end
97
130
  end
@@ -3,6 +3,10 @@
3
3
  module Mint
4
4
  # :nodoc:
5
5
  class Money
6
+ # The default display format pattern for formatting monetary values.
7
+ # Uses `%<symbol>s` for the currency symbol and `%<amount>f` for the rounded amount.
8
+ DEFAULT_FORMAT = '%<symbol>s%<amount>f'
9
+
6
10
  PRESETS = {
7
11
  amount: { format: '%<amount>f' },
8
12
  accounting: { format: { negative: '(%<symbol>s%<amount>f)' } },
@@ -17,7 +21,7 @@ module Mint
17
21
  # When provided, expands to the preset's format options and merges
18
22
  # with any explicit keyword arguments (kwargs override the preset).
19
23
  # @param format [String, Hash, nil] Either a Format string with placeholders
20
- # (%<symbol>s, %<amount>f, %<currency>s, %<integral>d, %<fractional>d),
24
+ # (%<symbol>s, %<amount>f, %<currency>s, %<integral>d, %<fractional>d, %<dsymbol>s),
21
25
  # or a Hash with per-sign keys (:positive, :negative, :zero) each
22
26
  # holding a format string. A Hash is convenient for sign-aware formats
23
27
  # such as accounting parentheses:
@@ -96,12 +100,25 @@ module Mint
96
100
  else raise ArgumentError, 'Invalid format. Only String or Hash are accepted'
97
101
  end
98
102
 
99
- formatted = format_amount(format, decimal: decimal, thousand: thousand)
103
+ formatted = format_amount(format, decimal:, thousand:)
100
104
 
101
105
  width ? formatted.rjust(width) : formatted
102
106
  end
103
107
 
104
- def to_s = format
108
+ THOUSAND_RE = /(\d)(?=(\d{3})+\z)/
109
+
110
+ def to_s
111
+ return format unless Mint.locale_backend.nil?
112
+
113
+ subunit = currency.subunit
114
+ integral = to_i.to_s
115
+ integral.gsub!(THOUSAND_RE, '\1,') if amount.abs >= 1000
116
+ if subunit > 0
117
+ "#{currency.symbol}#{integral}.#{fractional.to_s.rjust(subunit, '0')}"
118
+ else
119
+ "#{currency.symbol}#{integral}"
120
+ end
121
+ end
105
122
 
106
123
  alias to_fs :format
107
124
  end
@@ -17,10 +17,6 @@ module Mint
17
17
  # Money objects are immutable and support arithmetic, comparison,
18
18
  # formatting, allocation, and parsing operations.
19
19
  class Money
20
- # The default display format pattern for formatting monetary values.
21
- # Uses `%<symbol>s` for the currency symbol and `%<amount>f` for the rounded amount.
22
- DEFAULT_FORMAT = '%<symbol>s%<amount>f'
23
-
24
20
  attr_reader :amount, :currency
25
21
 
26
22
  # Returns the ISO 3-letter currency code string.
@@ -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 = '1.9.6'
6
+ VERSION = '1.9.7'
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: 1.9.6
4
+ version: 1.9.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gilson Ferraz
@@ -38,13 +38,14 @@ files:
38
38
  - bin/setup
39
39
  - doc/Mint.html
40
40
  - doc/Mint/Currency.html
41
- - doc/Mint/CurrencyRegistry.html
42
41
  - doc/Mint/Money.html
43
42
  - doc/Mint/RangeStepPatch.html
44
43
  - doc/Mint/Registry.html
45
44
  - doc/Mint/Rounding.html
46
45
  - doc/Mint/UnknownCurrency.html
47
46
  - doc/Minting.html
47
+ - doc/Numeric.html
48
+ - doc/String.html
48
49
  - doc/_index.html
49
50
  - doc/agents/api_review-2026-06-15.md
50
51
  - doc/agents/copilot-instructions.md