amounts 0.0.8 → 0.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/.rubocop.yml +37 -0
  3. data/CHANGELOG.md +113 -0
  4. data/Gemfile +4 -0
  5. data/README.md +215 -1
  6. data/Rakefile +47 -10
  7. data/lib/amount/active_record/amount_validator.rb +9 -18
  8. data/lib/amount/active_record/attribute_definition.rb +3 -1
  9. data/lib/amount/active_record/locale/en.yml +7 -0
  10. data/lib/amount/active_record/model.rb +9 -0
  11. data/lib/amount/active_record.rb +12 -0
  12. data/lib/amount/compact.rb +137 -0
  13. data/lib/amount/{allocation.rb → concerns/allocatable.rb} +1 -1
  14. data/lib/amount/{arithmetic.rb → concerns/calculable.rb} +1 -1
  15. data/lib/amount/concerns/coercible.rb +65 -0
  16. data/lib/amount/concerns/constructible.rb +92 -0
  17. data/lib/amount/{conversion.rb → concerns/convertible.rb} +1 -1
  18. data/lib/amount/concerns/formattable.rb +112 -0
  19. data/lib/amount/concerns/localizable.rb +39 -0
  20. data/lib/amount/{comparison.rb → concerns/orderable.rb} +2 -2
  21. data/lib/amount/concerns/parsable.rb +58 -0
  22. data/lib/amount/concerns/registrable.rb +80 -0
  23. data/lib/amount/{serialization.rb → concerns/serializable.rb} +3 -3
  24. data/lib/amount/display.rb +81 -46
  25. data/lib/amount/locale.rb +102 -0
  26. data/lib/amount/registry.rb +69 -7
  27. data/lib/amount/ui_parser.rb +109 -0
  28. data/lib/amount/version.rb +1 -1
  29. data/lib/amount.rb +55 -199
  30. data/test/amount/active_record/amount_validator_test.rb +201 -0
  31. data/test/amount/active_record/attribute_definition_test.rb +234 -0
  32. data/test/amount/active_record/migration_methods_test.rb +60 -0
  33. data/test/amount/active_record/model_test.rb +40 -0
  34. data/test/amount/active_record/type_test.rb +80 -0
  35. data/test/amount/active_record_test.rb +12 -0
  36. data/test/amount/compact_test.rb +371 -0
  37. data/test/amount/concerns/allocatable_test.rb +166 -0
  38. data/test/amount/concerns/calculable_test.rb +152 -0
  39. data/test/amount/concerns/coercible_test.rb +49 -0
  40. data/test/amount/concerns/constructible_test.rb +113 -0
  41. data/test/amount/concerns/convertible_test.rb +54 -0
  42. data/test/amount/concerns/formattable_test.rb +336 -0
  43. data/test/amount/concerns/localizable_test.rb +59 -0
  44. data/test/amount/concerns/orderable_test.rb +83 -0
  45. data/test/amount/concerns/parsable_test.rb +98 -0
  46. data/test/amount/concerns/registrable_test.rb +67 -0
  47. data/test/amount/concerns/serializable_test.rb +98 -0
  48. data/test/amount/display_test.rb +181 -0
  49. data/test/amount/locale_test.rb +590 -0
  50. data/test/amount/parser_test.rb +29 -0
  51. data/test/amount/registry/generated_constructors_test.rb +94 -0
  52. data/test/amount/registry_test.rb +149 -0
  53. data/test/amount/ui_parser_test.rb +284 -0
  54. data/test/amount_test.rb +55 -0
  55. data/test/dummy/config/application.rb +3 -0
  56. data/test/generators/amount/active_record/registry_generator_test.rb +116 -0
  57. data/test/integration/postgresql_test.rb +94 -0
  58. data/test/support/active_record_test_support.rb +65 -0
  59. data/test/support/error_helpers.rb +22 -0
  60. data/test/support/locale_test_setup.rb +36 -0
  61. data/test/test_helper.rb +1 -0
  62. metadata +47 -11
  63. data/test/postgresql_integration_test.rb +0 -71
  64. data/test/test_active_record.rb +0 -375
  65. data/test/test_amount.rb +0 -675
  66. data/test/test_registry_generator.rb +0 -104
