hypercast-wasm 0.7.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: 292a3d7a2650810b8c764162479aed060b9e05e04a15e12843bbe9d4a1e5f4ec
4
- data.tar.gz: '066569a04c5f82dd1ca427c58b0483c3fa0505bd414fe80add69dea5002a9667'
3
+ metadata.gz: 10c626d762b241c551b0120df1d77ec78d4831a9328c670d2a7bc50c4f3cdf42
4
+ data.tar.gz: 3fbb217bfe1717648b5a579e034894c2eb55fc797f232b19d2f41aaf950299c2
5
5
  SHA512:
6
- metadata.gz: 4c654511c4167a2addf7cb4894811c755bd6a9a9a5b7ae9bf3c35b7d24ff36cac233af75d61e7430dc7b08ef84dacf53801bf7853415a0887cbb8a61d9839b6b
7
- data.tar.gz: 3e7cefe5d32a2c8f0495eb37a93460499ed2673c7452b12f9eb6b2fa90ff5258607042564460e972027f23ba486b5210d758fa9efb49d4ae98534dc3b9754fa6
6
+ metadata.gz: 952da6145e72b6d45cda1cb1e56351941cd19f20008a5963ab0452c8c7eec2e6e4204de281d704f2dcf8967d2a8c4a12f0c69984528cd8fdd12e26daded490a2
7
+ data.tar.gz: e924d2a7f955fd4f748bea86a9e11b8a05343608e4eda4358c9a324aa225088b045a6b569d318fd2b4acc443c847c5809d2ddd91fdbd5fe0aeae19bfcdd56d23
data/README.md CHANGED
@@ -9,8 +9,8 @@ Symbol plus the exact span that offended. Two backends, one public surface: a Ma
9
9
  native extension where a precompiled platform gem covers you, stdlib Fiddle as the last
10
10
  resort everywhere else — selected automatically, zero compiles either way.**
11
11
 
12
- Allocation-lean scalar casts — booleans, the full integer family, reals, exact decimals,
13
- UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
12
+ Allocation-lean scalar casts — booleans, characters, the full integer family, reals, exact
13
+ decimals, UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
14
14
  floor. The fast path links the core straight into a Ruby extension (Magnus): on require it
15
15
  redefines the doors in place on the `HyperCast` module — no delegation layer, no second
16
16
  surface, which is exactly what keeps the backends provably in agreement.
@@ -39,15 +39,16 @@ midnight, durations come back as exact `Rational` seconds across the core's whol
39
39
  | Door | Value | Declares |
40
40
  |---|---|---|
41
41
  | `bool(text)` | `true`/`false` | — |
