hypercast 0.1.0-x86_64-linux → 0.3.0-x86_64-linux

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: 407ae1ab9906171822de8b0f52ae3e87cbafccb300817c2924dd57a5c3704334
4
- data.tar.gz: 7599c7638a989cd6e928e9684d6df90394dbe1b494ea8ff1cf8f2253d8b4e19c
3
+ metadata.gz: aee53d1b8f673abe7b19e073c8b50a5a26681ad0336ac9ce8667d2f3a6a7d429
4
+ data.tar.gz: a4639b10ee385435c10e180ac3697dab2dfb1ce28167246e8592c0d537eab333
5
5
  SHA512:
6
- metadata.gz: c6dc288ee5102b23c16eebc5b88516352b73b984a5976459b69b14fc96d4e014df3b46a4a0a3495ef63e0da92dd714094c6a058ec1dcd85b2f7b2906cbfa874b
7
- data.tar.gz: edd8eb78d4505ff350b51f957b275086f4d9a8d587c146e8449ea3f5a4fb01128fdc4c7ec8f8e72a956637e8d7ea489a3454c966c7bc6aeb9a83bf0fde49214e
6
+ metadata.gz: 6003a655d3dc6506a850b36514cae0e2b5a975197c66602b88dbd4008210ca0258e1bb42d4659c9b442cd5e1eade858bc7c0a0f9afdabc32d6664be3ecc1b081
7
+ data.tar.gz: 11511326d7eecf6bfe4431f215bc473db094e98d1d1c8420d027bc71fa2e04b473d19e16e32cfc9c3a5e6d17aca9e876c0c19b27591725520924b4e3838d4fcf
data/README.md CHANGED
@@ -5,17 +5,19 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
6
6
 
7
7
  **Ruby's own pattern matching over two `Data` case types — the value, or a closed reason
8
- Symbol plus the exact byte span that offended. Two backends, one public surface: a Magnus
8
+ Symbol plus the exact byte span that offended. Three backends, one public surface: a Magnus
9
9
  native extension where a precompiled platform gem covers you, stdlib Fiddle everywhere
10
- else — selected automatically, zero compiles either way.**
10
+ else — selected automatically, zero compiles either way — and the same core as a
11
+ WebAssembly module inside the `wasmtime` gem for any platform with no native build at all.**
11
12
 
12
- Allocation-lean scalar casts — booleans, the full integer family, reals, UUIDs, temporals —
13
- calling directly into the native `libhypercast` Rust core. Ruby 3.2 is the floor
13
+ Allocation-lean scalar casts — booleans, the full integer family, reals, exact decimals,
14
+ UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.2 is the floor
14
15
  (`Data.define` for the verdict case types). The fast path links the core straight into a
15
16
  Ruby extension (Magnus): on require it redefines the doors in place on the `HyperCast`
16
17
  module — no delegation layer, no second surface, which is exactly what keeps the backends
17
18
  provably in agreement. `HyperCast::BACKEND` reports which is live; `HYPERCAST_PURE=1`
