hypercast 0.3.0-x86_64-linux → 0.4.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: aee53d1b8f673abe7b19e073c8b50a5a26681ad0336ac9ce8667d2f3a6a7d429
4
- data.tar.gz: a4639b10ee385435c10e180ac3697dab2dfb1ce28167246e8592c0d537eab333
3
+ metadata.gz: 39c6dc5065dbf206aa1760bf0385ea8bea8f131f11dea3489d92423614393e13
4
+ data.tar.gz: fbcb0af9091c853eebdb56aa1c85fb229844c52cb9995281396ad004f5500ae5
5
5
  SHA512:
6
- metadata.gz: 6003a655d3dc6506a850b36514cae0e2b5a975197c66602b88dbd4008210ca0258e1bb42d4659c9b442cd5e1eade858bc7c0a0f9afdabc32d6664be3ecc1b081
7
- data.tar.gz: 11511326d7eecf6bfe4431f215bc473db094e98d1d1c8420d027bc71fa2e04b473d19e16e32cfc9c3a5e6d17aca9e876c0c19b27591725520924b4e3838d4fcf
6
+ metadata.gz: 74f52267197d0934ff760441a2be4e4435891bb067bb6357f60c378a7a00bc58d52d53307698f9ba2e65e8a380596bbb7c2a32fb6701682f4c4a5335ae60269f
7
+ data.tar.gz: 9701f13462c868a190ab02f3acbd9e43acc71d30c4684a3160c291b22427bbbfaac6b890308468ae4a1283908250cfa494157aa3007aa874f40ce364ef148c74
data/README.md CHANGED
@@ -1,28 +1,27 @@
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
8
+ Symbol plus the exact 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
10
  else — selected automatically, zero compiles either way — and the same core as a
11
11
  WebAssembly module inside the `wasmtime` gem for any platform with no native build at all.**
12
12
 
13
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
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).
14
+ UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
15
+ floor. The fast path links the core straight into a Ruby extension (Magnus): on require it
16
+ redefines the doors in place on the `HyperCast` module — no delegation layer, no second
17
+ surface, which is exactly what keeps the backends provably in agreement.
18
+ `HyperCast::BACKEND` reports which is live; `HYPERCAST_PURE=1` forces Fiddle,
19
+ `HYPERCAST_WASM=1` the wasm module — see [Backends](#backends).
21
20
 
22
21
  ```ruby
23
22
  case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
24
23
  in HyperCast::Success(value:) then puts "got #{value}" # -1234
25
- in HyperCast::Fault(reason:, offset:) then puts "#{reason} at byte #{offset}"
24
+ in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
26
25
  end
27
26
  ```
28
27
 
@@ -52,13 +51,43 @@ midnight, durations come back as exact `Rational` seconds across the core's whol
52
51
  | `duration(text)` | exact `Rational` seconds | — |
53
52
 
54
53
  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.)
54
+ to `nil`. Beside the doors, `native_version` returns the loaded core's
55
+ `"major.minor.patch"` and `available?` answers `true`/`false` without ever raising — see
56
+ [Is the native core there?](#is-the-native-core-there).
57
+
58
+ ### Errors
59
+
60
+ Bad data is never an exception — it is a `Fault`. What raises is a caller bug, and it raises
61
+ the same exception on every backend:
62
+
63
+ - `ArgumentError` from `NumFormat.new` for a malformed format: separators that are not single
64
+ characters or not distinct, a currency symbol that is too long or carries an ASCII digit or
65
+ whitespace.
66
+ - `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
67
+ that names nothing: an unknown Symbol, or anything that is not a Symbol (`"seconds"`
68
+ included). `date(text, nil)` is not one of these: an explicit `nil` order is the strict ISO
69
+ door, exactly as if it were left off.
70
+ - `TypeError` for text that is not a `String` (anything with `to_str` is taken as one).
71
+ - `String#encode`'s own `Encoding::` error for text in another encoding that cannot be
72
+ transcoded to UTF-8.
73
+
74
+ ### Is the native core there?
75
+
76
+ `HyperCast.available?` answers whether a backend actually loaded and exports the ABI this
77
+ binding was built against — probed once, cached, never raising — for a consumer with a
78
+ fallback of its own:
79
+
80
+ ```ruby
81
+ value = HyperCast.available? ? HyperCast.i32(text, format) : my_own_parse(text)
82
+ ```
83
+
84
+ `HyperCast.native_version` reads the version out of the loaded core itself (its
85
+ `hypercast_version` export — the cheapest probe that the backend resolved at all), so a
86
+ mismatch against `HyperCast::VERSION` can be named before the first cast.
87
+ `HyperCast::BACKEND` says which backend was *selected*, which is not the same question: it
88
+ reads `:fiddle` on a platform with no library at all, because that is the backend whose
89
+ first call explains what is missing — a `LoadError` naming the path it looked for, or
90
+ `HyperCast::NativePlatform::UnsupportedPlatformError` naming the platform.
62
91
 
63
92
  ### Fault spans index the String you passed
64
93
 
@@ -136,15 +165,14 @@ and memoized by identity on every backend, so declaring a currency costs a cast
136
165
  corpus replay; cross-backend agreement specs compare Magnus against Fiddle
137
166
  and wasm against Fiddle across a subprocess boundary).