@@ -5,6 +5,19 @@ require "bigdecimal"
5
5
  class Amount
6
6
  # Formats amounts for UI output without changing their type.
7
7
  class Display
8
+ # Rounds to `decimals` places (negative rounds to tens, hundreds, ...),
9
+ # down with `:floor` and up with `:ceil`, like `ui`'s `direction:`.
10
+ # @param value [BigDecimal]
11
+ # @param decimals [Integer]
12
+ # @param direction [Symbol]
13
+ # @return [BigDecimal]
14
+ def self.round(value, decimals, direction)
15
+ factor = BigDecimal(10)**decimals
16
+ scaled = value * factor
17
+ truncated = direction == :ceil ? scaled.ceil : scaled.floor
18
+ truncated / factor
19
+ end
20
+
8
21
  # @param amount [Amount]
9
22
  def initialize(amount)
10
23
  @amount = amount
@@ -25,6 +38,15 @@ class Amount
25
38
  # number. When `nil` (the default), falls back to the display unit's
26
39
  # setting, then the registry entry's setting. An explicit `true` or
27
40
  # `false` overrides both.
41
+ # @param locale [Symbol, String, nil] format the number with this locale's
42
+ # `number.format` delimiter and separator (requires I18n). When `nil`,
43
+ # uses `I18n.locale` if `Amount.localize` is true, else plain output.
44
+ # @param compact [Boolean, Hash] show numbers of one thousand or more in
45
+ # compact notation (`1.5M`, or `1,5 Mio.` with a locale's
46
+ # `number.human.decimal_units` keys). `true` keeps 3 significant digits;
47
+ # `{ precision: 2 }` changes that. Rounds in `direction` and trims
48
+ # trailing zeros. The label and template wrap the compacted number; plural
49
+ # labels agree with the full value. See {Compact}.
28
50
  # @return [String]
29
51
  # @example
30
52
  # Amount.of_usdc("1.50").ui # => "$1.50"
@@ -32,12 +54,45 @@ class Amount
32
54
  # Amount.of_gold("1").ui(unit: :gram) # => "31.10 g"
33
55
  # Amount.of_gold("1").ui(unit: :gram, decorated: false) # => "31.10"
34
56
  # Amount.of_sol("2.5").ui(trim_zeros: true) # => "2.5 SOL"
35
- def ui(unit: nil, direction: :floor, decorated: true, trim_zeros: nil)
36
- if unit
37
- render_display_unit(unit, direction, decorated:, trim_zeros:)
38
- else
39
- render_default(direction, decorated:, trim_zeros:)
40
- end
57
+ # # EUR registered with display_format: "%u%n"; de.yml has
58
+ # # amounts.formats.eur: "%n\u00A0%u"
59
+ # Amount.of_eur("12.5").ui(locale: :en) # => "€12.50"
60
+ # Amount.of_eur("12.5").ui(locale: :de) # => "12,50\u00A0€"
61
+ # Amount.of_usd("1500000").ui(compact: true) # => "$1.5M"
62
+ # Amount.of_tokens(50_000_000).ui(compact: true, decorated: false) # => "50M"
63
+ def ui(unit: nil, direction: :floor, decorated: true, trim_zeros: nil, locale: nil, compact: false)
64
+ locale = Locale.effective(locale)
65
+ precision = Compact.precision(compact)
66
+ spec = @entry.display_unit(unit) if unit
67
+ value = spec ? @amount.decimal * Amount.coerce_decimal(spec[:scale]) : @amount.decimal
68
+ decimals = spec&.dig(:ui_decimals) || @entry.ui_decimals
69
+ rounded = round(value, decimals, direction)
70
+ formatted =
71
+ if precision && Compact.applies?(value)
72
+ Compact.format(value, precision:, direction:, locale:)
73
+ else
74
+ format_number(rounded, decimals, resolve_trim(trim_zeros, spec), locale)
75
+ end
76
+ return formatted unless decorated
77
+
78
+ decorate(formatted, rounded, unit, spec, locale)
79
+ end
80
+
81
+ # The unit label alone, for column headers and chips that render it
82
+ # separately. Follows the same rules as `ui`: translated through I18n only
83
+ # when a locale is given or `Amount.localize` is true, else `display_symbol`
84
+ # (or the display unit's `:symbol`).
85
+ # @param unit [Symbol, nil] display unit
86
+ # @param locale [Symbol, String, nil]
87
+ # @param count [Numeric, String, nil] the number the label agrees with, for
88
+ # plural labels. Without it the `:other` form is used.
89
+ # @return [String]
90
+ # @example
91
+ # Amount.of_searches(3).unit_label(locale: :de) # => "Suchen"
92
+ # Amount.of_searches(1).unit_label(count: 1) # => "search" (hash display_symbol)
93
+ def unit_label(unit: nil, locale: nil, count: nil)
94
+ count = Amount.coerce_decimal(count) unless count.nil?
95
+ label(unit, Locale.effective(locale), nil, count)
41
96
  end