18
- forces Fiddle.
19
+ forces Fiddle, `HYPERCAST_WASM=1` the wasm module — see
20
+ [WebAssembly (wasmtime)](#webassembly-wasmtime).
19
21
 
20
22
  ```ruby
21
23
  case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
@@ -28,8 +30,99 @@ Door names mirror the native ABI (`i32`, `f64`, `timestamp`, …). Ruby-flavored
28
30
  stated proudly — nothing the core parses is lost on the way out: `Integer` is
29
31
  unbounded (u64 comes back as the true unsigned value), `Time` carries full nanoseconds
30
32
  across the whole 0001–9999 window, time-of-day is an exact Integer of nanoseconds since
31
- midnight, and durations come back as exact `Rational` seconds across the core's whole
32
- ±10,000-year window no truncation anywhere, no wrapping.
33
+ midnight, durations come back as exact `Rational` seconds across the core's whole
34
+ ±10,000-year window, and `decimal` returns an exact `HyperCast::Decimal` that never rounds
35
+ — no truncation anywhere, no wrapping.
36
+
37
+ ## The doors
38
+
39
+ | Door | Value | Declares |
40
+ |---|---|---|
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` |
45
+ | `uuid(text)` | lowercase hyphenated `String` (`SecureRandom.uuid`'s shape) | — |
46
+ | `timestamp(text)` | UTC `Time`, full nanoseconds | — |
47
+ | `unix(text, precision)` | UTC `Time` | `:seconds` / `:milliseconds` / `:microseconds` / `:nanoseconds` |
48
+ | `excel_serial(text, epoch)` | UTC `Time` | `:y1900` / `:y1904` |
49
+ | `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
+ | `time(text)` | `Integer` nanoseconds since midnight | — |
52
+ | `duration(text)` | exact `Rational` seconds | — |
53
+
54
+ Every door returns a `Success` or a `Fault`; `HyperCast.optional(verdict)` folds `:empty`
55
+ to `nil`. Beside the doors, `HyperCast.native_version` reads the loaded core's own version
56
+ (`"major.minor.patch"`, from the library's `hypercast_version` export — the cheapest probe
57
+ that the backend resolved at all, and the way to name a mismatch against
58
+ `HyperCast::VERSION` before the first cast), and `HyperCast.available?` answers whether a
59
+ backend loaded and exports that ABI at all — probed once, cached, never raising — for a
60
+ consumer with a fallback of its own. (The doors keep raising their precise `LoadError` on an
61
+ unavailable backend; `available?` only asks quietly.)
62
+
63
+ ### Fault spans index the String you passed
64
+
65
+ A `Fault`'s `offset`/`length` are in the units `String#[]` slices by on your own input:
66
+ character offsets for text — in any encoding; the core reads UTF-8, and the span is mapped
67
+ back, an identity for ASCII — and byte offsets for a binary (`ASCII-8BIT`) String, whose
68
+ characters are its bytes. Either way `text[offset, length]` is the offending text with no
69
+ arithmetic on your side:
70
+
71
+ ```ruby
72
+ HyperCast.i32("1€", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 1, length: 1)
73
+ HyperCast.i32("1€".b, HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 1, length: 3)
74
+ ```
75
+
76
+ The mapping happens only on the failure path, so a `Success` never pays for it.
77
+
78
+ ### `decimal`: exact, never rounded
79
+
80
+ `HyperCast::Decimal` is a `Data` of `magnitude` (an unbounded `Integer`, at most 2⁹⁶ − 1),
81
+ `scale` (0–28: places the magnitude is shifted right) and `negative` — the shape .NET's
82
+ `decimal` stores and `BigDecimal` builds from directly. No float is ever formed, so `"0.1"`
83
+ is one tenth and `"50%"` is exactly `0.5`. The triple is canonical: exact trailing zeros in
84
+ the fraction are always trimmed, so the scale is minimal — `"1.10"`, `"1.1"` and `"1.1000"`
85
+ are all magnitude 11, scale 1, while `"100"` stays magnitude 100, scale 0 — and zero is
86
+ scale 0 and never negative. Nothing but a zero is ever dropped: text carrying more precision
87
+ than 96 bits and 28 places can hold is an `:out_of_range` `Fault`, not an approximation.
88
+
89
+ ```ruby
90
+ value = HyperCast.decimal("-1,234.50", HyperCast::NumFormat::INVARIANT).value
91
+ value.to_s # => "-1234.5" (the canonical text, every binding renders the same)
92
+ value.to_r # => (-2469/2)
93
+ value.to_d # => BigDecimal("-1234.5")
94
+ ```
95
+
96
+ `to_d` requires the `bigdecimal` gem lazily on first call — a bundled gem since Ruby 3.4,
97
+ deliberately not a dependency of this one, so under Bundler add it to your own Gemfile.
98
+
99
+ ### `NumFormat`: declared separators, and a declared currency
100
+
101
+ ```ruby
102
+ HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
103
+ ```
104
+
105
+ The flags are `GROUPING`, `PARENTHESES`, `EXPONENT`, `RADIX_PREFIXES`, `PERCENT` and
106
+ `CURRENCY` (`ALL_STYLES` is all six), plus the `SEPARATOR_DETECT` policy. The currency
107
+ symbol is the one field a culture table would fill in: it is declared, never looked up, and
108
+ honored only while `CURRENCY` is set — once, leading (`$5`, `-$5`, `$ -5`) or trailing
109
+ (`5 €`, `1.234,50 kr.`), with optional ASCII whitespace between symbol and digits, and
110
+ accounting parentheses wrapping symbol and digits together (`($5)`). Declared with the
111
+ flag off, the symbol is a `:malformed` `Fault` at the symbol; the flag with nothing declared
112
+ (`currency: ""`, the default — `INVARIANT` and `DETECT` declare none) matches nothing. A
113
+ symbol longer than 16 UTF-8 bytes, or carrying an ASCII digit or ASCII whitespace, is an
114
+ `ArgumentError` at construction, the same caller-bug treatment equal separators get.
115
+
116
+ ```ruby
117
+ dollars = HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
118
+ HyperCast.i32("($1,234)", dollars) # => Success(value: -1234)
119
+ HyperCast.decimal("$1,234.50", dollars) # => Success(value: Decimal(magnitude: 12345, scale: 1, negative: false))
120
+ HyperCast.i32("$5", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 0, length: 1)
121
+ ```
122
+
123
+ Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code points, the
124
+ flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
125
+ and memoized by identity on every backend, so declaring a currency costs a cast nothing.
33
126
 
34
127
  ## Why not `Integer()` / `Time.iso8601` / `Float()`?
35
128
 
@@ -39,18 +132,26 @@ midnight, and durations come back as exact `Rational` seconds across the core's
39
132
  parentheses, declared separators, radix prefixes, all five .NET `Guid` text forms plus
40
133
  `urn:uuid:` prefixes, protobuf JSON durations.
41
134
  3. **One engine across a polyglot system** — bit-for-bit verdicts with every other binding,
42
- held by the shared corpus (24 examples green on *both* backends, full twelve-file corpus
43
- replay; a cross-backend agreement spec compares Magnus and Fiddle outputs across a
44
- subprocess boundary).
135
+ held by the shared corpus (the whole suite green on *all three* backends, full
136
+ corpus replay; cross-backend agreement specs compare Magnus against Fiddle
137
+ and wasm against Fiddle across a subprocess boundary).
45
138
  4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
46
139
  benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-arm64): timestamp **713 ns vs
47
140
  2.88 µs `Time.iso8601`** (4.0x) — while returning exact `Rational` durations on the
48
141
  duration door. The Fiddle fallback lands at ~3.5 µs: parity with `Time.iso8601`, sitting
49
142
  on Fiddle's measured 1.6 µs per-call marshalling floor.
50
143
 
51
- Separator detection is free here: `1.234.567,89` under `NumFormat::DETECT` runs
52
- 1.073M i/s against 1.092M i/s for the same text under a declared eurozone format —
53
- inside the error bars.
144
+ Separator detection is free here: `1.234.567,89` under `NumFormat::DETECT` runs at
145
+ 591 ns against 570 ns for the same text under a declared eurozone format — inside the
146
+ error bars. Both of those were ~990 ns in 0.1.0, for a reason that had nothing to do
147
+ with parsing: every format other than `INVARIANT` paid three method dispatches and two
148
+ `String` allocations per call to read its separators back out of the `Data`. `DETECT`
149
+ is now identity-matched like `INVARIANT`, and any other format is resolved once per
150
+ thread and memoized by identity — anchored in a thread-variable so the memo's key can
151
+ never be a recycled address — which turned the per-call cost into one pointer compare.
152
+ The three reason Symbols and the option Symbols (`:seconds`, `:month_day_year`, …) are
153
+ cached the same way, so a fault or a declared option is a pointer compare too, never a
154
+ `Symbol#name` materialization.
54
155
 
55
156
  **The honest trade-off, and Ruby's one real loss:** the civil date-time door is *slower*
56
157
  than `strptime` — 1.30 µs against `DateTime.strptime`'s 1.02 µs, and the date door 1.04 µs
@@ -63,9 +164,95 @@ defaults to `+00:00`, which is an artifact of the type, not a zone the parse ass
63
164
 
64
165
  On the Fiddle fallback the doors are parity-at-best — Fiddle's
65
166
  per-call floor is the mechanism's price, kept because it's the universal zero-compile
66
- path. (Benchmark forensics worth knowing: the doors read 4.3 µs until per-call
67
- `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch receipts
68
- include their own archaeology.)
167
+ path. 0.2.0 still took ~20% off its numeric doors (`i32` 3.37 → 2.62 µs, `f64` 3.31 →
168
+ 2.76 µs, same session) by not building things per call that never changed: the integer
169
+ doors interpolated and interned their `:cast_*` Symbol on every call, every door went
170
+ through a splat-and-resplat dispatcher, and every numeric call copied the format's 12
171
+ packed bytes into scratch — each format now owns one native pointer, memoized by identity,
172
+ passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
173
+ per-call `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch —
174
+ receipts include their own archaeology.)
175
+
176
+ ## WebAssembly (wasmtime)
177
+
178
+ The Rust core also ships inside this gem as a `wasm32-wasip1` module
179
+ (`lib/hypercast/native/wasm32-wasip1/hypercast.wasm`), and the
180
+ [`wasmtime`](https://rubygems.org/gems/wasmtime) gem can run it in-process. This is the
181
+ inverse of ruby.wasm — not Ruby inside a wasm sandbox, but a wasm module inside Ruby — and
182
+ it is the one backend that needs no shared library for the platform it runs on: no
183
+ `dlopen`, no Magnus extension, nothing compiled against this Ruby's ABI. The Magnus
184
+ extension replaces every public door in place; this backend replaces only the four private
185
+ bodies underneath them (`plain`, `numeric`, `declared`, `packed_version` — the whole native
186
+ crossing), so every door, both verdict `Data` types, the Symbol tables and the
187
+ `Time`/`DateTime`/`Rational`/`Decimal` carriers are the exact code the other two backends
188
+ run. `spec/wasm_backend_spec.rb` pins that the outputs agree with the Fiddle backend byte for
189
+ byte.
190
+
191
+ `wasmtime` is deliberately **not** a dependency of this gem; a consumer who wants this path
192
+ installs it:
193
+
194
+ ```sh
195
+ gem install wasmtime
196
+ HYPERCAST_WASM=1 ruby -rhypercast -e 'p HyperCast::BACKEND' # => :wasm
197
+ ```
198
+
199
+ `HYPERCAST_WASM=1` forces the backend (and raises a `LoadError` naming the gem if it is
200
+ missing). Without it, the wasm backend is only ever chosen automatically when there is no
201
+ native library for this platform at all — no Magnus extension and no `libhypercast` for the
202
+ RID — and `wasmtime` happens to be installed. No supported platform's behavior changes just
203
+ because this backend exists.
204
+
205
+ Two things are different under the sandbox, both by necessity. A wasm guest only sees its own
206
+ linear memory, so the input is copied into a grow-only guest buffer and the out-value, fault
207
+ span and `NumFormat` live in guest allocations made once at load — all from the module's own
208
+ exported `malloc` (the same wasi-libc allocator Rust's std uses on that target), read back
209
+ with `Memory#read`; using the guest's allocator rather than a host-picked offset is what keeps
210
+ a buffer from being clobbered by the guest's next allocation. And a `Wasmtime::Store` is
211
+ single-threaded, so every call is serialized under one Mutex around one shared instance.
212
+
213
+ Measured, same box as the benchmarks above (Ruby 4.0.6, linux-arm64 under WSL2, wasmtime
214
+ 48.0.1, `benchmark-ips`, same session, all three backends):
215
+
216
+ | Door | Magnus | Fiddle | wasmtime |
217
+ |---|---:|---:|---:|
218
+ | `bool` | 484 ns | 2.55 µs | 2.07 µs |
219
+ | `i32` | 555 ns | 2.78 µs | 2.65 µs |
220
+ | `f64` | 529 ns | 2.72 µs | 2.73 µs |
221
+ | `uuid` | 640 ns | 3.32 µs | 3.53 µs |
222
+ | `timestamp` | 769 ns | 3.12 µs | 3.10 µs |
223
+ | `datetime` (`1/7/2026 3:04 PM`) | 1.36 µs | 3.97 µs | 3.66 µs |
224
+ | `duration` (ISO) | 975 ns | 2.84 µs | 2.47 µs |
225
+ | `i32`, a fault | 658 ns | 2.89 µs | 2.42 µs |
226
+
227
+ The finding worth stating: in Ruby the wasm backend lands at Fiddle parity, door for door.
228
+ Fiddle's per-call floor is interpreted marshalling; wasmtime's is the host-to-guest crossing
229
+ plus the guest memory copies; on this box they cost the same, and both sit 4-5x behind the
230
+ Magnus extension. So on a platform with no native build, the fallback costs a consumer
231
+ nothing they were not already paying on the universal gem.
232
+
233
+ ## Verifying provenance
234
+
235
+ Every gem RubyGems.org serves — the universal fallback and each of the six precompiled
236
+ platform gems — carries its own GitHub build-provenance attestation, signed directly by
237
+ this repo's own `release.yml` (the `rubygems-publish` job attests `ruby/pkg/*.gem` right
238
+ before the push), so plain `--repo` verifies any of them:
239
+
240
+ ```sh
241
+ gem fetch hypercast -v X.Y.Z --platform <platform> # or omit --platform for the universal gem
242
+ gh attestation verify hypercast-X.Y.Z-<platform>.gem --repo SkunkWerkx/HyperCast
243
+ ```
244
+
245
+ That's the release's second layer of checking, not the only one: before any gem gets built,
246
+ the same job verifies every native binary it packs (six FFI libs, twelve Magnus extensions —
247
+ two ABIs per platform) against *their own* attestations — those are signed from
248
+ `SkunkWerkx/.github` by `hyper-build-native.yml`, so that check needs
249
+ `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an unverified one.
250
+ RubyGems.org has no unpublish and no duplicate-version overwrite, so this all happens while a
251
+ bad artifact is still reversible. The release run's job summary then re-fetches every gem
252
+ from the CDN and records attested-vs-served digests, turning "rubygems.org stores an upload
253
+ verbatim" into a per-release measurement rather than an assumption — see
254
+ [csharp/README.md's provenance section](../csharp/README.md#native-binary-provenance) for
255
+ more on why `--signer-repo` is needed for some artifacts here and not others.
69
256
 
70
257
  ## Install
71
258
 
@@ -73,11 +260,41 @@ include their own archaeology.)
73
260
  gem install hypercast
74
261
  ```
75
262
 
76
- Five gems are published per release: one universal `ruby`-platform gem (pure Fiddle, all six
77
- platforms' natives bundled) plus four precompiled Magnus platform gems (`x86_64-linux`,
78
- `aarch64-linux`, `x86_64-darwin`, `arm64-darwin`) that `gem install` auto-selects when they
79
- match. Nobody ever compiles anything; Windows and any unmatched platform land on the
80
- universal gem's Fiddle backend, which replays the same corpus green.
263
+ Seven gems are published per release: one universal `ruby`-platform gem (pure Fiddle, all
264
+ six platforms' natives bundled) plus six precompiled Magnus platform gems (`x86_64-linux`,
265
+ `aarch64-linux`, `x86_64-darwin`, `arm64-darwin`, `x64-mingw-ucrt`, `aarch64-mingw-ucrt`)
266
+ that `gem install` auto-selects when they match. Nobody ever compiles anything.
267
+
268
+ Selection has **two** axes here, unlike every other binding in this repo. A Magnus extension
269
+ is bound to one Ruby minor ABI — there is no `abi3` equivalent to collapse the version axis
270
+ the way [the Python binding's](../python/) wheels do — so each platform gem is a "fat" gem
271
+ carrying one compiled extension per supported Ruby, under `lib/hypercast/<minor>/`, and picks
272
+ one at `require` time:
273
+
274
+ | Ruby | the six platforms above | anywhere else (musl/Alpine, …) |
275
+ | --- | --- | --- |
276
+ | 4.0 (primary) | Magnus, `backend: :native` | Fiddle |
277
+ | 3.4 (floor, until its EOL 2028-03-31) | Magnus, `backend: :native` | Fiddle |
278
+ | 3.2 / 3.3 | Fiddle | Fiddle |
279
+
280
+ The platform gems declare `required_ruby_version >= 3.4, < 4.1` precisely so RubyGems
281
+ *declines* them outside that range and resolves the universal gem instead — a wrong-ABI
282
+ extension must never be installed in the first place. On Windows it would at least fail to
283
+ load cleanly (the extension imports `<arch>-ucrt-ruby<minor>.dll` by name —
284
+ `x64-ucrt-ruby400.dll` on x64, `aarch64-ucrt-ruby400.dll` on ARM), but Linux extensions don't
285
+ link libruby at all, so one can load successfully against the wrong ABI and misbehave later.
286
+ Every cell in that table replays the same corpus green.
287
+
288
+ Both Windows architectures get a Magnus gem, and the reasoning that once kept them on Fiddle
289
+ was backwards: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
290
+ `aarch64-mingw-ucrt`, both `supported: true` in its own `data/toolchains.json`); the one it
291
+ has no support for is MSVC. Windows is also where the Fiddle fallback cost the most, so these
292
+ are the most worthwhile gems in the set. Both extensions are built for the `gnullvm` Rust
293
+ targets rather than `gnu` — the same mingw-w64/UCRT ABI RubyInstaller's Ruby uses, linked
294
+ with LLVM and compiler-rt instead of GCC and a statically-linked libgcc, which is what keeps
295
+ the shipped extension small. The build script, and the two flags that are load-bearing on
296
+ the ARM leg (a static libunwind, and a clang-spelled `--target` for bindgen), live in the
297
+ forge's `hyper-build-native.yml`, shared with every other Hyper* repo.
81
298
 
82
299
  See [the repo root README](../README.md) for the full door table, the receipts, and the
83
300
  state of every other language binding.
@@ -18,23 +18,53 @@ module HyperCast
18
18
  cast_bool: PLAIN,
19
19
  cast_i8: NUMERIC, cast_i16: NUMERIC, cast_i32: NUMERIC, cast_i64: NUMERIC,
20
20
  cast_u8: NUMERIC, cast_u16: NUMERIC, cast_u32: NUMERIC, cast_u64: NUMERIC,
21
- cast_f32: NUMERIC, cast_f64: NUMERIC,
21
+ cast_f32: NUMERIC, cast_f64: NUMERIC, cast_decimal: NUMERIC,
22
22
  cast_uuid: PLAIN,
23
23
  cast_timestamp: PLAIN, cast_unix: UNIX, cast_excel_serial: UNIX,
24
24
  cast_date: PLAIN, cast_date_ordered: UNIX, cast_datetime: UNIX,
25
25
  cast_time: PLAIN, cast_duration: PLAIN
26
26
  }.freeze
27
27
 
28
+ # The one export that is not a door: the zero-argument version probe, returning the
29
+ # core's packed version word (major << 16 | minor << 8 | patch) rather than a verdict
30
+ # code — so it gets its own Fiddle signature beside DOORS instead of a row in it.
31
+ VERSION_PROBE = :hypercast_version
32
+
28
33
  @mutex = Mutex.new
29
34
  @functions = nil
30
35
 
31
36
  class << self
32
- def call(symbol, *args)
33
- functions.fetch(symbol).call(*args)
37
+ # The door's (or VERSION_PROBE's) Fiddle::Function, for the caller to invoke directly
38
+ # — a splat-through `call(symbol, *args)` here built and re-splatted an Array on
39
+ # every cast.
40
+ def function(symbol)
41
+ functions.fetch(symbol)
42
+ end
43
+
44
+ # Whether this platform has a shared library for Fiddle to dlopen at all: a known RID
45
+ # and the file actually present — in this install, or in the in-repo cargo build the
46
+ # dev loop falls back to. Backend selection (hypercast.rb) asks this before falling
47
+ # back to the WebAssembly backend, which needs neither.
48
+ def fiddle_library_available?
49
+ !library_path.nil?
50
+ rescue NativePlatform::UnsupportedPlatformError
51
+ false
34
52
  end
35
53
 
36
54
  private
37
55
 
56
+ # The shared library to dlopen: this install's native/{rid}/{lib}, or — the
57
+ # development loop — the in-repo cargo build, exactly what the other bindings' local
58
+ # staging does. Nil when neither exists.
59
+ def library_path
60
+ rid, lib_name = NativePlatform.rid_and_library_name
61
+ path = File.join(NATIVE_DIR, rid, lib_name)
62
+ return path if File.exist?(path)
63
+
64
+ repo_build = File.expand_path(File.join(__dir__, "../../../rust/target/release", lib_name))
65
+ File.exist?(repo_build) ? repo_build : nil
66
+ end
67
+
38
68
  # Loaded lazily and exactly once; the native library and its function pointers live
39
69
  # for the process's lifetime, same as every other binding (never dlclose'd). The
40
70
  # unsynchronized read is the fast path — a per-call mutex acquisition measured as a
@@ -44,24 +74,21 @@ module HyperCast
44
74
  end
45
75
 
46
76
  def load_functions
47
- rid, lib_name = NativePlatform.rid_and_library_name
48
- path = File.join(NATIVE_DIR, rid, lib_name)
49
- unless File.exist?(path)
50
- # Development loop: fall back to the in-repo cargo build, exactly what the other
51
- # bindings' local staging does.
52
- repo_build = File.expand_path(File.join(__dir__, "../../../rust/target/release", lib_name))
53
- path = repo_build if File.exist?(repo_build)
54
- end
55
- unless File.exist?(path)
77
+ path = library_path
78
+ if path.nil?
79
+ rid, lib_name = NativePlatform.rid_and_library_name
56
80
  raise LoadError,
57
- "hypercast: #{path} not found (unsupported platform, or this gem was built " \
58
- "without a native library for it)"
81
+ "hypercast: #{File.join(NATIVE_DIR, rid, lib_name)} not found (unsupported " \
82
+ "platform, or this gem was built without a native library for it)"
59
83
  end
60
84
 
61
85
  handle = Fiddle.dlopen(path)
62
- DOORS.to_h do |name, signature|
86
+ functions = DOORS.to_h do |name, signature|
63
87
  [name, Fiddle::Function.new(handle[name.to_s], signature, Fiddle::TYPE_INT32_T)]
64
88
  end
89
+ functions[VERSION_PROBE] =
90
+ Fiddle::Function.new(handle[VERSION_PROBE.to_s], [], Fiddle::TYPE_UINT32_T)
91
+ functions
65
92
  end
66
93
  end
67
94
  end
@@ -0,0 +1,169 @@
1
+ require "wasmtime"
2
+
3
+ module HyperCast
4
+ # The WebAssembly backend: the same Rust core, compiled once to a `wasm32-wasip1` module
5
+ # (lib/hypercast/native/wasm32-wasip1/hypercast.wasm) and run inside this process by the
6
+ # `wasmtime` gem — no per-platform shared library needed, and nothing dlopen'd. This is the
7
+ # inverse of ruby.wasm: not Ruby running inside a wasm sandbox, but a wasm module running
8
+ # inside Ruby.
9
+ #
10
+ # Same integration shape as the Magnus extension: on require (after hypercast.rb has
11
+ # defined the pure-Fiddle module) this redefines methods **in place** on HyperCast — no
12
+ # delegation layer, no second surface. The Magnus extension replaces every public door;
13
+ # this file replaces only the four private bodies underneath them (`plain`, `numeric`,
14
+ # `declared`, `packed_version` — the whole native crossing), so every door, every verdict
15
+ # type, `utf8`, `verdict`, `instant` and the Symbol tables stay the exact code the other
16
+ # two backends run, byte for byte.
17
+ #
18
+ # Two things differ from the native backends, both forced by the sandbox:
19
+ #
20
+ # * A wasm guest only sees its own linear memory, so no Fiddle::Pointer can cross. The
21
+ # input text is copied into a grow-only guest buffer, and the three out-params the doors
22
+ # fill — the 16-byte out-value, the 8-byte fault span, the 32-byte NumFormat — are guest
23
+ # allocations made once at load, all from the module's own exported `malloc` (the
24
+ # wasi-libc allocator Rust's std already uses on this target) and read back with
25
+ # `Memory#read`. Using the guest's allocator rather than a host-picked offset is
26
+ # load-bearing: dlmalloc claims the tail of the initial memory on first use, and
27
+ # HyperUuid observed a buffer written at a guessed offset corrupted by the guest's next
28
+ # allocation.
29
+ # * A `Wasmtime::Store` is single-threaded, so every call is serialized under one Mutex
30
+ # around one shared instance per process.
31
+ module WasmRuntime
32
+ MODULE_PATH = File.join(Runtime::NATIVE_DIR, "wasm32-wasip1", "hypercast.wasm")
33
+ # Development loop: the in-repo cargo build, the same fallback runtime.rb takes for the
34
+ # native library.
35
+ REPO_BUILD_PATH = File.expand_path(
36
+ File.join(__dir__, "../../../rust/target/wasm32-wasip1/release/hypercast.wasm")
37
+ )
38
+
39
+ DOORS = Runtime::DOORS.keys.freeze
40
+
41
+ # One instantiated module: the exported functions, the exported memory, and the guest
42
+ # buffers every door crosses through for the life of the process.
43
+ class Instance
44
+ attr_reader :memory, :out, :fault, :format
45
+
46
+ def initialize
47
+ path = File.exist?(MODULE_PATH) ? MODULE_PATH : REPO_BUILD_PATH
48
+ unless File.exist?(path)
49
+ raise LoadError,
50
+ "hypercast: #{MODULE_PATH} not found (this gem was built without its WebAssembly module)"
51
+ end
52
+
53
+ engine = Wasmtime::Engine.new
54
+ mod = Wasmtime::Module.from_file(engine, path)
55
+ linker = Wasmtime::Linker.new(engine)
56
+ # The module imports four WASI preview1 functions — wasi-libc's startup and panic
57
+ # plumbing (environ_*, fd_write, proc_exit); the core itself needs no clock and no
58
+ # entropy. A default WasiConfig — no stdio, no filesystem, no environment — is all
59
+ # any of them need.
60
+ Wasmtime::WASI::P1.add_to_linker_sync(linker)
61
+ store = Wasmtime::Store.new(engine, wasi_p1_config: Wasmtime::WasiConfig.new)
62
+ instance = linker.instantiate(store, mod)
63
+
64
+ @memory = instance.export("memory").to_memory
65
+ @fn = (DOORS + [Runtime::VERSION_PROBE] + %i[malloc free]).to_h do |name|
66
+ export = instance.export(name.to_s) or
67
+ raise LoadError, "hypercast: #{path} does not export #{name} (a module older than this binding)"
68
+ [name, export.to_func]
69
+ end
70
+ # dlmalloc hands back 8-byte-aligned (in fact 16) blocks, which covers the
71
+ # decimal out-value's 8-byte alignment.
72
+ @out = malloc(16)
73
+ @fault = malloc(8)
74
+ @format = malloc(32)
75
+ # Grow-only buffer for the input text, so a steady stream of ordinary scalars never
76
+ # touches the guest allocator again.
77
+ @in = 0
78
+ @in_cap = 0
79
+ # The format currently written at @format, memoized by identity: formats are reused
80
+ # constants in practice, and NumFormat is a frozen Data, so one `equal?` skips the
81
+ # 32-byte write on the overwhelming majority of numeric calls. The Instance holds
82
+ # the format it memoized, so the key can never be a recycled object.
83
+ @last_format = nil
84
+ end
85
+
86
+ def fn(name)
87
+ @fn.fetch(name)
88
+ end
89
+
90
+ def malloc(size)
91
+ ptr = @fn[:malloc].call(size)
92
+ raise NoMemoryError, "hypercast: wasm malloc(#{size}) failed" if ptr.zero?
93
+
94
+ ptr
95
+ end
96
+
97
+ # Copies the input into the guest and returns its address — 0 (the ABI's NULL) for
98
+ # empty input, which the core never dereferences.
99
+ def stage_input(bytes)
100
+ size = bytes.bytesize
101
+ return 0 if size.zero?
102
+
103
+ if size > @in_cap
104
+ @fn[:free].call(@in) unless @in.zero?
105
+ @in = malloc(size)
106
+ @in_cap = size
107
+ end
108
+ @memory.write(@in, bytes)
109
+ @in
110
+ end
111
+
112
+ def stage_format(format)
113
+ unless format.equal?(@last_format)
114
+ @memory.write(@format, format.packed)
115
+ @last_format = format
116
+ end
117
+ @format
118
+ end
119
+ end
120
+
121
+ @mutex = Mutex.new
122
+ @instance = nil
123
+
124
+ # Every call holds the one lock: the Store is single-threaded, and the lazily created
125
+ # instance is shared by every thread for the life of the process (never torn down,
126
+ # same as the dlopen'd library natively).
127
+ def self.with_instance
128
+ @mutex.synchronize do
129
+ @instance ||= Instance.new
130
+ yield @instance
131
+ end
132
+ end
133
+ end
134
+
135
+ class << self
136
+ private
137
+
138
+ # The four crossing bodies, redefined over the guest. Each door body reads the
139
+ # out-value back only on success and the fault span only on a failure code, so
140
+ # `verdict` sees exactly what the Fiddle path hands it.
141
+ def plain(symbol, text, out_size)
142
+ bytes = utf8(text)
143
+ WasmRuntime.with_instance do |w|
144
+ rc = w.fn(symbol).call(w.stage_input(bytes), bytes.bytesize, w.out, w.fault)
145
+ verdict(rc, rc.zero? ? nil : w.memory.read(w.fault, 8), bytes) { yield(w.memory.read(w.out, out_size)) }
146
+ end
147
+ end
148
+
149
+ def numeric(symbol, text, format, out_size)
150
+ bytes = utf8(text)
151
+ WasmRuntime.with_instance do |w|
152
+ rc = w.fn(symbol).call(w.stage_input(bytes), bytes.bytesize, w.stage_format(format), w.out, w.fault)
153
+ verdict(rc, rc.zero? ? nil : w.memory.read(w.fault, 8), bytes) { yield(w.memory.read(w.out, out_size)) }
154
+ end
155
+ end
156
+
157
+ def declared(symbol, text, code, out_size)
158
+ bytes = utf8(text)
159
+ WasmRuntime.with_instance do |w|
160
+ rc = w.fn(symbol).call(w.stage_input(bytes), bytes.bytesize, code, w.out, w.fault)
161
+ verdict(rc, rc.zero? ? nil : w.memory.read(w.fault, 8), bytes) { yield(w.memory.read(w.out, out_size)) }
162
+ end
163
+ end
164
+
165
+ def packed_version
166
+ WasmRuntime.with_instance { |w| w.fn(Runtime::VERSION_PROBE).call }
167
+ end
168
+ end
169
+ end
data/lib/hypercast.rb CHANGED
@@ -2,10 +2,11 @@ require "date"
2
2
  require_relative "hypercast/native_platform"
3
3
  require_relative "hypercast/runtime"
4
4
 
5
- # Allocation-lean scalar casts — booleans, numerics, UUIDs, temporals — calling directly
6
- # into the native libhypercast shared library via Fiddle. Every door returns a verdict:
7
- # Success or Fault (a closed reason plus the offending byte span), never an exception for
8
- # bad data — the only exceptions here are caller bugs (a malformed NumFormat), never data.
5
+ # Allocation-lean scalar casts — booleans, numerics, exact decimals, UUIDs, temporals —
6
+ # calling directly into the native libhypercast shared library via Fiddle. Every door
7
+ # returns a verdict: Success or Fault (a closed reason plus the offending byte span), never
8
+ # an exception for bad data — the only exceptions here are caller bugs (a malformed
9
+ # NumFormat), never data.
9
10
  #
10
11
  # Consume with Ruby's own pattern matching over the two Data case types:
11
12
  #
@@ -17,43 +18,68 @@ require_relative "hypercast/runtime"
17
18
  # Door names mirror the native ABI (i32, f64, timestamp, ...) so the polyglot surface reads
18
19
  # identically across bindings. Ruby-flavored fidelity: Integer is unbounded (u64 comes back
19
20
  # as the true unsigned value), Time carries full nanoseconds across the whole 0001-9999
20
- # window, time-of-day is an exact Integer of nanoseconds since midnight, and durations come
21
- # back as exact Rational seconds no truncation anywhere, and no wrapping: Ruby and the
22
- # JVM are the fidelity kings of this roster.
21
+ # window, time-of-day is an exact Integer of nanoseconds since midnight, durations come
22
+ # back as exact Rational seconds, and the decimal door returns an exact Decimal (sign,
23
+ # 96-bit magnitude, base-10 scale) that never rounds — no truncation anywhere, and no
24
+ # wrapping: Ruby and the JVM are the fidelity kings of this roster.
23
25
  module HyperCast
24
26
  # This gem's own version — kept in lockstep with hypercast.gemspec by the
25
27
  # prepare-release workflow, so the two can never drift apart again.
26
- VERSION = "0.1.0"
28
+ VERSION = "0.3.0"
27
29
 
28
30
  # The success case of a verdict: a cast value.
29
31
  Success = Data.define(:value)
30
32
 
31
33
  # The failure case: a closed reason Symbol (:empty, :malformed, :out_of_range) plus the
32
- # offending span as byte offsets into the UTF-8 input. Nothing is captured slicing the
33
- # offending text out of the input is the caller's choice.
34
+ # offending span, in the units String#[] slices by on the text you passed: character
35
+ # offsets for text (in any encoding — the core reads UTF-8 and the span is mapped back),
36
+ # byte offsets for a binary (ASCII-8BIT) String, whose characters are its bytes. Either
37
+ # way `text[offset, length]` is the offending text, with no mapping on your side.
38
+ # Nothing is captured — slicing it out of the input is the caller's choice.
34
39
  Fault = Data.define(:reason, :offset, :length)
35
40
 
36
41
  # The native core's failure codes, mapped to the closed reason Symbols a Fault carries.
37
42
  REASONS = { 1 => :empty, 2 => :malformed, 3 => :out_of_range }.freeze
38
43
 
39
- # Caller-declared numeric notation for the integer and real doors — declared out loud
40
- # (INVARIANT, or a literal), never defaulted, the same stance every binding takes.
41
- # Equal separators are a caller bug (ArgumentError), never a verdict.
42
- NumFormat = Data.define(:decimal_sep, :group_sep, :flags) do
44
+ # Caller-declared numeric notation for the integer, real and decimal doors — declared out
45
+ # loud (INVARIANT, or a literal), never defaulted, the same stance every binding takes.
46
+ # The currency symbol is the one field a culture table would fill in: it is declared
47
+ # here, never looked up, and honored only while the CURRENCY flag is set — declared with
48
+ # the flag off, the symbol is a :malformed Fault at the symbol, and the flag with no
49
+ # symbol ("" — the default) matches nothing. Equal separators, or a symbol longer than
50
+ # 16 UTF-8 bytes or carrying an ASCII digit or ASCII whitespace, are a caller bug
51
+ # (ArgumentError), never a verdict.
52
+ NumFormat = Data.define(:decimal_sep, :group_sep, :flags, :currency) do
53
+ # The widest currency symbol the native ABI carries inline, in UTF-8 bytes.
54
+ CURRENCY_MAX_BYTES = 16
55
+
43
56
  # Validates the declared separators up front — single characters, and distinct from
44
- # each other — so a malformed format fails loudly as the caller bug it is.
45
- def initialize(decimal_sep:, group_sep:, flags:)
57
+ # each other — and the currency symbol (a String of at most 16 UTF-8 bytes with no
58
+ # ASCII digit or ASCII whitespace, since those would collide with the digit scan and
59
+ # the trimming around the symbol), so a malformed format fails loudly as the caller bug
60
+ # it is. The symbol is stored transcoded to UTF-8, the encoding the core reads.
61
+ def initialize(decimal_sep:, group_sep:, flags:, currency: "")
46
62
  raise ArgumentError, "separators must be single characters" unless
47
63
  decimal_sep.is_a?(String) && decimal_sep.length == 1 &&
48
64
  group_sep.is_a?(String) && group_sep.length == 1
49
65
  raise ArgumentError, "decimal and group separators must differ; both are #{decimal_sep.inspect}" if
50
66
  decimal_sep == group_sep
51
- super
67
+ raise ArgumentError, "currency symbol must be a String; got #{currency.inspect}" unless currency.is_a?(String)
68
+
69
+ symbol = currency.encode(Encoding::UTF_8)
70
+ raise ArgumentError, "currency symbol #{currency.inspect} exceeds #{CURRENCY_MAX_BYTES} UTF-8 bytes" if
71
+ symbol.bytesize > CURRENCY_MAX_BYTES
72
+ raise ArgumentError, "currency symbol #{currency.inspect} must not contain an ASCII digit or whitespace" if
73
+ symbol.match?(/[0-9\t\n\f\r ]/)
74
+
75
+ super(decimal_sep: decimal_sep, group_sep: group_sep, flags: flags, currency: symbol)
52
76
  end
53
77
 
54
- # The 12-byte little-endian form the native ABI's NumFormat struct expects.
78
+ # The 32-byte little-endian form the native ABI's NumFormat struct expects: the two
79
+ # separators as code points, the flags, the symbol's byte length, then the symbol's
80
+ # UTF-8 bytes zero-padded to 16.
55
81
  def packed
56
- [decimal_sep.ord, group_sep.ord, flags].pack("L<L<L<")
82
+ [decimal_sep.ord, group_sep.ord, flags, currency.bytesize].pack("L<L<L<L<") + [currency].pack("a16")
57
83
  end
58
84
  end
59
85
 
@@ -65,7 +91,7 @@ module HyperCast
65
91
  EXPONENT = 1 << 2
66
92
  # Permit 0x/&H/0b two's-complement radix prefixes (0xFF is -1 for an i8).
67
93
  RADIX_PREFIXES = 1 << 3
68
- # Permit a trailing %, dividing by 100. Real doors only.
94
+ # Permit a trailing %, dividing by 100. Real and decimal doors only.
69
95
  PERCENT = 1 << 4
70
96
  # Resolve the ./, roles per input from structure instead of the declared separators
71
97
  # (which are ignored while this flag is set). Detection, not sniffing: a repeated
@@ -75,11 +101,18 @@ module HyperCast
75
101
  # Genuinely ambiguous input ("12.185", "1,000") is a :malformed Fault at the separator,
76
102
  # never guessed.
77
103
  SEPARATOR_DETECT = 1 << 5
104
+ # Permit the format's declared currency symbol at either edge of the numeric body —
105
+ # leading ("$5", "-$5", "$ -5") or trailing ("5 €", "1.234,50 kr."), once, with optional
106
+ # ASCII whitespace between symbol and digits; accounting parentheses wrap the symbol
107
+ # along with the digits ("($5)"). With no symbol declared the flag matches nothing.
108
+ # Integer, real and decimal doors.
109
+ CURRENCY = 1 << 6
78
110
  # Every lenience on (SEPARATOR_DETECT is a separator policy, not a lenience, and is
79
111
  # deliberately not included).
80
- ALL_STYLES = GROUPING | PARENTHESES | EXPONENT | RADIX_PREFIXES | PERCENT
112
+ ALL_STYLES = GROUPING | PARENTHESES | EXPONENT | RADIX_PREFIXES | PERCENT | CURRENCY
81
113
 
82
- # The invariant profile — '.' decimal, ',' grouping, every lenience on.
114
+ # The invariant profile — '.' decimal, ',' grouping, every lenience on, no currency
115
+ # symbol declared.
83
116
  NumFormat::INVARIANT = NumFormat.new(decimal_sep: ".", group_sep: ",", flags: ALL_STYLES)
84
117
 
85
118
  # The detection profile — every lenience on, ./, roles resolved per input by
@@ -100,6 +133,46 @@ module HyperCast
100
133
  # en-GB order) only because the caller said which.
101
134
  DATE_ORDERS = { year_month_day: 1, month_day_year: 2, day_month_year: 3 }.freeze
102
135
 
136
+ # An exact decimal, the decimal door's value: an unbounded Integer magnitude (the core's
137
+ # 96-bit unsigned, so at most 2**96 - 1), an Integer base-10 scale (0..28 — the number of
138
+ # places the magnitude is shifted right), and a negative flag. The value is
139
+ # (negative ? -1 : 1) * magnitude * 10**-scale. The triple is canonical: exact trailing
140
+ # zeros in the fraction are always trimmed, so the scale is minimal ("1.10", "1.1" and
141
+ # "1.1000" are all magnitude 11, scale 1; "100" stays magnitude 100, scale 0), and zero
142
+ # is scale 0 and never negative. Nothing but a zero is ever dropped — text carrying more
143
+ # precision than the core holds is an :out_of_range Fault, never an approximation.
144
+ # Convert with to_r (exact Rational), to_s (the canonical text), or to_d (BigDecimal).
145
+ Decimal = Data.define(:magnitude, :scale, :negative) do
146
+ # True for a negative value; zero is never negative.
147
+ def negative?
148
+ negative
149
+ end
150
+
151
+ # The value as an exact Rational.
152
+ def to_r
153
+ value = Rational(magnitude, 10**scale)
154
+ negative ? -value : value
155
+ end
156
+
157
+ # The canonical text form — "-1234.5", "-0.025", "0": the minimal scale rendered
158
+ # plainly, no exponent — the same string every binding renders and the conformance
159
+ # corpus pins as `value`.
160
+ def to_s
161
+ digits = magnitude.to_s.rjust(scale + 1, "0")
162
+ text = scale.zero? ? digits : "#{digits[0, digits.length - scale]}.#{digits[-scale, scale]}"
163
+ negative ? "-#{text}" : text
164
+ end
165
+
166
+ # The value as a BigDecimal, built exactly from the canonical text. Requires the
167
+ # bigdecimal gem (a bundled gem since Ruby 3.4, not a dependency of this one — add it
168
+ # to your Gemfile under Bundler), loaded lazily on first use so a consumer who never
169
+ # calls this pays nothing for it.
170
+ def to_d
171
+ require "bigdecimal"
172
+ BigDecimal(to_s)
173
+ end
174
+ end
175
+
103
176
  class << self
104
177
  # Presents a verdict optionally: an :empty fault becomes nil (Ruby's absent),
105
178
  # everything else flows through untouched.
@@ -109,6 +182,24 @@ module HyperCast
109
182
  verdict
110
183
  end
111
184
 
185
+ # Whether a backend actually loaded and exports the ABI this binding was built against
186
+ # — what a consumer with a fallback of its own checks before committing to these doors.
187
+ # Probed once (a native_version round trip: the cheapest call the core has), cached,
188
+ # and never raises: a missing shared library, an older core without the version
189
+ # export, or a wasm module the wasmtime gem cannot instantiate all answer false. The
190
+ # doors themselves keep their own behavior — the first call on an unavailable backend
191
+ # raises its precise LoadError, exactly as before — and so does require-time selection
192
+ # under HYPERCAST_WASM=1 without wasmtime; this only answers the question quietly.
193
+ def available?
194
+ return @available unless @available.nil?
195
+
196
+ @available = begin
197
+ native_version.is_a?(String)
198
+ rescue LoadError, StandardError
199
+ false
200
+ end
201
+ end
202
+
112
203
  # Casts boolean text: true/false plus the conventions untrusted sources actually send
113
204
  # (t/f, yes/no, y/n, 1/0, on/off, enabled/disabled, active/inactive,
114
205
  # checked/unchecked, in/out), ASCII case-insensitive.
@@ -120,11 +211,14 @@ module HyperCast
120
211
  .each do |door, unpack|
121
212
  sizes = { "c" => 1, "C" => 1, "s<" => 2, "S<" => 2, "l<" => 4, "L<" => 4, "q<" => 8, "Q<" => 8 }
122
213
  size = sizes.fetch(unpack)
214
+ # Resolved once here, not inside the method: interpolating a Symbol per call built a
215
+ # String and interned it on every integer cast.
216
+ symbol = :"cast_#{door}"
123
217
  # Integer doors: the target type's own range, declared grouping, accounting parens,
124
218
  # non-negative exponent, and 0x/&H/0b two's-complement radix prefixes. Ruby Integer
125
219
  # is unbounded, so u64 comes back as the true unsigned value.
126
220
  define_method(door) do |text, format|
127
- numeric(:"cast_#{door}", text, format, size) { |out| out.unpack1(unpack) }
221
+ numeric(symbol, text, format, size) { |out| out.unpack1(unpack) }
128
222
  end
129
223
  end
130
224
 
@@ -139,6 +233,19 @@ module HyperCast
139
233
  numeric(:cast_f64, text, format, 8) { |out| out.unpack1("E") }
140
234
  end
141
235
 
236
+ # Casts decimal text to an exact Decimal — the real doors' grammar (declared separators
237
+ # and grouping, parens, exponent, trailing percent, declared currency), but no float is
238
+ # ever formed: "0.1" is one tenth, "50%" is exactly 0.5 (magnitude 5, scale 1), and
239
+ # "1.10" is canonical magnitude 11, scale 1 — exact trailing zeros are always trimmed.
240
+ # A magnitude past 2**96 - 1, or more fractional precision than 28 places can hold, is
241
+ # an :out_of_range Fault — the door never rounds.
242
+ def decimal(text, format)
243
+ numeric(:cast_decimal, text, format, 16) do |out|
244
+ lo, hi, scale, negative = out.unpack("Q<L<CCx2")
245
+ Decimal.new(magnitude: (hi << 64) | lo, scale: scale, negative: negative != 0)
246
+ end
247
+ end
248
+
142
249
  # Casts UUID text — all five .NET Guid formats (D/N/B/P/X) plus urn:uuid:/GUID:/UUID:
143
250
  # prefixes — to Ruby's UUID lingua franca: the lowercase hyphenated String (the same
144
251
  # shape SecureRandom.uuid returns).
@@ -158,11 +265,7 @@ module HyperCast
158
265
  # (:seconds/:milliseconds/:microseconds/:nanoseconds) to a UTC Time. An unknown unit
159
266
  # is a caller bug (KeyError), never a verdict.
160
267
  def unix(text, precision)
161
- code = UNIX_PRECISIONS.fetch(precision)
162
- bytes = utf8(text)
163
- out, fault, = scratch
164
- rc = Runtime.call(:cast_unix, input_ptr(bytes), bytes.bytesize, code, out, fault)
165
- verdict(rc, fault) { instant(out[0, 16]) }
268
+ declared(:cast_unix, text, UNIX_PRECISIONS.fetch(precision), 16) { |out| instant(out) }
166
269
  end
167
270
 
168
271
  # Casts an Excel date serial under a caller-declared epoch Symbol (:y1900/:y1904) to a
@@ -176,11 +279,7 @@ module HyperCast
176
279
  # the text "1900-02-29" — so every serial above it is shifted one day against a naive
177
280
  # count. An unknown epoch is a caller bug (KeyError), never a verdict.
178
281
  def excel_serial(text, epoch)
179
- code = EXCEL_EPOCHS.fetch(epoch)
180
- bytes = utf8(text)
181
- out, fault, = scratch
182
- rc = Runtime.call(:cast_excel_serial, input_ptr(bytes), bytes.bytesize, code, out, fault)
183
- verdict(rc, fault) { instant(out[0, 16]) }
282
+ declared(:cast_excel_serial, text, EXCEL_EPOCHS.fetch(epoch), 16) { |out| instant(out) }
184
283
  end
185
284
 
186
285
  # Casts a calendar date to a Date. With no order declared: the strict ISO 8601
@@ -195,12 +294,8 @@ module HyperCast
195
294
  Date.new(year, month, day)
196
295
  end
197
296
  else
198
- code = DATE_ORDERS.fetch(order)
199
- bytes = utf8(text)
200
- out, fault, = scratch
201
- rc = Runtime.call(:cast_date_ordered, input_ptr(bytes), bytes.bytesize, code, out, fault)
202
- verdict(rc, fault) do
203
- year, month, day = out[0, 4].unpack("S<CC")
297
+ declared(:cast_date_ordered, text, DATE_ORDERS.fetch(order), 4) do |out|
298
+ year, month, day = out.unpack("S<CC")
204
299
  Date.new(year, month, day)
205
300
  end
206
301
  end
@@ -215,12 +310,8 @@ module HyperCast
215
310
  # fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
216
311
  # instant door. An unknown order is a caller bug (KeyError).
217
312
  def datetime(text, order)
218
- code = DATE_ORDERS.fetch(order)
219
- bytes = utf8(text)
220
- out, fault, = scratch
221
- rc = Runtime.call(:cast_datetime, input_ptr(bytes), bytes.bytesize, code, out, fault)
222
- verdict(rc, fault) do
223
- year, month, day, nanos = out[0, 16].unpack("S<CCx4Q<")
313
+ declared(:cast_datetime, text, DATE_ORDERS.fetch(order), 16) do |out|
314
+ year, month, day, nanos = out.unpack("S<CCx4Q<")
224
315
  second_of_day, frac = nanos.divmod(1_000_000_000)
225
316
  hour, rest = second_of_day.divmod(3600)
226
317
  minute, second = rest.divmod(60)
@@ -244,6 +335,16 @@ module HyperCast
244
335
  end
245
336
  end
246
337
 
338
+ # The version of the native core actually loaded, as "major.minor.patch" — read from
339
+ # the library itself, not from this gem, so a consumer can prove the core behind the
340
+ # doors is the one this binding was built against (and name the mismatch when it is
341
+ # not). The cheapest possible probe that the backend resolved at all: takes nothing,
342
+ # cannot fail.
343
+ def native_version
344
+ word = packed_version
345
+ "#{word >> 16}.#{(word >> 8) & 0xFF}.#{word & 0xFF}"
346
+ end
347
+
247
348
  private
248
349
 
249
350
  # Encodings whose bytes already are the UTF-8 (or byte-identical) form the core reads.
@@ -261,51 +362,92 @@ module HyperCast
261
362
  bytes.empty? ? nil : bytes
262
363
  end
263
364
 
264
- # One 36-byte scratch allocation per thread, reused by every call: out-value at 0
265
- # (16 bytes covers every door), fault span at 16, NumFormat at 24. Two
266
- # Fiddle::Pointer.malloc(..., RUBY_FREE) calls per cast — each registering a GC
267
- # finalizer — measured as the dominant per-call cost by an order of magnitude.
365
+ # One 24-byte scratch allocation per thread, reused by every call: out-value at 0
366
+ # (16 bytes covers every door, and malloc's alignment covers the decimal's 8), fault
367
+ # span at 16. Two Fiddle::Pointer.malloc(..., RUBY_FREE) calls per cast — each
368
+ # registering a GC finalizer — measured as the dominant per-call cost by an order of
369
+ # magnitude. The NumFormat no longer lives here: each format owns its own packed
370
+ # pointer (see packed_cache), so a numeric call copies nothing into scratch before
371
+ # crossing.
268
372
  def scratch
269
373
  Thread.current[:hypercast_scratch] ||= begin
270
- base = Fiddle::Pointer.malloc(36, Fiddle::RUBY_FREE)
271
- [base, base + 16, base + 24]
374
+ base = Fiddle::Pointer.malloc(24, Fiddle::RUBY_FREE)
375
+ [base, base + 16]
272
376
  end
273
377
  end
274
378
 
275
379
  # Presents a native return code as the verdict union: 0 yields a Success, a failure
276
- # code becomes a Fault, and -1 (contract violation) is a binding bug that raises.
277
- def verdict(rc, fault)
380
+ # code becomes a Fault over `bytes` (the UTF-8 the core read), and -1 (contract
381
+ # violation) is a binding bug that raises.
382
+ def verdict(rc, fault, bytes)
278
383
  if rc.zero?
279
384
  Success.new(value: yield)
280
385
  elsif rc == -1
281
386
  raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
282
387
  else
283
- offset, length = fault[0, 8].unpack("L<L<")
388
+ offset, length = characters(bytes, *fault[0, 8].unpack("L<L<"))
284
389
  Fault.new(reason: REASONS.fetch(rc), offset: offset, length: length)
285
390
  end
286
391
  end
287
392
 
393
+ # The core's byte span in the units String#[] slices by: an identity for a binary
394
+ # String (its characters are its bytes) and for ASCII text (`ascii_only?` reads the
395
+ # cached coderange — no scan), a byte-to-character remap otherwise. Character counts
396
+ # survive transcoding, so a span mapped on the UTF-8 form indexes the caller's own
397
+ # String whatever encoding it arrived in. Failure path only: a Success never pays.
398
+ def characters(bytes, offset, length)
399
+ return [offset, length] if bytes.encoding == Encoding::ASCII_8BIT || bytes.ascii_only?
400
+
401
+ [bytes.byteslice(0, offset).length, bytes.byteslice(offset, length).length]
402
+ end
403
+
288
404
  # The shared body of every format-free door: one native call over the scratch buffers.
289
405
  def plain(symbol, text, out_size)
290
406
  bytes = utf8(text)
291
- out, fault, = scratch
292
- rc = Runtime.call(symbol, input_ptr(bytes), bytes.bytesize, out, fault)
293
- verdict(rc, fault) { yield(out[0, out_size]) }
407
+ out, fault = scratch
408
+ rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, out, fault)
409
+ verdict(rc, fault, bytes) { yield(out[0, out_size]) }
294
410
  end
