minting 1.9.7 → 2.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +298 -122
  3. data/Rakefile +2 -7
  4. data/doc/agents/api_review-2026-06-15.md +1 -1
  5. data/doc/agents/copilot-instructions.md +2 -2
  6. data/doc/agents/expired/copilot-instructions.md +2 -2
  7. data/doc/agents/expired/gemini_gem_evaluation.md +2 -2
  8. data/lib/minting/aliases.rb +22 -0
  9. data/lib/minting/currency/currency.rb +77 -94
  10. data/lib/minting/currency/registry.rb +134 -0
  11. data/lib/minting/currency/rounding.rb +47 -0
  12. data/lib/minting/data/crypto-currencies.yaml +126 -0
  13. data/lib/minting/mint/i18n.rb +79 -29
  14. data/lib/minting/mint/mint.rb +1 -26
  15. data/lib/minting/mint/registry/crypto.rb +59 -0
  16. data/lib/minting/mint/registry/registration.rb +1 -2
  17. data/lib/minting/mint/registry/registry.rb +9 -11
  18. data/lib/minting/mint/registry/symbols.rb +37 -30
  19. data/lib/minting/mint.rb +3 -2
  20. data/lib/minting/money/allocation/allocation.rb +2 -2
  21. data/lib/minting/money/allocation/split.rb +2 -2
  22. data/lib/minting/money/arithmetics/operators.rb +10 -13
  23. data/lib/minting/money/clamp.rb +6 -6
  24. data/lib/minting/money/coercion.rb +1 -1
  25. data/lib/minting/money/comparable.rb +3 -3
  26. data/lib/minting/money/constructors.rb +3 -42
  27. data/lib/minting/money/conversion.rb +22 -18
  28. data/lib/minting/money/format/format.rb +100 -0
  29. data/lib/minting/money/format/formatter.rb +110 -0
  30. data/lib/minting/money/format/to_s.rb +20 -102
  31. data/lib/minting/money/format/validator.rb +34 -0
  32. data/lib/minting/money/money.rb +25 -9
  33. data/lib/minting/money/parse.rb +127 -0
  34. data/lib/minting/money/rounding.rb +26 -0
  35. data/lib/minting/version.rb +1 -1
  36. data/lib/minting.rb +17 -8
  37. metadata +12 -31
  38. data/doc/Mint/Currency.html +0 -2032
  39. data/doc/Mint/Money.html +0 -5139
  40. data/doc/Mint/RangeStepPatch.html +0 -277
  41. data/doc/Mint/Registry.html +0 -863
  42. data/doc/Mint/Rounding.html +0 -506
  43. data/doc/Mint/UnknownCurrency.html +0 -138
  44. data/doc/Mint.html +0 -931
  45. data/doc/Minting.html +0 -142
  46. data/doc/Numeric.html +0 -479
  47. data/doc/String.html +0 -241
  48. data/doc/_index.html +0 -206
  49. data/doc/class_list.html +0 -54
  50. data/doc/css/common.css +0 -1
  51. data/doc/css/full_list.css +0 -206
  52. data/doc/css/style.css +0 -1089
  53. data/doc/file.README.html +0 -291
  54. data/doc/file_list.html +0 -59
  55. data/doc/frames.html +0 -22
  56. data/doc/index.html +0 -291
  57. data/doc/js/app.js +0 -801
  58. data/doc/js/full_list.js +0 -334
  59. data/doc/js/jquery.js +0 -4
  60. data/doc/method_list.html +0 -758
  61. data/doc/top-level-namespace.html +0 -135
  62. data/lib/minting/mint/aliases.rb +0 -16
  63. data/lib/minting/mint/parser/parser.rb +0 -97
  64. data/lib/minting/mint/parser/separators.rb +0 -41
  65. data/lib/minting/mint/rounding.rb +0 -65
  66. data/lib/minting/money/format/formatting.rb +0 -130