42
97
 
43
98
  # @return [String]
@@ -49,56 +104,42 @@ class Amount
49
104
  # @param unit [Symbol]
50
105
  # @return [BigDecimal]
51
106
  def in_unit(unit)
52
- unit_spec = fetch_display_unit(unit)
107
+ unit_spec = @entry.display_unit(unit)
53
108
  @amount.decimal * Amount.coerce_decimal(unit_spec[:scale])
54
109
  end
55
110
 
56
111
  private
57
112
 
58
- def render_default(direction, decorated:, trim_zeros:)
59
- rounded = round(@amount.decimal, @entry.ui_decimals, direction)
60
- formatted = format_number(rounded, @entry.ui_decimals, resolve_trim(trim_zeros))
61
- return formatted unless decorated
62
-
63
- apply_symbol(formatted, @entry.display_symbol, @entry.display_position)
64
- end
65
-
66
- def render_display_unit(unit, direction, decorated:, trim_zeros:)
67
- spec = fetch_display_unit(unit)
68
- scaled = @amount.decimal * Amount.coerce_decimal(spec[:scale])
69
- decimals = spec[:ui_decimals] || @entry.ui_decimals
70
- rounded = round(scaled, decimals, direction)
71
- formatted = format_number(rounded, decimals, resolve_trim(trim_zeros, spec))
72
- return formatted unless decorated
113
+ # Places the number and its label with the template from
114
+ # {Formattable#display_format}. The label agrees with `count`, the number
115
+ # as displayed. Without a label there is nothing to place, so the number is
116
+ # returned alone.
117
+ def decorate(number, count, unit, spec, locale)
118
+ label = label(unit, locale, spec, count)
119
+ return number if label.nil? || label.empty?
73
120
 
74
- apply_symbol(
75
- formatted,
76
- spec[:symbol] || @entry.display_symbol,
77
- spec[:position] || @entry.display_position
78
- )
121
+ Formattable.fill(@amount.display_format(unit:, locale:), number:, label:)
79
122
  end
80
123
 
81
- def fetch_display_unit(unit)
82
- units = @entry.display_units
83
- unless units
84
- raise Registry::InvalidDisplayUnit, "#{@entry.symbol} has no display_units configured"
85
- end
124
+ def label(unit, locale, spec = nil, count = nil)
125
+ spec ||= @entry.display_unit(unit) if unit
126
+ if spec
127
+ return Locale.plain(spec[:symbol] || @entry.display_symbol, count) unless locale
86
128
 
87
- units.fetch(unit.to_sym) do
88
- raise Registry::InvalidDisplayUnit, "unknown display unit #{unit} for #{@entry.symbol}"
129
+ Locale.unit_label(@entry, unit.to_sym, spec, locale, count)
130
+ else
131
+ locale ? Locale.type_label(@entry, locale, count) : Locale.plain(@entry.display_symbol, count)
89
132
  end
90
133
  end
91
134
 
92
135
  def round(value, decimals, direction)
93
- factor = BigDecimal(10)**decimals
94
- scaled = value * factor
95
- truncated = direction == :ceil ? scaled.ceil : scaled.floor
96
- truncated / factor
136
+ self.class.round(value, decimals, direction)
97
137
  end
98
138
 
99
- def format_number(value, decimals, trim)
139
+ def format_number(value, decimals, trim, locale = nil)
100
140
  str = format("%.#{decimals}f", value)