295
411
 
296
- # The shared body of the integer/real doors: plain, plus the packed NumFormat.
412
+ # The shared body of the integer/real/decimal doors: plain, plus the format's own
413
+ # packed pointer, passed straight through — no per-call copy of the 32 bytes into
414
+ # scratch.
297
415
  def numeric(symbol, text, format, out_size)
298
416
  bytes = utf8(text)
299
- out, fault, raw_format = scratch
300
- raw_format[0, 12] = packed_cache[format]
301
- rc = Runtime.call(symbol, input_ptr(bytes), bytes.bytesize, raw_format, out, fault)
302
- verdict(rc, fault) { yield(out[0, out_size]) }
417
+ out, fault = scratch
418
+ rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, packed_cache[format], out, fault)
419
+ verdict(rc, fault, bytes) { yield(out[0, out_size]) }
420
+ end
421
+
422
+ # The shared body of the four doors that take a caller-declared u32 — a precision, an
423
+ # epoch, or a field order — already resolved from its Symbol by the door. These bodies
424
+ # (plain, numeric, declared, and packed_version below) are the whole native crossing:
425
+ # the wasm backend (wasm_runtime.rb) redefines exactly these four in place and nothing
426
+ # above them.
427
+ def declared(symbol, text, code, out_size)
428
+ bytes = utf8(text)
429
+ out, fault = scratch
430
+ rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, code, out, fault)
431
+ verdict(rc, fault, bytes) { yield(out[0, out_size]) }
303
432
  end
