hypercast 0.3.0 → 0.6.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: a5a3b3e6d5955b7431ae3452f9f80b9aecaae1c795408951cdfabd913d025e30
4
- data.tar.gz: 871aef11a7d72f3a025adf92340a3d66783d009604cf17d2c234ce25b0fbf60b
3
+ metadata.gz: f482418099cb3b96492d839da3f627716b4b4ba6a1b083d66f47f337edb197b3
4
+ data.tar.gz: 92e9560c193bd102dbbff75ace2b3aaa8dba75527a217c2b7f730f227af3e27b
5
5
  SHA512:
6
- metadata.gz: 8664495fde8462e5e3886b7e0b72733e16c4c8dc4cb85e6d71b21c66bdc949ba9d3729c5291349f31c15a4da1b17403db85adbcc5815d55615e223fea77be022
7
- data.tar.gz: 6e9396ad08a6634853726727121096fabbbf654275d81656725c0f7b620ccfea59b534d75f8ccc31812bdd01fd49220cb020e6c5f2999da2657a0ad9de20cba2
6
+ metadata.gz: 7f24fc4d78125c73b521c631318b778367321438c1791ee8e6055805d8bd2c274f2dd358d30af309350a201faba1aba0772815974a1c187d64896ad4f3f92af0
7
+ data.tar.gz: f349625cd4fd7b6dfa1311484186c924705a4c480213ace67aa1a6dea6b68e78d464f9187fbf0da88129c6995cc5f40e99fc01ae001088751ffe66b6e288af14
data/README.md CHANGED
@@ -1,28 +1,28 @@
1
1
  # hypercast
2
2
 
