hypercast-wasm 0.6.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 25e1d8efcfd0a330dc46665c67c6ab2fea5a6739c5101694b7fffc1e96676d1e
4
+ data.tar.gz: df0939ade9f6f8ce761ea44db684a3194624874a75deee25791cbf4873a4e63f
5
+ SHA512:
6
+ metadata.gz: f27aa076ab1ad70fd64317a6faa7de15f422d52a4b085e455eb8a0568c0287b245b529254f59638d676c8f8530ab804a9bf999ff96d013885862ea9135e6bc0e
7
+ data.tar.gz: cf3fddc48a1405b8a328f7fa5b39c15e3440d8eeda147336150faaf968610b78443c1d2455ed2050d9bab743f2429c49fbc0abc5a87e93b3a4210a604e81cf4f
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Skunk Werkx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,495 @@
1
+ # hypercast
2
+
3
+ [![CI](https://github.com/SkunkWerkx/HyperCast/actions/workflows/ci.yml/badge.svg)](https://github.com/SkunkWerkx/HyperCast/actions/workflows/ci.yml)
4
+ [![RubyGems](https://img.shields.io/gem/v/hypercast.svg)](https://rubygems.org/gems/hypercast)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
6
+
7
+ **Ruby's own pattern matching over two `Data` case types — the value, or a closed reason
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.**
11
+
12
+ Allocation-lean scalar casts — booleans, the full integer family, reals, exact decimals,
13
+ UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
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
+
22
+ ```ruby
23
+ case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
24
+ in HyperCast::Success(value:) then puts "got #{value}" # -1234
25
+ in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
26
+ end
27
+ ```
28
+
29
+ Door names mirror the native ABI (`i32`, `f64`, `timestamp`, …). Ruby-flavored fidelity,
30
+ stated proudly — nothing the core parses is lost on the way out: `Integer` is
31
+ unbounded (u64 comes back as the true unsigned value), `Time` carries full nanoseconds
32
+ across the whole 0001–9999 window, time-of-day is an exact Integer of nanoseconds since
33
+ midnight, durations come back as exact `Rational` seconds across the core's whole
34
+ ±10,000-year window, and `decimal` returns an exact `HyperCast::Decimal` that never rounds
35
+ — no truncation anywhere, no wrapping.
36
+
37
+ ## The doors
38
+
39
+ | Door | Value | Declares |
40
+ |---|---|---|
41
+ | `bool(text)` | `true`/`false` | — |
42
+ | `i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64` `(text, format)` | `Integer` (unbounded — u64 is the true unsigned value) | a `NumFormat` |
43
+ | `f32` `f64` `(text, format)` | `Float` (f32 widened losslessly) | a `NumFormat` |
44
+ | `decimal(text, format)` | `HyperCast::Decimal` — exact sign, 96-bit magnitude, base-10 scale | a `NumFormat` |
45
+ | `uuid(text)` | lowercase hyphenated `String` (`SecureRandom.uuid`'s shape) | — |
46
+ | `timestamp(text)` | UTC `Time`, full nanoseconds | — |
47
+ | `unix(text, precision)` | UTC `Time` | `:seconds` / `:milliseconds` / `:microseconds` / `:nanoseconds` |
48
+ | `excel_serial(text, epoch)` | UTC `Time` | `:y1900` / `:y1904` |
49
+ | `date(text, order = nil)` | `Date` | strict ISO, or `:year_month_day` / `:month_day_year` / `:day_month_year` |
50
+ | `datetime(text, order)` | zone-less `DateTime`, exact `Rational` seconds | a field order, as `date` |
51
+ | `time(text)` | `Integer` nanoseconds since midnight | — |
52
+ | `duration(text)` | exact `Rational` seconds | — |
53
+
54
+ Every door returns a `Success` or a `Fault`; `HyperCast.optional(verdict)` folds `:empty`
55
+ to `nil`. Beside the doors, `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)).
94
+
95
+ ### Fault spans index the String you passed
96
+
97
+ A `Fault`'s `offset`/`length` are in the units `String#[]` slices by on your own input:
98
+ character offsets for text — in any encoding; the core reads UTF-8, and the span is mapped
99
+ back, an identity for ASCII — and byte offsets for a binary (`ASCII-8BIT`) String, whose
100
+ characters are its bytes. Either way `text[offset, length]` is the offending text with no
101
+ arithmetic on your side:
102
+
103
+ ```ruby
104
+ HyperCast.i32("1€", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 1, length: 1)
105
+ HyperCast.i32("1€".b, HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 1, length: 3)
106
+ ```
107
+
108
+ The mapping happens only on the failure path, so a `Success` never pays for it.
109
+
110
+ ### `decimal`: exact, never rounded
111
+
112
+ `HyperCast::Decimal` is a `Data` of `magnitude` (an unbounded `Integer`, at most 2⁹⁶ − 1),
113
+ `scale` (0–28: places the magnitude is shifted right) and `negative` — the shape .NET's
114
+ `decimal` stores and `BigDecimal` builds from directly. No float is ever formed, so `"0.1"`
115
+ is one tenth and `"50%"` is exactly `0.5`. The triple is canonical: exact trailing zeros in
116
+ the fraction are always trimmed, so the scale is minimal — `"1.10"`, `"1.1"` and `"1.1000"`
117
+ are all magnitude 11, scale 1, while `"100"` stays magnitude 100, scale 0 — and zero is
118
+ scale 0 and never negative. Nothing but a zero is ever dropped: text carrying more precision
119
+ than 96 bits and 28 places can hold is an `:out_of_range` `Fault`, not an approximation.
120
+
121
+ ```ruby
122
+ value = HyperCast.decimal("-1,234.50", HyperCast::NumFormat::INVARIANT).value
123
+ value.to_s # => "-1234.5" (the canonical text, every binding renders the same)
124
+ value.to_r # => (-2469/2)
125
+ value.to_d # => BigDecimal("-1234.5")
126
+ ```
127
+
128
+ `to_d` requires the `bigdecimal` gem lazily on first call — a bundled gem since Ruby 3.4,
129
+ deliberately not a dependency of this one, so under Bundler add it to your own Gemfile.
130
+
131
+ ### `NumFormat`: declared separators, and a declared currency
132
+
133
+ ```ruby
134
+ HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
135
+ ```
136
+
137
+ The flags are `GROUPING`, `PARENTHESES`, `EXPONENT`, `RADIX_PREFIXES`, `PERCENT` and
138
+ `CURRENCY` (`ALL_STYLES` is all six), plus the `SEPARATOR_DETECT` policy. The currency
139
+ symbol is the one field a culture table would fill in: it is declared, never looked up, and
140
+ honored only while `CURRENCY` is set — once, leading (`$5`, `-$5`, `$ -5`) or trailing
141
+ (`5 €`, `1.234,50 kr.`), with optional ASCII whitespace between symbol and digits, and
142
+ accounting parentheses wrapping symbol and digits together (`($5)`). Declared with the
143
+ flag off, the symbol is a `:malformed` `Fault` at the symbol; the flag with nothing declared
144
+ (`currency: ""`, the default — `INVARIANT` and `DETECT` declare none) matches nothing. A
145
+ symbol longer than 16 UTF-8 bytes, or carrying an ASCII digit or ASCII whitespace, is an
146
+ `ArgumentError` at construction, the same caller-bug treatment equal separators get.
147
+
148
+ ```ruby
149
+ dollars = HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
150
+ HyperCast.i32("($1,234)", dollars) # => Success(value: -1234)
151
+ HyperCast.decimal("$1,234.50", dollars) # => Success(value: Decimal(magnitude: 12345, scale: 1, negative: false))
152
+ HyperCast.i32("$5", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 0, length: 1)
153
+ ```
154
+
155
+ Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code points, the
156
+ flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
157
+ and memoized by identity on every backend, so declaring a currency costs a cast nothing.
158
+
159
+ ## Why not `Integer()` / `Time.iso8601` / `Float()`?
160
+
161
+ 1. **Verdicts, not exceptions** — bad data is the expected case for untrusted text; a
162
+ `Fault` is a Symbol and two integers, not an `ArgumentError` to rescue.
163
+ 2. **The vocabulary untrusted sources actually send** — twenty boolean lexemes, accounting
164
+ parentheses, declared separators, radix prefixes, all five .NET `Guid` text forms plus
165
+ `urn:uuid:` prefixes, protobuf JSON durations.
166
+ 3. **One engine across a polyglot system** — bit-for-bit verdicts with every other binding,
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).
170
+ 4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
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
179
+ `String` allocations per call to read its separators back out of the `Data`. `DETECT`
180
+ is now identity-matched like `INVARIANT`, and any other format is resolved once per
181
+ thread and memoized by identity — anchored in a thread-variable so the memo's key can
182
+ never be a recycled address — which turned the per-call cost into one pointer compare.
183
+ The three reason Symbols and the option Symbols (`:seconds`, `:month_day_year`, …) are
184
+ cached the same way, so a fault or a declared option is a pointer compare too, never a
185
+ `Symbol#name` materialization.
186
+
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.
195
+
196
+ On the Fiddle fallback the doors are parity-at-best — Fiddle's
197
+ per-call floor is the mechanism's price, kept because it's the universal zero-compile
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
200
+ per-call `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch —
201
+ receipts include their own archaeology.)
202
+
203
+ ## Benchmarks
204
+
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.
210
+
211
+ Measured on Ruby 4.0.7, linux-x64 (an Intel Core i9-11900H), `benchmark-ips`, same session,
212
+ both backends:
213
+
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.
228
+
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) — or the `hypercast-wasm` gem is linked into a ruby.wasm interpreter; see [Ruby in the browser](#ruby-in-the-browser) |
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`.
256
+
257
+ ## Verifying provenance
258
+
259
+ Every gem RubyGems.org serves — the universal fallback, each of the seven precompiled
260
+ platform gems and `hypercast-wasm` — carries its own GitHub build-provenance attestation, signed directly by
261
+ this repo's own `release.yml` (the `rubygems-publish` job attests `ruby/pkg/*.gem` right
262
+ before the push), so plain `--repo` verifies any of them:
263
+
264
+ ```sh
265
+ gem fetch hypercast -v X.Y.Z --platform <platform> # or omit --platform for the universal gem
266
+ gh attestation verify hypercast-X.Y.Z-<platform>.gem --repo SkunkWerkx/HyperCast
267
+ ```
268
+
269
+ That's the release's second layer of checking, not the only one: before any gem gets built,
270
+ the same job verifies every native binary it packs — the Fiddle libraries, the Magnus
271
+ extensions (one per Ruby ABI per platform), the `wasm32-wasip1` archives — against *their own*
272
+ attestations — those are signed from `SkunkWerkx/.github` by `hyper-build-native.yml` and
273
+ `hyper-build-wasm.yml`, so
274
+ that check needs `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an
275
+ unverified one.
276
+ RubyGems.org has no unpublish and no duplicate-version overwrite, so this all happens while a
277
+ bad artifact is still reversible. The release run's job summary then re-fetches every gem
278
+ from the CDN and records attested-vs-served digests, turning "rubygems.org stores an upload
279
+ verbatim" into a per-release measurement rather than an assumption — see
280
+ [csharp/README.md's provenance section](../csharp/README.md#native-binary-provenance) for
281
+ more on why `--signer-repo` is needed for some artifacts here and not others.
282
+
283
+ ## Install
284
+
285
+ ```sh
286
+ gem install hypercast
287
+ ```
288
+
289
+ Nine gems are published per release. This section is about eight of them: seven precompiled Magnus platform gems that
290
+ `gem install` and `bundle` auto-select when they match — `x86_64-linux-gnu`,
291
+ `aarch64-linux-gnu`, `x86_64-linux-musl`, `aarch64-linux-musl`, `arm64-darwin`,
292
+ `x64-mingw-ucrt` and `aarch64-mingw-ucrt` — and one universal `ruby`-platform gem. A platform
293
+ gem carries its Magnus extensions and nothing else native: no Fiddle library at all. The
294
+ universal gem is the last resort, Fiddle with every platform's native library bundled, and it
295
+ is what RubyGems resolves for a Ruby the platform gems do not cover (3.3, or a Ruby newer than
296
+ the release, such as 4.1 before a release ships for it) and on a platform no platform gem is
297
+ built for — Intel macOS among them, which runs on Fiddle. No extra configuration needed either
298
+ way. The ninth, `hypercast-wasm`, is only for ruby.wasm; see [Ruby in the browser](#ruby-in-the-browser).
299
+
300
+ Selection has **two** axes here, unlike every other binding in this repo. A Magnus extension
301
+ is bound to one Ruby minor ABI — there is no `abi3` equivalent to collapse the version axis
302
+ the way [the Python binding's](../python/) wheels do — so each platform gem is a "fat" gem
303
+ carrying one compiled extension per supported Ruby, under `lib/hypercast/<minor>/`, and picks
304
+ one at `require` time:
305
+
306
+ | Ruby | Linux (glibc and musl), Apple silicon macOS, Windows — x64 and arm64 | Gem installed |
307
+ | --- | --- | --- |
308
+ | a newer Ruby than the release covers (4.1+) | Fiddle | universal |
309
+ | 4.0 (primary) | Magnus, `BACKEND == :native` | platform |
310
+ | 3.4 (until its EOL 2028-03-31) | Magnus, `BACKEND == :native` | platform |
311
+ | 3.3 (the floor, until its EOL 2027-03-31) | Fiddle | universal |
312
+
313
+ What stands behind each cell: CI replays the whole suite, shared corpus included, through
314
+ each Magnus extension on every push — Ruby 3.4 and 4.0 on all seven platform-gem platforms,
315
+ the two musl ones inside each Ruby's own Alpine image — and the Fiddle suite on Ruby 4.0 on
316
+ every one of them, plus on Ruby 3.3 inside Alpine. Intel macOS has no CI leg: its library is
317
+ cross-built and tested at the core, and Ruby there runs the universal gem's Fiddle backend
318
+ over it. Anywhere else — a platform with no native build at all — the universal gem installs
319
+ but `HyperCast.available?` answers `false`, and the first call raises
320
+ `HyperCast::NativePlatform::UnsupportedPlatformError`.
321
+
322
+ The platform gems declare `required_ruby_version >= 3.4, < 4.1` precisely so RubyGems
323
+ *declines* them outside that range and resolves the universal gem instead — a wrong-ABI
324
+ extension must never be installed in the first place. On Windows it would at least fail to
325
+ load cleanly (the extension imports `<arch>-ucrt-ruby<minor>.dll` by name —
326
+ `x64-ucrt-ruby400.dll` on x64, `aarch64-ucrt-ruby400.dll` on ARM), but Linux extensions don't
327
+ link libruby at all, so one can load successfully against the wrong ABI and misbehave later.
328
+ When 3.4 goes EOL it simply leaves the matrix and its users fall back to Fiddle, which is
329
+ exactly what the fallback is for.
330
+
331
+ **musl.** Alpine has platform gems of its own, `x86_64-linux-musl` and
332
+ `aarch64-linux-musl`, whose extensions are built inside each Ruby's official `ruby:*-alpine`
333
+ image and need nothing beyond musl's libc. The glibc gems name their libc too
334
+ (`x86_64-linux-gnu`, `aarch64-linux-gnu`), which is what makes both `gem install` and
335
+ Bundler pick the right one on every supported Ruby: next to a plain `x86_64-linux` gem,
336
+ RubyGems before 4.0 resolves that one on Alpine instead, even under
337
+ `--platform x86_64-linux-musl`.
338
+
339
+ **Nothing in this gem is ever compiled, on any platform.** The platform gems depend on nothing
340
+ at all. The universal gem depends on `fiddle`, which can be: `fiddle` is a bundled gem on Ruby
341
+ 4.0 and a default gem on 3.3 and 3.4, and `gem install` is satisfied by the copy Ruby ships.
342
+ Bundler resolves the newest `fiddle` on rubygems.org instead, and when that is newer than the
343
+ one your Ruby ships — true today on 3.3 and 3.4, not on 4.0 — it builds Fiddle's own C
344
+ extension, which takes a compiler and libffi's headers (`apk add build-base libffi-dev` on
345
+ Alpine). That only reaches you where the universal gem installs: Ruby 3.3, Intel macOS, a
346
+ Ruby newer than the release, or a platform with no platform gem. `bundle install
347
+ --prefer-local` makes Bundler use the copy Ruby ships where it can (it did on Ruby 3.4's
348
+ Bundler 2.6, not on 3.3's 2.5); otherwise install the compiler and headers. Pinning `fiddle`
349
+ to your Ruby's version in the `Gemfile.lock` does not stop the build.
350
+
351
+ Both Windows architectures get a Magnus gem, and the reasoning that once kept them on Fiddle
352
+ was backwards: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
353
+ `aarch64-mingw-ucrt`, both `supported: true` in its own `data/toolchains.json`); the one it
354
+ has no support for is MSVC. Windows is also where the Fiddle fallback cost the most, so these
355
+ are the most worthwhile gems in the set. Both extensions are built for the `gnullvm` Rust
356
+ targets rather than `gnu` — the same mingw-w64/UCRT ABI RubyInstaller's Ruby uses, linked
357
+ with LLVM and compiler-rt instead of GCC and a statically-linked libgcc, which is what keeps
358
+ the shipped extension small. The build script, and the two flags that are load-bearing on
359
+ the ARM leg (a static libunwind, and a clang-spelled `--target` for bindgen), live in the
360
+ forge's `ruby-magnus` action (`build-magnus.sh`), shared with every other Hyper* repo.
361
+
362
+ ## Ruby in the browser
363
+
364
+ ruby.wasm cannot load an extension at runtime. `rbwasm build` cross-compiles the extension of
365
+ every gem in a Gemfile and links them all into the one interpreter it builds, so the browser
366
+ gets a gem of its own: `hypercast-wasm`, the same library and the same Magnus extension,
367
+ prebuilt for `wasm32-wasip1`. List it **instead of** `hypercast` in the Gemfile you build the
368
+ interpreter from (the two carry the same `lib/` files):
369
+
370
+ ```ruby
371
+ source "https://rubygems.org"
372
+
373
+ gem "hypercast-wasm"
374
+ gem "js" # JavaScript interop, which a browser app almost always wants
375
+ gem "bigdecimal" # only for Decimal#to_d; see below
376
+
377
+ group :development do
378
+ gem "ruby_wasm"
379
+ end
380
+ ```
381
+
382
+ ```sh
383
+ bundle install
384
+ bundle exec rbwasm build --ruby-version 4.0 -o ruby.wasm
385
+ ```
386
+
387
+ Load `ruby.wasm` with [`@ruby/wasm-wasi`](https://www.npmjs.com/package/@ruby/wasm-wasi)
388
+ (`DefaultRubyVM` in a browser, `RubyVM.instantiateModule` under Node), then
389
+ `require "/bundle/setup"` and `require "hypercast"` as anywhere else. `HyperCast::BACKEND` is
390
+ `:native`, and every door works as it does on a host Ruby.
391
+
392
+ - **Ruby 3.4 and 4.0**, one archive each, the same minors the platform gems cover, picked by
393
+ `--ruby-version`. Any other minor stops the build with a message naming the ones the gem
394
+ carries. Built and tested against ruby_wasm 2.10.
395
+ - **No Rust toolchain.** The gem's `extconf.rb` only hands rbwasm the prebuilt archive, and
396
+ rbwasm downloads its own wasi-sdk. The first `rbwasm build` compiles Ruby itself and takes
397
+ 15–20 minutes; later builds reuse it.
398
+ - **`Decimal#to_d` needs `gem "bigdecimal"` in that Gemfile.** bigdecimal is a bundled gem
399
+ with a C extension, and ruby.wasm builds a bundled gem's extension only when the Gemfile
400
+ names it; without it, `to_d` raises `LoadError`. `to_s` and `to_r` need nothing.
401
+ - **Static linking only,** into ruby.wasm's default `wasm32-unknown-wasip1` interpreter.
402
+ rbwasm's dynamic-linking build (a pic target, for the component model) is not supported.
403
+ - **Size.** rbwasm packs every gem's files into the interpreter, the archives included, so the
404
+ gem adds about 2 MB to `ruby.wasm` (both archives are gzipped).
405
+ - **With HyperUuid.** `hyperuuid-wasm` links into the same interpreter. Every Rust extension
406
+ that carries std defines a few of the same symbols (`rust_eh_personality`, which ruby.wasm's
407
+ own wasi-vfs defines too, rb-sys's `ruby_abi_version`, and one of std's), and both gems
408
+ rename them to names of their own when CI builds the archive, which also fails if a newer
409
+ Rust starts exporting another.
410
+
411
+ What stands behind it: CI builds each minor's archive from the commit, packs the gem the way
412
+ it ships, links it into a fresh interpreter and runs [`wasm-smoke/test.rb`](wasm-smoke/test.rb)
413
+ under Node and in headless Chrome (the forge's `hyper-build-wasm.yml`). The published gem is
414
+ packed around those attested archives.
415
+
416
+ ## Development
417
+
418
+ Everything below runs from a checkout, with `rust/` and `corpus/` beside `ruby/`; none of it
419
+ is needed to use the gem. The Fiddle backend finds the in-repo build on its own when nothing
420
+ is staged under `lib/hypercast/native/`. The Magnus backend does not — an
421
+ extension has to be built for the Ruby you are running and put where `require` looks — and
422
+ that is what `rake native:dev` is for.
423
+
424
+ ```sh
425
+ cd rust
426
+ cargo cdylib # libhypercast, what the Fiddle backend loads
427
+
428
+ cd ../ruby
429
+ bundle install
430
+ bundle exec rake native:dev # build the Magnus extension for this Ruby and stage it
431
+ bundle exec rspec # BACKEND == :native
432
+ HYPERCAST_PURE=1 bundle exec rspec # BACKEND == :fiddle
433
+ bundle exec rake docs:check # every public object carries a doc comment
434
+ ruby benchmark/cast_benchmark.rb # prints the backend it measured
435
+ ```
436
+
437
+ `rake native:dev` runs `cargo ruby-ext` (an alias in `rust/.cargo/config.toml` that builds the
438
+ `ruby` feature into `rust/target/ruby/`, never over the plain library in
439
+ `rust/target/release/`) and copies the result to
440
+ `lib/hypercast/<minor>/hypercast_native.<so|bundle>` — the path `lib/hypercast.rb` tries
441
+ first. The copy is also a rename, and it is the step `cargo ruby-ext` alone does not do: cargo
442
+ names its output `libhypercast.so`, and Ruby derives the `Init_` function it calls from the
443
+ file name it was asked to `require`. Three things worth knowing:
444
+
445
+ - **Run it again after changing `rust/`.** The staged file is a copy; nothing rebuilds it.
446
+ - **One staging per Ruby.** An extension is bound to one Ruby minor, so under a second Ruby
447
+ (`RBENV_VERSION=3.4.11 bundle exec rake native:dev`, say) the task rebuilds for that ABI
448
+ and stages beside the first, each Ruby loading its own.
449
+ - **Without it, `bundle exec rspec` still passes — on Fiddle.** The examples under "native
450
+ backend" report themselves pending with `BACKEND=fiddle`; that line, or
451
+ `ruby -Ilib -rhypercast -e 'p HyperCast::BACKEND'`, is how to tell which backend a run
452
+ exercised. Deleting `lib/hypercast/<minor>/` goes back to Fiddle.
453
+
454
+ The task covers Linux and macOS. The Windows extensions need the `gnullvm` targets and linker
455
+ flags in the forge's `build-magnus.sh`, which is also what CI uses on every platform.
456
+
457
+ The musl build has no host to run on outside a container. With the core built for musl at
458
+ `rust/target/musl/linux-musl-x64/libhypercast.so`, this runs the Fiddle suite — corpus replay
459
+ included — against it on Alpine, with the checkout mounted read-only:
460
+
461
+ ```sh
462
+ docker run --rm -v "$PWD/..":/src:ro ruby:4.0-alpine sh -euc '
463
+ mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
464
+ mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
465
+ cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
466
+ cd /work/ruby && rm -f Gemfile.lock
467
+ bundle install --quiet --prefer-local
468
+ HYPERCAST_PURE=1 bundle exec rspec'
469
+ ```
470
+
471
+ The floor's test is the same container on `ruby:3.3-alpine`. Bundler would compile a newer
472
+ Fiddle there (see [Install](#install)), so this one goes around Bundler and runs against the
473
+ Fiddle that Ruby 3.3 ships:
474
+
475
+ ```sh
476
+ docker run --rm -v "$PWD/..":/src:ro ruby:3.3-alpine sh -euc '
477
+ mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
478
+ mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
479
+ cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
480
+ cd /work/ruby && rm -f Gemfile Gemfile.lock
481
+ gem install rspec -v "~> 3.13" --no-document --silent
482
+ HYPERCAST_PURE=1 rspec'
483
+ ```
484
+
485
+ The musl Magnus extension is built the way CI builds it by the forge's
486
+ `ruby-magnus-musl/build-magnus-musl.sh`, which runs on any machine with Docker: from the repo
487
+ root, `build-magnus-musl.sh hypercast linux-musl-x64 4.0 . <dir with the musl libhypercast.so> <out-dir>`
488
+ compiles it in `ruby:4.0-alpine`, then runs this suite through it on a bare copy of that image.
489
+
490
+ See [the repo root README](../README.md) for the full door table, the receipts, and the
491
+ state of every other language binding.
492
+
493
+ ## License
494
+
495
+ [MIT](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
@@ -0,0 +1,107 @@
1
+ # The hypercast-wasm gem's extension: the Magnus extension, linked statically into a ruby.wasm
2
+ # interpreter by `rbwasm build`. ruby.wasm cannot load an extension at runtime, so a consumer
3
+ # puts this gem in the Gemfile they hand to rbwasm and gets one interpreter with HyperCast
4
+ # compiled in. Only this companion gem ships this file; the hypercast gems do not, because an
5
+ # `extensions` entry there would run extconf on every host `gem install`.
6
+ #
7
+ # Bundler installs the gem on the host first, where there is nothing to build. Then rbwasm runs
8
+ # this again with the cross-compiled Ruby's RbConfig, `make clean`, `make static`, and links
9
+ # every *.a left in the build directory. Both paths below end in the same place: the archive
10
+ # saved as hypercast_native.prebuilt (not *.a, which `make clean` removes), copied into place
11
+ # by `make static`.
12
+ #
13
+ # * Prebuilt, what a consumer gets: the gem carries one gzipped archive per Ruby minor (Rakefile,
14
+ # wasm:gem), built and attested in CI. No Rust toolchain on the consumer's machine.
15
+ # * From source, how CI makes those archives: HYPERCAST_RUST_DIR names the crate, and
16
+ # HYPERCAST_WASM_OUT, if set, receives a copy of the finished archive.
17
+ require "mkmf"
18
+ require "rbconfig"
19
+ require "zlib"
20
+ require "fileutils"
21
+
22
+ unless RbConfig::CONFIG["host_os"].include?("wasi")
23
+ File.write("Makefile", dummy_makefile($srcdir).join)
24
+ exit
25
+ end
26
+
27
+ # rbwasm reads $target back out to name the extension's Init function in its extinit table.
28
+ $target = "hypercast_native"
29
+ minor = "#{RbConfig::CONFIG["MAJOR"]}.#{RbConfig::CONFIG["MINOR"]}"
30
+ prebuilt = "hypercast_native.prebuilt"
31
+
32
+ if (rust_dir = ENV["HYPERCAST_RUST_DIR"])
33
+ # rb-sys binds against the target Ruby through RBCONFIG_* variables. Ruby is not installed
34
+ # yet when rbwasm builds extensions, so the two header directories point at the source and
35
+ # build trees instead (rbwasm sets top_srcdir and extout for exactly this).
36
+ RbConfig::CONFIG.merge(
37
+ "rubyhdrdir" => File.join(ENV.fetch("top_srcdir"), "include"),
38
+ "rubyarchhdrdir" => File.join(ENV.fetch("extout"), "include", RbConfig::CONFIG["arch"])
39
+ ).each { |key, value| ENV["RBCONFIG_#{key}"] = value.to_s }
40
+ wasi_sdk = File.dirname(File.dirname(RbConfig::CONFIG["CC"].split.first))
41
+ ENV["BINDGEN_EXTRA_CLANG_ARGS"] = "--sysroot=#{wasi_sdk}/share/wasi-sysroot " \
42
+ "-D_WASI_EMULATED_SIGNAL -D_WASI_EMULATED_PROCESS_CLOCKS -D_WASI_EMULATED_MMAN"
43
+ ENV["CC_wasm32_wasip1"] = "#{wasi_sdk}/bin/clang"
44
+ target_dir = File.expand_path("target")
45
+ system("cargo", "rustc", "--manifest-path", File.join(rust_dir, "Cargo.toml"), "--release",
46
+ "--target", "wasm32-wasip1", "--crate-type", "staticlib", "--features", "ruby",
47
+ "--target-dir", target_dir, exception: true)
48
+ archive = File.binread(File.join(target_dir, "wasm32-wasip1/release/libhypercast.a"))
49
+ # The crate links into one object (release LTO), and besides Init_hypercast_native and the
50
+ # C ABI it exports three symbols that every Rust extension carrying std defines too:
51
+ # rust_eh_personality, which wasi-vfs (linked into every ruby.wasm) also defines;
52
+ # ruby_abi_version, rb-sys's ABI stamp for a dynamically loaded extension, which a static
53
+ # one never needs; and std's EMPTY_PANIC. Two definitions of any of them stop the link, so
54
+ # each is renamed to one this crate owns, and the HyperUuid extension, renamed the same way,
55
+ # can share the interpreter. wasm llvm-objcopy can neither localize nor rename a symbol, so
56
+ # the names are rewritten in place at the same length, which leaves every offset in the
57
+ # archive and its objects valid (and keeps EMPTY_PANIC's v0 mangling well-formed). Nothing
58
+ # calls the first two: a static extension's ABI stamp is never looked up, and wasm32-wasip1
59
+ # builds with panic=abort.
60
+ {
61
+ "rust_eh_personality" => "hypercast_eh_person",
62
+ "ruby_abi_version" => "hypercast_abiver",
63
+ "9panicking11EMPTY_PANIC" => "9panicking11HCAST_PANIC"
64
+ }.each { |from, to| archive.gsub!(from, to) }
65
+ File.binwrite(prebuilt, archive)
66
+ system("#{wasi_sdk}/bin/llvm-ranlib", prebuilt, exception: true)
67
+ # And a newer Rust that exports another of std's symbols fails here, by name, rather than in
68
+ # the link of an interpreter that also carries the HyperUuid extension.
69
+ member = nil
70
+ exported = IO.popen(["#{wasi_sdk}/bin/llvm-nm", "--defined-only", "--extern-only", prebuilt], err: File::NULL, &:read)
71
+ .each_line(chomp: true).filter_map do |line|
72
+ if line.end_with?(".o:")
73
+ member = line
74
+ next
75
+ end
76
+ line.split.last if member&.start_with?("hypercast-") && line.match?(/\A\h+ [A-Z] /)
77
+ end
78
+ shared = exported.grep(/\A(_R|_ZN|rust_|__rust|ruby_abi_version\z)/).grep_v(/HCAST_PANIC\z/)
79
+ shared.empty? or abort "hypercast-wasm: the extension exports #{shared.join(", ")}, which " \
80
+ "another Rust extension in the same interpreter would define too"
81
+ if (out = ENV["HYPERCAST_WASM_OUT"])
82
+ FileUtils.mkdir_p(File.join(out, minor))
83
+ FileUtils.cp(prebuilt, File.join(out, minor, "hypercast_native.a"))
84
+ end
85
+ else
86
+ gz = File.join(__dir__, minor, "hypercast_native.a.gz")
87
+ unless File.exist?(gz)
88
+ shipped = Dir[File.join(__dir__, "*", "hypercast_native.a.gz")].map { |f| File.basename(File.dirname(f)) }
89
+ abort "hypercast-wasm: no prebuilt extension for Ruby #{minor} (this gem carries #{shipped.sort.join(", ")})"
90
+ end
91
+ File.binwrite(prebuilt, Zlib::GzipReader.open(gz, &:read))
92
+ end
93
+
94
+ # install-so is rbwasm's dynamic-linking path (a pic target, for the component model), which
95
+ # a static archive cannot serve.
96
+ File.write("Makefile", <<~MAKE)
97
+ all: static
98
+ static:
99
+ \tcp #{prebuilt} hypercast_native.a
100
+ install-so:
101
+ \t@echo "hypercast-wasm links statically: build a wasm32-unknown-wasip1 interpreter" >&2; exit 1
102
+ install-rb:
103
+ \t@true
104
+ clean:
105
+ \trm -f hypercast_native.a
106
+ .PHONY: all static install-so install-rb clean
107
+ MAKE