138
167
  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
168
+ benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-x64 on an Intel Core
169
+ i9-11900H, Ruby 4.0.7): timestamp **447 ns vs 2.97 µs `Time.iso8601`** (6.6x) — while
170
+ returning exact `Rational` durations on the duration door. The Fiddle fallback lands at
171
+ 3.25 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
172
+
173
+ Separator detection is nearly free here: `1.234.567,89` under `NumFormat::DETECT` runs
174
+ at 178 ns against 170 ns for the same text under a declared eurozone format. Both cost
175
+ 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
176
  `String` allocations per call to read its separators back out of the `Data`. `DETECT`
149
177
  is now identity-matched like `INVARIANT`, and any other format is resolved once per
150
178
  thread and memoized by identity — anchored in a thread-variable so the memo's key can
@@ -153,26 +181,78 @@ and memoized by identity on every backend, so declaring a currency costs a cast
153
181
  cached the same way, so a fault or a declared option is a pointer compare too, never a
154
182
  `Symbol#name` materialization.
155
183
 
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.
184
+ **The honest trade-off:** the civil doors do not beat `strptime`. The date-time door is
185
+ level with `DateTime.strptime` (904 ns against 901 ns) and the date door a little behind
186
+ `Date.strptime` (620 ns against 513 ns). The parse isn't the cost; the carrier is. Building
187
+ a stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
188
+ where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`. If you want
189
+ Ruby's fastest civil parse and don't need the verdict or the declared order, `strptime` is
190
+ as good. Also note the carrier's other caveat — `DateTime`'s offset defaults to `+00:00`,
191
+ which is an artifact of the type, not a zone the parse assigned.
164
192
 
165
193
  On the Fiddle fallback the doors are parity-at-best — Fiddle's
166
194
  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
195
+ path. Its doors build nothing per call that does not change between calls: each format
196
+ owns one native pointer, memoized by identity and passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
173
197
  per-call `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch —
174
198
  receipts include their own archaeology.)
175
199
 
