hypercast-wasm 0.8.0 → 1.0.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: e8fba4b024f4c3f507d9c5d31187c8434d704e8ce0b613a5dd0d8de1100716e8
4
+ data.tar.gz: 9156a91e12b17035fa8e7cca34d79d0990e20388b6037386a1555e2a7f9526a9
5
5
  SHA512:
6
- metadata.gz: e33bef99a50f5f76ea13278877fe9ea895473ca69f066f5db9b6fd079be2f454884aa75603f29ad2628bcc8dbb0ce1584b75659ab4e1608517ae806bda797973
7
- data.tar.gz: c0e152cb5deb06d5f0e4ffdc14b8120f559926229b1f03fd42668b45a38e2f6c18668d2f2706628f20501168222e2ff6a5541c6d44e38486536f130f16dc5f96
6
+ metadata.gz: c18b51f977271062987a6c62a34154786b37fdee697661c921846c7034d07178c49b731cce9074aff07864d63a9027f77e11d4c88a9cf101cdb514aa9419aa05
7
+ data.tar.gz: 6f6ab24b0d6331b2d8020c93ebfb831ebc84df0bb796b6add5a6b733c938bf83d3c8525f307f5141273a81c86e448f46981bb0b27f0496936871c06618c02f8f
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 1 to 8 UTF-8
150
+ bytes (`", "`, comma then space, is a separator).
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`.
188
-
189
- A call without the keywords is exactly the call it was, with no extra allocation on either
190
- backend. A call with them builds a format each time (a few microseconds), so in a loop,
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, except that a missing mark never repeats the one the Hash declares:
254
+ `{separator: ","}` groups with `"."`, as Python's and PHP's locale bridges do. An empty `delimiter` (a locale that writes no grouping) turns `GROUPING` off. A
255
+ `negative_format` that writes the sign after the number (`"%n-"`, `"%n %u-"`) turns
256
+ `TRAILING_SIGN` on; `"-%n"`, the Rails default, leaves it off. `currency_format:` takes
257
+ `number.currency.format` beside `number.format`: its `separator` and `delimiter` become
258
+ `currency_decimal_sep` and `currency_group_sep` (a key it lacks declares nothing, so the
259
+ number separator stands in, unless that would repeat the currency mark it does declare), its `unit` the currency symbol, and its `negative_format`
260
+ counts the same way. `flags:` defaults to `ALL_STYLES`.
261
+
262
+ That is everything the Hash offers. Rails carries no minus or plus sign, no percent symbol
263
+ and no digits, so `from_i18n` leaves those undeclared — the core reads ASCII `-`, `+`, `%`
264
+ and `0`–`9` — and a locale that writes them differently declares them on the result:
265
+ `from_i18n(I18n.t("number.format")).with(minus_sign: "−")` for Estonian.
266
+
267
+ A call without the keywords allocates nothing extra on either 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 reads the
278
+ English pair only. 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).
@@ -244,19 +349,24 @@ HyperCast::Interop.version(0x000700) # => "0.7.0"
244
349
  2.85 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
245
350
 
246
351
  Separator detection is nearly free here: `1.234.567,89` under `NumFormat::DETECT` runs
247
- at 154 ns against 145 ns for the same text under a declared eurozone format. Both cost
248
- nearly twice that in 0.1.0, for a reason that had nothing to do with parsing: every format other than `INVARIANT` paid three method dispatches and two
249
- `String` allocations per call to read its separators back out of the `Data`. `DETECT`
250
- is now identity-matched like `INVARIANT`, and any other format is resolved once per
251
- thread and memoized by identity — anchored in a thread-variable so the memo's key can
252
- never be a recycled address — which turned the per-call cost into one pointer compare.
352
+ at 154 ns against 145 ns for the same text under a declared eurozone format. Reading a
353
+ format costs one pointer compare: `DETECT` is identity-matched like `INVARIANT`, and any
354
+ other format is resolved once per thread and memoized by identity — anchored in a
355
+ thread-variable so the memo's key can never be a recycled address.
356
+
357
+ A declared currency symbol costs text that does not carry one about 6 ns on the Magnus
358
+ backend: in a run for 0.9.0, separate from the [Benchmarks](#benchmarks) table, `1,234,567`
359
+ took 151 ns under `INVARIANT` and 157 ns under a format declaring `$`. A fault costs more than a success: the same run returned the `Fault` for
360
+ `12345x7` in 222 ns, against 101 ns for `Integer("12345x7", exception: false)` returning
361
+ `nil`. The door builds a reason and a span where `Integer()` hands back nothing.
253
362
  The three reason Symbols and the option Symbols (`:seconds`, `:month_day_year`, …) are
254
363
  cached the same way, so a fault or a declared option is a pointer compare too, never a
255
364
  `Symbol#name` materialization.
