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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f98fed5094ed802f28ba22608b5c0e2d97bbfb6f703163d289db02e68744e48
4
- data.tar.gz: 65ddb997b2b0b46fba536b5e9dd3e05229fd67b6f6e06b5a52d04cf36cc0e569
3
+ metadata.gz: a5a3b3e6d5955b7431ae3452f9f80b9aecaae1c795408951cdfabd913d025e30
4
+ data.tar.gz: 871aef11a7d72f3a025adf92340a3d66783d009604cf17d2c234ce25b0fbf60b
5
5
  SHA512:
6
- metadata.gz: 243b9f2f66050ce1ca6471cb59755e5fa0555461264f53e0a6471730288e2f7cb8f916b71c5bbc5fb2a110baa1fe7cc1556da3d050f2fe21319c91471ef1b730
7
- data.tar.gz: cfbb594b4a627db9cb13dbade86faf5ce4995fdf5c07f1bb86a4c426686703bbe973b7208f74dd44ff6cb089df9f2fcc55b3b9e95b97302e25b7929aa397d7f6
6
+ metadata.gz: 8664495fde8462e5e3886b7e0b72733e16c4c8dc4cb85e6d71b21c66bdc949ba9d3729c5291349f31c15a4da1b17403db85adbcc5815d55615e223fea77be022
7
+ data.tar.gz: 6e9396ad08a6634853726727121096fabbbf654275d81656725c0f7b620ccfea59b534d75f8ccc31812bdd01fd49220cb020e6c5f2999da2657a0ad9de20cba2
data/README.md CHANGED
@@ -5,17 +5,19 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
6
6
 
7
7
  **Ruby's own pattern matching over two `Data` case types — the value, or a closed reason
8
- Symbol plus the exact byte span that offended. Two backends, one public surface: a Magnus
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, UUIDs, temporals —
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, and durations come back as exact `Rational` seconds across the core's whole
32
- ±10,000-year window no truncation anywhere, no wrapping.
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 (24 examples green on *both* backends, full twelve-file corpus
43
- replay; a cross-backend agreement spec compares Magnus and Fiddle outputs across a
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
@@ -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 — a splat-through
33
- # `call(symbol, *args)` here built and re-splatted an Array on every cast.
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
- rid, lib_name = NativePlatform.rid_and_library_name
50
- path = File.join(NATIVE_DIR, rid, lib_name)
51
- unless File.exist?(path)
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: #{path} not found (unsupported platform, or this gem was built " \
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 — calling directly
6
- # into the native libhypercast shared library via Fiddle. Every door returns a verdict:
7
- # Success or Fault (a closed reason plus the offending byte span), never an exception for
8
- # bad data — the only exceptions here are caller bugs (a malformed NumFormat), never data.
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, and durations come
21
- # back as exact Rational seconds no truncation anywhere, and no wrapping: Ruby and the
22
- # JVM are the fidelity kings of this roster.
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.2.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 as byte offsets into the UTF-8 input. Nothing is captured slicing the
33
- # offending text out of the input is the caller's choice.
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 real doors — declared out loud
40
- # (INVARIANT, or a literal), never defaulted, the same stance every binding takes.
41
- # Equal separators are a caller bug (ArgumentError), never a verdict.
42
- NumFormat = Data.define(:decimal_sep, :group_sep, :flags) do
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 — so a malformed format fails loudly as the caller bug it is.
45
- def initialize(decimal_sep:, group_sep:, flags:)
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
- super
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 12-byte little-endian form the native ABI's NumFormat struct expects.
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
- code = UNIX_PRECISIONS.fetch(precision)
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
- code = EXCEL_EPOCHS.fetch(epoch)
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
- code = DATE_ORDERS.fetch(order)
202
- bytes = utf8(text)
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
- code = DATE_ORDERS.fetch(order)
222
- bytes = utf8(text)
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), fault span at 16. Two Fiddle::Pointer.malloc(...,
269
- # RUBY_FREE) calls per cast — each registering a GC finalizer — measured as the
270
- # dominant per-call cost by an order of magnitude. The NumFormat no longer lives here:
271
- # each format owns its own packed pointer (see packed_cache), so a numeric call copies
272
- # nothing into scratch before crossing.
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, and -1 (contract violation) is a binding bug that raises.
282
- def verdict(rc, fault)
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 packed
302
- # pointer, passed straight through — no per-call copy of the 12 bytes into scratch.
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 two Strings and an Integer) of a native 12-byte RawNumFormat per format
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(12, Fiddle::RUBY_FREE)
318
- pointer[0, 12] = format.packed
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["HYPERCAST_PURE"]
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
- :fiddle
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.2.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