200
+ ## Benchmarks
201
+
202
+ `ruby benchmark/cast_benchmark.rb` pairs each door with Ruby's closest stdlib parse. Its
203
+ first line names the backend, core version and Ruby it measured, so run it again under
204
+ `HYPERCAST_PURE=1` or `HYPERCAST_WASM=1` for the other two backends. The stdlib comparisons
205
+ are in [the section above](#why-not-integer--timeiso8601--float); this is the three backends
206
+ against each other.
207
+
208
+ Measured on Ruby 4.0.7, linux-x64 (an Intel Core i9-11900H), wasmtime 48.0.1,
209
+ `benchmark-ips`, same session, all three backends:
210
+
211
+ | Door | Magnus | Fiddle | wasmtime |
212
+ |---|---:|---:|---:|
213
+ | `bool` | 112 ns | 2.35 µs | 1.75 µs |
214
+ | `i32` | 133 ns | 2.63 µs | 1.80 µs |
215
+ | `f64` | 166 ns | 2.70 µs | 1.84 µs |
216
+ | `uuid` | 223 ns | 3.47 µs | 2.65 µs |
217
+ | `timestamp` | 447 ns | 3.25 µs | 2.42 µs |
218
+ | `datetime` (`1/7/2026 3:04 PM`) | 904 ns | 3.98 µs | 3.14 µs |
219
+ | `duration` (ISO) | 632 ns | 2.91 µs | 2.10 µs |
220
+ | `i32`, a fault | 225 ns | 3.18 µs | 2.50 µs |
221
+
222
+ A lean door on the Magnus backend is little more than the native call: the extension builds
223
+ the `Success` or `Fault` it returns directly — allocated, its members stored, frozen —
224
+ rather than through `Data.new`, whose keyword handling alone cost more than the cast.
225
+
226
+ ## Backends
227
+
228
+ | `HyperCast::BACKEND` | What runs | Chosen when |
229
+ |---|---|---|
230
+ | `:native` | the core linked into a Magnus extension | a precompiled platform gem carries an extension for this Ruby's ABI — see [Install](#install) |
231
+ | `:fiddle` | `libhypercast` for this platform, `dlopen`ed through Fiddle | no extension loads; also the answer when nothing loads at all |
232
+ | `:wasm` | the core as a `wasm32-wasip1` module inside the `wasmtime` gem | no extension and no library for this platform, and `wasmtime` is installed — see [WebAssembly (wasmtime)](#webassembly-wasmtime) |
233
+
234
+ Selection happens once, at `require`, in that order. Two environment variables override it:
235
+
236
+ | Variable | Effect |
237
+ |---|---|
238
+ | `HYPERCAST_WASM` | forces `:wasm`; `require` raises a `LoadError` naming the gem if `wasmtime` is missing |
239
+ | `HYPERCAST_PURE` | forces `:fiddle`, even where an extension would load |
240
+
241
+ Both are read for presence, not value — set to anything at all, `0` and the empty string
242
+ included, they force their backend — and `HYPERCAST_WASM` wins when both are set. A forced
243
+ backend that turns out to have nothing to load does not fall through to another one: the
244
+ first call raises, and `HyperCast.available?` answers `false`.
245
+
246
+ **Threads.** Every backend is safe to call from any number of threads. The extension runs
247
+ under the GVL. Fiddle releases the GVL for the duration of each call, so that is the one
248
+ backend where Ruby threads run the core truly in parallel — each through its own scratch
249
+ buffers; the core itself keeps no state between calls. The wasm backend serializes every call
250
+ on one Mutex around one instance. `spec/cast_spec.rb`'s concurrent-callers example runs under
251
+ all three.
252
+
253
+ **Ractors.** Main Ractor only, on every backend: called from another Ractor the doors raise
254
+ `Ractor::UnsafeError` (`Ractor::IsolationError` under wasm).
255
+
176
256
  ## WebAssembly (wasmtime)
177
257
 
178
258
  The Rust core also ships inside this gem as a `wasm32-wasip1` module
@@ -210,29 +290,20 @@ with `Memory#read`; using the guest's allocator rather than a host-picked offset
210
290
  a buffer from being clobbered by the guest's next allocation. And a `Wasmtime::Store` is
211
291
  single-threaded, so every call is serialized under one Mutex around one shared instance.
212
292
 
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):
293
+ Measured against the other two backends in the same session — the `wasmtime` column of the
294
+ table under [Benchmarks](#benchmarks).
215
295
 
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.
296
+ The finding worth stating: in Ruby the wasm backend is no slower than Fiddle, and on the
297
+ x64 box that table comes from it is the faster of the two on every door, by a fifth to a
298
+ third. Fiddle's per-call floor is interpreted marshalling; wasmtime's is the host-to-guest
299
+ crossing plus the guest memory copies; on the lean doors wasmtime sits eleven to sixteen
300
+ times behind the Magnus extension and Fiddle sixteen to twenty-one, narrowing to three to
301
+ five times where building the Ruby value dominates. So on a platform with no native build, the fallback costs a consumer nothing
302
+ they were not already paying on the universal gem.
232
303
 
233
304
  ## Verifying provenance
234
305
 
235
- Every gem RubyGems.org serves — the universal fallback and each of the six precompiled
306
+ Every gem RubyGems.org serves — the universal fallback and each of the five precompiled
236
307
  platform gems — carries its own GitHub build-provenance attestation, signed directly by
237
308
  this repo's own `release.yml` (the `rubygems-publish` job attests `ruby/pkg/*.gem` right
238
309
  before the push), so plain `--repo` verifies any of them:
@@ -243,10 +314,11 @@ gh attestation verify hypercast-X.Y.Z-<platform>.gem --repo SkunkWerkx/HyperCast
243
314
  ```
244
315
 
245
316
  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.
317
+ the same job verifies every native binary it packs — the Fiddle libraries, the Magnus
318
+ extensions (one per Ruby ABI per platform) and the wasm module — against *their own*
319
+ attestations — those are signed from `SkunkWerkx/.github` by `hyper-build-native.yml`, so
320
+ that check needs `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an
321
+ unverified one.
250
322
  RubyGems.org has no unpublish and no duplicate-version overwrite, so this all happens while a
251
323
  bad artifact is still reversible. The release run's job summary then re-fetches every gem
252
324
  from the CDN and records attested-vs-served digests, turning "rubygems.org stores an upload
@@ -260,10 +332,15 @@ more on why `--signer-repo` is needed for some artifacts here and not others.
260
332
  gem install hypercast
261
333
  ```
262
334
 
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.
335
+ Six gems are published per release: one universal `ruby`-platform gem (Fiddle, with every
336
+ platform's native library and the wasm module bundled) plus five precompiled Magnus platform
337
+ gems (`x86_64-linux`, `aarch64-linux`, `arm64-darwin`, `x64-mingw-ucrt`,
338
+ `aarch64-mingw-ucrt`) that `gem install` and `bundle` auto-select when they match. There is
339
+ no `x86_64-darwin` platform gem: an Intel Mac installs the universal gem and runs on Fiddle
340
+ over the bundled `osx-x64` library, as Alpine does. A platform
341
+ gem carries the same libraries and module beside its extensions, so the Fiddle and wasm
342
+ backends are still there behind `HYPERCAST_PURE` and `HYPERCAST_WASM`. No extra configuration
343
+ needed either way.
267
344
 
268
345
  Selection has **two** axes here, unlike every other binding in this repo. A Magnus extension
269
346
  is bound to one Ruby minor ABI — there is no `abi3` equivalent to collapse the version axis
@@ -271,11 +348,19 @@ the way [the Python binding's](../python/) wheels do — so each platform gem is
271
348
  carrying one compiled extension per supported Ruby, under `lib/hypercast/<minor>/`, and picks
272
349
  one at `require` time:
273
350
 
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 |
351
+ | Ruby | glibc Linux, macOS, Windows — x64 and arm64 | musl Linux (Alpine) — x64 and arm64 | anywhere else |
352
+ | --- | --- | --- | --- |
353
+ | 4.0 (primary) | Magnus, `BACKEND == :native` | Fiddle | wasm, if `wasmtime` is installed |
354
+ | 3.4 (until its EOL 2028-03-31) | Magnus, `BACKEND == :native` | Fiddle | wasm, if `wasmtime` is installed |
355
+ | 3.3 (the floor) | Fiddle | Fiddle | wasm, if `wasmtime` is installed |
356
+
357
+ What stands behind each cell: CI replays the whole suite, shared corpus included, for the
358
+ first column on every push — Magnus on Ruby 3.4 and 4.0, Fiddle and wasm on 4.0, on all five
359
+ platforms with a CI leg (Intel macOS has none) — and the Fiddle suite for the musl column inside an Alpine container. The 3.3 row
360
+ is the same Fiddle code path, run with the Docker command under [Development](#development)
361
+ (Ruby 3.3 with the Fiddle 1.1.2 it ships) rather than on every push. The last column — wasm
362
+ chosen automatically because nothing native exists — has no leg of its own: CI runs the wasm
363
+ backend's suite forced, on platforms that have native builds too.
279
364
 
280
365
  The platform gems declare `required_ruby_version >= 3.4, < 4.1` precisely so RubyGems
281
366
  *declines* them outside that range and resolves the universal gem instead — a wrong-ABI
@@ -283,7 +368,27 @@ extension must never be installed in the first place. On Windows it would at lea
283
368
  load cleanly (the extension imports `<arch>-ucrt-ruby<minor>.dll` by name —
284
369
  `x64-ucrt-ruby400.dll` on x64, `aarch64-ucrt-ruby400.dll` on ARM), but Linux extensions don't
285
370
  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.
371
+ When 3.4 goes EOL it simply leaves the matrix and its users fall back to Fiddle, which is
372
+ exactly what the fallback is for.
373
+
374
+ **musl.** RubyGems does not tell musl from glibc for these gems: on Alpine, `gem install`
375
+ picks the `x86_64-linux` or `aarch64-linux` platform gem exactly as it does on any other
376
+ Linux (checked with RubyGems 4.0.20 on `x86_64-linux-musl`). The Magnus extension inside is
377
+ linked against glibc and cannot load there — `require` fails cleanly on the missing
378
+ `ld-linux` loader — so the gem falls back to Fiddle, which loads the musl build of the core
379
+ (`native/linux-musl-x64` or `native/linux-musl-arm64`, chosen from `RUBY_PLATFORM`) that
380
+ every gem carries. Alpine therefore works as installed, on the Fiddle backend and at Fiddle's
381
+ speed. Releases through 0.3.0 carried no musl library: there the fallback found only the
382
+ glibc one, `HyperCast.available?` answered `false`, and the first cast raised
383
+ `Fiddle::DLError`.
384
+
385
+ **Nothing in this gem is ever compiled, on any platform.** Its one dependency can be:
386
+ `fiddle` is a bundled gem on Ruby 4.0 and a default gem on 3.3 and 3.4, and `gem install` is
387
+ satisfied by the copy Ruby ships. Bundler resolves the newest `fiddle` on rubygems.org
388
+ instead, and when that is newer than the one your Ruby ships — true today on 3.3 and 3.4, not
389
+ on 4.0 — it builds Fiddle's own C extension, which takes a compiler and libffi's headers
390
+ (`apk add build-base libffi-dev` on Alpine). Holding `fiddle` at your Ruby's version in the
391
+ `Gemfile.lock` avoids that.
287
392
 
288
393
  Both Windows architectures get a Magnus gem, and the reasoning that once kept them on Fiddle
289
394
  was backwards: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
@@ -294,7 +399,82 @@ targets rather than `gnu` — the same mingw-w64/UCRT ABI RubyInstaller's Ruby u
294
399
  with LLVM and compiler-rt instead of GCC and a statically-linked libgcc, which is what keeps
295
400
  the shipped extension small. The build script, and the two flags that are load-bearing on
296
401
  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.
402
+ forge's `ruby-magnus` action (`build-magnus.sh`), shared with every other Hyper* repo.
403
+
404
+ ## Development
405
+
406
+ Everything below runs from a checkout, with `rust/` and `corpus/` beside `ruby/`; none of it
407
+ is needed to use the gem. The Fiddle and wasm backends find the in-repo builds on their own
408
+ when nothing is staged under `lib/hypercast/native/`. The Magnus backend does not — an
409
+ extension has to be built for the Ruby you are running and put where `require` looks — and
410
+ that is what `rake native:dev` is for.
411
+
412
+ ```sh
413
+ cd rust
414
+ cargo build --release # libhypercast, what the Fiddle backend loads
415
+ cargo build --release --target wasm32-wasip1 # hypercast.wasm, what the wasm backend loads
416
+
417
+ cd ../ruby
418
+ bundle install
419
+ bundle exec rake native:dev # build the Magnus extension for this Ruby and stage it
420
+ bundle exec rspec # BACKEND == :native
421
+ HYPERCAST_PURE=1 bundle exec rspec # BACKEND == :fiddle
422
+ HYPERCAST_WASM=1 bundle exec rspec # BACKEND == :wasm
423
+ bundle exec rake docs:check # every public object carries a doc comment
424
+ ruby benchmark/cast_benchmark.rb # prints the backend it measured
425
+ ```
426
+
427
+ `rake native:dev` runs `cargo ruby-ext` (an alias in `rust/.cargo/config.toml` that builds the
428
+ `ruby` feature into `rust/target/ruby/`, never over the plain library in
429
+ `rust/target/release/`) and copies the result to
430
+ `lib/hypercast/<minor>/hypercast_native.<so|bundle>` — the path `lib/hypercast.rb` tries
431
+ first. The copy is also a rename, and it is the step `cargo ruby-ext` alone does not do: cargo
432
+ names its output `libhypercast.so`, and Ruby derives the `Init_` function it calls from the
433
+ file name it was asked to `require`. Three things worth knowing:
434
+
435
+ - **Run it again after changing `rust/`.** The staged file is a copy; nothing rebuilds it.
436
+ - **One staging per Ruby.** An extension is bound to one Ruby minor, so under a second Ruby
437
+ (`RBENV_VERSION=3.4.11 bundle exec rake native:dev`, say) the task rebuilds for that ABI
438
+ and stages beside the first, each Ruby loading its own.
439
+ - **Without it, `bundle exec rspec` still passes — on Fiddle.** The examples under "native
440
+ backend" report themselves pending with `BACKEND=fiddle`; that line, or
441
+ `ruby -Ilib -rhypercast -e 'p HyperCast::BACKEND'`, is how to tell which backend a run
442
+ exercised. Deleting `lib/hypercast/<minor>/` goes back to Fiddle.
443
+
444
+ The task covers Linux and macOS. The Windows extensions need the `gnullvm` targets and linker
445
+ flags in the forge's `build-magnus.sh`, which is also what CI uses on every platform.
446
+
447
+ The musl build has no host to run on outside a container. With the core built for musl at
448
+ `rust/target/musl/linux-musl-x64/libhypercast.so`, this runs the Fiddle suite — corpus replay
449
+ included — against it on Alpine, with the checkout mounted read-only:
450
+
451
+ ```sh
452
+ docker run --rm -v "$PWD/..":/src:ro ruby:4.0-alpine sh -euc '
453
+ mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
454
+ mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
455
+ cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
456
+ cd /work/ruby && rm -f Gemfile.lock
457
+ BUNDLE_WITHOUT=wasm bundle install --quiet --prefer-local
458
+ HYPERCAST_PURE=1 BUNDLE_WITHOUT=wasm bundle exec rspec'
459
+ ```
460
+
461
+ The floor's test is the same container on `ruby:3.3-alpine`. Bundler would compile a newer
462
+ Fiddle there (see [Install](#install)), so this one goes around Bundler and runs against the
463
+ Fiddle that Ruby 3.3 ships:
464
+
465
+ ```sh
466
+ docker run --rm -v "$PWD/..":/src:ro ruby:3.3-alpine sh -euc '
467
+ mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
468
+ mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
469
+ cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
470
+ cd /work/ruby && rm -f Gemfile Gemfile.lock
471
+ gem install rspec -v "~> 3.13" --no-document --silent
472
+ HYPERCAST_PURE=1 rspec'
473
+ ```
298
474
 
299
475
  See [the repo root README](../README.md) for the full door table, the receipts, and the
300
476
  state of every other language binding.
477
+
478
+ ## License
479
+
480
+ [MIT](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
Binary file
Binary file
@@ -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
data/lib/hypercast.rb CHANGED
@@ -3,16 +3,21 @@ 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 three backends this install can load:
7
+ # the core linked straight into a Magnus native extension (shipped precompiled in the
8
+ # platform gems), the native libhypercast shared library called through Fiddle (the
9
+ # universal gem bundles one per supported platform), or the same core as a WebAssembly
10
+ # module inside the wasmtime gem. HyperCast::BACKEND names the one that loaded; the
11
+ # selection logic is at the bottom of this file. Every door returns a verdict: Success or
12
+ # Fault (a closed reason plus the offending span), never an exception for bad data — the
13
+ # only exceptions here are caller bugs (a malformed NumFormat, an undeclared option, text
14
+ # that is not a String), never data, and the same exception on every backend.
10
15
  #
11
16
  # Consume with Ruby's own pattern matching over the two Data case types:
12
17
  #
13
18
  # case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
14
19
  # in HyperCast::Success(value:) then puts "got #{value}" # -1234
15
- # in HyperCast::Fault(reason:, offset:) then puts "#{reason} at byte #{offset}"
20
+ # in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
16
21
  # end
17
22
  #
18
23
  # Door names mirror the native ABI (i32, f64, timestamp, ...) so the polyglot surface reads
@@ -25,7 +30,7 @@ require_relative "hypercast/runtime"
25
30
  module HyperCast
26
31
  # This gem's own version — kept in lockstep with hypercast.gemspec by the
27
32
  # prepare-release workflow, so the two can never drift apart again.
28
- VERSION = "0.3.0"
33
+ VERSION = "0.4.0"
29
34
 
30
35
  # The success case of a verdict: a cast value.
31
36
  Success = Data.define(:value)
@@ -50,25 +55,29 @@ module HyperCast
50
55
  # 16 UTF-8 bytes or carrying an ASCII digit or ASCII whitespace, are a caller bug
51
56
  # (ArgumentError), never a verdict.
52
57
  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
58
  # Validates the declared separators up front — single characters, and distinct from
57
59
  # each other — and the currency symbol (a String of at most 16 UTF-8 bytes with no
58
60
  # ASCII digit or ASCII whitespace, since those would collide with the digit scan and
59
61
  # 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.
62
+ # it is. All three are stored transcoded to UTF-8, the encoding the core reads — a
63
+ # separator handed over in another encoding (a no-break space read from a Latin-1
64
+ # file, say) is the same code point on every backend rather than whatever its bytes
65
+ # happened to spell in the encoding it arrived in.
61
66
  def initialize(decimal_sep:, group_sep:, flags:, currency: "")
62
67
  raise ArgumentError, "separators must be single characters" unless
63
68
  decimal_sep.is_a?(String) && decimal_sep.length == 1 &&
64
69
  group_sep.is_a?(String) && group_sep.length == 1
70
+
71
+ decimal_sep = decimal_sep.encode(Encoding::UTF_8)
72
+ group_sep = group_sep.encode(Encoding::UTF_8)
65
73
  raise ArgumentError, "decimal and group separators must differ; both are #{decimal_sep.inspect}" if
66
74
  decimal_sep == group_sep
67
75
  raise ArgumentError, "currency symbol must be a String; got #{currency.inspect}" unless currency.is_a?(String)
68
76
 
69
77
  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
78
+ limit = self.class::CURRENCY_MAX_BYTES
79
+ raise ArgumentError, "currency symbol #{currency.inspect} exceeds #{limit} UTF-8 bytes" if
80
+ symbol.bytesize > limit
72
81
  raise ArgumentError, "currency symbol #{currency.inspect} must not contain an ASCII digit or whitespace" if
73
82
  symbol.match?(/[0-9\t\n\f\r ]/)
74
83
 
@@ -83,6 +92,11 @@ module HyperCast
83
92
  end
84
93
  end
85
94
 
95
+ # The widest currency symbol the native ABI carries inline, in UTF-8 bytes. Assigned out
96
+ # here rather than inside the Data.define block above: a constant written in that block
97
+ # is scoped lexically, and landed on HyperCast instead of on NumFormat.
98
+ NumFormat::CURRENCY_MAX_BYTES = 16
99
+
86
100
  # Permit the group separator between digits (sizes not validated — between digits is the rule).
87
101
  GROUPING = 1
88
102
  # Permit accounting parentheses as negation: (1,234) is -1234.
@@ -351,9 +365,19 @@ module HyperCast
351
365
  BYTE_COMPATIBLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].freeze
352
366
 
353
367
  # 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.
368
+ # passes a String's own bytes for void* — no copy of them); only foreign encodings pay
369
+ # a transcode, and text that cannot be transcoded raises String#encode's own
370
+ # Encoding:: error, the same one the Magnus extension raises through the same call.
371
+ #
372
+ # Text that is not a String is a caller bug, and the same TypeError on every backend:
373
+ # the extension takes its argument through Ruby's implicit String conversion (to_str,
374
+ # or a TypeError), so this does too.
356
375
  def utf8(text)
376
+ unless text.is_a?(String)
377
+ converted = String.try_convert(text) or
378
+ raise TypeError, "no implicit conversion of #{text.class} into String"
379
+ text = converted
380
+ end
357
381
  BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
358
382
  end
359
383
 
@@ -437,13 +461,25 @@ module HyperCast
437
461
  Runtime.function(:hypercast_version).call
438
462
  end
439
463
 
464
+ # How many formats packed_cache remembers before it starts forgetting the oldest. Far
465
+ # more than a process that reuses its formats ever holds; what it bounds is the one
466
+ # that builds a NumFormat per request.
467
+ PACKED_CACHE_LIMIT = 64
468
+
440
469
  # Identity-keyed memo (compare_by_identity — a pointer hash, not Data's structural
441
470
  # #hash over three Strings and an Integer) of a native 32-byte RawNumFormat per format
442
471
  # object, filled once from NumFormat#packed. Formats are reused constants in practice,
443
472
  # so the common call finds its pointer in one lookup and packs nothing. The Hash holds
444
473
  # the format, so a key can never be a recycled address; the race is benign (idempotent).
474
+ #
475
+ # Bounded, oldest entry out first (a Hash keeps insertion order): unbounded, it held
476
+ # every format ever cast with and its 32-byte allocation for the life of the process,
477
+ # which a consumer building a format per request experienced as a leak. A format
478
+ # evicted and then used again is simply packed again, and an evicted pointer stays
479
+ # alive for any call already holding it — the caller's own reference outlives the cache's.
445
480
  def packed_cache
446
481
  @packed_cache ||= Hash.new do |cache, format|
482
+ cache.shift while cache.size >= PACKED_CACHE_LIMIT
447
483
  pointer = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
448
484
  pointer[0, 32] = format.packed
449
485
  cache[format] = pointer
@@ -470,6 +506,10 @@ end
470
506
  # forces it (and fails loudly if wasmtime is missing); otherwise it is only ever chosen when
471
507
  # there is no native library for this platform at all and wasmtime happens to be available,
472
508
  # so no supported platform's behavior changes by its existence.
509
+ #
510
+ # Both variables are read for presence, not value — set to anything at all, "0" and the
511
+ # empty string included, they force their backend — and HYPERCAST_WASM wins when both are
512
+ # set.
473
513
  HyperCast::BACKEND =
474
514
  if ENV["HYPERCAST_WASM"]
475
515
  begin
@@ -486,9 +526,12 @@ HyperCast::BACKEND =
486
526
  # Two layouts, and both have to work. A released platform gem is a "fat" gem carrying one
487
527
  # extension per supported Ruby ABI under lib/hypercast/<minor>/ (see the Rakefile's
488
528
  # 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.
529
+ # equivalent), and `rake native:dev` — the local dev loop — stages its own build at that
530
+ # same versioned path. CI's in-job staging additionally drops a single extension flat at
531
+ # lib/, which is also where a hand copy of `cargo ruby-ext`'s output goes (the alias only
532
+ # builds into rust/target/ruby/release/; nothing but that rake task copies it here).
533
+ # Trying the versioned path first and the flat one second means neither has to know the
534
+ # other exists.
492
535
  #
493
536
  # A miss on both is not an error: it means this Ruby/platform combination has no
494
537
  # precompiled extension, which is precisely what the Fiddle backend is for.
@@ -505,8 +548,9 @@ HyperCast::BACKEND =
505
548
  else
506
549
  # No shared library for this platform either. wasmtime, if the consumer has it,
507
550
  # 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.
551
+ # first call raises its own precise error — a "not found" LoadError naming the
552
+ # path, or NativePlatform::UnsupportedPlatformError naming the platform — rather
553
+ # than a vaguer one from here. HyperCast.available? is the quiet way to ask.
510
554
  begin
511
555
  require "wasmtime"
512
556
  require_relative "hypercast/wasm_runtime"
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.4.0
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Brian Buvinghausen
@@ -51,12 +51,14 @@ 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. Three backends behind one surface,
58
+ selected automatically and all shipped prebuilt: a Magnus extension where a precompiled
59
+ platform gem matches, stdlib Fiddle everywhere else, and the same core as a WebAssembly
60
+ module for anyone who installs the wasmtime gem. No runtime bridge, no dependencies
61
+ beyond Fiddle.
60
62
  executables: []
61
63
  extensions: []
62
64
  extra_rdoc_files: []
@@ -67,6 +69,8 @@ files:
67
69
  - lib/hypercast/3.4/hypercast_native.so
68
70
  - lib/hypercast/4.0/hypercast_native.so
69
71
  - lib/hypercast/native/linux-arm64/libhypercast.so
72
+ - lib/hypercast/native/linux-musl-arm64/libhypercast.so
73
+ - lib/hypercast/native/linux-musl-x64/libhypercast.so
70
74
  - lib/hypercast/native/linux-x64/libhypercast.so
71
75
  - lib/hypercast/native/osx-arm64/libhypercast.dylib
72
76
  - lib/hypercast/native/osx-x64/libhypercast.dylib
@@ -81,6 +85,10 @@ licenses:
81
85
  - MIT
82
86
  metadata:
83
87
  source_code_uri: https://github.com/SkunkWerkx/HyperCast
88
+ changelog_uri: https://github.com/SkunkWerkx/HyperCast/blob/master/CHANGELOG.md
89
+ bug_tracker_uri: https://github.com/SkunkWerkx/HyperCast/issues
90
+ documentation_uri: https://github.com/SkunkWerkx/HyperCast/tree/master/ruby#readme
91
+ rubygems_mfa_required: 'true'
84
92
  rdoc_options: []
85
93
  require_paths:
86
94
  - lib
@@ -98,8 +106,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
98
106
  - !ruby/object:Gem::Version
99
107
  version: '0'
100
108
  requirements: []
101
- rubygems_version: 4.0.16
109
+ rubygems_version: 4.0.20
102
110
  specification_version: 4
103
- summary: Allocation-free scalar parsing as Success/Fault verdicts over a native Rust
104
- core
111
+ summary: Scalar parsing as Success/Fault verdicts over a native Rust core, shipped
112
+ prebuilt
105
113
  test_files: []