256
365
 
257
366
  **The honest trade-off:** the civil doors do not beat `strptime`. The date-time door is
258
- level with `DateTime.strptime` (904 ns against 901 ns) and the date door a little behind
259
- `Date.strptime` (620 ns against 513 ns). The parse isn't the cost; the carrier is. Building
367
+ level with `DateTime.strptime` (877 ns against 851 ns, the session of the
368
+ [table below](#benchmarks)) and the date door a little behind `Date.strptime` (620 ns against
369
+ 513 ns, measured for 0.4.0 and not re-timed since). The parse isn't the cost; the carrier is. Building
260
370
  a stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
261
371
  where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`. If you want
262
372
  Ruby's fastest civil parse and don't need the verdict or the declared order, `strptime` is
@@ -266,9 +376,8 @@ which is an artifact of the type, not a zone the parse assigned.
266
376
  On the Fiddle fallback the doors are parity-at-best — Fiddle's
267
377
  per-call floor is the mechanism's price, kept because it's the universal zero-compile
268
378
  path. Its doors build nothing per call that does not change between calls: each format
269
- owns one native pointer, memoized by identity and passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
270
- per-call `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch —
271
- receipts include their own archaeology.)
379
+ owns one native pointer, memoized by identity and passed straight through, and its scratch
380
+ is thread-local rather than a `Fiddle::Pointer.malloc` per call.
272
381
 
273
382
  ## Benchmarks
274
383
 
@@ -322,33 +431,41 @@ buffers; the core itself keeps no state between calls. `spec/cast_spec.rb`'s
322
431
  concurrent-callers example runs under both.
323
432
 
324
433
  **Ractors.** Main Ractor only, on every backend: called from another Ractor the doors raise
325
- `Ractor::UnsafeError`.
434
+ (`Ractor::UnsafeError` from the Magnus extension, `Ractor::IsolationError` on Fiddle).
326
435
 
327
436
  ## Verifying provenance
328
437
 
329
- Every gem RubyGems.org serves — the universal fallback, each of the seven precompiled
330
- platform gems and `hypercast-wasm` — carries its own GitHub build-provenance attestation, signed directly by
331
- this repo's own `release.yml` (the `rubygems-publish` job attests `ruby/pkg/*.gem` right
332
- before the push), so plain `--repo` verifies any of them:
438
+ Every gem RubyGems.org serves — the universal fallback, each of the seven precompiled platform
439
+ gems and `hypercast-wasm` — verifies from the release tag alone: the release attestation,
440
+ signed by this repository's `release.yml` at the tag, names every one of them, with the
441
+ release inventory as its predicate.
442
+
443
+ ```sh
444
+ gem fetch hypercast -v <version> --platform <platform> # or omit --platform for the universal gem
445
+ gh attestation verify hypercast-<version>-<platform>.gem --repo SkunkWerkx/HyperCast \
446
+ --source-ref refs/tags/v<version> --predicate-type https://github.com/SkunkWerkx/.github/predicates/release/v1
447
+ ```
448
+
449
+ Each gem's build provenance is this repository's too: the `ruby-pack` job attests
450
+ `ruby/pkg/*.gem` as soon as they are built and `rubygems-publish` pushes those preserved
451
+ bytes, so plain `--repo` verifies it:
333
452
 
