hypercast-wasm 0.8.0 → 0.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 120ea39dc2cdbbac9c8dc5a4c2e8b4caf96b353a0822b890a8a02aebfd18823b
4
- data.tar.gz: '08ff59eee9eaf739e985da0a8a9a06091e37ea7cbc6a24d6970d031b72f9bf67'
3
+ metadata.gz: 10c626d762b241c551b0120df1d77ec78d4831a9328c670d2a7bc50c4f3cdf42
4
+ data.tar.gz: 3fbb217bfe1717648b5a579e034894c2eb55fc797f232b19d2f41aaf950299c2
5
5
  SHA512:
6
- metadata.gz: e33bef99a50f5f76ea13278877fe9ea895473ca69f066f5db9b6fd079be2f454884aa75603f29ad2628bcc8dbb0ce1584b75659ab4e1608517ae806bda797973
7
- data.tar.gz: c0e152cb5deb06d5f0e4ffdc14b8120f559926229b1f03fd42668b45a38e2f6c18668d2f2706628f20501168222e2ff6a5541c6d44e38486536f130f16dc5f96
6
+ metadata.gz: 952da6145e72b6d45cda1cb1e56351941cd19f20008a5963ab0452c8c7eec2e6e4204de281d704f2dcf8967d2a8c4a12f0c69984528cd8fdd12e26daded490a2
7
+ data.tar.gz: e924d2a7f955fd4f748bea86a9e11b8a05343608e4eda4358c9a324aa225088b045a6b569d318fd2b4acc443c847c5809d2ddd91fdbd5fe0aeae19bfcdd56d23
data/README.md CHANGED
@@ -48,7 +48,7 @@ midnight, durations come back as exact `Rational` seconds across the core's whol
48
48
  | `unix(text, precision)` | UTC `Time` | `:seconds` / `:milliseconds` / `:microseconds` / `:nanoseconds` |
49
49
  | `excel_serial(text, epoch)` | UTC `Time` | `:y1900` / `:y1904` |
50
50
  | `date(text, order = nil)` | `Date` | strict ISO, or `:year_month_day` / `:month_day_year` / `:day_month_year` |
51
- | `datetime(text, order)` | zone-less `DateTime`, exact `Rational` seconds | a field order, as `date` |
51
+ | `datetime(text, order, am: nil, pm: nil)` | zone-less `DateTime`, exact `Rational` seconds | a field order, as `date`; optionally a culture's own AM/PM words, read beside the English ones — see [`datetime`: a culture's AM/PM](#datetime-a-cultures-ampm) |
52
52
  | `time(text)` | `Integer` nanoseconds since midnight | — |
53
53
  | `duration(text)` | exact `Rational` seconds | — |
54
54
  | `decimal_from_float(value)` | `HyperCast::Decimal`, the shortest that names the number (`0.1` is one tenth) | — |
@@ -67,9 +67,13 @@ Bad data is never an exception — it is a `Fault`. What raises is a caller bug,
67
67
  the same exception on every backend:
68
68
 
69
69
  - `ArgumentError` from `NumFormat.new`, `NumFormat#override` (and so the `separator:`,
70
- `delimiter:` and `unit:` keywords) or `NumFormat.from_i18n` for a malformed format: separators that are not single
71
- characters or not distinct, a currency symbol that is too long or carries an ASCII digit or
72
- whitespace.
70
+ `delimiter:` and `unit:` keywords) or `NumFormat.from_i18n` for a malformed format: a
71
+ separator that is empty, longer than 8 UTF-8 bytes or not distinct from its partner
72
+ (the pair in force for currency amounts included), any symbol carrying an ASCII digit, a
73
+ sign, percent or currency symbol carrying ASCII whitespace, a currency symbol longer than
74
+ 16 bytes, or a digit zero that is ASCII or starts no digit block. And from `datetime` for
75
+ a day-period designator that is empty, longer than 16 bytes, carries an ASCII digit or
76
+ starts or ends in ASCII whitespace.
73
77
  - `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
74
78
  that names nothing: an unknown Symbol, or anything that is not a Symbol (`"seconds"`
75
79
  included). `date(text, nil)` is not one of these: an explicit `nil` order is the strict ISO
@@ -134,41 +138,98 @@ value.to_d # => BigDecimal("-1234.5")
134
138
  `to_d` requires the `bigdecimal` gem lazily on first call — a bundled gem since Ruby 3.4,
135
139
  deliberately not a dependency of this one, so under Bundler add it to your own Gemfile.
136
140
 
137
- ### `NumFormat`: declared separators, and a declared currency
141
+ ### `NumFormat`: declared separators, symbols and digits
138
142
 
139
143
  ```ruby
140
144
  HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