304
433
 
305
- # Identity-keyed memo of NumFormat#packed formats are reused constants in practice,
306
- # and re-packing per call is a measurable allocation. The race is benign (idempotent).
434
+ # The loaded core's version word, major << 16 | minor << 8 | patch, straight from the
435
+ # library's zero-argument hypercast_version export.
436
+ def packed_version
437
+ Runtime.function(:hypercast_version).call
438
+ end
439
+
440
+ # Identity-keyed memo (compare_by_identity — a pointer hash, not Data's structural
441
+ # #hash over three Strings and an Integer) of a native 32-byte RawNumFormat per format
442
+ # object, filled once from NumFormat#packed. Formats are reused constants in practice,
443
+ # so the common call finds its pointer in one lookup and packs nothing. The Hash holds
444
+ # the format, so a key can never be a recycled address; the race is benign (idempotent).
307
445
  def packed_cache
308
- @packed_cache ||= Hash.new { |cache, format| cache[format] = format.packed }
446
+ @packed_cache ||= Hash.new do |cache, format|
447
+ pointer = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
448
+ pointer[0, 32] = format.packed
449
+ cache[format] = pointer
450
+ end.compare_by_identity
309
451
  end
310
452
 
311
453
  # Builds a UTC Time from the core's protobuf-shaped {seconds, nanos} pair, exactly.