334
453
  ```sh
335
- gem fetch hypercast -v X.Y.Z --platform <platform> # or omit --platform for the universal gem
336
- gh attestation verify hypercast-X.Y.Z-<platform>.gem --repo SkunkWerkx/HyperCast
454
+ gh attestation verify hypercast-<version>-<platform>.gem --repo SkunkWerkx/HyperCast
337
455
  ```
338
456
 
339
- That's the release's second layer of checking, not the only one: before any gem gets built,
340
- the same job verifies every native binary it packs — the Fiddle libraries, the Magnus
341
- extensions (one per Ruby ABI per platform), the `wasm32-wasip1` archives — against *their own*
342
- attestations — those are signed from `SkunkWerkx/.github` by `hyper-build-native.yml` and
343
- `hyper-build-wasm.yml`, so
344
- that check needs `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an
345
- unverified one.
346
- RubyGems.org has no unpublish and no duplicate-version overwrite, so this all happens while a
347
- bad artifact is still reversible. The release run's job summary then re-fetches every gem
348
- from the CDN and records attested-vs-served digests, turning "rubygems.org stores an upload
349
- verbatim" into a per-release measurement rather than an assumption — see
350
- [csharp/README.md's provenance section](../csharp/README.md#native-binary-provenance) for
351
- more on why `--signer-repo` is needed for some artifacts here and not others.
457
+ That is the release's second layer of checking, not the only one: before any gem is built, the
458
+ same job verifies every native binary it packs — the Fiddle libraries, the Magnus extensions
459
+ (one per Ruby ABI per platform), the `wasm32-wasip1` archives — against *their own* build
460
+ provenance, signed from `SkunkWerkx/.github` by `hyper-build-native.yml` and
461
+ `hyper-build-wasm.yml` (so that check needs `--signer-repo SkunkWerkx/.github` added), and
462
+ refuses to proceed on an unverified one. RubyGems.org has no unpublish and no
463
+ duplicate-version overwrite, so all of this happens while a bad artifact is still reversible,
464
+ and the push itself is a separate dispatch from the preserved bytes. The release run then
465
+ re-fetches every gem from rubygems.org and fails unless each matches the attested bytes
466
+ exactly, turning "rubygems.org stores an upload verbatim" into a per-release check rather than
467
+ an assumption. The root README's [Provenance](../README.md#provenance) section has the whole
468
+ picture.
352
469
 
353
470
  ## Install
354
471
 
@@ -418,8 +535,7 @@ Ruby newer than the release, or a platform with no platform gem. `bundle install
418
535
  Bundler 2.6, not on 3.3's 2.5); otherwise install the compiler and headers. Pinning `fiddle`
419
536
  to your Ruby's version in the `Gemfile.lock` does not stop the build.
420
537
 
421
- Both Windows architectures get a Magnus gem, and the reasoning that once kept them on Fiddle
422
- was backwards: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
538
+ Both Windows architectures get a Magnus gem: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
423
539
  `aarch64-mingw-ucrt`, both `supported: true` in its own `data/toolchains.json`); the one it
424
540
  has no support for is MSVC. Windows is also where the Fiddle fallback cost the most, so these
425
541
  are the most worthwhile gems in the set. Both extensions are built for the `gnullvm` Rust