101
- trim ? str.sub(/(\.\d*?)0+\z/, '\1').chomp('.') : str
141
+ str = str.sub(/(\.\d*?)0+\z/, '\1').chomp('.') if trim
142
+ locale ? Locale.apply(str, locale) : str
102
143
  end
103
144
 
104
145
  def resolve_trim(call_site, spec = nil)
@@ -107,11 +148,5 @@ class Amount
107
148
 
108
149
  @entry.trim_zeros
109
150
  end
110
-
111
- def apply_symbol(str, symbol, position)
112
- return str if symbol.nil? || symbol.empty?
113
-
114
- position == :prefix ? "#{symbol}#{str}" : "#{str} #{symbol}"
115
- end
116
151
  end
117
152
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ # Internal: decides the effective locale and reads number formatting from
5
+ # I18n. I18n is used only when it is already loaded; it is never required.
6
+ module Locale
7
+ DEFAULT_DELIMITER = ","
8
+ DEFAULT_SEPARATOR = "."
9
+ MISSING_I18N = "the i18n gem is required for locale-aware output; add it to your Gemfile and require it"
10
+
11
+ module_function
12
+
13
+ # @return [Boolean]
14
+ def i18n_loaded?
15
+ defined?(::I18n) ? true : false
16
+ end
17
+
18
+ # @raise [ArgumentError] when I18n isn't loaded
19
+ def require_i18n!
20
+ raise ArgumentError, MISSING_I18N unless i18n_loaded?
21
+ end
22
+
23
+ # 1. an explicit locale, 2. I18n.locale when Amount.localize, 3. nil.
24
+ # @param explicit [Symbol, String, nil]
25
+ # @return [Symbol, String, nil]
26
+ def effective(explicit = nil)
27
+ unless explicit.nil?
28
+ require_i18n!
29
+ return explicit
30
+ end
31
+ return nil unless Amount.localize?
32
+
33
+ require_i18n!
34
+ ::I18n.locale
35
+ end
36
+
37
+ # @return [Hash{Symbol=>String}] `:delimiter` and `:separator`
38
+ def number_format(locale)
39
+ format = ::I18n.t("number.format", locale:, default: {})
40
+ format = {} unless format.is_a?(Hash)
41
+ {
42
+ delimiter: format[:delimiter] || DEFAULT_DELIMITER,
43
+ separator: format[:separator] || DEFAULT_SEPARATOR
44
+ }
45
+ end
46
+
47
+ # Picks a form from a plural hash without I18n: `:one` when the number
48
+ # equals 1, else `:other`. Strings pass through.
49
+ # @param label [String, Hash, nil]
50
+ # @param count [BigDecimal, nil]
51
+ def plain(label, count)
52
+ return label unless label.is_a?(Hash)
53
+
54
+ count == 1 && label.key?(:one) ? label[:one] : label[:other]
55
+ end
56
+
57
+ # Label of a registered type: its `label_key`, then
58
+ # `amounts.units.<symbol downcased>`, then `display_symbol`.
59
+ # @return [String, nil]
60
+ def type_label(entry, locale, count = nil)
61
+ key = entry.label_key || "amounts.units.#{entry.symbol.to_s.downcase}"
62
+ translate(key, locale, entry.display_symbol, count)
63
+ end
64
+
65
+ # Label of a display unit: the spec's `label_key`, then
66
+ # `amounts.display_units.<symbol downcased>.<unit>`, then the spec's
67
+ # `:symbol`, then the type's label.
68
+ # @return [String, nil]
69
+ def unit_label(entry, unit, spec, locale, count = nil)
70
+ key = spec[:label_key] || "amounts.display_units.#{entry.symbol.to_s.downcase}.#{unit}"
71
+ translate(key, locale, spec[:symbol] || type_label(entry, locale, count), count)
72
+ end
73
+
74
+ # Looks a key up, falling back to `default` so a miss never yields
75
+ # "translation missing". With a count, I18n pluralizes a hash translation
76
+ # or default using the locale's rules; without one, or when the form is
77
+ # missing, the `:other` form is used.
78
+ def translate(key, locale, default, count = nil)
79
+ opts = count.nil? ? {} : { count: whole(count) }
80
+ found = ::I18n.t(key.to_s, locale:, default:, **opts)
81
+ found.is_a?(Hash) ? found[:other] : found
82
+ rescue ::I18n::InvalidPluralizationData => e
83
+ e.entry[:other]
84
+ end
85
+
86
+ # 1.0 -> 1, so custom pluralization rules can match integers.
87
+ def whole(count)
88
+ count.frac.zero? ? count.to_i : count
89
+ end
90
+
91
+ # Rewrites a plain "-1234.50" style number with the locale's characters.
92
+ # @param str [String] output of `format("%.Nf", value)`
93
+ # @return [String]
94
+ def apply(str, locale)
95
+ fmt = number_format(locale)
96
+ sign = str.start_with?("-") ? "-" : ""
97
+ int, frac = str.delete_prefix("-").split(".", 2)
98
+ int = int.reverse.scan(/\d{1,3}/).join(fmt[:delimiter].reverse).reverse
99
+ "#{sign}#{int}#{frac ? "#{fmt[:separator]}#{frac}" : ''}"
100
+ end
101
+ end
102
+ end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "bigdecimal"
4
4
  require "thread"