@@ -321,14 +463,58 @@ end
321
463
  # drops to an ordinary extension call. The pure-Fiddle definitions stay the universal
322
464
  # zero-compile fallback; precompiled platform gems are how the extension ships without
323
465
  # ever making a consumer compile anything. Set HYPERCAST_PURE=1 to force Fiddle.
466
+ #
467
+ # The third backend is WebAssembly (lib/hypercast/wasm_runtime.rb): the same core as a
468
+ # wasm32-wasip1 module, run in-process by the `wasmtime` gem, which is deliberately not a
469
+ # runtime dependency of this gem — a consumer who wants it installs it. HYPERCAST_WASM=1
470
+ # forces it (and fails loudly if wasmtime is missing); otherwise it is only ever chosen when
471
+ # there is no native library for this platform at all and wasmtime happens to be available,
472
+ # so no supported platform's behavior changes by its existence.
324
473
  HyperCast::BACKEND =
325
- if ENV["HYPERCAST_PURE"]
474
+ if ENV["HYPERCAST_WASM"]
475
+ begin
476
+ require "wasmtime"
477
+ rescue LoadError
478
+ raise LoadError,
479
+ "hypercast: HYPERCAST_WASM=1 needs the wasmtime gem — `gem install wasmtime` (or add it to your Gemfile)"
480
+ end
481
+ require_relative "hypercast/wasm_runtime"
482
+ :wasm
483
+ elsif ENV["HYPERCAST_PURE"]
326
484
  :fiddle