42
- | `i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64` `(text, format)` | `Integer` (unbounded — u64 is the true unsigned value) | a `NumFormat` |
43
- | `f32` `f64` `(text, format)` | `Float` (f32 widened losslessly) | a `NumFormat` |
44
- | `decimal(text, format)` | `HyperCast::Decimal` — exact sign, 96-bit magnitude, base-10 scale | a `NumFormat` |
42
+ | `char(text)` | one-character UTF-8 `String` — the text verbatim when it is one character (`" "` is a space), else a declared code point (`65`, `U+0041`, `0x41`, `&H41`, `A`, `A`) | — |
43
+ | `i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64` `(text, format)` [^overrides] | `Integer` (unbounded — u64 is the true unsigned value) | a `NumFormat` |
44
+ | `f32` `f64` `(text, format)` [^overrides] | `Float` (f32 widened losslessly) | a `NumFormat` |
45
+ | `decimal(text, format)` [^overrides] | `HyperCast::Decimal` — exact sign, 96-bit magnitude, base-10 scale | a `NumFormat` |
45
46
  | `uuid(text)` | lowercase hyphenated `String` (`SecureRandom.uuid`'s shape) | — |
46
47
  | `timestamp(text)` | UTC `Time`, full nanoseconds | — |
47
48
  | `unix(text, precision)` | UTC `Time` | `:seconds` / `:milliseconds` / `:microseconds` / `:nanoseconds` |
48
49
  | `excel_serial(text, epoch)` | UTC `Time` | `:y1900` / `:y1904` |
49
50
  | `date(text, order = nil)` | `Date` | strict ISO, or `:year_month_day` / `:month_day_year` / `:day_month_year` |
50
- | `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) |
51
52
  | `time(text)` | `Integer` nanoseconds since midnight | — |
52
53
  | `duration(text)` | exact `Rational` seconds | — |
53
54
  | `decimal_from_float(value)` | `HyperCast::Decimal`, the shortest that names the number (`0.1` is one tenth) | — |
@@ -65,9 +66,14 @@ to `nil`. Beside the doors, `native_version` returns the loaded core's
65
66
  Bad data is never an exception — it is a `Fault`. What raises is a caller bug, and it raises
66
67
  the same exception on every backend:
67
68
 
68
- - `ArgumentError` from `NumFormat.new` for a malformed format: separators that are not single
69
- characters or not distinct, a currency symbol that is too long or carries an ASCII digit or
70
- whitespace.
69
+ - `ArgumentError` from `NumFormat.new`, `NumFormat#override` (and so the `separator:`,
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.
71
77
  - `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
72
78
  that names nothing: an unknown Symbol, or anything that is not a Symbol (`"seconds"`
73
79
  included). `date(text, nil)` is not one of these: an explicit `nil` order is the strict ISO
@@ -132,33 +138,166 @@ value.to_d # => BigDecimal("-1234.5")
132
138
  `to_d` requires the `bigdecimal` gem lazily on first call — a bundled gem since Ruby 3.4,
133
139
  deliberately not a dependency of this one, so under Bundler add it to your own Gemfile.
134
140
 
135
- ### `NumFormat`: declared separators, and a declared currency
141
+ ### `NumFormat`: declared separators, symbols and digits
136
142
 
137
143
  ```ruby
138
144
  HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
139
145
  ```
140
146
 
141
147
  The flags are `GROUPING`, `PARENTHESES`, `EXPONENT`, `RADIX_PREFIXES`, `PERCENT` and
142
- `CURRENCY` (`ALL_STYLES` is all six), plus the `SEPARATOR_DETECT` policy. The currency
143
- symbol is the one field a culture table would fill in: it is declared, never looked up, and
144
- honored only while `CURRENCY` is set — once, leading (`$5`, `-$5`, `$ -5`) or trailing
145
- (`5 €`, `1.234,50 kr.`), with optional ASCII whitespace between symbol and digits, and
146
- accounting parentheses wrapping symbol and digits together (`($5)`). Declared with the
147
- flag off, the symbol is a `:malformed` `Fault` at the symbol; the flag with nothing declared
148
- (`currency: ""`, the default — `INVARIANT` and `DETECT` declare none) matches nothing. A
149
- symbol longer than 16 UTF-8 bytes, or carrying an ASCII digit or ASCII whitespace, is an
150
- `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.
151
186
 
152
187
  ```ruby
153
188
  dollars = HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
154
189
  HyperCast.i32("($1,234)", dollars) # => Success(value: -1234)
155
190
  HyperCast.decimal("$1,234.50", dollars) # => Success(value: Decimal(magnitude: 12345, scale: 1, negative: false))
156
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))
157
205
  ```
158
206
 
159
- Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code points, the
160
- flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
161
- 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.
224
+
225
+ ### Rails' words for a format: `separator:`, `delimiter:`, `unit:`
226
+
227
+ Ruby's standard library carries no locale number data, so the vocabulary Ruby code already
228
+ speaks is Rails' — the number helpers' and I18n's `number.format`: `separator` (the decimal
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:
233
+
234
+ ```ruby
235
+ inv = HyperCast::NumFormat::INVARIANT
236
+ HyperCast.f64("1.234,5", inv, separator: ",", delimiter: ".") # => Success(value: 1234.5)
237
+ HyperCast.decimal("1.234,50 kr.", inv, separator: ",", delimiter: ".", unit: "kr.")
238
+
239
+ german = HyperCast::NumFormat.from_i18n(I18n.t("number.format")) # {separator: ",", delimiter: ".", ...}
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)
246
+ ```
247
+
248
+ The keywords go through `NumFormat#override`, which is public: the result is validated like
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.
265
+
266
+ A call without the keywords is exactly the call it was, with no extra allocation on either
267
+ backend. A call with them builds a format each time (a few microseconds), so in a loop,
268
+ build it once with `override` or `from_i18n` and pass that instead.
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
+
298
+ [^overrides]: Plus the optional keywords `separator:`, `delimiter:` and `unit:`, which
299
+ override those fields of `format` for the call. See
300
+ [Rails' words for a format](#rails-words-for-a-format-separator-delimiter-unit).
162
301
 
163
302
  ## Interop: building on HyperCast's C ABI
164
303
 
@@ -216,6 +355,12 @@ HyperCast::Interop.version(0x000700) # => "0.7.0"
216
355
  is now identity-matched like `INVARIANT`, and any other format is resolved once per
217
356
  thread and memoized by identity — anchored in a thread-variable so the memo's key can
218
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.
219
364
  The three reason Symbols and the option Symbols (`:seconds`, `:month_day_year`, …) are
220
365
  cached the same way, so a fault or a declared option is a pointer compare too, never a
221
366
  `Symbol#name` materialization.
@@ -347,7 +492,7 @@ one at `require` time:
347
492
  | 3.3 (the floor, until its EOL 2027-03-31) | Fiddle | universal |
348
493
 
349
494
  What stands behind each cell: CI replays the whole suite, shared corpus included, through
350
- each Magnus extension on every push — Ruby 3.4 and 4.0 on all seven platform-gem platforms,
495
+ each Magnus extension — Ruby 4.0 on every pull request and Ruby 3.4 in the weekly and release builds, on all seven platform-gem platforms,
351
496
  the two musl ones inside each Ruby's own Alpine image — and the Fiddle suite on Ruby 4.0 on
352
497
  every one of them, plus on Ruby 3.3 inside Alpine. Intel macOS has no CI leg: its library is
353
498
  cross-built and tested at the core, and Ruby there runs the universal gem's Fiddle backend
@@ -18,6 +18,7 @@ module HyperCast
18
18
  # The doors whose value is a record: the directive that unpacks one, how many fields that
19
19
  # yields, and what builds the door's Ruby value from them, starting at field +at+.
20
20
  RECORDS = {
21
+ char: ["L<", 1, ->(fields, at) { fields[at].chr(Encoding::UTF_8) }],
21
22
  decimal: ["Q<L<CCx2", 4, ->(fields, at) { decimal(fields[at], fields[at + 1], fields[at + 2], fields[at + 3]) }],
22
23
  uuid: ["H8H4H4H4H12", 5, ->(fields, at) { fields[at, 5].join("-") }],
23
24
  timestamp: ["q<l<x4", 2, ->(fields, at) { instant(fields[at], fields[at + 1]) }],
@@ -31,7 +32,7 @@ module HyperCast
31
32
 
32
33
  # The bytes one value of each door takes.
33
34
  VALUE_BYTES = {
34
- bool: 1, i8: 1, u8: 1, i16: 2, u16: 2, i32: 4, u32: 4, f32: 4, date: 4, date_ordered: 4,
35
+ bool: 1, i8: 1, u8: 1, i16: 2, u16: 2, i32: 4, u32: 4, f32: 4, char: 4, date: 4, date_ordered: 4,
35
36
  i64: 8, u64: 8, f64: 8, time: 8,
36
37
  decimal: 16, uuid: 16, timestamp: 16, unix: 16, excel_serial: 16, datetime: 16, duration: 16
37
38
  }.freeze
@@ -16,18 +16,19 @@ 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
- cast_bool: :plain,
25
+ cast_bool: :plain, cast_char: :plain,
25
26
  cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
26
27
  cast_u8: :numeric, cast_u16: :numeric, cast_u32: :numeric, cast_u64: :numeric,
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.7.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,47 +48,202 @@ 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
96
+
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))
104
+ end
105
+
106
+ # This format with the fields Ruby's number vocabulary names replaced — the words Rails'
107
+ # number helpers and I18n's number.format use: separator: (the decimal mark),
108
+ # delimiter: (grouping) and unit: (the currency symbol). A nil keyword leaves its
109
+ # field alone, so with none given this is the format itself, not a copy. The result is
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.
117
+ def override(separator: nil, delimiter: nil, unit: nil)
118
+ return self unless separator || delimiter || unit
119
+
120
+ with(**{ decimal_sep: separator, group_sep: delimiter, currency: unit }.compact)
121
+ end
122
+
123
+ # A format from a Hash shaped like I18n's number.format (or number.currency.format):
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.
133
+ #
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;
149
+ # narrow flags: for anything stricter.
150
+ def self.from_i18n(number_format, currency_format: nil, flags: ALL_STYLES)
151
+ entries = i18n_entries(number_format)
152
+ separator = entries[:separator] || "."
153
+ delimiter = entries[:delimiter] || ","
154
+ if delimiter == ""
155
+ flags &= ~GROUPING
156
+ delimiter = separator == "," ? "." : ","
157
+ end
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
83
172
 
84
- super(decimal_sep: decimal_sep, group_sep: group_sep, flags: flags, currency: symbol)
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 }
85
179
  end