141
145
  ```
142
146
 
143
147
  The flags are `GROUPING`, `PARENTHESES`, `EXPONENT`, `RADIX_PREFIXES`, `PERCENT` and
144
- `CURRENCY` (`ALL_STYLES` is all six), plus the `SEPARATOR_DETECT` policy. The currency
145
- symbol is the one field a culture table would fill in: it is declared, never looked up, and
146
- honored only while `CURRENCY` is set — once, leading (`$5`, `-$5`, `$ -5`) or trailing
147
- (`5 €`, `1.234,50 kr.`), with optional ASCII whitespace between symbol and digits, and
148
- accounting parentheses wrapping symbol and digits together (`($5)`). Declared with the
149
- flag off, the symbol is a `:malformed` `Fault` at the symbol; the flag with nothing declared
150
- (`currency: ""`, the default — `INVARIANT` and `DETECT` declare none) matches nothing. A
151
- symbol longer than 16 UTF-8 bytes, or carrying an ASCII digit or ASCII whitespace, is an
152
- `ArgumentError` at construction, the same caller-bug treatment equal separators get.
148
+ `CURRENCY` (`ALL_STYLES` is all six), plus `TRAILING_SIGN` — deliberately not in
149
+ `ALL_STYLES`; see below — and the `SEPARATOR_DETECT` policy. A separator is one or two
150
+ characters (`", "`, comma then space, is a separator), up to 8 UTF-8 bytes.
151
+
152
+ Everything past the separators and the flags is what a culture table would fill in, and
153
+ every such field is declared, never looked up — `from_i18n` fills what Rails' locale data
154
+ offers, and the rest is a keyword on `new` or `Data#with`. The rule for all of them: **a
155
+ declared symbol is read beside its ASCII default, never instead of it.** ASCII `-`, `+`,
156
+ `%` and `0`–`9` are always read, so a format that declares nothing (`""` everywhere, the
157
+ default — `INVARIANT` and `DETECT` declare none) reads exactly as the invariant culture,
158
+ and nothing that parsed before stops parsing when a symbol is declared.
159
+
160
+ - `currency:` — honored only while `CURRENCY` is set: once, leading (`$5`, `-$5`, `$ -5`)
161
+ or trailing (`5 €`, `1.234,50 kr.`), with optional ASCII whitespace between symbol and
162
+ digits, and accounting parentheses wrapping symbol and digits together (`($5)`). Declared
163
+ with the flag off, the symbol is a `:malformed` `Fault` at the symbol; the flag with
164
+ nothing declared matches nothing.
165
+ - `currency_decimal_sep:`, `currency_group_sep:` — the separators a currency amount is
166
+ written with, for a culture whose plain numbers and amounts differ (Swiss German writes
167
+ `1.234,5` and `CHF 1'234.50`). Read in place of the number separators when `CURRENCY` is
168
+ set, the symbol is declared, and the symbol is found in the text at either edge; each
169
+ `""` keeps its number counterpart. Under `SEPARATOR_DETECT`, detection governs the whole
170
+ token and these are ignored.
171
+ - `minus_sign:`, `plus_sign:` — further spellings of the signs (`"−"`, U+2212, for Estonian
172
+ and Basque; `"+"` fullwidth), read wherever ASCII `-`/`+` are: ahead of the digits, before
173
+ or after a leading currency symbol. Two signs are `:malformed` at the later one.
174
+ - `TRAILING_SIGN` — permits the sign *after* the digits, the way a locale whose negative
175
+ pattern is `n-` writes it: `5-`, `5 -`, `5+`, `5 €-`, `5- €`, `2.5%-`, ASCII or
176
+ declared, once, never beside a leading sign nor inside parentheses. It is a flag, not a
177
+ symbol, and it is not in `ALL_STYLES`: `from_i18n` turns it on from the locale's own
178
+ `negative_format` rather than assuming it.
179
+ - `percent:` — a further spelling of the percent symbol (`"٪"` in Arabic-script cultures,
180
+ `"%"` fullwidth), read as a trailing `%` is while `PERCENT` is set, on the real and
181
+ decimal doors; integer doors never read a percent.
182
+ - `digit_zero:` — the culture's own digits, as their zero: `"٠"` (U+0660) declares the ten
183
+ Arabic-Indic digits, `"0"` the fullwidth ones. They are read beside ASCII `0`–`9`
184
+ everywhere digits are — integer part, fraction, exponent — and may be mixed with them;
185
+ radix-prefixed (`0x`, `0b`) digits stay ASCII.
153
186
 
154
187
  ```ruby
155
188
  dollars = HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
156
189
  HyperCast.i32("($1,234)", dollars) # => Success(value: -1234)
157
190
  HyperCast.decimal("$1,234.50", dollars) # => Success(value: Decimal(magnitude: 12345, scale: 1, negative: false))
158
191
  HyperCast.i32("$5", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 0, length: 1)
192
+
193
+ estonian = HyperCast::NumFormat.new(decimal_sep: ",", group_sep: " ", flags: HyperCast::ALL_STYLES, minus_sign: "−")
194
+ HyperCast.f64("−1 234,5", estonian) # => Success(value: -1234.5) (U+2212 minus; ASCII "-" still reads)
195
+
196
+ francs = HyperCast::NumFormat.new(decimal_sep: ",", group_sep: ".", flags: HyperCast::ALL_STYLES | HyperCast::TRAILING_SIGN,
197
+ currency: "CHF", currency_decimal_sep: ".", currency_group_sep: "'")
198
+ HyperCast.f64("CHF 1'234.50", francs) # => Success(value: 1234.5) (the currency separators, the symbol being present)
199
+ HyperCast.f64("1.234,50", francs) # => Success(value: 1234.5) (the number separators)
200
+ HyperCast.i32("5 CHF-", francs) # => Success(value: -5) (TRAILING_SIGN)
201
+
202
+ arabic = HyperCast::NumFormat.new(decimal_sep: "٫", group_sep: "٬", flags: HyperCast::ALL_STYLES, percent: "٪", digit_zero: "٠")
203
+ HyperCast.f64("١٬٢٣٤٫٥", arabic) # => Success(value: 1234.5)
204
+ HyperCast.decimal("٥٠٪", arabic) # => Success(value: Decimal(magnitude: 5, scale: 1, negative: false))
159
205
  ```
160
206
 
161
- Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code points, the
162
- flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
163
- and memoized by identity on every backend, so declaring a currency costs a cast nothing.
207
+ A malformed field is an `ArgumentError` at construction, naming the field — the same
208
+ caller-bug treatment equal separators have always had: an empty or over-long separator, an
209
+ equal pair (the number pair, or the pair in force for currency amounts), a symbol carrying
210
+ an ASCII digit, a sign, percent or currency symbol carrying ASCII whitespace, a currency
211
+ symbol over 16 bytes, a digit zero that is ASCII or does not start a digit block. The
212
+ limits are `NumFormat::SYMBOL_MAX_BYTES` (8) and `NumFormat::CURRENCY_MAX_BYTES` (16).
213
+
214
+ Across the ABI a `NumFormat` is a 92-byte, size-tagged struct that the core reads in
215
+ place — `NumFormat#packed` is exactly those bytes: the size word, the flags, the digit
216
+ zero's code point, then each symbol's UTF-8 bytes and length held inline. It is packed
217
+ once per format object and memoized by identity on every backend, so declaring a currency
218
+ or a culture's digits costs a cast nothing.
219
+
220
+ **What stays English: the boolean lexicon.** `bool` reads its twenty lexemes
221
+ (`true`/`false`, `yes`/`no`, `on`/`off`, …) in English only. No platform carries culture
222
+ data for boolean words, so there is no bridge that could fill them; a caller with a
223
+ localized source maps its words before the door.
164
224
 
165
225
  ### Rails' words for a format: `separator:`, `delimiter:`, `unit:`
166
226
 
167
227
  Ruby's standard library carries no locale number data, so the vocabulary Ruby code already
168
228
  speaks is Rails' — the number helpers' and I18n's `number.format`: `separator` (the decimal