@@ -465,6 +581,12 @@ Load `ruby.wasm` with [`@ruby/wasm-wasi`](https://www.npmjs.com/package/@ruby/wa
465
581
  - **No Rust toolchain.** The gem's `extconf.rb` only hands rbwasm the prebuilt archive, and
466
582
  rbwasm downloads its own wasi-sdk. The first `rbwasm build` compiles Ruby itself and takes
467
583
  15–20 minutes; later builds reuse it.
584
+ - **macOS with Homebrew: limit pkg-config to `openssl@3`.** Run the build as
585
+ `PKG_CONFIG_LIBDIR="$(brew --prefix openssl@3)/lib/pkgconfig" bundle exec rbwasm build …`.
586
+ Without it Homebrew's pkg-config files reach into the build twice: its `openssl` is OpenSSL
587
+ 4, which the native Ruby rbwasm builds first does not compile against, and its `libyaml`
588
+ is a macOS library that psych then links into the wasm interpreter, which fails for Ruby
589
+ 4.0.
468
590
  - **`Decimal#to_d` needs `gem "bigdecimal"` in that Gemfile.** bigdecimal is a bundled gem
469
591
  with a C extension, and ruby.wasm builds a bundled gem's extension only when the Gemfile
470
592
  names it; without it, `to_d` raises `LoadError`. `to_s` and `to_r` need nothing.
@@ -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 = "1.0.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 wider than one character (", " —
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,138 @@ 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, except where that would repeat the mark the Hash does declare: a separator of
129
+ # "," with no delimiter groups with ".", and a delimiter of "." with no separator takes
130
+ # "," for the decimal mark. An empty delimiter — the locale writes no grouping — turns GROUPING off rather
131
+ # than declaring a separator, since a NumFormat always carries one; the placeholder it
132
+ # gets is whichever of "," and "." the decimal mark is not. A negative_format that writes
133
+ # the sign after the number ("%n-", "%n %u-") turns TRAILING_SIGN on; "-%n", the Rails
134
+ # default, leaves it off. flags defaults to every lenience, the same as INVARIANT.
109
135
  #
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;
136
+ # currency_format: takes the locale's number.currency.format Hash beside number.format,
137
+ # for a column that mixes plain numbers and currency amounts in a locale whose two
138
+ # differ (the Swiss German franc, say — "1'234.50" beside "1.234,50"): its separator and
139
+ # delimiter become the currency separators, read in place of the number ones when the
140
+ # unit is found in the text, and its unit the currency symbol. A missing key there
141
+ # declares nothing, keeping the number separator it stands in for, unless that would
142
+ # repeat the currency mark it does declare, in which case the missing one is whichever
143
+ # of "," and "." that mark is not; an explicitly empty
144
+ # delimiter gets the same placeholder rule as above; its negative_format turns
145
+ # TRAILING_SIGN on the same way. Passed alone, number.currency.format reads as the one
146
+ # number format, as before.
147
+ #
148
+ # The rest of the format is what the Hash cannot offer: Rails carries no minus or plus
149
+ # sign, no percent symbol and no digits, so those stay undeclared here — the core reads
150
+ # ASCII "-", "+", "%" and 0-9 — and a locale that writes negatives with another sign
151
+ # (U+2212 for Estonian and Basque, for instance) declares it on the result with
152
+ # Data#with (minus_sign: "−"). Every lenience is on, radix prefixes and percent included;
116
153
  # narrow flags: for anything stricter.
117
- def self.from_i18n(number_format, flags: ALL_STYLES)
154
+ def self.from_i18n(number_format, currency_format: nil, flags: ALL_STYLES)
155
+ entries = i18n_entries(number_format)
156
+ separator = entries[:separator] || (entries[:delimiter] == "." ? "," : ".")
157
+ delimiter = entries[:delimiter]
158
+ if delimiter.nil? || delimiter == ""
159
+ flags &= ~GROUPING if delimiter == ""
160
+ delimiter = separator == "," ? "." : ","
161
+ end
162
+ flags |= TRAILING_SIGN if trailing_sign?(entries[:negative_format])
163
+ currency = entries[:unit] || ""
164
+ currency_separator = currency_delimiter = ""
165
+ if currency_format
166
+ money = i18n_entries(currency_format)
167
+ currency = money[:unit] || currency
168
+ currency_separator = money[:separator] || ""
169
+ currency_delimiter = money[:delimiter] || ""
170
+ currency_delimiter = (money[:separator] || separator) == "," ? "." : "," if money[:delimiter] == ""
171
+ if currency_separator.empty? && currency_delimiter == separator
172
+ currency_separator = currency_delimiter == "," ? "." : ","
173
+ elsif currency_delimiter.empty? && currency_separator == delimiter
174
+ currency_delimiter = currency_separator == "," ? "." : ","
175
+ end
176
+ flags |= TRAILING_SIGN if trailing_sign?(money[:negative_format])
177
+ end
178
+ new(decimal_sep: separator, group_sep: delimiter, flags: flags, currency: currency,
179
+ currency_decimal_sep: currency_separator, currency_group_sep: currency_delimiter)
180
+ end
181
+
182
+ # The I18n Hash's entries under Symbol keys, whatever keys it arrived with.
183
+ def self.i18n_entries(number_format)
118
184
  raise ArgumentError, "expected a Hash-like number format; got #{number_format.inspect}" unless
119
185
  number_format.respond_to?(:to_h)
120
186
 
121
- entries = number_format.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
122
- separator = entries[:separator] || "."
123
- delimiter = entries[:delimiter] || ","
124
- if delimiter == ""
125
- flags &= ~GROUPING
126
- delimiter = separator == "," ? "." : ","
127
- end
128
- new(decimal_sep: separator, group_sep: delimiter, flags: flags, currency: entries[:unit] || "")
187
+ number_format.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
129
188
  end
189
+ private_class_method :i18n_entries
130
190
 
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.
191
+ # Whether an I18n negative_format ("-%n", "%n-", "-%u%n", "%n %u-") writes the sign
192
+ # after the number: a "-" to the right of the "%n" placeholder.
193
+ def self.trailing_sign?(negative_format)
194
+ return false unless negative_format.is_a?(String)
195
+
196
+ number = negative_format.index("%n")
197
+ sign = negative_format.rindex("-")
198
+ !number.nil? && !sign.nil? && sign > number
199
+ end
200
+ private_class_method :trailing_sign?
201
+
202
+ # The 92-byte little-endian form of the native ABI's size-tagged NumFormat struct, the
203
+ # layout the core reads in place: the size word (92), the flags, the digit zero's code
204
+ # point (0 for none), then each symbol as its UTF-8 bytes zero-padded to its field
205
+ # followed by its byte length — eight bytes and a length for each of the two number
206
+ # separators, the two currency separators, the two signs and the percent symbol, in
207
+ # that order; sixteen and a length for the currency symbol. A length of 0 declares
208
+ # nothing.
134
209
  def packed
135
- [decimal_sep.ord, group_sep.ord, flags, currency.bytesize].pack("L<L<L<L<") + [currency].pack("a16")
210
+ head = [self.class::PACKED_BYTES, flags, digit_zero.empty? ? 0 : digit_zero.ord].pack("L<L<L<")
211
+ symbols = [decimal_sep, group_sep, currency_decimal_sep, currency_group_sep, minus_sign, plus_sign, percent]
212
+ head + symbols.map { |symbol| [symbol, symbol.bytesize].pack("a8C") }.join +
213
+ [currency, currency.bytesize].pack("a16C")
214
+ end
215
+
216
+ private
217
+
218
+ # The declared text of one field as the UTF-8 the core reads: a String of 1 to `limit`
219
+ # UTF-8 bytes with no ASCII digit — nor, for a symbol that sits flush against the
220
+ # digits (the signs, the percent and currency symbols), any ASCII whitespace — or ""
221
+ # where the field may be left undeclared. Anything else is the caller bug it names.
222
+ def declared(field, text, limit, flush: false, required: false)
223
+ raise ArgumentError, "#{field} must be a String; got #{text.inspect}" unless text.is_a?(String)
224
+
225
+ symbol = text.encode(Encoding::UTF_8)
226
+ if symbol.empty?
227
+ raise ArgumentError, "#{field} must not be empty" if required
228
+
229
+ return symbol
230
+ end
231
+ raise ArgumentError, "#{field} #{text.inspect} exceeds #{limit} UTF-8 bytes" if symbol.bytesize > limit
232
+
233
+ if flush
234
+ raise ArgumentError, "#{field} #{text.inspect} must not contain an ASCII digit or whitespace" if
235
+ symbol.match?(/[0-9\t\n\f\r ]/)
236
+ elsif symbol.match?(/[0-9]/)
237
+ raise ArgumentError, "#{field} #{text.inspect} must not contain an ASCII digit"
238
+ end
239
+ symbol
240
+ end
241
+
242
+ # The declared digit zero as UTF-8: "" for none, else exactly one character that is not
243
+ # ASCII and whose code point modulo 64 is at most 54 — true of the zero of every decimal
244
+ # digit block Unicode defines, and the shape the core recognises a digit by its last
245
+ # byte from.
246
+ def digit_zero_of(text)
247
+ raise ArgumentError, "digit zero must be a String; got #{text.inspect}" unless text.is_a?(String)
248
+
249
+ zero = text.encode(Encoding::UTF_8)
250
+ return zero if zero.empty?
251
+
252
+ raise ArgumentError, "digit zero #{text.inspect} must be one non-ASCII character that starts a digit block" unless
253
+ zero.length == 1 && zero.ord >= 0x80 && zero.ord % 64 <= 54
254
+
255
+ zero
136
256
  end
137
257
  end
138
258
 
@@ -141,6 +261,13 @@ module HyperCast
141
261
  # is scoped lexically, and landed on HyperCast instead of on NumFormat.
142
262
  NumFormat::CURRENCY_MAX_BYTES = 16
143
263
 
264
+ # The widest separator, sign or percent symbol the native ABI carries inline, in UTF-8
265
+ # bytes: enough for every separator a real culture declares (one or two characters).
266
+ NumFormat::SYMBOL_MAX_BYTES = 8
267
+
268
+ # The size of NumFormat#packed — the native ABI's size-tagged NumFormat layout — in bytes.
269
+ NumFormat::PACKED_BYTES = 92
270
+
144
271
  # Permit the group separator between digits (sizes not validated — between digits is the rule).
145
272
  GROUPING = 1
146
273
  # Permit accounting parentheses as negation: (1,234) is -1234.
@@ -149,7 +276,8 @@ module HyperCast
149
276
  EXPONENT = 1 << 2
150
277
  # Permit 0x/&H/0b two's-complement radix prefixes (0xFF is -1 for an i8).
151
278
  RADIX_PREFIXES = 1 << 3
152
- # Permit a trailing %, dividing by 100. Real and decimal doors only.
279
+ # Permit a trailing % (or the format's declared percent symbol), dividing by 100. Real
280
+ # and decimal doors only.
153
281
  PERCENT = 1 << 4
154
282
  # Resolve the ./, roles per input from structure instead of the declared separators
155
283
  # (which are ignored while this flag is set). Detection, not sniffing: a repeated
@@ -165,8 +293,15 @@ module HyperCast
165
293
  # along with the digits ("($5)"). With no symbol declared the flag matches nothing.
166
294
  # Integer, real and decimal doors.
167
295
  CURRENCY = 1 << 6
168
- # Every lenience on (SEPARATOR_DETECT is a separator policy, not a lenience, and is
169
- # deliberately not included).
296
+ # Permit the sign after the digits ("5-", "5 -", "5 €-", "5- €"), the way a locale whose
297
+ # negative_format is "%n-" writes it: ASCII - and + or the format's declared signs, once,
298
+ # with optional ASCII whitespace before it, and never beside a leading sign or inside
299
+ # accounting parentheses. Not part of ALL_STYLES — NumFormat.from_i18n sets it from the
300
+ # locale's own negative_format rather than assuming it.
301
+ TRAILING_SIGN = 1 << 7
302
+ # Every lenience on — every one there was before TRAILING_SIGN, which is declared from a
303
+ # locale's pattern rather than assumed (SEPARATOR_DETECT is a separator policy, not a
304
+ # lenience, and is deliberately not included either).
170
305
  ALL_STYLES = GROUPING | PARENTHESES | EXPONENT | RADIX_PREFIXES | PERCENT | CURRENCY
171
306
 
172
307
  # The invariant profile — '.' decimal, ',' grouping, every lenience on, no currency
@@ -373,8 +508,21 @@ module HyperCast
373
508
  # artifact, not data (the same caveat PHP's UTC-labeled DateTimeImmutable carries);
374
509
  # fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
375
510
  # 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) }
511
+ #
512
+ # A 12-hour clock's AM/PM is read, before or after the clock ("3:04 PM", "PM 3:04"),
513
+ # ASCII case-insensitively; am: and pm: declare a culture's own designators beside
514
+ # them — "vorm."/"nachm.", "오전"/"오후", "a. m."/"p. m." — matched exactly, after the
515
+ # clock ("3:04 nachm.") or ahead of it ("오후 3:04"), with or without a space between.
516
+ # Declared, never looked up, like a NumFormat's symbols; nil (the default) declares
517
+ # nothing, so a call without them is exactly the call it was. A designator is 1 to 16
518
+ # UTF-8 bytes with no ASCII digit and no ASCII whitespace at either end (a space inside
519
+ # is fine); anything else is a caller bug (ArgumentError). The designators are read,
520
+ # validated and packed per call: a call that declares them costs about twice the plain
521
+ # call on the Magnus backend (the keyword scan itself — nil keywords pay the same) and
522
+ # about half again here on Fiddle.
523
+ def datetime(text, order, am: nil, pm: nil)
524
+ periods = packed_day_periods(am, pm) if am || pm
525
+ civil(:cast_datetime, text, DATE_ORDERS.fetch(order), periods, 16) { |out| Interop.decode(:datetime, out) }
378
526
  end
379
527
 
380
528
  # Casts an ISO 24-hour time-of-day to an exact Integer of nanoseconds since midnight
@@ -454,18 +602,19 @@ module HyperCast
454
602
  bytes.empty? ? nil : bytes
455
603
  end
456
604
 
457
- # One 24-byte scratch allocation per thread, reused by every call: out-value at 0
605
+ # One 64-byte scratch allocation per thread, reused by every call: out-value at 0
458
606
  # (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.
607
+ # span at 16, and the datetime door's packed day periods at 24 (40 bytes, 4-aligned,
608
+ # written only by a call that declares them). Two Fiddle::Pointer.malloc(..., RUBY_FREE)
609
+ # calls per cast — each registering a GC finalizer — measured as the dominant per-call
610
+ # cost by an order of magnitude. The NumFormat does not live here: each format owns
611
+ # its own packed pointer (see packed_cache), so a numeric call copies nothing into
612
+ # scratch before crossing. Allocated through Runtime.buffer, which loads the library
613
+ # first, so a missing one is reported as such before Fiddle is touched.
465
614
  def scratch
466
615
  Thread.current[:hypercast_scratch] ||= begin
467
- base = Runtime.buffer(24)
468
- [base, base + 16]
616
+ base = Runtime.buffer(64)
617
+ [base, base + 16, base + 24]
469
618
  end
470
619
  end
471
620
 
@@ -491,7 +640,7 @@ module HyperCast
491
640
  end
492
641
 
493
642
  # 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
643
+ # packed pointer, passed straight through — no per-call copy of the 92 bytes into
495
644
  # scratch.
496
645
  def numeric(symbol, text, format, out_size)
497
646
  bytes = utf8(text)
@@ -500,6 +649,47 @@ module HyperCast
500
649
  verdict(rc, fault, bytes) { yield(out[0, out_size]) }
501
650
  end
502
651
 
652
+ # The civil date-time door's body: declared, plus the day periods after the order — the
653
+ # 40 packed bytes copied into this thread's scratch and passed from there, or nil
654
+ # (Fiddle's null) when none are declared, which the core reads as English AM/PM only.
655
+ def civil(symbol, text, code, periods, out_size)
656
+ bytes = utf8(text)
657
+ out, fault, slot = scratch
658
+ if periods
659
+ slot[0, periods.bytesize] = periods
660
+ else
661
+ slot = nil
662
+ end
663
+ rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, code, slot, out, fault)
664
+ verdict(rc, fault, bytes) { yield(out[0, out_size]) }
665
+ end
666
+
667
+ # The 40-byte little-endian form of the native ABI's size-tagged DayPeriods struct: the
668
+ # size word (40), then each designator as its UTF-8 bytes zero-padded to 16 followed by
669
+ # its byte length — am, then pm — and two bytes of padding. A nil designator is a
670
+ # length of 0: nothing declared beside AM or PM.
671
+ def packed_day_periods(am, pm)
672
+ am = designator("am", am)
673
+ pm = designator("pm", pm)
674
+ [40].pack("L<") + [am, am.bytesize, pm, pm.bytesize].pack("a16Ca16C") + "\0\0"
675
+ end
676
+
677
+ # A declared day-period designator as the UTF-8 the core reads — 1 to 16 bytes, no
678
+ # ASCII digit, no ASCII whitespace at either end (the grammar around it reads that) —
679
+ # or "" for nil. Anything else is the caller bug it names, the same ArgumentError on
680
+ # every backend.
681
+ def designator(name, text)
682
+ return "" if text.nil?
683
+ raise ArgumentError, "#{name} designator must be a String; got #{text.inspect}" unless text.is_a?(String)
684
+
685
+ designator = text.encode(Encoding::UTF_8)
686
+ unless designator.bytesize.between?(1, 16) && !designator.match?(/[0-9]|\A[\t\n\f\r ]|[\t\n\f\r ]\z/)
687
+ raise ArgumentError, "#{name} designator #{text.inspect} must be 1 to 16 UTF-8 bytes with no ASCII digit " \
688
+ "and no ASCII whitespace at either end"
689
+ end
690
+ designator
691
+ end
692
+
503
693
  # The shared body of the four doors that take a caller-declared u32 — a precision, an