180
+ private_class_method :i18n_entries
181
+
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)
86
186
 
87
- # The 32-byte little-endian form the native ABI's NumFormat struct expects: the two
88
- # separators as code points, the flags, the symbol's byte length, then the symbol's
89
- # UTF-8 bytes zero-padded to 16.
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.
90
200
  def packed
91
- [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
92
247
  end
93
248
  end
94
249
 
@@ -97,6 +252,13 @@ module HyperCast
97
252
  # is scoped lexically, and landed on HyperCast instead of on NumFormat.
98
253
  NumFormat::CURRENCY_MAX_BYTES = 16
99
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
+
100
262
  # Permit the group separator between digits (sizes not validated — between digits is the rule).
101
263
  GROUPING = 1
102
264
  # Permit accounting parentheses as negation: (1,234) is -1234.
@@ -105,7 +267,8 @@ module HyperCast
105
267
  EXPONENT = 1 << 2
106
268
  # Permit 0x/&H/0b two's-complement radix prefixes (0xFF is -1 for an i8).
107
269
  RADIX_PREFIXES = 1 << 3
108
- # 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.
109
272
  PERCENT = 1 << 4
110
273
  # Resolve the ./, roles per input from structure instead of the declared separators
111
274
  # (which are ignored while this flag is set). Detection, not sniffing: a repeated
@@ -121,8 +284,15 @@ module HyperCast
121
284
  # along with the digits ("($5)"). With no symbol declared the flag matches nothing.
122
285
  # Integer, real and decimal doors.
123
286
  CURRENCY = 1 << 6
124
- # Every lenience on (SEPARATOR_DETECT is a separator policy, not a lenience, and is
125
- # 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).
126
296
  ALL_STYLES = GROUPING | PARENTHESES | EXPONENT | RADIX_PREFIXES | PERCENT | CURRENCY
127
297
 
128
298
  # The invariant profile — '.' decimal, ',' grouping, every lenience on, no currency
@@ -220,6 +390,14 @@ module HyperCast
220
390
  plain(:cast_bool, text, 1) { |out| Interop.decode(:bool, out) }
221
391
  end
222
392
 
393
+ # Casts char text to a one-character UTF-8 String: an input of exactly one character is
394
+ # that character verbatim (" " is a space), otherwise one declared code point — 65,
395
+ # U+0041, 0x41, &H41, &#65; or &#x41; — surrounded by optional ASCII whitespace. A code
396
+ # point past U+10FFFF, or a surrogate, is :out_of_range.
397
+ def char(text)
398
+ plain(:cast_char, text, 4) { |out| Interop.decode(:char, out) }
399
+ end
400
+
223
401
  %i[i8 i16 i32 i64 u8 u16 u32 u64].each do |door|
224
402
  unpack = Interop::SCALARS.fetch(door)
225
403
  size = Interop::VALUE_BYTES.fetch(door)
@@ -228,20 +406,29 @@ module HyperCast
228
406
  symbol = :"cast_#{door}"
229
407
  # Integer doors: the target type's own range, declared grouping, accounting parens,
230
408
  # non-negative exponent, and 0x/&H/0b two's-complement radix prefixes. Ruby Integer
231
- # is unbounded, so u64 comes back as the true unsigned value.
232
- define_method(door) do |text, format|
409
+ # is unbounded, so u64 comes back as the true unsigned value. separator:, delimiter:
410
+ # and unit: override those fields of format for this call (NumFormat#override).
411
+ define_method(door) do |text, format, separator: nil, delimiter: nil, unit: nil|
412
+ format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
413
+ separator || delimiter || unit
233
414
  numeric(symbol, text, format, size) { |out| out.unpack1(unpack) }
234
415
  end
235
416
  end
236
417
 
237
418
  # Casts real text to an IEEE single (widened losslessly on the way out): finite values
238
419
  # only, declared separators and grouping, parens, exponent, and trailing percent.
239
- def f32(text, format)
420
+ # separator:, delimiter: and unit: override those fields of format for this call
421
+ # (NumFormat#override), on this door and every other numeric one.
422
+ def f32(text, format, separator: nil, delimiter: nil, unit: nil)
423
+ format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
424
+ separator || delimiter || unit
240
425
  numeric(:cast_f32, text, format, 4) { |out| Interop.decode(:f32, out) }
241
426
  end
242
427
 
243
428
  # Casts real text to an IEEE double. Notation rules as f32.
244
- def f64(text, format)
429
+ def f64(text, format, separator: nil, delimiter: nil, unit: nil)
430
+ format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
431
+ separator || delimiter || unit
245
432
  numeric(:cast_f64, text, format, 8) { |out| Interop.decode(:f64, out) }
246
433
  end
247
434
 
@@ -251,7 +438,9 @@ module HyperCast
251
438
  # "1.10" is canonical magnitude 11, scale 1 — exact trailing zeros are always trimmed.
252
439
  # A magnitude past 2**96 - 1, or more fractional precision than 28 places can hold, is
253
440
  # an :out_of_range Fault — the door never rounds.
254
- def decimal(text, format)
441
+ def decimal(text, format, separator: nil, delimiter: nil, unit: nil)
442
+ format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
443
+ separator || delimiter || unit
255
444
  numeric(:cast_decimal, text, format, 16) { |out| Interop.decode(:decimal, out) }
256
445
  end
257
446
 
@@ -310,8 +499,21 @@ module HyperCast
310
499
  # artifact, not data (the same caveat PHP's UTC-labeled DateTimeImmutable carries);
311
500
  # fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
312
501
  # instant door. An unknown order is a caller bug (KeyError).
313
- def datetime(text, order)
314
- 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) }
315
517
  end