169
- mark), `delimiter` (grouping) and `unit` (the currency symbol). Every numeric door takes
170
- them as keywords that replace those fields of the format for the call, and
171
- `NumFormat.from_i18n` builds a format from the Hash I18n hands back:
229
+ mark), `delimiter` (grouping), `unit` (the currency symbol) and `negative_format` (where
230
+ the sign goes). Every numeric door takes the first three as keywords that replace those
231
+ fields of the format for the call, and `NumFormat.from_i18n` builds a format from the Hash
232
+ I18n hands back:
172
233
 
173
234
  ```ruby
174
235
  inv = HyperCast::NumFormat::INVARIANT
@@ -177,19 +238,63 @@ HyperCast.decimal("1.234,50 kr.", inv, separator: ",", delimiter: ".", unit: "kr
177
238
 
178
239
  german = HyperCast::NumFormat.from_i18n(I18n.t("number.format")) # {separator: ",", delimiter: ".", ...}
179
240
  HyperCast.i32("1.234", german) # => Success(value: 1234)
241
+
242
+ # A locale whose currency amounts use other separators than its numbers: both Hashes.
243
+ swiss = HyperCast::NumFormat.from_i18n(I18n.t("number.format"), currency_format: I18n.t("number.currency.format"))
244
+ HyperCast.f64("CHF 1'234.50", swiss) # => Success(value: 1234.5)
245
+ HyperCast.f64("1.234,50", swiss) # => Success(value: 1234.5)
180
246
  ```
181
247
 
182
248
  The keywords go through `NumFormat#override`, which is public: the result is validated like
183
- any `NumFormat`, and the flags are left alone, so a `unit:` on a format without `CURRENCY`
184
- is declared but not honored, as it would be in the constructor. `from_i18n` reads Symbol or
185
- String keys and ignores every other key, so `number.currency.format` works as well. Missing
186
- keys keep the invariant's fields. An empty `delimiter` (a locale that writes no grouping)
187
- turns `GROUPING` off. `flags:` defaults to `ALL_STYLES`.
249
+ any `NumFormat`, and the flags and every other field are left alone, so a `unit:` on a
250
+ format without `CURRENCY` is declared but not honored, as it would be in the constructor.
251
+ `from_i18n` reads Symbol or String keys and ignores every other key, so
252
+ `number.currency.format` passed alone works as well. Missing keys keep the invariant's
253
+ fields. An empty `delimiter` (a locale that writes no grouping) turns `GROUPING` off. A
254
+ `negative_format` that writes the sign after the number (`"%n-"`, `"%n %u-"`) turns
255
+ `TRAILING_SIGN` on; `"-%n"`, the Rails default, leaves it off. `currency_format:` takes
256
+ `number.currency.format` beside `number.format`: its `separator` and `delimiter` become
257
+ `currency_decimal_sep` and `currency_group_sep` (a key it lacks declares nothing, so the
258
+ number separator stands in), its `unit` the currency symbol, and its `negative_format`
259
+ counts the same way. `flags:` defaults to `ALL_STYLES`.
260
+
261
+ That is everything the Hash offers. Rails carries no minus or plus sign, no percent symbol
262
+ and no digits, so `from_i18n` leaves those undeclared — the core reads ASCII `-`, `+`, `%`
263
+ and `0`–`9` — and a locale that writes them differently declares them on the result:
264
+ `from_i18n(I18n.t("number.format")).with(minus_sign: "−")` for Estonian.
188
265
 
189
266
  A call without the keywords is exactly the call it was, with no extra allocation on either
190
267
  backend. A call with them builds a format each time (a few microseconds), so in a loop,
191
268
  build it once with `override` or `from_i18n` and pass that instead.
192
269
 
270
+ ### `datetime`: a culture's AM/PM
271
+
272
+ The civil date-time door reads a 12-hour clock's `AM`/`PM` ASCII case-insensitively, after
273
+ the clock (`3:04 PM`) or ahead of it (`PM 3:04`). `am:` and `pm:` declare a culture's own
274
+ words beside the English ones — German's `vorm.`/`nachm.`, Korean's `오전`/`오후`
275
+ (written ahead of the clock), Spanish's `a. m.`/`p. m.` (a space inside is fine) — matched
276
+ exactly, with or without a space between word and clock. English is still read whatever is
277
+ declared; `nil`, the default, declares nothing, so a call without the keywords is exactly
278
+ the call it was. Like a `NumFormat`'s symbols the words are declared, never looked up:
279
+ Ruby's stdlib has no day-period data, and `I18n.t("time.am")`/`("time.pm")` are where a
280
+ Rails app finds them.
281
+
282
+ ```ruby
283
+ HyperCast.datetime("1/7/2026 3:04 nachm.", :day_month_year, am: "vorm.", pm: "nachm.")
284
+ # => Success(value: #<DateTime: 2026-07-01T15:04:00+00:00 ...>)
285
+ HyperCast.datetime("2026-01-07 오후 3:04", :year_month_day, am: "오전", pm: "오후")
286
+ # => Success(value: #<DateTime: 2026-01-07T15:04:00+00:00 ...>)
287
+ HyperCast.datetime("1/7/2026 3:04 nachm.", :day_month_year) # => Fault(reason: :malformed, offset: 13, length: 1)
288
+ ```
289
+
290
+ A designator is 1 to 16 UTF-8 bytes with no ASCII digit and no ASCII whitespace at either
291
+ end; anything else is an `ArgumentError`. Only `datetime` reads them — `date` has no time
292
+ part. Across the ABI they are a 40-byte, size-tagged struct passed beside the order, null
293
+ when nothing is declared. The designators are keywords, so they are read, validated and
294
+ packed per call — the Fiddle backend packs them into its per-thread scratch — and a call
295
+ that declares them costs about twice the plain call on the Magnus backend (the keyword
296
+ scan is the cost: `am: nil, pm: nil` pays the same) and about half again on Fiddle.
297
+
193
298
  [^overrides]: Plus the optional keywords `separator:`, `delimiter:` and `unit:`, which
194
299
  override those fields of `format` for the call. See