5
- require_relative "registry/generated_constructors"
6
5
 
7
6
  class Amount
8
7
  # Stores registered amount types and default directional conversion rates.
@@ -25,19 +24,33 @@ class Amount
25
24
  class RegistryLocked < StandardError; end
26
25
  class AlreadyRegistered < StandardError; end
27
26
  class InvalidDisplayUnit < StandardError; end
27
+ class InvalidDisplaySymbol < StandardError; end
28
28
 
29
29
  Entry = Struct.new(
30
30
  :symbol,
31
31
  :decimals,
32
32
  :display_symbol,
33
+ :label_key,
33
34
  :display_position,
35
+ :display_format,
34
36
  :ui_decimals,
35
37
  :display_units,
36
38
  :default_display,
37
39
  :amount_class,
38
40
  :trim_zeros,
39
41
  keyword_init: true
40
- )
42
+ ) do
43
+ # @param unit [Symbol, String]
44
+ # @return [Hash] the display unit's spec
45
+ # @raise [InvalidDisplayUnit] when the type has no such display unit
46
+ def display_unit(unit)
47
+ raise InvalidDisplayUnit, "#{symbol} has no display_units configured" unless display_units
48
+
49
+ display_units.fetch(unit.to_sym) do
50
+ raise InvalidDisplayUnit, "unknown display unit #{unit} for #{symbol}"
51
+ end
52
+ end
53
+ end
41
54
 
42
55
  def initialize
43
56
  @entries = {}
@@ -57,8 +70,19 @@ class Amount
57
70
  #
58
71
  # @param symbol [Symbol, String] registered type identifier
59
72
  # @param decimals [Integer] number of storage decimals
60
- # @param display_symbol [String] symbol used by UI helpers
61
- # @param display_position [Symbol] either `:prefix` or `:suffix`
73
+ # @param display_symbol [String, Hash] symbol used by UI helpers. A hash
74
+ # (`{ one: "search", other: "searches" }`) gives plural forms and must
75
+ # have `:other`
76
+ # @param label_key [String, Symbol, nil] I18n key for the unit label, used
77
+ # when localizing. Defaults to `amounts.units.<symbol downcased>`;
78
+ # a missing translation falls back to `display_symbol`
79
+ # @param display_position [Symbol, nil] `:prefix` (`"$1.50"`) or `:suffix`
80
+ # (`"1.50 SOL"`, the default). Shorthand for the templates `"%u%n"` and
81
+ # `"%n %u"`; can't be combined with `display_format`
82
+ # @param display_format [String, nil] template for decorated `ui` output,
83
+ # where `%n` is the formatted number (sign included) and `%u` the label,
84
+ # e.g. `"%n\u00A0%u"`. Used exactly as given. When localizing,
85
+ # `amounts.formats.<symbol downcased>` overrides it
62
86
  # @param ui_decimals [Integer] decimals displayed by default UI formatting
63
87
  # @param display_units [Hash, nil] optional display-only scaling definitions
64
88
  # @param default_display [Symbol, nil] optional default display unit key
@@ -68,14 +92,16 @@ class Amount
68
92
  # @raise [AlreadyRegistered] if the symbol is already registered or the
69
93
  # generated constructor would collide with an existing method