3
3
  [![CI](https://github.com/SkunkWerkx/HyperCast/actions/workflows/ci.yml/badge.svg)](https://github.com/SkunkWerkx/HyperCast/actions/workflows/ci.yml)
4
- [![Gem](https://img.shields.io/gem/v/hypercast.svg)](https://rubygems.org/gems/hypercast)
4
+ [![RubyGems](https://img.shields.io/gem/v/hypercast.svg)](https://rubygems.org/gems/hypercast)
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. Three backends, one public surface: a Magnus
9
- native extension where a precompiled platform gem covers you, stdlib Fiddle everywhere
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.**
8
+ Symbol plus the exact span that offended. Two backends, one public surface: a Magnus
9
+ native extension where a precompiled platform gem covers you, stdlib Fiddle as the last
10
+ resort everywhere else — selected automatically, zero compiles either way.**
12
11
 
13
12
  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
15
- (`Data.define` for the verdict case types). The fast path links the core straight into a
16
- Ruby extension (Magnus): on require it redefines the doors in place on the `HyperCast`
17
- module — no delegation layer, no second surface, which is exactly what keeps the backends
18
- provably in agreement. `HyperCast::BACKEND` reports which is live; `HYPERCAST_PURE=1`
19
- forces Fiddle, `HYPERCAST_WASM=1` the wasm module — see
20
- [WebAssembly (wasmtime)](#webassembly-wasmtime).
13
+ UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
14
+ floor. The fast path links the core straight into a Ruby extension (Magnus): on require it
15
+ redefines the doors in place on the `HyperCast` module — no delegation layer, no second
16
+ surface, which is exactly what keeps the backends provably in agreement.
17
+ The last-resort fallback, in the universal gem only, calls the native `libhypercast` shared
18
+ library via [`Fiddle`](https://docs.ruby-lang.org/en/master/Fiddle.html) — dlopen/dlsym plus
19
+ raw C-ABI calls, no runtime bridge. `HyperCast::BACKEND` reports which one is live;
20
+ `HYPERCAST_PURE=1` forces Fiddle for testing — see [Backends](#backends).
21
21
 
22
22
  ```ruby
23
23
  case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
24
24
  in HyperCast::Success(value:) then puts "got #{value}" # -1234
25
- in HyperCast::Fault(reason:, offset:) then puts "#{reason} at byte #{offset}"
25
+ in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
26
26
  end
27
27
  ```
28
28
 
@@ -52,13 +52,45 @@ midnight, durations come back as exact `Rational` seconds across the core's whol
52
52
  | `duration(text)` | exact `Rational` seconds | — |
53
53
 
54
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.)
55
+ to `nil`. Beside the doors, `native_version` returns the loaded core's
56
+ `"major.minor.patch"` and `available?` answers `true`/`false` without ever raising — see
57
+ [Is the native core there?](#is-the-native-core-there).
58
+
59
+ ### Errors
60
+
61
+ Bad data is never an exception — it is a `Fault`. What raises is a caller bug, and it raises
62
+ the same exception on every backend:
63
+
64
+ - `ArgumentError` from `NumFormat.new` for a malformed format: separators that are not single
65
+ characters or not distinct, a currency symbol that is too long or carries an ASCII digit or
66
+ whitespace.
67
+ - `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
68
+ that names nothing: an unknown Symbol, or anything that is not a Symbol (`"seconds"`
69
+ included). `date(text, nil)` is not one of these: an explicit `nil` order is the strict ISO
70
+ door, exactly as if it were left off.
71
+ - `TypeError` for text that is not a `String` (anything with `to_str` is taken as one).
72
+ - `String#encode`'s own `Encoding::` error for text in another encoding that cannot be
73
+ transcoded to UTF-8.
74
+
75
+ ### Is the native core there?
76
+
77
+ `HyperCast.available?` answers whether a backend actually loaded and exports the ABI this
78
+ binding was built against — probed once, cached, never raising — for a consumer with a
79
+ fallback of its own:
80
+
81
+ ```ruby
82
+ value = HyperCast.available? ? HyperCast.i32(text, format) : my_own_parse(text)
83
+ ```
84
+
85
+ `HyperCast.native_version` reads the version out of the loaded core itself (its
86
+ `hypercast_version` export — the cheapest probe that the backend resolved at all), so a
87
+ mismatch against `HyperCast::VERSION` can be named before the first cast.
88
+ `HyperCast::BACKEND` says which backend was *selected*, which is not the same question: it
89
+ reads `:fiddle` on a platform with no library at all, because that is the backend whose
90
+ first call explains what is missing — a `LoadError` naming the path it looked for, or
91
+ `HyperCast::NativePlatform::UnsupportedPlatformError` naming the platform. Inside a platform
92
+ gem, which carries no Fiddle library, that `LoadError` names the universal gem instead (see
93
+ [Backends](#backends)).
62
94
 
63
95
  ### Fault spans index the String you passed
64
96
 
@@ -132,19 +164,18 @@ and memoized by identity on every backend, so declaring a currency costs a cast
132
164
  parentheses, declared separators, radix prefixes, all five .NET `Guid` text forms plus
133
165
  `urn:uuid:` prefixes, protobuf JSON durations.
134
166
  3. **One engine across a polyglot system** — bit-for-bit verdicts with every other binding,
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).
167
+ held by the shared corpus (the whole suite green on both backends, full corpus replay;
168
+ cross-backend agreement specs compare Magnus against Fiddle across a subprocess
169
+ boundary).
138
170
  4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
139
- benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-arm64): timestamp **713 ns vs
140
- 2.88 µs `Time.iso8601`** (4.0x) — while returning exact `Rational` durations on the
141
- duration door. The Fiddle fallback lands at ~3.5 µs: parity with `Time.iso8601`, sitting
142
- on Fiddle's measured 1.6 µs per-call marshalling floor.
143
-
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
171
+ benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-x64 on an Intel Core
172
+ i9-11900H, Ruby 4.0.7): timestamp **447 ns vs 2.97 µs `Time.iso8601`** (6.6x) — while
173
+ returning exact `Rational` durations on the duration door. The Fiddle fallback lands at
174
+ 3.25 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
175
+
176
+ Separator detection is nearly free here: `1.234.567,89` under `NumFormat::DETECT` runs
177
+ at 178 ns against 170 ns for the same text under a declared eurozone format. Both cost
178
+ 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
148
179
  `String` allocations per call to read its separators back out of the `Data`. `DETECT`
149
180
  is now identity-matched like `INVARIANT`, and any other format is resolved once per
150
181
  thread and memoized by identity — anchored in a thread-variable so the memo's key can
@@ -153,86 +184,79 @@ and memoized by identity on every backend, so declaring a currency costs a cast
153
184
  cached the same way, so a fault or a declared option is a pointer compare too, never a
154
185
  `Symbol#name` materialization.
155
186
 
156
- **The honest trade-off, and Ruby's one real loss:** the civil date-time door is *slower*
157
- than `strptime` — 1.30 µs against `DateTime.strptime`'s 1.02 µs, and the date door 1.04 µs
158
- against `Date.strptime`'s 619 ns. The parse isn't the problem; the carrier is. Building a
159
- stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
160
- where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`. Printed
161
- because it's real: if you want Ruby's fastest civil parse and don't need the verdict or the
162
- declared order, `strptime` wins. Also note the carrier's other caveat — `DateTime`'s offset
163
- defaults to `+00:00`, which is an artifact of the type, not a zone the parse assigned.
187
+ **The honest trade-off:** the civil doors do not beat `strptime`. The date-time door is
188
+ level with `DateTime.strptime` (904 ns against 901 ns) and the date door a little behind
189
+ `Date.strptime` (620 ns against 513 ns). The parse isn't the cost; the carrier is. Building
190
+ a stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
191
+ where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`. If you want
192
+ Ruby's fastest civil parse and don't need the verdict or the declared order, `strptime` is
193
+ as good. Also note the carrier's other caveat — `DateTime`'s offset defaults to `+00:00`,
194
+ which is an artifact of the type, not a zone the parse assigned.
164
195
 
165
196
  On the Fiddle fallback the doors are parity-at-best — Fiddle's
166
197
  per-call floor is the mechanism's price, kept because it's the universal zero-compile
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
198
+ path. Its doors build nothing per call that does not change between calls: each format
199
+ owns one native pointer, memoized by identity and passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
173
200
  per-call `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch —
174
201
  receipts include their own archaeology.)
175
202
 
176
- ## WebAssembly (wasmtime)
203
+ ## Benchmarks
177
204
 
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.
205
+ `ruby benchmark/cast_benchmark.rb` pairs each door with Ruby's closest stdlib parse. Its
206
+ first line names the backend, core version and Ruby it measured, so run it again under
207
+ `HYPERCAST_PURE=1` for the other backend. The stdlib comparisons are in
208
+ [the section above](#why-not-integer--timeiso8601--float); this is the two backends against
209
+ each other.
190
210
 
191
- `wasmtime` is deliberately **not** a dependency of this gem; a consumer who wants this path
192
- installs it:
211
+ Measured on Ruby 4.0.7, linux-x64 (an Intel Core i9-11900H), `benchmark-ips`, same session,
212
+ both backends:
193
213
 
194
- ```sh
195
- gem install wasmtime
196
- HYPERCAST_WASM=1 ruby -rhypercast -e 'p HyperCast::BACKEND' # => :wasm
197
- ```
214
+ | Door | Magnus | Fiddle |
215
+ |---|---:|---:|
216
+ | `bool` | 112 ns | 2.35 µs |
217
+ | `i32` | 133 ns | 2.63 µs |
218
+ | `f64` | 166 ns | 2.70 µs |
219
+ | `uuid` | 223 ns | 3.47 µs |
220
+ | `timestamp` | 447 ns | 3.25 µs |
221
+ | `datetime` (`1/7/2026 3:04 PM`) | 904 ns | 3.98 µs |
222
+ | `duration` (ISO) | 632 ns | 2.91 µs |
223
+ | `i32`, a fault | 225 ns | 3.18 µs |
224
+
225
+ A lean door on the Magnus backend is little more than the native call: the extension builds
226
+ the `Success` or `Fault` it returns directly — allocated, its members stored, frozen —
227
+ rather than through `Data.new`, whose keyword handling alone cost more than the cast.
198
228
 
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.
229
+ ## Backends
230
+
231
+ | `HyperCast::BACKEND` | What runs | Chosen when |
232
+ |---|---|---|
233
+ | `:native` | the core linked into a Magnus extension | a precompiled platform gem is installed — every one carries an extension for each Ruby it installs on; see [Install](#install) |
234
+ | `:fiddle` | `libhypercast` for this platform, `dlopen`ed through Fiddle | no extension loads: the universal gem, on a Ruby or platform no platform gem covers; also the answer when nothing loads at all |
235
+
236
+ Selection happens once, at `require`, in that order.
237
+
238
+ `HYPERCAST_PURE` forces `:fiddle`, even where an extension would load. It is a testing and
239
+ diagnostic switch — CI runs the whole suite through it, and it is how to rule an extension
240
+ problem in or out — not a setting a deployment needs. It is read for presence, not value: set
241
+ to anything at all, `0` and the empty string included, it forces Fiddle. A forced backend that
242
+ turns out to have nothing to load does not fall through to another one: the first call raises,
243
+ and `HyperCast.available?` answers `false`. That is what happens inside a platform gem, which
244
+ carries no Fiddle library: the `LoadError` names the universal gem
245
+ (`gem install hypercast --platform ruby`, or Bundler's `force_ruby_platform`), where the
246
+ Fiddle backend lives.
247
+
248
+ **Threads.** Every backend is safe to call from any number of threads. The extension runs
249
+ under the GVL. Fiddle releases the GVL for the duration of each call, so that is the one
250
+ backend where Ruby threads run the core truly in parallel — each through its own scratch
251
+ buffers; the core itself keeps no state between calls. `spec/cast_spec.rb`'s
252
+ concurrent-callers example runs under both.
253
+
254
+ **Ractors.** Main Ractor only, on every backend: called from another Ractor the doors raise
255
+ `Ractor::UnsafeError`.
232
256
 
233
257
  ## Verifying provenance
234
258
 
235
- Every gem RubyGems.org serves — the universal fallback and each of the six precompiled
259
+ Every gem RubyGems.org serves — the universal fallback and each of the seven precompiled
236
260
  platform gems — carries its own GitHub build-provenance attestation, signed directly by
237
261
  this repo's own `release.yml` (the `rubygems-publish` job attests `ruby/pkg/*.gem` right
238
262
  before the push), so plain `--repo` verifies any of them:
@@ -243,10 +267,11 @@ gh attestation verify hypercast-X.Y.Z-<platform>.gem --repo SkunkWerkx/HyperCast
243
267
  ```
244
268
 
245
269
  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.
270
+ the same job verifies every native binary it packs — the Fiddle libraries, the Magnus
271
+ extensions (one per Ruby ABI per platform) — against *their own*
272
+ attestations — those are signed from `SkunkWerkx/.github` by `hyper-build-native.yml`, so
273
+ that check needs `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an
274
+ unverified one.
250
275
  RubyGems.org has no unpublish and no duplicate-version overwrite, so this all happens while a
251
276
  bad artifact is still reversible. The release run's job summary then re-fetches every gem
252
277
  from the CDN and records attested-vs-served digests, turning "rubygems.org stores an upload
@@ -260,10 +285,16 @@ more on why `--signer-repo` is needed for some artifacts here and not others.
260
285
  gem install hypercast
261
286
  ```
262
287
 
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.
288
+ Eight gems are published per release: seven precompiled Magnus platform gems that
289
+ `gem install` and `bundle` auto-select when they match — `x86_64-linux-gnu`,
290
+ `aarch64-linux-gnu`, `x86_64-linux-musl`, `aarch64-linux-musl`, `arm64-darwin`,
291
+ `x64-mingw-ucrt` and `aarch64-mingw-ucrt` — and one universal `ruby`-platform gem. A platform
292
+ gem carries its Magnus extensions and nothing else native: no Fiddle library at all. The
293
+ universal gem is the last resort, Fiddle with every platform's native library bundled, and it
294
+ is what RubyGems resolves for a Ruby the platform gems do not cover (3.3, or a Ruby newer than
295
+ the release, such as 4.1 before a release ships for it) and on a platform no platform gem is
296
+ built for — Intel macOS among them, which runs on Fiddle. No extra configuration needed either
297
+ way.
267
298
 
268
299
  Selection has **two** axes here, unlike every other binding in this repo. A Magnus extension
269
300
  is bound to one Ruby minor ABI — there is no `abi3` equivalent to collapse the version axis
@@ -271,11 +302,21 @@ the way [the Python binding's](../python/) wheels do — so each platform gem is
271
302
  carrying one compiled extension per supported Ruby, under `lib/hypercast/<minor>/`, and picks
272
303
  one at `require` time:
273
304
 
274
- | Ruby | the six platforms above | anywhere else (musl/Alpine, …) |
305
+ | Ruby | Linux (glibc and musl), Apple silicon macOS, Windows — x64 and arm64 | Gem installed |
275
306
  | --- | --- | --- |
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 |
307
+ | a newer Ruby than the release covers (4.1+) | Fiddle | universal |
308
+ | 4.0 (primary) | Magnus, `BACKEND == :native` | platform |
309
+ | 3.4 (until its EOL 2028-03-31) | Magnus, `BACKEND == :native` | platform |
310
+ | 3.3 (the floor, until its EOL 2027-03-31) | Fiddle | universal |
311
+
312
+ What stands behind each cell: CI replays the whole suite, shared corpus included, through
313
+ each Magnus extension on every push — Ruby 3.4 and 4.0 on all seven platform-gem platforms,
314
+ the two musl ones inside each Ruby's own Alpine image — and the Fiddle suite on Ruby 4.0 on
315
+ every one of them, plus on Ruby 3.3 inside Alpine. Intel macOS has no CI leg: its library is
316
+ cross-built and tested at the core, and Ruby there runs the universal gem's Fiddle backend
317
+ over it. Anywhere else — a platform with no native build at all — the universal gem installs
318
+ but `HyperCast.available?` answers `false`, and the first call raises
319
+ `HyperCast::NativePlatform::UnsupportedPlatformError`.
279
320
 
280
321
  The platform gems declare `required_ruby_version >= 3.4, < 4.1` precisely so RubyGems
281
322
  *declines* them outside that range and resolves the universal gem instead — a wrong-ABI
@@ -283,7 +324,28 @@ extension must never be installed in the first place. On Windows it would at lea
283
324
  load cleanly (the extension imports `<arch>-ucrt-ruby<minor>.dll` by name —
284
325
  `x64-ucrt-ruby400.dll` on x64, `aarch64-ucrt-ruby400.dll` on ARM), but Linux extensions don't
285
326
  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.
327
+ When 3.4 goes EOL it simply leaves the matrix and its users fall back to Fiddle, which is
328
+ exactly what the fallback is for.
329
+
330
+ **musl.** Alpine has platform gems of its own, `x86_64-linux-musl` and
331
+ `aarch64-linux-musl`, whose extensions are built inside each Ruby's official `ruby:*-alpine`
332
+ image and need nothing beyond musl's libc. The glibc gems name their libc too
333
+ (`x86_64-linux-gnu`, `aarch64-linux-gnu`), which is what makes both `gem install` and
334
+ Bundler pick the right one on every supported Ruby: next to a plain `x86_64-linux` gem,
335
+ RubyGems before 4.0 resolves that one on Alpine instead, even under
336
+ `--platform x86_64-linux-musl`.
337
+
338
+ **Nothing in this gem is ever compiled, on any platform.** The platform gems depend on nothing
339
+ at all. The universal gem depends on `fiddle`, which can be: `fiddle` is a bundled gem on Ruby
340
+ 4.0 and a default gem on 3.3 and 3.4, and `gem install` is satisfied by the copy Ruby ships.
341
+ Bundler resolves the newest `fiddle` on rubygems.org instead, and when that is newer than the
342
+ one your Ruby ships — true today on 3.3 and 3.4, not on 4.0 — it builds Fiddle's own C
343
+ extension, which takes a compiler and libffi's headers (`apk add build-base libffi-dev` on
344
+ Alpine). That only reaches you where the universal gem installs: Ruby 3.3, Intel macOS, a
345
+ Ruby newer than the release, or a platform with no platform gem. `bundle install
346
+ --prefer-local` makes Bundler use the copy Ruby ships where it can (it did on Ruby 3.4's
347
+ Bundler 2.6, not on 3.3's 2.5); otherwise install the compiler and headers. Pinning `fiddle`
348
+ to your Ruby's version in the `Gemfile.lock` does not stop the build.
287
349
 
288
350
  Both Windows architectures get a Magnus gem, and the reasoning that once kept them on Fiddle
289
351
  was backwards: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
@@ -294,7 +356,85 @@ targets rather than `gnu` — the same mingw-w64/UCRT ABI RubyInstaller's Ruby u
294
356
  with LLVM and compiler-rt instead of GCC and a statically-linked libgcc, which is what keeps
295
357
  the shipped extension small. The build script, and the two flags that are load-bearing on
296
358
  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.
359
+ forge's `ruby-magnus` action (`build-magnus.sh`), shared with every other Hyper* repo.
360
+
361
+ ## Development
362
+
363
+ Everything below runs from a checkout, with `rust/` and `corpus/` beside `ruby/`; none of it
364
+ is needed to use the gem. The Fiddle backend finds the in-repo build on its own when nothing
365
+ is staged under `lib/hypercast/native/`. The Magnus backend does not — an
366
+ extension has to be built for the Ruby you are running and put where `require` looks — and
367
+ that is what `rake native:dev` is for.
368
+
369
+ ```sh
370
+ cd rust
371
+ cargo cdylib # libhypercast, what the Fiddle backend loads
372
+
373
+ cd ../ruby
374
+ bundle install
375
+ bundle exec rake native:dev # build the Magnus extension for this Ruby and stage it
376
+ bundle exec rspec # BACKEND == :native
377
+ HYPERCAST_PURE=1 bundle exec rspec # BACKEND == :fiddle
378
+ bundle exec rake docs:check # every public object carries a doc comment
379
+ ruby benchmark/cast_benchmark.rb # prints the backend it measured
380
+ ```
381
+
382
+ `rake native:dev` runs `cargo ruby-ext` (an alias in `rust/.cargo/config.toml` that builds the
383
+ `ruby` feature into `rust/target/ruby/`, never over the plain library in
384
+ `rust/target/release/`) and copies the result to
385
+ `lib/hypercast/<minor>/hypercast_native.<so|bundle>` — the path `lib/hypercast.rb` tries
386
+ first. The copy is also a rename, and it is the step `cargo ruby-ext` alone does not do: cargo
387
+ names its output `libhypercast.so`, and Ruby derives the `Init_` function it calls from the
388
+ file name it was asked to `require`. Three things worth knowing:
389
+
390
+ - **Run it again after changing `rust/`.** The staged file is a copy; nothing rebuilds it.
391
+ - **One staging per Ruby.** An extension is bound to one Ruby minor, so under a second Ruby
392
+ (`RBENV_VERSION=3.4.11 bundle exec rake native:dev`, say) the task rebuilds for that ABI
393
+ and stages beside the first, each Ruby loading its own.
394
+ - **Without it, `bundle exec rspec` still passes — on Fiddle.** The examples under "native
395
+ backend" report themselves pending with `BACKEND=fiddle`; that line, or
396
+ `ruby -Ilib -rhypercast -e 'p HyperCast::BACKEND'`, is how to tell which backend a run
397
+ exercised. Deleting `lib/hypercast/<minor>/` goes back to Fiddle.
398
+
399
+ The task covers Linux and macOS. The Windows extensions need the `gnullvm` targets and linker
400
+ flags in the forge's `build-magnus.sh`, which is also what CI uses on every platform.
401
+
402
+ The musl build has no host to run on outside a container. With the core built for musl at
403
+ `rust/target/musl/linux-musl-x64/libhypercast.so`, this runs the Fiddle suite — corpus replay
404
+ included — against it on Alpine, with the checkout mounted read-only:
405
+
406
+ ```sh
407
+ docker run --rm -v "$PWD/..":/src:ro ruby:4.0-alpine sh -euc '
408
+ mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
409
+ mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
410
+ cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
411
+ cd /work/ruby && rm -f Gemfile.lock
412
+ bundle install --quiet --prefer-local
413
+ HYPERCAST_PURE=1 bundle exec rspec'
414
+ ```
415
+
416
+ The floor's test is the same container on `ruby:3.3-alpine`. Bundler would compile a newer
417
+ Fiddle there (see [Install](#install)), so this one goes around Bundler and runs against the
418
+ Fiddle that Ruby 3.3 ships:
419
+
420
+ ```sh
421
+ docker run --rm -v "$PWD/..":/src:ro ruby:3.3-alpine sh -euc '
422
+ mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
423
+ mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
424
+ cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
425
+ cd /work/ruby && rm -f Gemfile Gemfile.lock
426
+ gem install rspec -v "~> 3.13" --no-document --silent
427
+ HYPERCAST_PURE=1 rspec'
428
+ ```
429
+
430
+ The musl Magnus extension is built the way CI builds it by the forge's
431
+ `ruby-magnus-musl/build-magnus-musl.sh`, which runs on any machine with Docker: from the repo
432
+ root, `build-magnus-musl.sh hypercast linux-musl-x64 4.0 . <dir with the musl libhypercast.so> <out-dir>`
433
+ compiles it in `ruby:4.0-alpine`, then runs this suite through it on a bare copy of that image.
298
434
 
299
435
  See [the repo root README](../README.md) for the full door table, the receipts, and the
300
436
  state of every other language binding.
437
+
438
+ ## License
439
+
440
+ [MIT](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
@@ -4,18 +4,24 @@ module HyperCast
4
4
  module NativePlatform
5
5
  class UnsupportedPlatformError < StandardError; end
6
6
 
7
- def self.rid_and_library_name
8
- is_arm = RUBY_PLATFORM.match?(/arm64|aarch64/)
7
+ # +platform+ is a parameter only so the specs can walk the whole table from one host;
8
+ # every real caller takes the default.
9
+ def self.rid_and_library_name(platform = RUBY_PLATFORM)
10
+ arch = platform.match?(/arm64|aarch64/) ? "arm64" : "x64"
9
11
 
10
- case RUBY_PLATFORM
12
+ case platform
11
13
  when /mingw|mswin|windows/
12
- is_arm ? ["win-arm64", "hypercast.dll"] : ["win-x64", "hypercast.dll"]
14
+ ["win-#{arch}", "hypercast.dll"]
13
15
  when /darwin/
14
- is_arm ? ["osx-arm64", "libhypercast.dylib"] : ["osx-x64", "libhypercast.dylib"]
16
+ ["osx-#{arch}", "libhypercast.dylib"]
15
17
  when /linux/
16
- is_arm ? ["linux-arm64", "libhypercast.so"] : ["linux-x64", "libhypercast.so"]
18
+ # Two C libraries, two builds: a glibc-linked library cannot be relied on to dlopen
19
+ # into a musl process (Alpine), so musl gets RIDs of its own. Ruby names the libc
20
+ # in its platform string there ("x86_64-linux-musl") and leaves it off on glibc.
21
+ os = platform.include?("musl") ? "linux-musl" : "linux"
22
+ ["#{os}-#{arch}", "libhypercast.so"]
17
23
  else
18
- raise UnsupportedPlatformError, "hypercast: unsupported platform RUBY_PLATFORM=#{RUBY_PLATFORM}"
24
+ raise UnsupportedPlatformError, "hypercast: unsupported platform RUBY_PLATFORM=#{platform}"
19
25
  end
20
26
  end
21
27
  end
@@ -1,28 +1,32 @@
1
- require "fiddle"
1
+ # Autoloaded, not required: the platform gems carry no library for Fiddle to open and do not
2
+ # declare fiddle, so loading it eagerly would make `require "hypercast"` fail under Bundler
3
+ # wherever fiddle is a bundled rather than a default gem (Ruby 4.0). It loads the first time
4
+ # the Fiddle backend runs, and every path there goes through `functions` first, so a platform
5
+ # gem forced onto Fiddle still gets missing_library_message rather than a bare LoadError.
6
+ autoload :Fiddle, "fiddle"
2
7
 
3
8
  module HyperCast
4
9
  # Fiddle plumbing for the native libhypercast shared library — dlopen/dlsym plus raw
5
10
  # C-ABI calls, no runtime bridge. Fiddle ships with every Ruby install; it's a plain gem
6
- # dependency here rather than a third-party one. A Ruby gem's files are already plain
7
- # files on disk once installed, so native/{rid}/{lib} dlopen's directly — no extraction.
11
+ # dependency of the universal gem (see hypercast.gemspec) rather than a third-party one.
12
+ # A Ruby gem's files are already plain files on disk once installed, so native/{rid}/{lib}
13
+ # dlopen's directly — no extraction.
8
14
  module Runtime
9
15
  NATIVE_DIR = File.join(__dir__, "native")
10
16
 
11
- PLAIN = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP].freeze
12
- NUMERIC = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
13
- Fiddle::TYPE_VOIDP].freeze
14
- UNIX = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP,
15
- Fiddle::TYPE_VOIDP].freeze
16
-
17
+ # Each door's C signature, by shape — resolved to Fiddle types only in load_functions,
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.
17
21
  DOORS = {
18
- cast_bool: PLAIN,
19
- cast_i8: NUMERIC, cast_i16: NUMERIC, cast_i32: NUMERIC, cast_i64: NUMERIC,
20
- cast_u8: NUMERIC, cast_u16: NUMERIC, cast_u32: NUMERIC, cast_u64: NUMERIC,
21
- cast_f32: NUMERIC, cast_f64: NUMERIC, cast_decimal: NUMERIC,
22
- cast_uuid: PLAIN,
23
- cast_timestamp: PLAIN, cast_unix: UNIX, cast_excel_serial: UNIX,
24
- cast_date: PLAIN, cast_date_ordered: UNIX, cast_datetime: UNIX,
25
- cast_time: PLAIN, cast_duration: PLAIN
22
+ cast_bool: :plain,
23
+ cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
24
+ cast_u8: :numeric, cast_u16: :numeric, cast_u32: :numeric, cast_u64: :numeric,
25
+ cast_f32: :numeric, cast_f64: :numeric, cast_decimal: :numeric,
26
+ cast_uuid: :plain,
27
+ cast_timestamp: :plain, cast_unix: :declared, cast_excel_serial: :declared,
28
+ cast_date: :plain, cast_date_ordered: :declared, cast_datetime: :declared,
29
+ cast_time: :plain, cast_duration: :plain
26
30
  }.freeze
27
31
 
28
32
  # The one export that is not a door: the zero-argument version probe, returning the
@@ -41,14 +45,13 @@ module HyperCast
41
45
  functions.fetch(symbol)
42
46
  end
43
47
 
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
48
+ # Every Fiddle allocation goes through here, and loads the library first: that is what
49
+ # keeps a missing library reported as missing_library_message instead of as whatever
50
+ # touching Fiddle raises first. The per-thread scratch buffer and each packed NumFormat
51
+ # (hypercast.rb) pay for it once apiece.
52
+ def buffer(size)
53
+ functions
54
+ Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
52
55
  end
53
56
 
54
57
  private
@@ -65,6 +68,35 @@ module HyperCast
65
68
  File.exist?(repo_build) ? repo_build : nil
66
69
  end
67
70
 
71
+ # Why Fiddle found nothing to load. A precompiled platform gem is the one install where
72
+ # that is by design rather than a gap: it carries only its Magnus extensions, and the
73
+ # Fiddle backend is reached there only by forcing it (HYPERCAST_PURE) or because none
74
+ # of its extensions loaded — a gem RubyGems matched to a Ruby it was not built for,
75
+ # such as the glibc Linux gem that `gem install` on RubyGems 3.x picks on Alpine. So
76
+ # that case names its fix, the universal gem, which carries every platform's library,
77
+ # instead of a missing path that reads like a packaging bug. Both arguments are
78
+ # parameters only so the specs can ask for every wording.
79
+ def missing_library_message(gem_platform = Gem.loaded_specs["hypercast"]&.platform,
80
+ forced = ENV.key?("HYPERCAST_PURE"))
81
+ rid, lib_name = NativePlatform.rid_and_library_name
82
+ missing = File.join(NATIVE_DIR, rid, lib_name)
83
+ if gem_platform && gem_platform.to_s != Gem::Platform::RUBY
84
+ reason =
85
+ if forced
86
+ "HYPERCAST_PURE forces the Fiddle backend (unset it to use the extension)"
87
+ else
88
+ "none of its extensions loads on this Ruby (#{RUBY_VERSION}, #{RUBY_PLATFORM})"
89
+ end
90
+ "hypercast: this #{gem_platform} platform gem carries only Magnus extensions, no " \
91
+ "Fiddle library, and #{reason}. The universal gem has the Fiddle backend for every " \
92
+ "platform: `gem install hypercast --platform ruby`, or Bundler's force_ruby_platform " \
93
+ "(#{missing} not found)"
94
+ else
95
+ "hypercast: #{missing} not found (unsupported platform, or this gem was built " \
96
+ "without a native library for it)"
97
+ end
98
+ end
99
+
68
100
  # Loaded lazily and exactly once; the native library and its function pointers live
69
101
  # for the process's lifetime, same as every other binding (never dlclose'd). The
70
102
  # unsynchronized read is the fast path — a per-call mutex acquisition measured as a
@@ -75,16 +107,17 @@ module HyperCast
75
107
 
76
108
  def load_functions
77
109
  path = library_path
78
- if path.nil?
79
- rid, lib_name = NativePlatform.rid_and_library_name
80
- raise LoadError,
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)"
83
- end
110
+ raise LoadError, missing_library_message if path.nil?
84
111
 
85
112
  handle = Fiddle.dlopen(path)
86
- functions = DOORS.to_h do |name, signature|
87
- [name, Fiddle::Function.new(handle[name.to_s], signature, Fiddle::TYPE_INT32_T)]
113
+ plain = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
114
+ signatures = {
115
+ plain: plain,
116
+ numeric: plain.dup.insert(2, Fiddle::TYPE_VOIDP),
117
+ declared: plain.dup.insert(2, Fiddle::TYPE_UINT32_T)
118
+ }
119
+ functions = DOORS.to_h do |name, shape|
120
+ [name, Fiddle::Function.new(handle[name.to_s], signatures.fetch(shape), Fiddle::TYPE_INT32_T)]
88
121
  end
89
122
  functions[VERSION_PROBE] =
90
123
  Fiddle::Function.new(handle[VERSION_PROBE.to_s], [], Fiddle::TYPE_UINT32_T)
data/lib/hypercast.rb CHANGED
@@ -3,16 +3,20 @@ require_relative "hypercast/native_platform"
3
3
  require_relative "hypercast/runtime"
4
4
 
5
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.
6
+ # from one Rust core, reached through whichever of two backends this install can load:
7
+ # the core linked straight into a Magnus native extension (shipped precompiled in the
8
+ # platform gems), or the native libhypercast shared library called through Fiddle (the
9
+ # universal gem bundles one per supported platform). HyperCast::BACKEND names the one that
10
+ # loaded; the selection logic is at the bottom of this file. Every door returns a verdict: Success or
11
+ # Fault (a closed reason plus the offending span), never an exception for bad data — the
12
+ # only exceptions here are caller bugs (a malformed NumFormat, an undeclared option, text
13
+ # that is not a String), never data, and the same exception on every backend.
10
14
  #
11
15
  # Consume with Ruby's own pattern matching over the two Data case types:
12
16
  #
13
17
  # case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
14
18
  # in HyperCast::Success(value:) then puts "got #{value}" # -1234
15
- # in HyperCast::Fault(reason:, offset:) then puts "#{reason} at byte #{offset}"
19
+ # in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
16
20
  # end
17
21
  #
18
22
  # Door names mirror the native ABI (i32, f64, timestamp, ...) so the polyglot surface reads
@@ -25,7 +29,7 @@ require_relative "hypercast/runtime"
25
29
  module HyperCast
26
30
  # This gem's own version — kept in lockstep with hypercast.gemspec by the
27
31
  # prepare-release workflow, so the two can never drift apart again.
28
- VERSION = "0.3.0"
32
+ VERSION = "0.6.0"
29
33
 
30
34
  # The success case of a verdict: a cast value.
31
35
  Success = Data.define(:value)
@@ -50,25 +54,29 @@ module HyperCast
50
54
  # 16 UTF-8 bytes or carrying an ASCII digit or ASCII whitespace, are a caller bug
51
55
  # (ArgumentError), never a verdict.
52
56
  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
-
56
57
  # Validates the declared separators up front — single characters, and distinct from
57
58
  # each other — and the currency symbol (a String of at most 16 UTF-8 bytes with no
58
59
  # ASCII digit or ASCII whitespace, since those would collide with the digit scan and
59
60
  # 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
+ # it is. All three are stored transcoded to UTF-8, the encoding the core reads — a
62
+ # separator handed over in another encoding (a no-break space read from a Latin-1
63
+ # file, say) is the same code point on every backend rather than whatever its bytes
64
+ # happened to spell in the encoding it arrived in.
61
65
  def initialize(decimal_sep:, group_sep:, flags:, currency: "")
62
66
  raise ArgumentError, "separators must be single characters" unless
63
67
  decimal_sep.is_a?(String) && decimal_sep.length == 1 &&
64
68
  group_sep.is_a?(String) && group_sep.length == 1
69
+
70
+ decimal_sep = decimal_sep.encode(Encoding::UTF_8)
71
+ group_sep = group_sep.encode(Encoding::UTF_8)
65
72
  raise ArgumentError, "decimal and group separators must differ; both are #{decimal_sep.inspect}" if
66
73
  decimal_sep == group_sep
67
74
  raise ArgumentError, "currency symbol must be a String; got #{currency.inspect}" unless currency.is_a?(String)
68
75
 
69
76
  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
77
+ limit = self.class::CURRENCY_MAX_BYTES
78
+ raise ArgumentError, "currency symbol #{currency.inspect} exceeds #{limit} UTF-8 bytes" if
79
+ symbol.bytesize > limit
72
80
  raise ArgumentError, "currency symbol #{currency.inspect} must not contain an ASCII digit or whitespace" if
73
81
  symbol.match?(/[0-9\t\n\f\r ]/)
74
82
 
@@ -83,6 +91,11 @@ module HyperCast
83
91
  end
84
92
  end
85
93
 
94
+ # The widest currency symbol the native ABI carries inline, in UTF-8 bytes. Assigned out
95
+ # here rather than inside the Data.define block above: a constant written in that block
96
+ # is scoped lexically, and landed on HyperCast instead of on NumFormat.
97
+ NumFormat::CURRENCY_MAX_BYTES = 16
98
+
86
99
  # Permit the group separator between digits (sizes not validated — between digits is the rule).
87
100
  GROUPING = 1
88
101
  # Permit accounting parentheses as negation: (1,234) is -1234.
@@ -185,11 +198,10 @@ module HyperCast
185
198
  # Whether a backend actually loaded and exports the ABI this binding was built against
186
199
  # — what a consumer with a fallback of its own checks before committing to these doors.
187
200
  # 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.
201
+ # and never raises: a missing shared library, an unsupported platform, or an older core
202
+ # without the version export all answer false. The doors themselves keep their own
203
+ # behavior — the first call on an unavailable backend raises its precise LoadError —
204
+ # this only answers the question quietly.
193
205
  def available?
194
206
  return @available unless @available.nil?
195
207
 
@@ -275,7 +287,7 @@ module HyperCast
275
287
  #
276
288
  # The 1900 system contains a day that never existed: serial 60 is 1900-02-29, kept
277
289
  # deliberately because Lotus 1-2-3 wrongly treated 1900 as a leap year and Excel copied
278
- # the bug for file compatibility. It is :malformed here — the same verdict .date gives
290
+ # the bug for file compatibility. It is :out_of_range here — the same verdict .date gives
279
291
  # the text "1900-02-29" — so every serial above it is shifted one day against a naive
280
292
  # count. An unknown epoch is a caller bug (KeyError), never a verdict.
281
293
  def excel_serial(text, epoch)
@@ -351,9 +363,19 @@ module HyperCast
351
363
  BYTE_COMPATIBLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].freeze
352
364
 
353
365
  # Presents the input as UTF-8 bytes: already-compatible text crosses as-is (Fiddle
354
- # passes a String's bytes for void* directly — no Pointer wrapper, no dup); only
355
- # foreign encodings pay a transcode.
366
+ # passes a String's own bytes for void* — no copy of them); only foreign encodings pay
367
+ # a transcode, and text that cannot be transcoded raises String#encode's own
368
+ # Encoding:: error, the same one the Magnus extension raises through the same call.
369
+ #
370
+ # Text that is not a String is a caller bug, and the same TypeError on every backend:
371
+ # the extension takes its argument through Ruby's implicit String conversion (to_str,
372
+ # or a TypeError), so this does too.
356
373
  def utf8(text)
374
+ unless text.is_a?(String)
375
+ converted = String.try_convert(text) or
376
+ raise TypeError, "no implicit conversion of #{text.class} into String"
377
+ text = converted
378
+ end
357
379
  BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
358
380
  end
359
381
 
@@ -368,10 +390,11 @@ module HyperCast
368
390
  # registering a GC finalizer — measured as the dominant per-call cost by an order of
369
391
  # magnitude. The NumFormat no longer lives here: each format owns its own packed
370
392
  # pointer (see packed_cache), so a numeric call copies nothing into scratch before
371
- # crossing.
393
+ # crossing. Allocated through Runtime.buffer, which loads the library first, so a
394
+ # missing one is reported as such before Fiddle is touched.
372
395
  def scratch
373
396
  Thread.current[:hypercast_scratch] ||= begin
374
- base = Fiddle::Pointer.malloc(24, Fiddle::RUBY_FREE)
397
+ base = Runtime.buffer(24)
375
398
  [base, base + 16]
376
399
  end
377
400
  end
@@ -421,9 +444,7 @@ module HyperCast
421
444
 
422
445
  # The shared body of the four doors that take a caller-declared u32 — a precision, an
423
446
  # 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.
447
+ # (plain, numeric, declared, and packed_version below) are the whole Fiddle crossing.
427
448
  def declared(symbol, text, code, out_size)
428
449
  bytes = utf8(text)
429
450
  out, fault = scratch
@@ -437,14 +458,26 @@ module HyperCast
437
458
  Runtime.function(:hypercast_version).call
438
459
  end
439
460
 
461
+ # How many formats packed_cache remembers before it starts forgetting the oldest. Far
462
+ # more than a process that reuses its formats ever holds; what it bounds is the one
463
+ # that builds a NumFormat per request.
464
+ PACKED_CACHE_LIMIT = 64
465
+
440
466
  # Identity-keyed memo (compare_by_identity — a pointer hash, not Data's structural
441
467
  # #hash over three Strings and an Integer) of a native 32-byte RawNumFormat per format
442
468
  # object, filled once from NumFormat#packed. Formats are reused constants in practice,
443
469
  # so the common call finds its pointer in one lookup and packs nothing. The Hash holds
444
470
  # the format, so a key can never be a recycled address; the race is benign (idempotent).
471
+ #
472
+ # Bounded, oldest entry out first (a Hash keeps insertion order): unbounded, it held
473
+ # every format ever cast with and its 32-byte allocation for the life of the process,
474
+ # which a consumer building a format per request experienced as a leak. A format
475
+ # evicted and then used again is simply packed again, and an evicted pointer stays
476
+ # alive for any call already holding it — the caller's own reference outlives the cache's.
445
477
  def packed_cache
446
478
  @packed_cache ||= Hash.new do |cache, format|
447
- pointer = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
479
+ cache.shift while cache.size >= PACKED_CACHE_LIMIT
480
+ pointer = Runtime.buffer(32)
448
481
  pointer[0, 32] = format.packed
449
482
  cache[format] = pointer
450
483
  end.compare_by_identity
@@ -461,37 +494,36 @@ end
461
494
  # --- backend selection: the Magnus extension, when present, replaces the doors above in
462
495
  # place on this module (no delegation layer) — Fiddle's measured 1.6 µs per-call floor
463
496
  # drops to an ordinary extension call. The pure-Fiddle definitions stay the universal
464
- # zero-compile fallback; precompiled platform gems are how the extension ships without
465
- # ever making a consumer compile anything. Set HYPERCAST_PURE=1 to force Fiddle.
497
+ # zero-compile fallback — the last resort, for a Ruby or a platform no platform gem covers;
498
+ # precompiled platform gems are how the extension ships without ever making a consumer
499
+ # compile anything, and they carry no Fiddle library at all.
466
500
  #
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.
501
+ # HYPERCAST_PURE forces Fiddle. It is a testing and diagnostic switch — CI runs the whole
502
+ # suite through it, and it is how a suspected extension bug is ruled in or out — not a
503
+ # setting a deployment needs. It is read for presence, not value, so "0" and the empty
504
+ # string force it too. Inside a platform gem there is no library for it to load: BACKEND
505
+ # still reads :fiddle, HyperCast.available? answers false, and the first door call raises a
506
+ # LoadError naming the universal gem, which is where the Fiddle backend lives.
473
507
  HyperCast::BACKEND =
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"]
508
+ if ENV["HYPERCAST_PURE"]
484
509
  :fiddle
485
510
  else
486
511
  # Two layouts, and both have to work. A released platform gem is a "fat" gem carrying one
487
512
  # extension per supported Ruby ABI under lib/hypercast/<minor>/ (see the Rakefile's
488
513
  # 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.
514
+ # equivalent), and `rake native:dev` — the local dev loop — stages its own build at that
515
+ # same versioned path. CI's in-job staging additionally drops a single extension flat at
516
+ # lib/, which is also where a hand copy of `cargo ruby-ext`'s output goes (the alias only
517
+ # builds into rust/target/ruby/release/; nothing but that rake task copies it here).
518
+ # Trying the versioned path first and the flat one second means neither has to know the
519
+ # other exists.
492
520
  #
493
521
  # 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.
522
+ # precompiled extension, which is precisely what the Fiddle backend is for. A platform
523
+ # with no shared library either still lands on Fiddle, so the first call raises its own
524
+ # precise error — a "not found" LoadError naming the path, or
525
+ # NativePlatform::UnsupportedPlatformError naming the platform. HyperCast.available? is
526
+ # the quiet way to ask.
495
527
  begin
496
528
  require "hypercast/#{RUBY_VERSION[/\d+\.\d+/]}/hypercast_native"
497
529
  :native
@@ -500,21 +532,7 @@ HyperCast::BACKEND =
500
532
  require "hypercast_native"
501
533
  :native
502
534
  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
535
+ :fiddle
518
536
  end
519
537
  end
520
538
  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.3.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Buvinghausen
@@ -51,12 +51,13 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.9'
54
- description: 'Booleans, numerics, UUIDs, and temporals cast from untrusted text by
55
- a native Rust core — every parse returns a verdict (the value, or a reason plus
56
- the offending span), never an exception for bad data. Two backends behind one surface,
57
- selected automatically with nothing ever compiled: a Magnus extension where a precompiled
58
- platform gem matches, stdlib Fiddle everywhere else. No runtime bridge, no dependencies
59
- beyond Fiddle.'
54
+ description: |
55
+ Booleans, numerics, exact decimals, UUIDs, and temporals cast from untrusted text by a
56
+ native Rust core — every parse returns a verdict (the value, or a reason plus the
57
+ offending span), never an exception for bad data. Two backends behind one surface,
58
+ selected automatically and both shipped prebuilt: a Magnus extension where a precompiled
59
+ platform gem matches, and stdlib Fiddle everywhere else. No runtime bridge, no
60
+ dependencies beyond Fiddle.
60
61
  executables: []
61
62
  extensions: []
62
63
  extra_rdoc_files: []
@@ -65,20 +66,24 @@ files:
65
66
  - README.md
66
67
  - lib/hypercast.rb
67
68
  - lib/hypercast/native/linux-arm64/libhypercast.so
69
+ - lib/hypercast/native/linux-musl-arm64/libhypercast.so
70
+ - lib/hypercast/native/linux-musl-x64/libhypercast.so
68
71
  - lib/hypercast/native/linux-x64/libhypercast.so
69
72
  - lib/hypercast/native/osx-arm64/libhypercast.dylib
70
73
  - lib/hypercast/native/osx-x64/libhypercast.dylib
71
- - lib/hypercast/native/wasm32-wasip1/hypercast.wasm
72
74
  - lib/hypercast/native/win-arm64/hypercast.dll
73
75
  - lib/hypercast/native/win-x64/hypercast.dll
74
76
  - lib/hypercast/native_platform.rb
75
77
  - lib/hypercast/runtime.rb
76
- - lib/hypercast/wasm_runtime.rb
77
78
  homepage: https://github.com/SkunkWerkx/HyperCast
78
79
  licenses:
79
80
  - MIT
80
81
  metadata:
81
82
  source_code_uri: https://github.com/SkunkWerkx/HyperCast
83
+ changelog_uri: https://github.com/SkunkWerkx/HyperCast/blob/master/CHANGELOG.md
84
+ bug_tracker_uri: https://github.com/SkunkWerkx/HyperCast/issues
85
+ documentation_uri: https://github.com/SkunkWerkx/HyperCast/tree/master/ruby#readme
86
+ rubygems_mfa_required: 'true'
82
87
  rdoc_options: []
83
88
  require_paths:
84
89
  - lib
@@ -86,15 +91,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
86
91
  requirements:
87
92
  - - ">="
88
93
  - !ruby/object:Gem::Version
89
- version: '3.2'
94
+ version: '3.3'
90
95
  required_rubygems_version: !ruby/object:Gem::Requirement
91
96
  requirements:
92
97
  - - ">="
93
98
  - !ruby/object:Gem::Version
94
99
  version: '0'
95
100
  requirements: []
96
- rubygems_version: 4.0.16
101
+ rubygems_version: 4.0.20
97
102
  specification_version: 4
98
- summary: Allocation-free scalar parsing as Success/Fault verdicts over a native Rust
99
- core
103
+ summary: Scalar parsing as Success/Fault verdicts over a native Rust core, shipped
104
+ prebuilt
100
105
  test_files: []
@@ -1,169 +0,0 @@
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