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