316
518
 
317
519
  # Casts an ISO 24-hour time-of-day to an exact Integer of nanoseconds since midnight
@@ -391,18 +593,19 @@ module HyperCast
391
593
  bytes.empty? ? nil : bytes
392
594
  end
393
595
 
394
- # 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
395
597
  # (16 bytes covers every door, and malloc's alignment covers the decimal's 8), fault
396
- # span at 16. Two Fiddle::Pointer.malloc(..., RUBY_FREE) calls per cast — each
397
- # registering a GC finalizer — measured as the dominant per-call cost by an order of
398
- # magnitude. The NumFormat no longer lives here: each format owns its own packed
399
- # pointer (see packed_cache), so a numeric call copies nothing into scratch before
400
- # crossing. Allocated through Runtime.buffer, which loads the library first, so a
401
- # 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.
402
605
  def scratch
403
606
  Thread.current[:hypercast_scratch] ||= begin
404
- base = Runtime.buffer(24)
405
- [base, base + 16]
607
+ base = Runtime.buffer(64)
608
+ [base, base + 16, base + 24]
406
609
  end
407
610
  end
408
611
 
@@ -428,7 +631,7 @@ module HyperCast
428
631
  end
429
632
 
430
633
  # The shared body of the integer/real/decimal doors: plain, plus the format's own