327
485
  else
486
+ # Two layouts, and both have to work. A released platform gem is a "fat" gem carrying one
487
+ # extension per supported Ruby ABI under lib/hypercast/<minor>/ (see the Rakefile's
488
+ # native:gem task for why an ABI-per-file is unavoidable — Magnus has no `abi3`
489
+ # equivalent). CI's in-job staging and a local `cargo build --release --features ruby`
490
+ # instead drop a single extension flat at lib/. Trying the versioned path first and the
491
+ # flat one second means neither has to know the other exists.
492
+ #
493
+ # A miss on both is not an error: it means this Ruby/platform combination has no
494
+ # precompiled extension, which is precisely what the Fiddle backend is for.
328
495
  begin
329
- require "hypercast_native"
496
+ require "hypercast/#{RUBY_VERSION[/\d+\.\d+/]}/hypercast_native"
330
497
  :native
331
498
  rescue LoadError
332
- :fiddle
499
+ begin
500
+ require "hypercast_native"
501
+ :native
502
+ rescue LoadError
503
+ if HyperCast::Runtime.fiddle_library_available?
504
+ :fiddle
505
+ else
506
+ # No shared library for this platform either. wasmtime, if the consumer has it,
507
+ # is the only backend left that can run here; without it, stay on Fiddle so the
508
+ # first call raises its own precise "not found" LoadError rather than a vaguer one
509
+ # from here.
510
+ begin
511
+ require "wasmtime"
512
+ require_relative "hypercast/wasm_runtime"
513
+ :wasm
514
+ rescue LoadError
515
+ :fiddle
516
+ end
517
+ end
518
+ end
333
519
  end