195
300
  [Rails' words for a format](#rails-words-for-a-format-separator-delimiter-unit).
@@ -250,6 +355,12 @@ HyperCast::Interop.version(0x000700) # => "0.7.0"
250
355
  is now identity-matched like `INVARIANT`, and any other format is resolved once per
251
356
  thread and memoized by identity — anchored in a thread-variable so the memo's key can
252
357
  never be a recycled address — which turned the per-call cost into one pointer compare.
358
+
359
+ A declared currency symbol costs text that does not carry one about 6 ns on the Magnus
360
+ backend: in one run, `1,234,567` took 151 ns under `INVARIANT` and 157 ns under a format
361
+ declaring `$`. A fault costs more than a success: the same run returned the `Fault` for
362
+ `12345x7` in 222 ns, against 101 ns for `Integer("12345x7", exception: false)` returning
363
+ `nil`. The door builds a reason and a span where `Integer()` hands back nothing.
253
364
  The three reason Symbols and the option Symbols (`:seconds`, `:month_day_year`, …) are
254
365
  cached the same way, so a fault or a declared option is a pointer compare too, never a
255
366
  `Symbol#name` materialization.
@@ -16,10 +16,11 @@ module HyperCast
16
16
 
17
17
  # Each door's C signature, by shape — resolved to Fiddle types only in load_functions,
18
18
  # so nothing here touches Fiddle until the Fiddle backend actually runs: plain is
19
- # (text, len, out, fault), numeric adds the packed NumFormat pointer before out, and
20
- # declared a caller-declared u32 (precision, epoch, field order) in the same place. The
21
- # typed doors take a double instead of the text and its length: typed is (value, out,
22
- # fault), typed_declared adds the u32 after the value.
19
+ # (text, len, out, fault), numeric adds the packed NumFormat pointer before out,
20
+ # declared a caller-declared u32 (precision, epoch, field order) in the same place, and
21
+ # civil the field order then the packed DayPeriods pointer (null for none). The typed
22
+ # doors take a double instead of the text and its length: typed is (value, out, fault),
23
+ # typed_declared adds the u32 after the value.
23
24
  DOORS = {
24
25
  cast_bool: :plain, cast_char: :plain,
25
26
  cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
@@ -27,7 +28,7 @@ module HyperCast
27
28
  cast_f32: :numeric, cast_f64: :numeric, cast_decimal: :numeric,
28
29
  cast_uuid: :plain,
29
30
  cast_timestamp: :plain, cast_unix: :declared, cast_excel_serial: :declared,
30
- cast_date: :plain, cast_date_ordered: :declared, cast_datetime: :declared,
31
+ cast_date: :plain, cast_date_ordered: :declared, cast_datetime: :civil,
31
32
  cast_time: :plain, cast_duration: :plain,
32
33
  cast_decimal_from_f64: :typed, cast_excel_serial_from_f64: :typed_declared,
33
34
  cast_excel_time: :typed, cast_excel_duration: :typed
@@ -114,6 +115,7 @@ module HyperCast
114
115
  plain: plain,
115
116
  numeric: plain.dup.insert(2, Fiddle::TYPE_VOIDP),
116
117
  declared: plain.dup.insert(2, Fiddle::TYPE_UINT32_T),
118
+ civil: plain.dup.insert(2, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP),
117
119
  typed: [Fiddle::TYPE_DOUBLE, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP],
118
120
  typed_declared: [Fiddle::TYPE_DOUBLE, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
119
121
  }
data/lib/hypercast.rb CHANGED
@@ -30,7 +30,7 @@ require_relative "hypercast/runtime"
30
30
  module HyperCast
31
31
  # This gem's own version — kept in lockstep with hypercast.gemspec by the
32
32
  # prepare-release workflow, so the two can never drift apart again.
33
- VERSION = "0.8.0"
33
+ VERSION = "0.9.0"
34
34
 
35
35
  # The success case of a verdict: a cast value.
36
36
  Success = Data.define(:value)
@@ -48,50 +48,72 @@ module HyperCast
48
48
 
49
49
  # Caller-declared numeric notation for the integer, real and decimal doors — declared out
50
50
  # loud (INVARIANT, or a literal), never defaulted, the same stance every binding takes.
51
- # The currency symbol is the one field a culture table would fill in: it is declared
52
- # here, never looked up, and honored only while the CURRENCY flag is set — declared with
53
- # the flag off, the symbol is a :malformed Fault at the symbol, and the flag with no
54
- # symbol ("" — the default) matches nothing. Equal separators, or a symbol longer than
55
- # 16 UTF-8 bytes or carrying an ASCII digit or ASCII whitespace, are a caller bug
56
- # (ArgumentError), never a verdict.
57
- NumFormat = Data.define(:decimal_sep, :group_sep, :flags, :currency) do
58
- # Validates the declared separators up front — single characters, and distinct from
59
- # each other — and the currency symbol (a String of at most 16 UTF-8 bytes with no
60
- # ASCII digit or ASCII whitespace, since those would collide with the digit scan and
61
- # the trimming around the symbol), so a malformed format fails loudly as the caller bug
62
- # it is. All three are stored transcoded to UTF-8, the encoding the core reads — a
63
- # separator handed over in another encoding (a no-break space read from a Latin-1
64
- # file, say) is the same code point on every backend rather than whatever its bytes
65
- # happened to spell in the encoding it arrived in.
66
- def initialize(decimal_sep:, group_sep:, flags:, currency: "")
67
- raise ArgumentError, "separators must be single characters" unless
68
- decimal_sep.is_a?(String) && decimal_sep.length == 1 &&
69
- group_sep.is_a?(String) && group_sep.length == 1
70
-
71
- decimal_sep = decimal_sep.encode(Encoding::UTF_8)
72
- group_sep = group_sep.encode(Encoding::UTF_8)
51
+ # Past the separators and the flags, every field is one a culture table would fill in,
52
+ # and each is declared here, never looked up: the currency symbol; the separators a
53
+ # currency amount is written with (currency_decimal_sep, currency_group_sep — read in
54
+ # place of the number separators when the symbol is found in the text, each "" keeping
55
+ # its number counterpart); further spellings of the minus and plus signs and of the
56
+ # percent symbol; and the culture's own digits (digit_zero — the ten code points from
57
+ # that zero up, "٠" for the Arabic-Indic digits). A declared symbol is read *beside* its
58
+ # ASCII default, never instead of it — ASCII "-", "+", "%" and 0-9 are always read — so a
59
+ # format that declares nothing ("" everywhere; the default) reads exactly as the
60
+ # invariant culture, and nothing that parsed before stops parsing. The currency symbol is
61
+ # honored only while the CURRENCY flag is set — declared with the flag off, the symbol is
62
+ # a :malformed Fault at the symbol, and the flag with no symbol matches nothing — and the
63
+ # percent symbol while PERCENT is. A separator may be one or two characters (", " —
64
+ # comma, space — is a separator), up to 8 UTF-8 bytes. A malformed format is a caller bug
65
+ # (ArgumentError), never a verdict: an empty or over-long separator, equal separators (or
66
+ # an equal pair in force for currency amounts), a symbol carrying an ASCII digit, a sign
67
+ # or percent symbol carrying ASCII whitespace, a currency symbol longer than 16 bytes or
68
+ # carrying a digit or whitespace, or a digit zero that is ASCII or starts no digit block.
69
+ NumFormat = Data.define(:decimal_sep, :group_sep, :flags, :currency,
70
+ :currency_decimal_sep, :currency_group_sep,
71
+ :minus_sign, :plus_sign, :percent, :digit_zero) do
72
+ # Validates every declared field up front — the separators (1 to 8 UTF-8 bytes each,
73
+ # distinct from each other, and so the pair in force for currency amounts), the signs,
74
+ # the percent and currency symbols (no ASCII digit, since a digit is a digit before it
75
+ # is anything else, and no ASCII whitespace, which would collide with the trimming
76
+ # around them) and the digit zero (one non-ASCII character whose ten successors share
77
+ # its UTF-8 prefix, which every Unicode digit block's zero does) — so a malformed
78
+ # format fails loudly as the caller bug it is. Every field is stored transcoded to
79
+ # UTF-8, the encoding the core reads — a separator handed over in another encoding (a
80
+ # no-break space read from a Latin-1 file, say) is the same code point on every backend
81
+ # rather than whatever its bytes happened to spell in the encoding it arrived in.
82
+ def initialize(decimal_sep:, group_sep:, flags:, currency: "", currency_decimal_sep: "",
83
+ currency_group_sep: "", minus_sign: "", plus_sign: "", percent: "", digit_zero: "")
84
+ limit = self.class::SYMBOL_MAX_BYTES
85
+ decimal_sep = declared("decimal separator", decimal_sep, limit, required: true)
86
+ group_sep = declared("group separator", group_sep, limit, required: true)
73
87
  raise ArgumentError, "decimal and group separators must differ; both are #{decimal_sep.inspect}" if
74
88
  decimal_sep == group_sep
75
- raise ArgumentError, "currency symbol must be a String; got #{currency.inspect}" unless currency.is_a?(String)
76
89
 
77
- symbol = currency.encode(Encoding::UTF_8)
78
- limit = self.class::CURRENCY_MAX_BYTES
79
- raise ArgumentError, "currency symbol #{currency.inspect} exceeds #{limit} UTF-8 bytes" if
80
- symbol.bytesize > limit
81
- raise ArgumentError, "currency symbol #{currency.inspect} must not contain an ASCII digit or whitespace" if
82
- symbol.match?(/[0-9\t\n\f\r ]/)
90
+ currency_decimal_sep = declared("currency decimal separator", currency_decimal_sep, limit)
91
+ currency_group_sep = declared("currency group separator", currency_group_sep, limit)
92
+ money_decimal = currency_decimal_sep.empty? ? decimal_sep : currency_decimal_sep
93
+ money_group = currency_group_sep.empty? ? group_sep : currency_group_sep
94
+ raise ArgumentError, "currency decimal and group separators must differ; both are #{money_decimal.inspect}" if
95
+ money_decimal == money_group
83
96
 
84
- super(decimal_sep: decimal_sep, group_sep: group_sep, flags: flags, currency: symbol)
97
+ super(decimal_sep: decimal_sep, group_sep: group_sep, flags: flags,
98
+ currency: declared("currency symbol", currency, self.class::CURRENCY_MAX_BYTES, flush: true),
99
+ currency_decimal_sep: currency_decimal_sep, currency_group_sep: currency_group_sep,
100
+ minus_sign: declared("minus sign", minus_sign, limit, flush: true),
101
+ plus_sign: declared("plus sign", plus_sign, limit, flush: true),
102
+ percent: declared("percent symbol", percent, limit, flush: true),
103
+ digit_zero: digit_zero_of(digit_zero))
85
104
  end
86
105
 
87
106
  # This format with the fields Ruby's number vocabulary names replaced — the words Rails'
88
107
  # number helpers and I18n's number.format use: separator: (the decimal mark),
89
108
  # delimiter: (grouping) and unit: (the currency symbol). A nil keyword leaves its
90
109
  # field alone, so with none given this is the format itself, not a copy. The result is
91
- # validated like any NumFormat (single, distinct separators; a declarable symbol), and
92
- # the flags are untouched: a unit: declared on a format without CURRENCY is a :malformed
93
- # Fault at the symbol, exactly as declaring it in the constructor would be. Every
94
- # numeric door takes the same three keywords and applies them through this method.
110
+ # validated like any NumFormat (declarable, distinct separators; a declarable symbol),
111
+ # and the flags and every other field are untouched: a unit: declared on a format
112
+ # without CURRENCY is a :malformed Fault at the symbol, exactly as declaring it in the
113
+ # constructor would be. Every numeric door takes the same three keywords and applies
114
+ # them through this method; the fields Rails has no word for (the signs, the percent
115
+ # symbol, the digits, the currency separators) are declared in the constructor or
116
+ # through Data#with.
95
117
  def override(separator: nil, delimiter: nil, unit: nil)
96
118
  return self unless separator || delimiter || unit
97
119
 
@@ -99,40 +121,129 @@ module HyperCast
99
121
  end
100
122
 
101
123
  # A format from a Hash shaped like I18n's number.format (or number.currency.format):
102
- # :separator, :delimiter and :unit, Symbol or String keys, every other key (precision,
103
- # format, significant, ...) ignored — so `NumFormat.from_i18n(I18n.t("number.format"))`
104
- # drops a Rails locale straight in, with no I18n dependency here. A missing or nil key
105
- # keeps the invariant's "." / "," / no symbol. An empty delimiter — the locale writes no
106
- # grouping — turns GROUPING off rather than declaring a separator, since a NumFormat
107
- # always carries one; the placeholder it gets is whichever of "," and "." the decimal
108
- # mark is not. flags defaults to every lenience, the same as INVARIANT.
124
+ # :separator, :delimiter, :unit and :negative_format, Symbol or String keys, every other
125
+ # key (precision, format, significant, ...) ignored — so
126
+ # `NumFormat.from_i18n(I18n.t("number.format"))` drops a Rails locale straight in, with
127
+ # no I18n dependency here. A missing or nil key keeps the invariant's "." / "," / no
128
+ # symbol. An empty delimiter — the locale writes no grouping — turns GROUPING off rather
129
+ # than declaring a separator, since a NumFormat always carries one; the placeholder it
130
+ # gets is whichever of "," and "." the decimal mark is not. A negative_format that writes
131
+ # the sign after the number ("%n-", "%n %u-") turns TRAILING_SIGN on; "-%n", the Rails
132
+ # default, leaves it off. flags defaults to every lenience, the same as INVARIANT.
109
133
  #
110
- # Only the separators and the unit carry over; the native format has no field for the
111
- # rest. The core reads ASCII "+" and "-" only, so a locale that writes negatives with
112
- # another sign (U+2212 for Estonian and Basque, for instance) faults on them; currency
113
- # amounts in a locale whose number.currency.format separators differ from number.format's
114
- # read with whichever Hash is passed, so pass the currency one for currency columns; and
115
- # percent is an ASCII "%". Every lenience is on, radix prefixes and percent included;
134
+ # currency_format: takes the locale's number.currency.format Hash beside number.format,
135
+ # for a column that mixes plain numbers and currency amounts in a locale whose two
136
+ # differ (the Swiss German franc, say — "1'234.50" beside "1.234,50"): its separator and
137
+ # delimiter become the currency separators, read in place of the number ones when the
138
+ # unit is found in the text, and its unit the currency symbol. A missing key there
139
+ # declares nothing, keeping the number separator it stands in for; an explicitly empty
140
+ # delimiter gets the same placeholder rule as above; its negative_format turns
141
+ # TRAILING_SIGN on the same way. Passed alone, number.currency.format reads as the one
142
+ # number format, as before.
143
+ #
144
+ # The rest of the format is what the Hash cannot offer: Rails carries no minus or plus
145
+ # sign, no percent symbol and no digits, so those stay undeclared here — the core reads
146
+ # ASCII "-", "+", "%" and 0-9 — and a locale that writes negatives with another sign
147
+ # (U+2212 for Estonian and Basque, for instance) declares it on the result with
148
+ # Data#with (minus_sign: "−"). Every lenience is on, radix prefixes and percent included;
116
149
  # narrow flags: for anything stricter.
117
- def self.from_i18n(number_format, flags: ALL_STYLES)
118
- raise ArgumentError, "expected a Hash-like number format; got #{number_format.inspect}" unless
119
- number_format.respond_to?(:to_h)
120
-
121
- entries = number_format.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
150
+ def self.from_i18n(number_format, currency_format: nil, flags: ALL_STYLES)
151
+ entries = i18n_entries(number_format)
122
152
  separator = entries[:separator] || "."
123
153
  delimiter = entries[:delimiter] || ","
124
154
  if delimiter == ""
125
155
  flags &= ~GROUPING
126
156
  delimiter = separator == "," ? "." : ","
127
157
  end
128
- new(decimal_sep: separator, group_sep: delimiter, flags: flags, currency: entries[:unit] || "")
158
+ flags |= TRAILING_SIGN if trailing_sign?(entries[:negative_format])
159
+ currency = entries[:unit] || ""
160
+ currency_separator = currency_delimiter = ""
161
+ if currency_format
162
+ money = i18n_entries(currency_format)
163
+ currency = money[:unit] || currency
164
+ currency_separator = money[:separator] || ""
165
+ currency_delimiter = money[:delimiter] || ""
166
+ currency_delimiter = (money[:separator] || separator) == "," ? "." : "," if money[:delimiter] == ""
167
+ flags |= TRAILING_SIGN if trailing_sign?(money[:negative_format])
168
+ end
169
+ new(decimal_sep: separator, group_sep: delimiter, flags: flags, currency: currency,
170
+ currency_decimal_sep: currency_separator, currency_group_sep: currency_delimiter)
171
+ end
172
+
173
+ # The I18n Hash's entries under Symbol keys, whatever keys it arrived with.
174
+ def self.i18n_entries(number_format)
175
+ raise ArgumentError, "expected a Hash-like number format; got #{number_format.inspect}" unless
176
+ number_format.respond_to?(:to_h)
177
+
178
+ number_format.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
129
179
  end
180
+ private_class_method :i18n_entries
130
181
 
131
- # The 32-byte little-endian form the native ABI's NumFormat struct expects: the two
132
- # separators as code points, the flags, the symbol's byte length, then the symbol's
133
- # UTF-8 bytes zero-padded to 16.
182
+ # Whether an I18n negative_format ("-%n", "%n-", "-%u%n", "%n %u-") writes the sign
183
+ # after the number: a "-" to the right of the "%n" placeholder.
184
+ def self.trailing_sign?(negative_format)
185
+ return false unless negative_format.is_a?(String)
186
+
187
+ number = negative_format.index("%n")
188
+ sign = negative_format.rindex("-")
189
+ !number.nil? && !sign.nil? && sign > number
190
+ end
191
+ private_class_method :trailing_sign?
192
+
193
+ # The 92-byte little-endian form of the native ABI's size-tagged NumFormat struct, the
194
+ # layout the core reads in place: the size word (92), the flags, the digit zero's code
195
+ # point (0 for none), then each symbol as its UTF-8 bytes zero-padded to its field
196
+ # followed by its byte length — eight bytes and a length for each of the two number
197
+ # separators, the two currency separators, the two signs and the percent symbol, in
198
+ # that order; sixteen and a length for the currency symbol. A length of 0 declares
199
+ # nothing.
134
200
  def packed
135
- [decimal_sep.ord, group_sep.ord, flags, currency.bytesize].pack("L<L<L<L<") + [currency].pack("a16")
201
+ head = [self.class::PACKED_BYTES, flags, digit_zero.empty? ? 0 : digit_zero.ord].pack("L<L<L<")
202
+ symbols = [decimal_sep, group_sep, currency_decimal_sep, currency_group_sep, minus_sign, plus_sign, percent]
203
+ head + symbols.map { |symbol| [symbol, symbol.bytesize].pack("a8C") }.join +
204
+ [currency, currency.bytesize].pack("a16C")
205
+ end
206
+
207
+ private
208
+
209
+ # The declared text of one field as the UTF-8 the core reads: a String of 1 to `limit`
210
+ # UTF-8 bytes with no ASCII digit — nor, for a symbol that sits flush against the
211
+ # digits (the signs, the percent and currency symbols), any ASCII whitespace — or ""
212
+ # where the field may be left undeclared. Anything else is the caller bug it names.
213
+ def declared(field, text, limit, flush: false, required: false)
214
+ raise ArgumentError, "#{field} must be a String; got #{text.inspect}" unless text.is_a?(String)
215
+
216
+ symbol = text.encode(Encoding::UTF_8)
217
+ if symbol.empty?
218
+ raise ArgumentError, "#{field} must not be empty" if required
219
+
220
+ return symbol
221
+ end
222
+ raise ArgumentError, "#{field} #{text.inspect} exceeds #{limit} UTF-8 bytes" if symbol.bytesize > limit
223
+
224
+ if flush
225
+ raise ArgumentError, "#{field} #{text.inspect} must not contain an ASCII digit or whitespace" if
226
+ symbol.match?(/[0-9\t\n\f\r ]/)
227
+ elsif symbol.match?(/[0-9]/)
228
+ raise ArgumentError, "#{field} #{text.inspect} must not contain an ASCII digit"
229
+ end
230
+ symbol
231
+ end
232
+
233
+ # The declared digit zero as UTF-8: "" for none, else exactly one character that is not
234
+ # ASCII and whose code point modulo 64 is at most 54 — true of the zero of every decimal
235
+ # digit block Unicode defines, and the shape the core recognises a digit by its last
236
+ # byte from.
237
+ def digit_zero_of(text)
238
+ raise ArgumentError, "digit zero must be a String; got #{text.inspect}" unless text.is_a?(String)
239
+
240
+ zero = text.encode(Encoding::UTF_8)
241
+ return zero if zero.empty?
242
+
243
+ raise ArgumentError, "digit zero #{text.inspect} must be one non-ASCII character that starts a digit block" unless
244
+ zero.length == 1 && zero.ord >= 0x80 && zero.ord % 64 <= 54
245
+
246
+ zero
136
247
  end
137
248
  end
138
249
 
@@ -141,6 +252,13 @@ module HyperCast
141
252
  # is scoped lexically, and landed on HyperCast instead of on NumFormat.
142
253
  NumFormat::CURRENCY_MAX_BYTES = 16
143
254
 
255
+ # The widest separator, sign or percent symbol the native ABI carries inline, in UTF-8
256
+ # bytes: one or two characters of any script.
257
+ NumFormat::SYMBOL_MAX_BYTES = 8
258
+
259
+ # The size of NumFormat#packed — the native ABI's size-tagged NumFormat layout — in bytes.
260
+ NumFormat::PACKED_BYTES = 92
261
+
144
262
  # Permit the group separator between digits (sizes not validated — between digits is the rule).
145
263
  GROUPING = 1
146
264
  # Permit accounting parentheses as negation: (1,234) is -1234.
@@ -149,7 +267,8 @@ module HyperCast
149
267
  EXPONENT = 1 << 2
150
268
  # Permit 0x/&H/0b two's-complement radix prefixes (0xFF is -1 for an i8).
151
269
  RADIX_PREFIXES = 1 << 3
152
- # Permit a trailing %, dividing by 100. Real and decimal doors only.
270
+ # Permit a trailing % (or the format's declared percent symbol), dividing by 100. Real
271
+ # and decimal doors only.
153
272
  PERCENT = 1 << 4
154
273
  # Resolve the ./, roles per input from structure instead of the declared separators
155
274
  # (which are ignored while this flag is set). Detection, not sniffing: a repeated
@@ -165,8 +284,15 @@ module HyperCast
165
284
  # along with the digits ("($5)"). With no symbol declared the flag matches nothing.
166
285
  # Integer, real and decimal doors.
167
286
  CURRENCY = 1 << 6
168
- # Every lenience on (SEPARATOR_DETECT is a separator policy, not a lenience, and is
169
- # deliberately not included).
287
+ # Permit the sign after the digits ("5-", "5 -", "5 €-", "5- €"), the way a locale whose
288
+ # negative_format is "%n-" writes it: ASCII - and + or the format's declared signs, once,
289
+ # with optional ASCII whitespace before it, and never beside a leading sign or inside
290
+ # accounting parentheses. Not part of ALL_STYLES — NumFormat.from_i18n sets it from the
291
+ # locale's own negative_format rather than assuming it.
292
+ TRAILING_SIGN = 1 << 7
293
+ # Every lenience on — every one there was before TRAILING_SIGN, which is declared from a
294
+ # locale's pattern rather than assumed (SEPARATOR_DETECT is a separator policy, not a
295
+ # lenience, and is deliberately not included either).
170
296
  ALL_STYLES = GROUPING | PARENTHESES | EXPONENT | RADIX_PREFIXES | PERCENT | CURRENCY
171
297
 
172
298
  # The invariant profile — '.' decimal, ',' grouping, every lenience on, no currency
@@ -373,8 +499,21 @@ module HyperCast
373
499
  # artifact, not data (the same caveat PHP's UTC-labeled DateTimeImmutable carries);
374
500
  # fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
375
501
  # instant door. An unknown order is a caller bug (KeyError).
376
- def datetime(text, order)
377
- declared(:cast_datetime, text, DATE_ORDERS.fetch(order), 16) { |out| Interop.decode(:datetime, out) }
502
+ #
503
+ # A 12-hour clock's AM/PM is read, before or after the clock ("3:04 PM", "PM 3:04"),
504
+ # ASCII case-insensitively; am: and pm: declare a culture's own designators beside
505
+ # them — "vorm."/"nachm.", "오전"/"오후", "a. m."/"p. m." — matched exactly, after the
506
+ # clock ("3:04 nachm.") or ahead of it ("오후 3:04"), with or without a space between.
507
+ # Declared, never looked up, like a NumFormat's symbols; nil (the default) declares
508
+ # nothing, so a call without them is exactly the call it was. A designator is 1 to 16
509
+ # UTF-8 bytes with no ASCII digit and no ASCII whitespace at either end (a space inside
510
+ # is fine); anything else is a caller bug (ArgumentError). The designators are read,
511
+ # validated and packed per call: a call that declares them costs about twice the plain
512
+ # call on the Magnus backend (the keyword scan itself — nil keywords pay the same) and
513
+ # about half again here on Fiddle.
514
+ def datetime(text, order, am: nil, pm: nil)
515
+ periods = packed_day_periods(am, pm) if am || pm
516
+ civil(:cast_datetime, text, DATE_ORDERS.fetch(order), periods, 16) { |out| Interop.decode(:datetime, out) }
378
517
  end
379
518
 
380
519
  # Casts an ISO 24-hour time-of-day to an exact Integer of nanoseconds since midnight
@@ -454,18 +593,19 @@ module HyperCast
454
593
  bytes.empty? ? nil : bytes
455
594
  end
456
595
 
457
- # One 24-byte scratch allocation per thread, reused by every call: out-value at 0
596
+ # One 64-byte scratch allocation per thread, reused by every call: out-value at 0
458
597
  # (16 bytes covers every door, and malloc's alignment covers the decimal's 8), fault
459
- # span at 16. Two Fiddle::Pointer.malloc(..., RUBY_FREE) calls per cast — each
460
- # registering a GC finalizer — measured as the dominant per-call cost by an order of
461
- # magnitude. The NumFormat no longer lives here: each format owns its own packed
462
- # pointer (see packed_cache), so a numeric call copies nothing into scratch before
463
- # crossing. Allocated through Runtime.buffer, which loads the library first, so a
464
- # missing one is reported as such before Fiddle is touched.
598
+ # span at 16, and the datetime door's packed day periods at 24 (40 bytes, 4-aligned,
599
+ # written only by a call that declares them). Two Fiddle::Pointer.malloc(..., RUBY_FREE)
600
+ # calls per cast — each registering a GC finalizer — measured as the dominant per-call
601
+ # cost by an order of magnitude. The NumFormat does not live here: each format owns
602
+ # its own packed pointer (see packed_cache), so a numeric call copies nothing into
603
+ # scratch before crossing. Allocated through Runtime.buffer, which loads the library
604
+ # first, so a missing one is reported as such before Fiddle is touched.
465
605
  def scratch
466
606
  Thread.current[:hypercast_scratch] ||= begin
467
- base = Runtime.buffer(24)
468
- [base, base + 16]
607
+ base = Runtime.buffer(64)
608
+ [base, base + 16, base + 24]
469
609
  end
470
610
  end
471
611
 
@@ -491,7 +631,7 @@ module HyperCast
491
631
  end
492
632
 
493
633
  # The shared body of the integer/real/decimal doors: plain, plus the format's own
494
- # packed pointer, passed straight through — no per-call copy of the 32 bytes into
634
+ # packed pointer, passed straight through — no per-call copy of the 92 bytes into
495
635
  # scratch.
496
636
  def numeric(symbol, text, format, out_size)
497
637
  bytes = utf8(text)
@@ -500,6 +640,47 @@ module HyperCast
500
640
  verdict(rc, fault, bytes) { yield(out[0, out_size]) }
501
641
  end
502
642
 
643
+ # The civil date-time door's body: declared, plus the day periods after the order — the
644
+ # 40 packed bytes copied into this thread's scratch and passed from there, or nil
645
+ # (Fiddle's null) when none are declared, which the core reads as English AM/PM only.
646
+ def civil(symbol, text, code, periods, out_size)
647
+ bytes = utf8(text)
648
+ out, fault, slot = scratch
649
+ if periods
650
+ slot[0, periods.bytesize] = periods
651
+ else
652
+ slot = nil
653
+ end
654
+ rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, code, slot, out, fault)
655
+ verdict(rc, fault, bytes) { yield(out[0, out_size]) }
656
+ end
657
+
658
+ # The 40-byte little-endian form of the native ABI's size-tagged DayPeriods struct: the
659
+ # size word (40), then each designator as its UTF-8 bytes zero-padded to 16 followed by
660
+ # its byte length — am, then pm — and two bytes of padding. A nil designator is a
661
+ # length of 0: nothing declared beside AM or PM.
662
+ def packed_day_periods(am, pm)
663
+ am = designator("am", am)
664
+ pm = designator("pm", pm)
665
+ [40].pack("L<") + [am, am.bytesize, pm, pm.bytesize].pack("a16Ca16C") + "\0\0"
666
+ end
667
+
668
+ # A declared day-period designator as the UTF-8 the core reads — 1 to 16 bytes, no
669
+ # ASCII digit, no ASCII whitespace at either end (the grammar around it reads that) —
670
+ # or "" for nil. Anything else is the caller bug it names, the same ArgumentError on
671
+ # every backend.
672
+ def designator(name, text)
673
+ return "" if text.nil?
674
+ raise ArgumentError, "#{name} designator must be a String; got #{text.inspect}" unless text.is_a?(String)
675
+
676
+ designator = text.encode(Encoding::UTF_8)
677
+ unless designator.bytesize.between?(1, 16) && !designator.match?(/[0-9]|\A[\t\n\f\r ]|[\t\n\f\r ]\z/)
678
+ raise ArgumentError, "#{name} designator #{text.inspect} must be 1 to 16 UTF-8 bytes with no ASCII digit " \
679
+ "and no ASCII whitespace at either end"
680
+ end
681
+ designator
682
+ end
683
+
503
684
  # The shared body of the four doors that take a caller-declared u32 — a precision, an
504
685
  # epoch, or a field order — already resolved from its Symbol by the door. These bodies
505
686
  # (plain, numeric, declared, and packed_version below) are the whole Fiddle crossing.
@@ -550,21 +731,22 @@ module HyperCast
550
731
  PACKED_CACHE_LIMIT = 64
551
732
 
552
733
  # Identity-keyed memo (compare_by_identity — a pointer hash, not Data's structural
553
- # #hash over three Strings and an Integer) of a native 32-byte RawNumFormat per format
734
+ # #hash over nine Strings and an Integer) of a native 92-byte RawNumFormat per format
554
735
  # object, filled once from NumFormat#packed. Formats are reused constants in practice,
555
736
  # so the common call finds its pointer in one lookup and packs nothing. The Hash holds
556
737
  # the format, so a key can never be a recycled address; the race is benign (idempotent).
557
738
  #
558
739
  # Bounded, oldest entry out first (a Hash keeps insertion order): unbounded, it held
559
- # every format ever cast with and its 32-byte allocation for the life of the process,
740
+ # every format ever cast with and its 92-byte allocation for the life of the process,
560
741
  # which a consumer building a format per request experienced as a leak. A format
561
742
  # evicted and then used again is simply packed again, and an evicted pointer stays
562
743
  # alive for any call already holding it — the caller's own reference outlives the cache's.
563
744
  def packed_cache
564
745
  @packed_cache ||= Hash.new do |cache, format|
565
746
  cache.shift while cache.size >= PACKED_CACHE_LIMIT
566
- pointer = Runtime.buffer(32)
567
- pointer[0, 32] = format.packed
747
+ size = NumFormat::PACKED_BYTES
748
+ pointer = Runtime.buffer(size)
749
+ pointer[0, size] = format.packed
568
750
  cache[format] = pointer
569
751
  end.compare_by_identity
570
752
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hypercast-wasm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Buvinghausen