431
- # 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
432
635
  # scratch.
433
636
  def numeric(symbol, text, format, out_size)
434
637
  bytes = utf8(text)
@@ -437,6 +640,47 @@ module HyperCast
437
640
  verdict(rc, fault, bytes) { yield(out[0, out_size]) }
438
641
  end
439
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
+
440
684
  # The shared body of the four doors that take a caller-declared u32 — a precision, an
441
685
  # epoch, or a field order — already resolved from its Symbol by the door. These bodies
442
686
  # (plain, numeric, declared, and packed_version below) are the whole Fiddle crossing.
@@ -487,21 +731,22 @@ module HyperCast
487
731
  PACKED_CACHE_LIMIT = 64
488
732
 
489
733
  # Identity-keyed memo (compare_by_identity — a pointer hash, not Data's structural
490
- # #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
491
735
  # object, filled once from NumFormat#packed. Formats are reused constants in practice,
492
736
  # so the common call finds its pointer in one lookup and packs nothing. The Hash holds
493
737
  # the format, so a key can never be a recycled address; the race is benign (idempotent).
494
738
  #
495
739
  # Bounded, oldest entry out first (a Hash keeps insertion order): unbounded, it held
496
- # 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,
497
741
  # which a consumer building a format per request experienced as a leak. A format
498
742
  # evicted and then used again is simply packed again, and an evicted pointer stays
499
743
  # alive for any call already holding it — the caller's own reference outlives the cache's.
500
744
  def packed_cache
501
745
  @packed_cache ||= Hash.new do |cache, format|
502
746
  cache.shift while cache.size >= PACKED_CACHE_LIMIT
503
- pointer = Runtime.buffer(32)
504
- pointer[0, 32] = format.packed
747
+ size = NumFormat::PACKED_BYTES
748
+ pointer = Runtime.buffer(size)
749
+ pointer[0, size] = format.packed
505
750
  cache[format] = pointer
506
751
  end.compare_by_identity
507
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.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Buvinghausen