334
520
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hypercast
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Brian Buvinghausen
@@ -64,15 +64,18 @@ files:
64
64
  - LICENSE
65
65
  - README.md
66
66
  - lib/hypercast.rb
67
+ - lib/hypercast/3.4/hypercast_native.so
68
+ - lib/hypercast/4.0/hypercast_native.so
67
69
  - lib/hypercast/native/linux-arm64/libhypercast.so
68
70
  - lib/hypercast/native/linux-x64/libhypercast.so
69
71
  - lib/hypercast/native/osx-arm64/libhypercast.dylib
70
72
  - lib/hypercast/native/osx-x64/libhypercast.dylib
73
+ - lib/hypercast/native/wasm32-wasip1/hypercast.wasm
71
74
  - lib/hypercast/native/win-arm64/hypercast.dll
72
75
  - lib/hypercast/native/win-x64/hypercast.dll
73
76
  - lib/hypercast/native_platform.rb
74
77
  - lib/hypercast/runtime.rb
75
- - lib/hypercast_native.so
78
+ - lib/hypercast/wasm_runtime.rb
76
79
  homepage: https://github.com/SkunkWerkx/HyperCast
77
80
  licenses:
78
81
  - MIT
@@ -85,14 +88,17 @@ required_ruby_version: !ruby/object:Gem::Requirement
85
88
  requirements:
86
89
  - - ">="
87
90
  - !ruby/object:Gem::Version
88
- version: '3.2'
91
+ version: '3.4'
92
+ - - "<"
93
+ - !ruby/object:Gem::Version
94
+ version: '4.1'
89
95
  required_rubygems_version: !ruby/object:Gem::Requirement
90
96
  requirements:
91
97
  - - ">="
92
98
  - !ruby/object:Gem::Version
93
99
  version: '0'
94
100
  requirements: []
95
- rubygems_version: 3.6.9
101
+ rubygems_version: 4.0.16
96
102
  specification_version: 4
97
103
  summary: Allocation-free scalar parsing as Success/Fault verdicts over a native Rust
98
104
  core
Binary file