70
94
  # @raise [RegistryLocked] if the registry has been locked
95
+ # @raise [ArgumentError] if both `display_format` and `display_position`
96
+ # are given, or `display_format` is not a String containing `%n`
71
97
  # @example
72
98
  # Amount.register :USDC,
73
99
  # decimals: 6,
74
100
  # display_symbol: "$",
75
101
  # display_position: :prefix,
76
102
  # ui_decimals: 2
77
- def register(symbol, decimals:, display_symbol: symbol.to_s, display_position: :suffix,
78
- ui_decimals: decimals, display_units: nil, default_display: nil,
103
+ def register(symbol, decimals:, display_symbol: symbol.to_s, label_key: nil, display_position: nil,
104
+ display_format: nil, ui_decimals: decimals, display_units: nil, default_display: nil,
79
105
  trim_zeros: false, class: nil)
80
106
  raise ArgumentError, "symbol must not be blank" if symbol.nil? || symbol.to_s.empty?
81
107
 
@@ -85,13 +111,17 @@ class Amount
85
111
  ensure_unlocked!
86
112
  raise AlreadyRegistered, "#{symbol} already registered" if @entries.key?(symbol)
87
113
 
114
+ validate_display_symbol!(:display_symbol, display_symbol, InvalidDisplaySymbol)
115
+ validate_display_format!(display_format, display_position) if display_format
88
116
  validate_display_units!(display_units, default_display) if display_units
89
117
 
90
118
  entry = Entry.new(
91
119
  symbol:,
92
120
  decimals:,
93
121
  display_symbol:,
94
- display_position:,
122
+ label_key: label_key&.to_s,
123
+ display_position: display_format ? nil : (display_position || :suffix),
124
+ display_format: display_format || Formattable.template_for(display_position),
95
125
  ui_decimals:,
96
126
  display_units:,
97
127
  default_display:,
@@ -230,6 +260,13 @@ class Amount
230
260
  raise RegistryLocked, "registry is locked" if @locked
231
261
  end
232
262
 
263
+ def validate_display_format!(format, position)
264
+ raise ArgumentError, "pass display_format or display_position, not both" if position
265
+ return if Formattable.valid_template?(format)
266
+
267
+ raise ArgumentError, "display_format must be a String containing %n"
268
+ end
269
+
233
270
  def validate_display_units!(units, default)
234
271
  unless units.is_a?(Hash) && !units.empty?
235
272
  raise InvalidDisplayUnit, "display_units must be a non-empty hash"
@@ -243,7 +280,32 @@ class Amount
243
280
  unless spec.is_a?(Hash) && spec.key?(:scale)
244
281
  raise InvalidDisplayUnit, "display_unit #{key} must have :scale"
245
282
  end
283
+
284
+ validate_unit_format!(key, spec) if spec.key?(:format)
285
+ validate_display_symbol!("display_unit #{key} :symbol", spec[:symbol], InvalidDisplayUnit) if spec[:symbol]
286
+ end
287
+ end
288
+
289
+ def validate_unit_format!(key, spec)
290
+ if spec.key?(:position)
291
+ raise InvalidDisplayUnit, "display_unit #{key} takes :format or :position, not both"
246
292
  end
293
+ return if Formattable.valid_template?(spec[:format])
294
+
295
+ raise InvalidDisplayUnit, "display_unit #{key} :format must be a String containing %n"
296
+ end
297
+
298
+ def validate_display_symbol!(name, value, error)
299
+ return unless value.is_a?(Hash)
300
+
301
+ unless value.key?(:other)
302
+ raise error, "#{name} hash must have an :other key (got #{value.keys.inspect})"
303
+ end
304
+
305
+ bad = value.reject { |k, v| k.is_a?(Symbol) && v.is_a?(String) }
306
+ return if bad.empty?
307
+
308
+ raise error, "#{name} hash must map Symbol plural keys to String labels (got #{bad.inspect})"
247
309
  end
248
310
  end
249
311
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Amount
4
+ # Internal: parses the text a person types into a form field, optionally in
5
+ # a locale's number format. Exact: never rounds, never goes through Float.
6
+ class UiParser
7
+ # @param input [#to_s]
8
+ # @param symbol [Symbol, String]
9
+ # @param locale [Symbol, String, nil]
10
+ def initialize(input, symbol, locale)
11
+ @input = input
12
+ @entry = Amount.registry.lookup(symbol)
13
+ @locale = Locale.effective(locale)
14
+ format = @locale ? Locale.number_format(@locale) : { delimiter: "", separator: "." }
15
+ @delimiter = format[:delimiter].to_s
16
+ @separator = format[:separator].to_s
17
+ end
18
+
19
+ # @return [Amount]
20
+ def parse
21
+ text = strip(@input.to_s)
22
+ fail_with("is empty") if text.empty?
23
+ original = text
24
+
25
+ sign, text = take_sign(text)
26
+ @signed = text != original
27
+ unlabeled = strip_label(text)
28
+ # "$-1.50": a sign may follow a prefix label, but not as a second sign
29
+ sign, unlabeled = take_sign(unlabeled) if sign.empty? && unlabeled != text && !@signed
30
+ text = unlabeled
31
+ value = atomic(text)
32
+ Amount.new(sign.empty? ? value : -value, @entry.symbol)
33
+ end
34
+
35
+ private
36
+
37
+ def fail_with(reason)
38
+ raise InvalidInput, "cannot parse #{@input.inspect} as #{@entry.symbol}: #{reason}"
39
+ end
40
+
41
+ def strip(str)
42
+ str.gsub(/\A[[:space:]]+|[[:space:]]+\z/, "")
43
+ end
44
+
45
+ # @return [Array(String, String)] "-" or "" (a "+" is dropped), and the rest
46
+ def take_sign(text)
47
+ match = text.match(/\A([+-])[[:space:]]*(.*)\z/m)
48
+ return ["", text] unless match
49
+
50
+ [match[1] == "-" ? "-" : "", match[2]]
51
+ end
52
+
53
+ # Removes one label (the longest match) from either end.
54
+ def strip_label(text)
55
+ labels.each do |label|
56
+ if text.start_with?(label)
57
+ return strip(text.delete_prefix(label))
58
+ elsif text.end_with?(label)
59
+ return strip(text.delete_suffix(label))
60
+ end
61
+ end
62
+ text
63
+ end
64
+
65
+ # Every form of the type's label: `display_symbol` (and its plural
66
+ # forms), plus the locale's translation when localizing.
67
+ def labels
68
+ found = forms(@entry.display_symbol)
69
+ found += forms(translation) if @locale
70
+ found.map(&:to_s).reject(&:empty?).uniq.sort_by { |label| -label.length }
71
+ end
72
+
73
+ def translation
74
+ key = @entry.label_key || "amounts.units.#{@entry.symbol.to_s.downcase}"
75
+ ::I18n.t(key.to_s, locale: @locale, default: nil)
76
+ end
77
+
78
+ def forms(label)
79
+ case label
80
+ when Hash then label.values.grep(String)
81
+ when String then [label]
82
+ else []
83
+ end
84
+ end
85
+
86
+ # @return [Integer] atomic units, unsigned
87
+ def atomic(text)
88
+ whole, fraction, extra = text.split(@separator, -1)
89
+ fail_with("a decimal separator is used twice") if extra
90
+ fail_with("is not a number") unless whole&.match?(whole_pattern)
91
+ fraction ||= ""
92
+ fail_with("is not a number") if text.include?(@separator) && fraction.empty?
93
+ fail_with("is not a number") unless fraction.match?(/\A\d*\z/)
94
+ if fraction.length > @entry.decimals
95
+ fail_with("has more than #{@entry.decimals} decimal places")
96
+ end
97
+
98
+ digits = whole.gsub(/\D/, "") + fraction.ljust(@entry.decimals, "0")
99
+ digits.to_i
100
+ end
101
+
102
+ def whole_pattern
103
+ return /\A\d+\z/ if @delimiter.empty?
104
+
105
+ glue = @delimiter.match?(/\A[[:space:]]+\z/) ? "[[:space:]]" : Regexp.escape(@delimiter)
106
+ /\A(?:\d+|\d{1,3}(?:#{glue}\d{3})+)\z/
107
+ end
108
+ end
109
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Amount
4
- VERSION = "0.0.8"
4
+ VERSION = "0.1.0"
5
5
  end