504
694
  # epoch, or a field order — already resolved from its Symbol by the door. These bodies
505
695
  # (plain, numeric, declared, and packed_version below) are the whole Fiddle crossing.
@@ -550,21 +740,22 @@ module HyperCast
550
740
  PACKED_CACHE_LIMIT = 64
551
741
 
552
742
  # 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
743
+ # #hash over nine Strings and an Integer) of a native 92-byte RawNumFormat per format
554
744
  # object, filled once from NumFormat#packed. Formats are reused constants in practice,
555
745
  # so the common call finds its pointer in one lookup and packs nothing. The Hash holds
556
746
  # the format, so a key can never be a recycled address; the race is benign (idempotent).
557
747
  #
558
748
  # 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,
749
+ # every format ever cast with and its 92-byte allocation for the life of the process,
560
750
  # which a consumer building a format per request experienced as a leak. A format
561
751
  # evicted and then used again is simply packed again, and an evicted pointer stays
562
752
  # alive for any call already holding it — the caller's own reference outlives the cache's.
563
753
  def packed_cache
564
754
  @packed_cache ||= Hash.new do |cache, format|
565
755
  cache.shift while cache.size >= PACKED_CACHE_LIMIT
566
- pointer = Runtime.buffer(32)
567
- pointer[0, 32] = format.packed
756
+ size = NumFormat::PACKED_BYTES
757
+ pointer = Runtime.buffer(size)
758
+ pointer[0, size] = format.packed
568
759
  cache[format] = pointer
569
760
  end.compare_by_identity
570
761
  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: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Buvinghausen