@@ -1,135 +0,0 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>
7
- Top Level Namespace
8
-
9
- &mdash; Documentation by YARD 0.9.44
10
-
11
- </title>
12
-
13
- <link rel="stylesheet" href="css/style.css" type="text/css">
14
-
15
- <link rel="stylesheet" href="css/common.css" type="text/css">
16
-
17
- <script type="text/javascript">
18
- pathId = "";
19
- relpath = '';
20
- </script>
21
-
22
-
23
- <script type="text/javascript" charset="utf-8" src="js/jquery.js"></script>
24
-
25
- <script type="text/javascript" charset="utf-8" src="js/app.js"></script>
26
-
27
-
28
- </head>
29
- <body>
30
- <div id="main_progress" aria-hidden="true"></div>
31
-
32
- <div class="nav_wrap">
33
- <iframe id="nav" src="class_list.html?1"></iframe>
34
- <div id="resizer"></div>
35
- </div>
36
-
37
- <div id="main" tabindex="-1">
38
- <div id="header">
39
- <div id="menu">
40
-
41
- <a href="_index.html">Index</a> &raquo;
42
-
43
-
44
- <span class="title">Top Level Namespace</span>
45
-
46
- </div>
47
-
48
- <div id="search">
49
-
50
- <a class="full_list_link" id="class_list_link"
51
- href="class_list.html">
52
-
53
- <svg width="24" height="24">
54
- <rect x="0" y="4" width="24" height="4" rx="1" ry="1"></rect>
55
- <rect x="0" y="12" width="24" height="4" rx="1" ry="1"></rect>
56
- <rect x="0" y="20" width="24" height="4" rx="1" ry="1"></rect>
57
- </svg>
58
- </a>
59
-
60
- </div>
61
- <div class="clear"></div>
62
- </div>
63
-
64
- <div id="content"><h1>Top Level Namespace
65
-
66
-
67
-
68
- </h1>
69
- <div class="box_info">
70
-
71
-
72
-
73
-
74
-
75
-
76
-
77
-
78
-
79
-
80
-
81
- </div>
82
-
83
- <h2>Defined Under Namespace</h2>
84
- <p class="children">
85
-
86
-
87
- <strong class="modules">Modules:</strong> <span class='object_link'><a href="Mint.html" title="Mint (module)">Mint</a></span>, <span class='object_link'><a href="Minting.html" title="Minting (module)">Minting</a></span>
88
-
89
-
90
-
91
- <strong class="classes">Classes:</strong> <span class='object_link'><a href="Numeric.html" title="Numeric (class)">Numeric</a></span>, <span class='object_link'><a href="String.html" title="String (class)">String</a></span>
92
-
93
-
94
- </p>
95
-
96
-
97
- <h2>
98
- Constant Summary
99
- <small><a href="#" class="constants_summary_toggle">collapse</a></small>
100
- </h2>
101
-
102
- <dl class="constants">
103
-
104
- <dt id="Money-constant" class="">Money =
105
-
106
- </dt>
107
- <dd><pre class="code"><span class='const'><span class='object_link'><a href="Mint.html" title="Mint (module)">Mint</a></span></span><span class='op'>::</span><span class='const'><span class='object_link'><a href="Mint/Money.html" title="Mint::Money (class)">Money</a></span></span></pre></dd>
108
-
109
- <dt id="Currency-constant" class="">Currency =
110
-
111
- </dt>
112
- <dd><pre class="code"><span class='const'><span class='object_link'><a href="Mint.html" title="Mint (module)">Mint</a></span></span><span class='op'>::</span><span class='const'><span class='object_link'><a href="Mint/Currency.html" title="Mint::Currency (class)">Currency</a></span></span></pre></dd>
113
-
114
- </dl>
115
-
116
-
117
-
118
-
119
-
120
-
121
-
122
-
123
-
124
-
125
- </div>
126
-
127
- <div id="footer">
128
- Generated on Wed Jul 1 01:11:52 2026 by
129
- <a href="https://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
130
- 0.9.44 (ruby-4.0.5).
131
- </div>
132
-
133
- </div>
134
- </body>
135
- </html>
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Optional top-level alias for Mint::Currency.
4
- #
5
- # Mint::Money is auto-bound as the top-level Money constant by
6
- # `require 'minting'` (see lib/minting.rb). Currency is not auto-bound
7
- # because application domain models are commonly named Currency (e.g. a
8
- # Rails model). Require this file to opt in:
9
- #
10
- # require 'minting/mint/aliases'
11
- #
12
- if defined?(Currency) && Currency != Mint::Currency
13
- warn "minting: top-level Currency is already defined (#{Currency}); skipping alias."
14
- else
15
- Currency = Mint::Currency unless defined?(Currency)
16
- end
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Mint Money parsing
4
- module Mint
5
- extend self
6
-
7
- # Parses a human-readable money string into a {Money} object.
8
- #
9
- # Returns +nil+ when the input is invalid or currency cannot be determined.
10
- #
11
- # @param input [String] Amount input, optionally including a currency symbol or code
12
- # @param currency [String, Symbol, Currency, nil] ISO code when not present in +input+
13
- # @return [Money, nil]
14
- #
15
- # @example With explicit currency
16
- # Mint.parse('19.99', 'USD') #=> [USD 19.99]
17
- # Mint.parse('garbage', 'USD') #=> nil
18
- #
19
- # @example With symbol or code in the string
20
- # Mint.parse('$19.99') #=> [USD 19.99]
21
- # Mint.parse('USD 1,234.56') #=> [USD 1234.56]
22
- def parse(input, currency = nil)
23
- return nil unless input.is_a?(String)
24
-
25
- input = input.strip
26
- return nil if input.empty?
27
-
28
- currency = parse_currency(input, currency)
29
- return nil unless currency
30
-
31
- amount = parse_amount(input)
32
- return nil unless amount
33
-
34
- amount = currency.normalize_amount(amount)
35
- Mint::Money.new(amount, currency)
36
- end
37
-
38
- # Like {.parse} but raises on failure.
39
- #
40
- # @param input [String] Amount input, optionally including a currency symbol or code
41
- # @param currency [String, Symbol, Currency, nil] ISO code when not present in +input+
42
- # @return [Money]
43
- # @raise [ArgumentError] when +input+ is invalid or currency cannot be determined
44
- #
45
- # @example
46
- # Mint.parse!('19.99', 'USD') #=> [USD 19.99]
47
- # Mint.parse!('garbage', 'USD') #=> ArgumentError
48
- def parse!(input, currency = nil)
49
- raise ArgumentError, 'input must be a String' unless input.is_a?(String)
50
-
51
- input = input.strip
52
- raise ArgumentError, 'input cannot be empty' if input.empty?
53
-
54
- currency = parse_currency(input, currency)
55
- raise ArgumentError, "Currency [#{currency}] not found" unless currency
56
-
57
- amount = parse_amount(input)
58
- raise ArgumentError, "Could not parse [#{input}]" unless amount
59
-
60
- amount = currency.normalize_amount(amount)
61
- Mint::Money.new(amount, currency)
62
- end
63
-
64
- private
65
-
66
- # Extracts a numeric value from input that should only contain an amount.
67
- # @private
68
- def parse_amount(input)
69
- accounting_negative = input.start_with?('(') && input.end_with?(')')
70
-
71
- # Remove any charater that is not a digit, comma or period
72
- numeric_input = input.scan(/[\d.,-]/).join
73
- numeric = parse_separators(numeric_input)
74
- return nil unless numeric
75
-
76
- amount = Rational(numeric)
77
- accounting_negative ? -amount : amount
78
- end
79
-
80
- # Extracts currency from a string by matching ISO code or symbol.
81
- #
82
- # Scans all uppercase words and returns the first registered code, falling
83
- # back to symbol matching. This correctly handles inputs like
84
- # "MAX 10.00 USD" where the first uppercase word isn't a currency code.
85
- # @private
86
- def parse_currency(input, currency = nil)
87
- input.scan(/\b([A-Z_]+)\b/) do |(code)|
88
- found = Currency.for_code(code)
89
- return found if found
90
- end
91
-
92
- found = Registry.detect_currency(input)
93
- return found if found
94
-
95
- Currency.resolve(currency)
96
- end
97
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Mint Money parsing
4
- module Mint
5
- extend self
6
-
7
- private
8
-
9
- # Classifies the separator pattern in a numeric string.
10
- # @private
11
- def classify_separators(numeric)
12
- case [numeric.count('.'), numeric.count(',')]
13
- in [0, 1] if numeric[-4] == ',' then :thousands_comma # Comma is a thousand separator
14
- in [0, 1] then :decimal_comma # Only one comma: decimal (e.g. 19,99 or 1,4 or 1,2345).
15
- in [0, 0] | [1, 0] then :decimal_period # e.g. "1500" or "34.21".
16
- in [p, c] if p > 1 && c > 1 then :invalid # Both separators appear multiple times
17
- in [p, c] if p > 0 && c > 0 then :mixed # Commas and dots: the rightmost one is the decimal
18
- else :thousands # Multiple of the same separator only (e.g. 1,234,567)
19
- end
20
- end
21
-
22
- # Converts locale-specific decimal/thousand separators into a plain decimal string.
23
- # @private
24
- def parse_separators(numeric)
25
- return nil unless numeric.match?(/\d/)
26
-
27
- case classify_separators(numeric)
28
- when :decimal_period then numeric # Nothing to normalize (e.g. "1500" or "34.21").
29
- when :decimal_comma then numeric.tr(',', '.') # Only one comma: decimal (e.g. 19,99 or 1,234).
30
- when :thousands_comma then numeric.delete(',')
31
- when :thousands then numeric.delete('.,')
32
- when :invalid then nil
33
- when :mixed # Commas and dots: the rightmost one is the decimal separator.
34
- if numeric.rindex(',') > numeric.rindex('.')
35
- numeric.delete('.').tr(',', '.')
36
- else
37
- numeric.delete(',')
38
- end
39
- end
40
- end
41
- end
@@ -1,65 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mint
4
- # Rounding-mode dispatch table and block-scoped context.
5
- # @api private
6
- module Rounding
7
- # Maps mode symbols to their corresponding +Rational+ rounding lambdas.
8
- # @return [Hash{Symbol => Proc}]
9
- # @api private
10
- MODES = {
11
- half_up: ->(amount, ndigits) { amount.round(ndigits, half: :up) },
12
- half_down: ->(amount, ndigits) { amount.round(ndigits, half: :down) },
13
- half_even: ->(amount, ndigits) { amount.round(ndigits, half: :even) },
14
- floor: ->(amount, ndigits) { amount.floor(ndigits) },
15
- ceil: ->(amount, ndigits) { amount.ceil(ndigits) },
16
- truncate: ->(amount, ndigits) { amount.truncate(ndigits) },
17
- down: ->(amount, ndigits) { amount.truncate(ndigits) }
18
- }.freeze
19
-
20
- # Returns the currently active rounding mode, falling back to +:half_up+.
21
- # @api private
22
- # @return [Symbol]
23
- def self.current_mode
24
- Thread.current[:minting_rounding_mode] || :half_up
25
- end
26
-
27
- # Rounds +amount+ to +ndigits+ using the currently scoped rounding mode.
28
- # Uses the fast path (+to_r.round+) when no custom mode is active.
29
- # @api private
30
- # @param amount [Numeric]
31
- # @param ndigits [Integer]
32
- # @return [Rational]
33
- def self.apply(amount, ndigits)
34
- mode = Thread.current[:minting_rounding_mode]
35
- if mode
36
- MODES.fetch(mode).call(amount.to_r, ndigits)
37
- else
38
- amount.to_r.round(ndigits)
39
- end
40
- end
41
-
42
- # Sets a rounding mode for the duration of a block, restoring the
43
- # previous mode on exit (even on exception).
44
- # @api private
45
- # @param mode [Symbol]
46
- # @yield block to execute with the mode active
47
- # @raise [ArgumentError] on unknown mode
48
- def self.with_mode(mode)
49
- raise ArgumentError, "Unknown rounding mode: #{mode}" unless MODES.key?(mode)
50
-
51
- prev = Thread.current[:minting_rounding_mode]
52
- Thread.current[:minting_rounding_mode] = mode
53
- yield
54
- ensure
55
- Thread.current[:minting_rounding_mode] = prev
56
- end
57
- end
58
- end
59
-
60
- # When loaded, patch Currency to delegate rounding through this module.
61
- # Remove the default fast-path method first to avoid redefinition warnings.
62
- Mint::Currency.remove_method(:normalize_amount)
63
- Mint::Currency.define_method(:normalize_amount) do |amount|
64
- Mint::Rounding.apply(amount, subunit)
65
- end
@@ -1,130 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Mint
4
- # :nodoc:
5
- class Money
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
34
-
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}.
38
- # @private
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
87
- end
88
- end
89
-
90
- private
91
-
92
- # Validates that format hash contains only known keys.
93
- # @private
94
- def validate_format_hash(format)
95
- unknown = format.keys - %i[positive negative zero]
96
-
97
- raise ArgumentError, "Unknown format parameter(s): #{unknown.inspect}. " unless unknown.empty?
98
- end
99
-
100
- # Validates +decimal+ and +thousand+ separator arguments.
101
- # @private
102
- def validate_separators!(decimal:, thousand:)
103
- case decimal
104
- when nil # :noop
105
- when '' then raise ArgumentError, 'decimal must be a non-empty'
106
- when thousand then raise ArgumentError, "decimal and thousand cannot be identical: #{decimal.inspect}"
107
- when String # :noop
108
- else raise ArgumentError, "decimal must be a String, false, or nil, got #{decimal.inspect}"
109
- end
110
-
111
- case thousand
112
- when false, nil, String # :noop
113
- else raise ArgumentError, "thousand must be a String, false, or nil, got #{thousand.inspect}"
114
- end
115
- end
116
-
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.
121
- # @private
122
- def format_amount(format, decimal:, thousand:)
123
- key = [format, currency_code, decimal, thousand].hash
124
-
125
- formatter = Money.compiled_formatters[key] ||= Money.compile_formatter(format, currency, decimal, thousand)
126
-
127
- formatter.call(amount)
128
- end
129
- end
130
- end