hypercast 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +160 -10
- 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 +40 -15
- data/lib/hypercast/wasm_runtime.rb +169 -0
- data/lib/hypercast.rb +221 -61
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5a3b3e6d5955b7431ae3452f9f80b9aecaae1c795408951cdfabd913d025e30
|
|
4
|
+
data.tar.gz: 871aef11a7d72f3a025adf92340a3d66783d009604cf17d2c234ce25b0fbf60b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8664495fde8462e5e3886b7e0b72733e16c4c8dc4cb85e6d71b21c66bdc949ba9d3729c5291349f31c15a4da1b17403db85adbcc5815d55615e223fea77be022
|
|
7
|
+
data.tar.gz: 6e9396ad08a6634853726727121096fabbbf654275d81656725c0f7b620ccfea59b534d75f8ccc31812bdd01fd49220cb020e6c5f2999da2657a0ad9de20cba2
|
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,9 +132,9 @@ 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
|
|
@@ -80,6 +173,63 @@ passed straight through. (Benchmark forensics worth knowing: the doors read 4.3
|
|
|
80
173
|
per-call `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch —
|
|
81
174
|
receipts include their own archaeology.)
|
|
82
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
|
+
|
|
83
233
|
## Verifying provenance
|
|
84
234
|
|
|
85
235
|
Every gem RubyGems.org serves — the universal fallback and each of the six precompiled
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/hypercast/runtime.rb
CHANGED
|
@@ -18,25 +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
|
-
# The door's Fiddle::Function, for the caller to invoke directly
|
|
33
|
-
# `call(symbol, *args)` here built and re-splatted an Array on
|
|
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.
|
|
34
40
|
def function(symbol)
|
|
35
41
|
functions.fetch(symbol)
|
|
36
42
|
end
|
|
37
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
|
|
52
|
+
end
|
|
53
|
+
|
|
38
54
|
private
|
|
39
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
|
+
|
|
40
68
|
# Loaded lazily and exactly once; the native library and its function pointers live
|
|
41
69
|
# for the process's lifetime, same as every other binding (never dlclose'd). The
|
|
42
70
|
# unsynchronized read is the fast path — a per-call mutex acquisition measured as a
|
|
@@ -46,24 +74,21 @@ module HyperCast
|
|
|
46
74
|
end
|
|
47
75
|
|
|
48
76
|
def load_functions
|
|
49
|
-
|
|
50
|
-
path
|
|
51
|
-
|
|
52
|
-
# Development loop: fall back to the in-repo cargo build, exactly what the other
|
|
53
|
-
# bindings' local staging does.
|
|
54
|
-
repo_build = File.expand_path(File.join(__dir__, "../../../rust/target/release", lib_name))
|
|
55
|
-
path = repo_build if File.exist?(repo_build)
|
|
56
|
-
end
|
|
57
|
-
unless File.exist?(path)
|
|
77
|
+
path = library_path
|
|
78
|
+
if path.nil?
|
|
79
|
+
rid, lib_name = NativePlatform.rid_and_library_name
|
|
58
80
|
raise LoadError,
|
|
59
|
-
"hypercast: #{
|
|
60
|
-
"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)"
|
|
61
83
|
end
|
|
62
84
|
|
|
63
85
|
handle = Fiddle.dlopen(path)
|
|
64
|
-
DOORS.to_h do |name, signature|
|
|
86
|
+
functions = DOORS.to_h do |name, signature|
|
|
65
87
|
[name, Fiddle::Function.new(handle[name.to_s], signature, Fiddle::TYPE_INT32_T)]
|
|
66
88
|
end
|
|
89
|
+
functions[VERSION_PROBE] =
|
|
90
|
+
Fiddle::Function.new(handle[VERSION_PROBE.to_s], [], Fiddle::TYPE_UINT32_T)
|
|
91
|
+
functions
|
|
67
92
|
end
|
|
68
93
|
end
|
|
69
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.
|
|
@@ -142,6 +233,19 @@ module HyperCast
|
|
|
142
233
|
numeric(:cast_f64, text, format, 8) { |out| out.unpack1("E") }
|
|
143
234
|
end
|
|
144
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
|
+
|
|
145
249
|
# Casts UUID text — all five .NET Guid formats (D/N/B/P/X) plus urn:uuid:/GUID:/UUID:
|
|
146
250
|
# prefixes — to Ruby's UUID lingua franca: the lowercase hyphenated String (the same
|
|
147
251
|
# shape SecureRandom.uuid returns).
|
|
@@ -161,11 +265,7 @@ module HyperCast
|
|
|
161
265
|
# (:seconds/:milliseconds/:microseconds/:nanoseconds) to a UTC Time. An unknown unit
|
|
162
266
|
# is a caller bug (KeyError), never a verdict.
|
|
163
267
|
def unix(text, precision)
|
|
164
|
-
|
|
165
|
-
bytes = utf8(text)
|
|
166
|
-
out, fault, = scratch
|
|
167
|
-
rc = Runtime.function(:cast_unix).call(input_ptr(bytes), bytes.bytesize, code, out, fault)
|
|
168
|
-
verdict(rc, fault) { instant(out[0, 16]) }
|
|
268
|
+
declared(:cast_unix, text, UNIX_PRECISIONS.fetch(precision), 16) { |out| instant(out) }
|
|
169
269
|
end
|
|
170
270
|
|
|
171
271
|
# Casts an Excel date serial under a caller-declared epoch Symbol (:y1900/:y1904) to a
|
|
@@ -179,11 +279,7 @@ module HyperCast
|
|
|
179
279
|
# the text "1900-02-29" — so every serial above it is shifted one day against a naive
|
|
180
280
|
# count. An unknown epoch is a caller bug (KeyError), never a verdict.
|
|
181
281
|
def excel_serial(text, epoch)
|
|
182
|
-
|
|
183
|
-
bytes = utf8(text)
|
|
184
|
-
out, fault, = scratch
|
|
185
|
-
rc = Runtime.function(:cast_excel_serial).call(input_ptr(bytes), bytes.bytesize, code, out, fault)
|
|
186
|
-
verdict(rc, fault) { instant(out[0, 16]) }
|
|
282
|
+
declared(:cast_excel_serial, text, EXCEL_EPOCHS.fetch(epoch), 16) { |out| instant(out) }
|
|
187
283
|
end
|
|
188
284
|
|
|
189
285
|
# Casts a calendar date to a Date. With no order declared: the strict ISO 8601
|
|
@@ -198,12 +294,8 @@ module HyperCast
|
|
|
198
294
|
Date.new(year, month, day)
|
|
199
295
|
end
|
|
200
296
|
else
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
out, fault, = scratch
|
|
204
|
-
rc = Runtime.function(:cast_date_ordered).call(input_ptr(bytes), bytes.bytesize, code, out, fault)
|
|
205
|
-
verdict(rc, fault) do
|
|
206
|
-
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")
|
|
207
299
|
Date.new(year, month, day)
|
|
208
300
|
end
|
|
209
301
|
end
|
|
@@ -218,12 +310,8 @@ module HyperCast
|
|
|
218
310
|
# fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
|
|
219
311
|
# instant door. An unknown order is a caller bug (KeyError).
|
|
220
312
|
def datetime(text, order)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
out, fault, = scratch
|
|
224
|
-
rc = Runtime.function(:cast_datetime).call(input_ptr(bytes), bytes.bytesize, code, out, fault)
|
|
225
|
-
verdict(rc, fault) do
|
|
226
|
-
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<")
|
|
227
315
|
second_of_day, frac = nanos.divmod(1_000_000_000)
|
|
228
316
|
hour, rest = second_of_day.divmod(3600)
|
|
229
317
|
minute, second = rest.divmod(60)
|
|
@@ -247,6 +335,16 @@ module HyperCast
|
|
|
247
335
|
end
|
|
248
336
|
end
|
|
249
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
|
+
|
|
250
348
|
private
|
|
251
349
|
|
|
252
350
|
# Encodings whose bytes already are the UTF-8 (or byte-identical) form the core reads.
|
|
@@ -265,11 +363,12 @@ module HyperCast
|
|
|
265
363
|
end
|
|
266
364
|
|
|
267
365
|
# One 24-byte scratch allocation per thread, reused by every call: out-value at 0
|
|
268
|
-
# (16 bytes covers every door
|
|
269
|
-
# RUBY_FREE) calls per cast — each
|
|
270
|
-
# dominant per-call cost by an order of
|
|
271
|
-
#
|
|
272
|
-
# nothing into scratch before
|
|
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.
|
|
273
372
|
def scratch
|
|
274
373
|
Thread.current[:hypercast_scratch] ||= begin
|
|
275
374
|
base = Fiddle::Pointer.malloc(24, Fiddle::RUBY_FREE)
|
|
@@ -278,44 +377,75 @@ module HyperCast
|
|
|
278
377
|
end
|
|
279
378
|
|
|
280
379
|
# Presents a native return code as the verdict union: 0 yields a Success, a failure
|
|
281
|
-
# code becomes a Fault
|
|
282
|
-
|
|
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)
|
|
283
383
|
if rc.zero?
|
|
284
384
|
Success.new(value: yield)
|
|
285
385
|
elsif rc == -1
|
|
286
386
|
raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
|
|
287
387
|
else
|
|
288
|
-
offset, length = fault[0, 8].unpack("L<L<")
|
|
388
|
+
offset, length = characters(bytes, *fault[0, 8].unpack("L<L<"))
|
|
289
389
|
Fault.new(reason: REASONS.fetch(rc), offset: offset, length: length)
|
|
290
390
|
end
|
|
291
391
|
end
|
|
292
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
|
+
|
|
293
404
|
# The shared body of every format-free door: one native call over the scratch buffers.
|
|
294
405
|
def plain(symbol, text, out_size)
|
|
295
406
|
bytes = utf8(text)
|
|
296
407
|
out, fault = scratch
|
|
297
408
|
rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, out, fault)
|
|
298
|
-
verdict(rc, fault) { yield(out[0, out_size]) }
|
|
409
|
+
verdict(rc, fault, bytes) { yield(out[0, out_size]) }
|
|
299
410
|
end
|
|
300
411
|
|
|
301
|
-
# The shared body of the integer/real doors: plain, plus the format's own
|
|
302
|
-
# pointer, passed straight through — no per-call copy of 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.
|
|
303
415
|
def numeric(symbol, text, format, out_size)
|
|
304
416
|
bytes = utf8(text)
|
|
305
417
|
out, fault = scratch
|
|
306
418
|
rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, packed_cache[format], out, fault)
|
|
307
|
-
verdict(rc, fault) { yield(out[0, out_size]) }
|
|
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]) }
|
|
432
|
+
end
|
|
433
|
+
|
|
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
|
|
308
438
|
end
|
|
309
439
|
|
|
310
440
|
# Identity-keyed memo (compare_by_identity — a pointer hash, not Data's structural
|
|
311
|
-
# #hash over
|
|
441
|
+
# #hash over three Strings and an Integer) of a native 32-byte RawNumFormat per format
|
|
312
442
|
# object, filled once from NumFormat#packed. Formats are reused constants in practice,
|
|
313
443
|
# so the common call finds its pointer in one lookup and packs nothing. The Hash holds
|
|
314
444
|
# the format, so a key can never be a recycled address; the race is benign (idempotent).
|
|
315
445
|
def packed_cache
|
|
316
446
|
@packed_cache ||= Hash.new do |cache, format|
|
|
317
|
-
pointer = Fiddle::Pointer.malloc(
|
|
318
|
-
pointer[0,
|
|
447
|
+
pointer = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
|
|
448
|
+
pointer[0, 32] = format.packed
|
|
319
449
|
cache[format] = pointer
|
|
320
450
|
end.compare_by_identity
|
|
321
451
|
end
|
|
@@ -333,8 +463,24 @@ end
|
|
|
333
463
|
# drops to an ordinary extension call. The pure-Fiddle definitions stay the universal
|
|
334
464
|
# zero-compile fallback; precompiled platform gems are how the extension ships without
|
|
335
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.
|
|
336
473
|
HyperCast::BACKEND =
|
|
337
|
-
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"]
|
|
338
484
|
:fiddle
|
|
339
485
|
else
|
|
340
486
|
# Two layouts, and both have to work. A released platform gem is a "fat" gem carrying one
|
|
@@ -354,7 +500,21 @@ HyperCast::BACKEND =
|
|
|
354
500
|
require "hypercast_native"
|
|
355
501
|
:native
|
|
356
502
|
rescue LoadError
|
|
357
|
-
|
|
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
|
|
358
518
|
end
|
|
359
519
|
end
|
|
360
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: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Buvinghausen
|
|
@@ -68,10 +68,12 @@ files:
|
|
|
68
68
|
- lib/hypercast/native/linux-x64/libhypercast.so
|
|
69
69
|
- lib/hypercast/native/osx-arm64/libhypercast.dylib
|
|
70
70
|
- lib/hypercast/native/osx-x64/libhypercast.dylib
|
|
71
|
+
- lib/hypercast/native/wasm32-wasip1/hypercast.wasm
|
|
71
72
|
- lib/hypercast/native/win-arm64/hypercast.dll
|
|
72
73
|
- lib/hypercast/native/win-x64/hypercast.dll
|
|
73
74
|
- lib/hypercast/native_platform.rb
|
|
74
75
|
- lib/hypercast/runtime.rb
|
|
76
|
+
- lib/hypercast/wasm_runtime.rb
|
|
75
77
|
homepage: https://github.com/SkunkWerkx/HyperCast
|
|
76
78
|
licenses:
|
|
77
79
|
- MIT
|