hypercast 0.6.0-aarch64-linux-musl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f3faf9c988a5e8ff5d927aa1ca98f838a4b0cc1b2226bcf8cd041471f8769acb
4
+ data.tar.gz: '085185fd364b6b9c77db85264e4485bcd2ef1607d57f7da134203113e02cc916'
5
+ SHA512:
6
+ metadata.gz: a117b491a68581472304827e62beab4443c6bd15b4232d2387f07d0b5b9ea8a72bb97d37a038c7009ecb23c25288bd303ad9cac086e406c9c1732a751c50916f
7
+ data.tar.gz: 754deef4beefe1905da6537811c57ca5ad4c5daccd4394683c1f97e57f994f394a28b42e315253feaf49f847fe417c066524850604537def6383a89e460aee5e
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Skunk Werkx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,440 @@
1
+ # hypercast
2
+
3
+ [![CI](https://github.com/SkunkWerkx/HyperCast/actions/workflows/ci.yml/badge.svg)](https://github.com/SkunkWerkx/HyperCast/actions/workflows/ci.yml)
4
+ [![RubyGems](https://img.shields.io/gem/v/hypercast.svg)](https://rubygems.org/gems/hypercast)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
6
+
7
+ **Ruby's own pattern matching over two `Data` case types — the value, or a closed reason
8
+ Symbol plus the exact span that offended. Two backends, one public surface: a Magnus
9
+ native extension where a precompiled platform gem covers you, stdlib Fiddle as the last
10
+ resort everywhere else — selected automatically, zero compiles either way.**
11
+
12
+ Allocation-lean scalar casts — booleans, the full integer family, reals, exact decimals,
13
+ UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
14
+ floor. The fast path links the core straight into a Ruby extension (Magnus): on require it
15
+ redefines the doors in place on the `HyperCast` module — no delegation layer, no second
16
+ surface, which is exactly what keeps the backends provably in agreement.
17
+ The last-resort fallback, in the universal gem only, calls the native `libhypercast` shared
18
+ library via [`Fiddle`](https://docs.ruby-lang.org/en/master/Fiddle.html) — dlopen/dlsym plus
19
+ raw C-ABI calls, no runtime bridge. `HyperCast::BACKEND` reports which one is live;
20
+ `HYPERCAST_PURE=1` forces Fiddle for testing — see [Backends](#backends).
21
+
22
+ ```ruby
23
+ case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
24
+ in HyperCast::Success(value:) then puts "got #{value}" # -1234
25
+ in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
26
+ end
27
+ ```
28
+
29
+ Door names mirror the native ABI (`i32`, `f64`, `timestamp`, …). Ruby-flavored fidelity,
30
+ stated proudly — nothing the core parses is lost on the way out: `Integer` is
31
+ unbounded (u64 comes back as the true unsigned value), `Time` carries full nanoseconds
32
+ across the whole 0001–9999 window, time-of-day is an exact Integer of nanoseconds since
33
+ midnight, durations come back as exact `Rational` seconds across the core's whole
34
+ ±10,000-year window, and `decimal` returns an exact `HyperCast::Decimal` that never rounds
35
+ — no truncation anywhere, no wrapping.
36
+
37
+ ## The doors
38
+
39
+ | Door | Value | Declares |
40
+ |---|---|---|
41
+ | `bool(text)` | `true`/`false` | — |
42
+ | `i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64` `(text, format)` | `Integer` (unbounded — u64 is the true unsigned value) | a `NumFormat` |
43
+ | `f32` `f64` `(text, format)` | `Float` (f32 widened losslessly) | a `NumFormat` |
44
+ | `decimal(text, format)` | `HyperCast::Decimal` — exact sign, 96-bit magnitude, base-10 scale | a `NumFormat` |
45
+ | `uuid(text)` | lowercase hyphenated `String` (`SecureRandom.uuid`'s shape) | — |
46
+ | `timestamp(text)` | UTC `Time`, full nanoseconds | — |
47
+ | `unix(text, precision)` | UTC `Time` | `:seconds` / `:milliseconds` / `:microseconds` / `:nanoseconds` |
48
+ | `excel_serial(text, epoch)` | UTC `Time` | `:y1900` / `:y1904` |
49
+ | `date(text, order = nil)` | `Date` | strict ISO, or `:year_month_day` / `:month_day_year` / `:day_month_year` |
50
+ | `datetime(text, order)` | zone-less `DateTime`, exact `Rational` seconds | a field order, as `date` |
51
+ | `time(text)` | `Integer` nanoseconds since midnight | — |
52
+ | `duration(text)` | exact `Rational` seconds | — |
53
+
54
+ Every door returns a `Success` or a `Fault`; `HyperCast.optional(verdict)` folds `:empty`
55
+ to `nil`. Beside the doors, `native_version` returns the loaded core's
56
+ `"major.minor.patch"` and `available?` answers `true`/`false` without ever raising — see
57
+ [Is the native core there?](#is-the-native-core-there).
58
+
59
+ ### Errors
60
+
61
+ Bad data is never an exception — it is a `Fault`. What raises is a caller bug, and it raises
62
+ the same exception on every backend:
63
+
64
+ - `ArgumentError` from `NumFormat.new` for a malformed format: separators that are not single
65
+ characters or not distinct, a currency symbol that is too long or carries an ASCII digit or
66
+ whitespace.
67
+ - `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
68
+ that names nothing: an unknown Symbol, or anything that is not a Symbol (`"seconds"`
69
+ included). `date(text, nil)` is not one of these: an explicit `nil` order is the strict ISO
70
+ door, exactly as if it were left off.
71
+ - `TypeError` for text that is not a `String` (anything with `to_str` is taken as one).
72
+ - `String#encode`'s own `Encoding::` error for text in another encoding that cannot be
73
+ transcoded to UTF-8.
74
+
75
+ ### Is the native core there?
76
+
77
+ `HyperCast.available?` answers whether a backend actually loaded and exports the ABI this
78
+ binding was built against — probed once, cached, never raising — for a consumer with a
79
+ fallback of its own:
80
+
81
+ ```ruby
82
+ value = HyperCast.available? ? HyperCast.i32(text, format) : my_own_parse(text)
83
+ ```
84
+
85
+ `HyperCast.native_version` reads the version out of the loaded core itself (its
86
+ `hypercast_version` export — the cheapest probe that the backend resolved at all), so a
87
+ mismatch against `HyperCast::VERSION` can be named before the first cast.
88
+ `HyperCast::BACKEND` says which backend was *selected*, which is not the same question: it
89
+ reads `:fiddle` on a platform with no library at all, because that is the backend whose
90
+ first call explains what is missing — a `LoadError` naming the path it looked for, or
91
+ `HyperCast::NativePlatform::UnsupportedPlatformError` naming the platform. Inside a platform
92
+ gem, which carries no Fiddle library, that `LoadError` names the universal gem instead (see
93
+ [Backends](#backends)).
94
+
95
+ ### Fault spans index the String you passed
96
+
97
+ A `Fault`'s `offset`/`length` are in the units `String#[]` slices by on your own input:
98
+ character offsets for text — in any encoding; the core reads UTF-8, and the span is mapped
99
+ back, an identity for ASCII — and byte offsets for a binary (`ASCII-8BIT`) String, whose
100
+ characters are its bytes. Either way `text[offset, length]` is the offending text with no
101
+ arithmetic on your side:
102
+
103
+ ```ruby
104
+ HyperCast.i32("1€", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 1, length: 1)
105
+ HyperCast.i32("1€".b, HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 1, length: 3)
106
+ ```
107
+
108
+ The mapping happens only on the failure path, so a `Success` never pays for it.
109
+
110
+ ### `decimal`: exact, never rounded
111
+
112
+ `HyperCast::Decimal` is a `Data` of `magnitude` (an unbounded `Integer`, at most 2⁹⁶ − 1),
113
+ `scale` (0–28: places the magnitude is shifted right) and `negative` — the shape .NET's
114
+ `decimal` stores and `BigDecimal` builds from directly. No float is ever formed, so `"0.1"`
115
+ is one tenth and `"50%"` is exactly `0.5`. The triple is canonical: exact trailing zeros in
116
+ the fraction are always trimmed, so the scale is minimal — `"1.10"`, `"1.1"` and `"1.1000"`
117
+ are all magnitude 11, scale 1, while `"100"` stays magnitude 100, scale 0 — and zero is
118
+ scale 0 and never negative. Nothing but a zero is ever dropped: text carrying more precision
119
+ than 96 bits and 28 places can hold is an `:out_of_range` `Fault`, not an approximation.
120
+
121
+ ```ruby
122
+ value = HyperCast.decimal("-1,234.50", HyperCast::NumFormat::INVARIANT).value
123
+ value.to_s # => "-1234.5" (the canonical text, every binding renders the same)
124
+ value.to_r # => (-2469/2)
125
+ value.to_d # => BigDecimal("-1234.5")
126
+ ```
127
+
128
+ `to_d` requires the `bigdecimal` gem lazily on first call — a bundled gem since Ruby 3.4,
129
+ deliberately not a dependency of this one, so under Bundler add it to your own Gemfile.
130
+
131
+ ### `NumFormat`: declared separators, and a declared currency
132
+
133
+ ```ruby
134
+ HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
135
+ ```
136
+
137
+ The flags are `GROUPING`, `PARENTHESES`, `EXPONENT`, `RADIX_PREFIXES`, `PERCENT` and
138
+ `CURRENCY` (`ALL_STYLES` is all six), plus the `SEPARATOR_DETECT` policy. The currency
139
+ symbol is the one field a culture table would fill in: it is declared, never looked up, and
140
+ honored only while `CURRENCY` is set — once, leading (`$5`, `-$5`, `$ -5`) or trailing
141
+ (`5 €`, `1.234,50 kr.`), with optional ASCII whitespace between symbol and digits, and
142
+ accounting parentheses wrapping symbol and digits together (`($5)`). Declared with the
143
+ flag off, the symbol is a `:malformed` `Fault` at the symbol; the flag with nothing declared
144
+ (`currency: ""`, the default — `INVARIANT` and `DETECT` declare none) matches nothing. A
145
+ symbol longer than 16 UTF-8 bytes, or carrying an ASCII digit or ASCII whitespace, is an
146
+ `ArgumentError` at construction, the same caller-bug treatment equal separators get.
147
+
148
+ ```ruby
149
+ dollars = HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
150
+ HyperCast.i32("($1,234)", dollars) # => Success(value: -1234)
151
+ HyperCast.decimal("$1,234.50", dollars) # => Success(value: Decimal(magnitude: 12345, scale: 1, negative: false))
152
+ HyperCast.i32("$5", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 0, length: 1)
153
+ ```
154
+
155
+ Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code points, the
156
+ flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
157
+ and memoized by identity on every backend, so declaring a currency costs a cast nothing.
158
+
159
+ ## Why not `Integer()` / `Time.iso8601` / `Float()`?
160
+
161
+ 1. **Verdicts, not exceptions** — bad data is the expected case for untrusted text; a
162
+ `Fault` is a Symbol and two integers, not an `ArgumentError` to rescue.
163
+ 2. **The vocabulary untrusted sources actually send** — twenty boolean lexemes, accounting
164
+ parentheses, declared separators, radix prefixes, all five .NET `Guid` text forms plus
165
+ `urn:uuid:` prefixes, protobuf JSON durations.
166
+ 3. **One engine across a polyglot system** — bit-for-bit verdicts with every other binding,
167
+ held by the shared corpus (the whole suite green on both backends, full corpus replay;
168
+ cross-backend agreement specs compare Magnus against Fiddle across a subprocess
169
+ boundary).
170
+ 4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
171
+ benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-x64 on an Intel Core
172
+ i9-11900H, Ruby 4.0.7): timestamp **447 ns vs 2.97 µs `Time.iso8601`** (6.6x) — while
173
+ returning exact `Rational` durations on the duration door. The Fiddle fallback lands at
174
+ 3.25 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
175
+
176
+ Separator detection is nearly free here: `1.234.567,89` under `NumFormat::DETECT` runs
177
+ at 178 ns against 170 ns for the same text under a declared eurozone format. Both cost
178
+ nearly twice that in 0.1.0, for a reason that had nothing to do with parsing: every format other than `INVARIANT` paid three method dispatches and two
179
+ `String` allocations per call to read its separators back out of the `Data`. `DETECT`
180
+ is now identity-matched like `INVARIANT`, and any other format is resolved once per
181
+ thread and memoized by identity — anchored in a thread-variable so the memo's key can
182
+ never be a recycled address — which turned the per-call cost into one pointer compare.
183
+ The three reason Symbols and the option Symbols (`:seconds`, `:month_day_year`, …) are
184
+ cached the same way, so a fault or a declared option is a pointer compare too, never a
185
+ `Symbol#name` materialization.
186
+
187
+ **The honest trade-off:** the civil doors do not beat `strptime`. The date-time door is
188
+ level with `DateTime.strptime` (904 ns against 901 ns) and the date door a little behind
189
+ `Date.strptime` (620 ns against 513 ns). The parse isn't the cost; the carrier is. Building
190
+ a stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
191
+ where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`. If you want
192
+ Ruby's fastest civil parse and don't need the verdict or the declared order, `strptime` is
193
+ as good. Also note the carrier's other caveat — `DateTime`'s offset defaults to `+00:00`,
194
+ which is an artifact of the type, not a zone the parse assigned.
195
+
196
+ On the Fiddle fallback the doors are parity-at-best — Fiddle's
197
+ per-call floor is the mechanism's price, kept because it's the universal zero-compile
198
+ path. Its doors build nothing per call that does not change between calls: each format
199
+ owns one native pointer, memoized by identity and passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
200
+ per-call `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch —
201
+ receipts include their own archaeology.)
202
+
203
+ ## Benchmarks
204
+
205
+ `ruby benchmark/cast_benchmark.rb` pairs each door with Ruby's closest stdlib parse. Its
206
+ first line names the backend, core version and Ruby it measured, so run it again under
207
+ `HYPERCAST_PURE=1` for the other backend. The stdlib comparisons are in
208
+ [the section above](#why-not-integer--timeiso8601--float); this is the two backends against
209
+ each other.
210
+
211
+ Measured on Ruby 4.0.7, linux-x64 (an Intel Core i9-11900H), `benchmark-ips`, same session,
212
+ both backends:
213
+
214
+ | Door | Magnus | Fiddle |
215
+ |---|---:|---:|
216
+ | `bool` | 112 ns | 2.35 µs |
217
+ | `i32` | 133 ns | 2.63 µs |
218
+ | `f64` | 166 ns | 2.70 µs |
219
+ | `uuid` | 223 ns | 3.47 µs |
220
+ | `timestamp` | 447 ns | 3.25 µs |
221
+ | `datetime` (`1/7/2026 3:04 PM`) | 904 ns | 3.98 µs |
222
+ | `duration` (ISO) | 632 ns | 2.91 µs |
223
+ | `i32`, a fault | 225 ns | 3.18 µs |
224
+
225
+ A lean door on the Magnus backend is little more than the native call: the extension builds
226
+ the `Success` or `Fault` it returns directly — allocated, its members stored, frozen —
227
+ rather than through `Data.new`, whose keyword handling alone cost more than the cast.
228
+
229
+ ## Backends
230
+
231
+ | `HyperCast::BACKEND` | What runs | Chosen when |
232
+ |---|---|---|
233
+ | `:native` | the core linked into a Magnus extension | a precompiled platform gem is installed — every one carries an extension for each Ruby it installs on; see [Install](#install) |
234
+ | `:fiddle` | `libhypercast` for this platform, `dlopen`ed through Fiddle | no extension loads: the universal gem, on a Ruby or platform no platform gem covers; also the answer when nothing loads at all |
235
+
236
+ Selection happens once, at `require`, in that order.
237
+
238
+ `HYPERCAST_PURE` forces `:fiddle`, even where an extension would load. It is a testing and
239
+ diagnostic switch — CI runs the whole suite through it, and it is how to rule an extension
240
+ problem in or out — not a setting a deployment needs. It is read for presence, not value: set
241
+ to anything at all, `0` and the empty string included, it forces Fiddle. A forced backend that
242
+ turns out to have nothing to load does not fall through to another one: the first call raises,
243
+ and `HyperCast.available?` answers `false`. That is what happens inside a platform gem, which
244
+ carries no Fiddle library: the `LoadError` names the universal gem
245
+ (`gem install hypercast --platform ruby`, or Bundler's `force_ruby_platform`), where the
246
+ Fiddle backend lives.
247
+
248
+ **Threads.** Every backend is safe to call from any number of threads. The extension runs
249
+ under the GVL. Fiddle releases the GVL for the duration of each call, so that is the one
250
+ backend where Ruby threads run the core truly in parallel — each through its own scratch
251
+ buffers; the core itself keeps no state between calls. `spec/cast_spec.rb`'s
252
+ concurrent-callers example runs under both.
253
+
254
+ **Ractors.** Main Ractor only, on every backend: called from another Ractor the doors raise
255
+ `Ractor::UnsafeError`.
256
+
257
+ ## Verifying provenance
258
+
259
+ Every gem RubyGems.org serves — the universal fallback and each of the seven precompiled
260
+ platform gems — carries its own GitHub build-provenance attestation, signed directly by
261
+ this repo's own `release.yml` (the `rubygems-publish` job attests `ruby/pkg/*.gem` right
262
+ before the push), so plain `--repo` verifies any of them:
263
+
264
+ ```sh
265
+ gem fetch hypercast -v X.Y.Z --platform <platform> # or omit --platform for the universal gem
266
+ gh attestation verify hypercast-X.Y.Z-<platform>.gem --repo SkunkWerkx/HyperCast
267
+ ```
268
+
269
+ That's the release's second layer of checking, not the only one: before any gem gets built,
270
+ the same job verifies every native binary it packs — the Fiddle libraries, the Magnus
271
+ extensions (one per Ruby ABI per platform) — against *their own*
272
+ attestations — those are signed from `SkunkWerkx/.github` by `hyper-build-native.yml`, so
273
+ that check needs `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an
274
+ unverified one.
275
+ RubyGems.org has no unpublish and no duplicate-version overwrite, so this all happens while a
276
+ bad artifact is still reversible. The release run's job summary then re-fetches every gem
277
+ from the CDN and records attested-vs-served digests, turning "rubygems.org stores an upload
278
+ verbatim" into a per-release measurement rather than an assumption — see
279
+ [csharp/README.md's provenance section](../csharp/README.md#native-binary-provenance) for
280
+ more on why `--signer-repo` is needed for some artifacts here and not others.
281
+
282
+ ## Install
283
+
284
+ ```sh
285
+ gem install hypercast
286
+ ```
287
+
288
+ Eight gems are published per release: seven precompiled Magnus platform gems that
289
+ `gem install` and `bundle` auto-select when they match — `x86_64-linux-gnu`,
290
+ `aarch64-linux-gnu`, `x86_64-linux-musl`, `aarch64-linux-musl`, `arm64-darwin`,
291
+ `x64-mingw-ucrt` and `aarch64-mingw-ucrt` — and one universal `ruby`-platform gem. A platform
292
+ gem carries its Magnus extensions and nothing else native: no Fiddle library at all. The
293
+ universal gem is the last resort, Fiddle with every platform's native library bundled, and it
294
+ is what RubyGems resolves for a Ruby the platform gems do not cover (3.3, or a Ruby newer than
295
+ the release, such as 4.1 before a release ships for it) and on a platform no platform gem is
296
+ built for — Intel macOS among them, which runs on Fiddle. No extra configuration needed either
297
+ way.
298
+
299
+ Selection has **two** axes here, unlike every other binding in this repo. A Magnus extension
300
+ is bound to one Ruby minor ABI — there is no `abi3` equivalent to collapse the version axis
301
+ the way [the Python binding's](../python/) wheels do — so each platform gem is a "fat" gem
302
+ carrying one compiled extension per supported Ruby, under `lib/hypercast/<minor>/`, and picks
303
+ one at `require` time:
304
+
305
+ | Ruby | Linux (glibc and musl), Apple silicon macOS, Windows — x64 and arm64 | Gem installed |
306
+ | --- | --- | --- |
307
+ | a newer Ruby than the release covers (4.1+) | Fiddle | universal |
308
+ | 4.0 (primary) | Magnus, `BACKEND == :native` | platform |
309
+ | 3.4 (until its EOL 2028-03-31) | Magnus, `BACKEND == :native` | platform |
310
+ | 3.3 (the floor, until its EOL 2027-03-31) | Fiddle | universal |
311
+
312
+ What stands behind each cell: CI replays the whole suite, shared corpus included, through
313
+ each Magnus extension on every push — Ruby 3.4 and 4.0 on all seven platform-gem platforms,
314
+ the two musl ones inside each Ruby's own Alpine image — and the Fiddle suite on Ruby 4.0 on
315
+ every one of them, plus on Ruby 3.3 inside Alpine. Intel macOS has no CI leg: its library is
316
+ cross-built and tested at the core, and Ruby there runs the universal gem's Fiddle backend
317
+ over it. Anywhere else — a platform with no native build at all — the universal gem installs
318
+ but `HyperCast.available?` answers `false`, and the first call raises
319
+ `HyperCast::NativePlatform::UnsupportedPlatformError`.
320
+
321
+ The platform gems declare `required_ruby_version >= 3.4, < 4.1` precisely so RubyGems
322
+ *declines* them outside that range and resolves the universal gem instead — a wrong-ABI
323
+ extension must never be installed in the first place. On Windows it would at least fail to
324
+ load cleanly (the extension imports `<arch>-ucrt-ruby<minor>.dll` by name —
325
+ `x64-ucrt-ruby400.dll` on x64, `aarch64-ucrt-ruby400.dll` on ARM), but Linux extensions don't
326
+ link libruby at all, so one can load successfully against the wrong ABI and misbehave later.
327
+ When 3.4 goes EOL it simply leaves the matrix and its users fall back to Fiddle, which is
328
+ exactly what the fallback is for.
329
+
330
+ **musl.** Alpine has platform gems of its own, `x86_64-linux-musl` and
331
+ `aarch64-linux-musl`, whose extensions are built inside each Ruby's official `ruby:*-alpine`
332
+ image and need nothing beyond musl's libc. The glibc gems name their libc too
333
+ (`x86_64-linux-gnu`, `aarch64-linux-gnu`), which is what makes both `gem install` and
334
+ Bundler pick the right one on every supported Ruby: next to a plain `x86_64-linux` gem,
335
+ RubyGems before 4.0 resolves that one on Alpine instead, even under
336
+ `--platform x86_64-linux-musl`.
337
+
338
+ **Nothing in this gem is ever compiled, on any platform.** The platform gems depend on nothing
339
+ at all. The universal gem depends on `fiddle`, which can be: `fiddle` is a bundled gem on Ruby
340
+ 4.0 and a default gem on 3.3 and 3.4, and `gem install` is satisfied by the copy Ruby ships.
341
+ Bundler resolves the newest `fiddle` on rubygems.org instead, and when that is newer than the
342
+ one your Ruby ships — true today on 3.3 and 3.4, not on 4.0 — it builds Fiddle's own C
343
+ extension, which takes a compiler and libffi's headers (`apk add build-base libffi-dev` on
344
+ Alpine). That only reaches you where the universal gem installs: Ruby 3.3, Intel macOS, a
345
+ Ruby newer than the release, or a platform with no platform gem. `bundle install
346
+ --prefer-local` makes Bundler use the copy Ruby ships where it can (it did on Ruby 3.4's
347
+ Bundler 2.6, not on 3.3's 2.5); otherwise install the compiler and headers. Pinning `fiddle`
348
+ to your Ruby's version in the `Gemfile.lock` does not stop the build.
349
+
350
+ Both Windows architectures get a Magnus gem, and the reasoning that once kept them on Fiddle
351
+ was backwards: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
352
+ `aarch64-mingw-ucrt`, both `supported: true` in its own `data/toolchains.json`); the one it
353
+ has no support for is MSVC. Windows is also where the Fiddle fallback cost the most, so these
354
+ are the most worthwhile gems in the set. Both extensions are built for the `gnullvm` Rust
355
+ targets rather than `gnu` — the same mingw-w64/UCRT ABI RubyInstaller's Ruby uses, linked
356
+ with LLVM and compiler-rt instead of GCC and a statically-linked libgcc, which is what keeps
357
+ the shipped extension small. The build script, and the two flags that are load-bearing on
358
+ the ARM leg (a static libunwind, and a clang-spelled `--target` for bindgen), live in the
359
+ forge's `ruby-magnus` action (`build-magnus.sh`), shared with every other Hyper* repo.
360
+
361
+ ## Development
362
+
363
+ Everything below runs from a checkout, with `rust/` and `corpus/` beside `ruby/`; none of it
364
+ is needed to use the gem. The Fiddle backend finds the in-repo build on its own when nothing
365
+ is staged under `lib/hypercast/native/`. The Magnus backend does not — an
366
+ extension has to be built for the Ruby you are running and put where `require` looks — and
367
+ that is what `rake native:dev` is for.
368
+
369
+ ```sh
370
+ cd rust
371
+ cargo cdylib # libhypercast, what the Fiddle backend loads
372
+
373
+ cd ../ruby
374
+ bundle install
375
+ bundle exec rake native:dev # build the Magnus extension for this Ruby and stage it
376
+ bundle exec rspec # BACKEND == :native
377
+ HYPERCAST_PURE=1 bundle exec rspec # BACKEND == :fiddle
378
+ bundle exec rake docs:check # every public object carries a doc comment
379
+ ruby benchmark/cast_benchmark.rb # prints the backend it measured
380
+ ```
381
+
382
+ `rake native:dev` runs `cargo ruby-ext` (an alias in `rust/.cargo/config.toml` that builds the
383
+ `ruby` feature into `rust/target/ruby/`, never over the plain library in
384
+ `rust/target/release/`) and copies the result to
385
+ `lib/hypercast/<minor>/hypercast_native.<so|bundle>` — the path `lib/hypercast.rb` tries
386
+ first. The copy is also a rename, and it is the step `cargo ruby-ext` alone does not do: cargo
387
+ names its output `libhypercast.so`, and Ruby derives the `Init_` function it calls from the
388
+ file name it was asked to `require`. Three things worth knowing:
389
+
390
+ - **Run it again after changing `rust/`.** The staged file is a copy; nothing rebuilds it.
391
+ - **One staging per Ruby.** An extension is bound to one Ruby minor, so under a second Ruby
392
+ (`RBENV_VERSION=3.4.11 bundle exec rake native:dev`, say) the task rebuilds for that ABI
393
+ and stages beside the first, each Ruby loading its own.
394
+ - **Without it, `bundle exec rspec` still passes — on Fiddle.** The examples under "native
395
+ backend" report themselves pending with `BACKEND=fiddle`; that line, or
396
+ `ruby -Ilib -rhypercast -e 'p HyperCast::BACKEND'`, is how to tell which backend a run
397
+ exercised. Deleting `lib/hypercast/<minor>/` goes back to Fiddle.
398
+
399
+ The task covers Linux and macOS. The Windows extensions need the `gnullvm` targets and linker
400
+ flags in the forge's `build-magnus.sh`, which is also what CI uses on every platform.
401
+
402
+ The musl build has no host to run on outside a container. With the core built for musl at
403
+ `rust/target/musl/linux-musl-x64/libhypercast.so`, this runs the Fiddle suite — corpus replay
404
+ included — against it on Alpine, with the checkout mounted read-only:
405
+
406
+ ```sh
407
+ docker run --rm -v "$PWD/..":/src:ro ruby:4.0-alpine sh -euc '
408
+ mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
409
+ mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
410
+ cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
411
+ cd /work/ruby && rm -f Gemfile.lock
412
+ bundle install --quiet --prefer-local
413
+ HYPERCAST_PURE=1 bundle exec rspec'
414
+ ```
415
+
416
+ The floor's test is the same container on `ruby:3.3-alpine`. Bundler would compile a newer
417
+ Fiddle there (see [Install](#install)), so this one goes around Bundler and runs against the
418
+ Fiddle that Ruby 3.3 ships:
419
+
420
+ ```sh
421
+ docker run --rm -v "$PWD/..":/src:ro ruby:3.3-alpine sh -euc '
422
+ mkdir /work && cp -r /src/ruby /work/ruby && cp -r /src/corpus /work/corpus
423
+ mkdir -p /work/ruby/lib/hypercast/native/linux-musl-x64
424
+ cp /src/rust/target/musl/linux-musl-x64/libhypercast.so /work/ruby/lib/hypercast/native/linux-musl-x64/
425
+ cd /work/ruby && rm -f Gemfile Gemfile.lock
426
+ gem install rspec -v "~> 3.13" --no-document --silent
427
+ HYPERCAST_PURE=1 rspec'
428
+ ```
429
+
430
+ The musl Magnus extension is built the way CI builds it by the forge's
431
+ `ruby-magnus-musl/build-magnus-musl.sh`, which runs on any machine with Docker: from the repo
432
+ root, `build-magnus-musl.sh hypercast linux-musl-x64 4.0 . <dir with the musl libhypercast.so> <out-dir>`
433
+ compiles it in `ruby:4.0-alpine`, then runs this suite through it on a bare copy of that image.
434
+
435
+ See [the repo root README](../README.md) for the full door table, the receipts, and the
436
+ state of every other language binding.
437
+
438
+ ## License
439
+
440
+ [MIT](https://github.com/SkunkWerkx/HyperCast/blob/master/LICENSE)
@@ -0,0 +1,28 @@
1
+ module HyperCast
2
+ # Maps the running RUBY_PLATFORM to the RID-style directory (matching the other bindings'
3
+ # runtimes/{rid}/native/ / native/{rid}/ convention) and native library filename to load.
4
+ module NativePlatform
5
+ class UnsupportedPlatformError < StandardError; end
6
+
7
+ # +platform+ is a parameter only so the specs can walk the whole table from one host;
8
+ # every real caller takes the default.
9
+ def self.rid_and_library_name(platform = RUBY_PLATFORM)
10
+ arch = platform.match?(/arm64|aarch64/) ? "arm64" : "x64"
11
+
12
+ case platform
13
+ when /mingw|mswin|windows/
14
+ ["win-#{arch}", "hypercast.dll"]
15
+ when /darwin/
16
+ ["osx-#{arch}", "libhypercast.dylib"]
17
+ when /linux/
18
+ # Two C libraries, two builds: a glibc-linked library cannot be relied on to dlopen
19
+ # into a musl process (Alpine), so musl gets RIDs of its own. Ruby names the libc
20
+ # in its platform string there ("x86_64-linux-musl") and leaves it off on glibc.
21
+ os = platform.include?("musl") ? "linux-musl" : "linux"
22
+ ["#{os}-#{arch}", "libhypercast.so"]
23
+ else
24
+ raise UnsupportedPlatformError, "hypercast: unsupported platform RUBY_PLATFORM=#{platform}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,128 @@
1
+ # Autoloaded, not required: the platform gems carry no library for Fiddle to open and do not
2
+ # declare fiddle, so loading it eagerly would make `require "hypercast"` fail under Bundler
3
+ # wherever fiddle is a bundled rather than a default gem (Ruby 4.0). It loads the first time
4
+ # the Fiddle backend runs, and every path there goes through `functions` first, so a platform
5
+ # gem forced onto Fiddle still gets missing_library_message rather than a bare LoadError.
6
+ autoload :Fiddle, "fiddle"
7
+
8
+ module HyperCast
9
+ # Fiddle plumbing for the native libhypercast shared library — dlopen/dlsym plus raw
10
+ # C-ABI calls, no runtime bridge. Fiddle ships with every Ruby install; it's a plain gem
11
+ # dependency of the universal gem (see hypercast.gemspec) rather than a third-party one.
12
+ # A Ruby gem's files are already plain files on disk once installed, so native/{rid}/{lib}
13
+ # dlopen's directly — no extraction.
14
+ module Runtime
15
+ NATIVE_DIR = File.join(__dir__, "native")
16
+
17
+ # Each door's C signature, by shape — resolved to Fiddle types only in load_functions,
18
+ # so nothing here touches Fiddle until the Fiddle backend actually runs: plain is
19
+ # (text, len, out, fault), numeric adds the packed NumFormat pointer before out, and
20
+ # declared a caller-declared u32 (precision, epoch, field order) in the same place.
21
+ DOORS = {
22
+ cast_bool: :plain,
23
+ cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
24
+ cast_u8: :numeric, cast_u16: :numeric, cast_u32: :numeric, cast_u64: :numeric,
25
+ cast_f32: :numeric, cast_f64: :numeric, cast_decimal: :numeric,
26
+ cast_uuid: :plain,
27
+ cast_timestamp: :plain, cast_unix: :declared, cast_excel_serial: :declared,
28
+ cast_date: :plain, cast_date_ordered: :declared, cast_datetime: :declared,
29
+ cast_time: :plain, cast_duration: :plain
30
+ }.freeze
31
+
32
+ # The one export that is not a door: the zero-argument version probe, returning the
33
+ # core's packed version word (major << 16 | minor << 8 | patch) rather than a verdict
34
+ # code — so it gets its own Fiddle signature beside DOORS instead of a row in it.
35
+ VERSION_PROBE = :hypercast_version
36
+
37
+ @mutex = Mutex.new
38
+ @functions = nil
39
+
40
+ class << self
41
+ # The door's (or VERSION_PROBE's) Fiddle::Function, for the caller to invoke directly
42
+ # — a splat-through `call(symbol, *args)` here built and re-splatted an Array on
43
+ # every cast.
44
+ def function(symbol)
45
+ functions.fetch(symbol)
46
+ end
47
+
48
+ # Every Fiddle allocation goes through here, and loads the library first: that is what
49
+ # keeps a missing library reported as missing_library_message instead of as whatever
50
+ # touching Fiddle raises first. The per-thread scratch buffer and each packed NumFormat
51
+ # (hypercast.rb) pay for it once apiece.
52
+ def buffer(size)
53
+ functions
54
+ Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
55
+ end
56
+
57
+ private
58
+
59
+ # The shared library to dlopen: this install's native/{rid}/{lib}, or — the
60
+ # development loop — the in-repo cargo build, exactly what the other bindings' local
61
+ # staging does. Nil when neither exists.
62
+ def library_path
63
+ rid, lib_name = NativePlatform.rid_and_library_name
64
+ path = File.join(NATIVE_DIR, rid, lib_name)
65
+ return path if File.exist?(path)
66
+
67
+ repo_build = File.expand_path(File.join(__dir__, "../../../rust/target/release", lib_name))
68
+ File.exist?(repo_build) ? repo_build : nil
69
+ end
70
+
71
+ # Why Fiddle found nothing to load. A precompiled platform gem is the one install where
72
+ # that is by design rather than a gap: it carries only its Magnus extensions, and the
73
+ # Fiddle backend is reached there only by forcing it (HYPERCAST_PURE) or because none
74
+ # of its extensions loaded — a gem RubyGems matched to a Ruby it was not built for,
75
+ # such as the glibc Linux gem that `gem install` on RubyGems 3.x picks on Alpine. So
76
+ # that case names its fix, the universal gem, which carries every platform's library,
77
+ # instead of a missing path that reads like a packaging bug. Both arguments are
78
+ # parameters only so the specs can ask for every wording.
79
+ def missing_library_message(gem_platform = Gem.loaded_specs["hypercast"]&.platform,
80
+ forced = ENV.key?("HYPERCAST_PURE"))
81
+ rid, lib_name = NativePlatform.rid_and_library_name
82
+ missing = File.join(NATIVE_DIR, rid, lib_name)
83
+ if gem_platform && gem_platform.to_s != Gem::Platform::RUBY
84
+ reason =
85
+ if forced
86
+ "HYPERCAST_PURE forces the Fiddle backend (unset it to use the extension)"
87
+ else
88
+ "none of its extensions loads on this Ruby (#{RUBY_VERSION}, #{RUBY_PLATFORM})"
89
+ end
90
+ "hypercast: this #{gem_platform} platform gem carries only Magnus extensions, no " \
91
+ "Fiddle library, and #{reason}. The universal gem has the Fiddle backend for every " \
92
+ "platform: `gem install hypercast --platform ruby`, or Bundler's force_ruby_platform " \
93
+ "(#{missing} not found)"
94
+ else
95
+ "hypercast: #{missing} not found (unsupported platform, or this gem was built " \
96
+ "without a native library for it)"
97
+ end
98
+ end
99
+
100
+ # Loaded lazily and exactly once; the native library and its function pointers live
101
+ # for the process's lifetime, same as every other binding (never dlclose'd). The
102
+ # unsynchronized read is the fast path — a per-call mutex acquisition measured as a
103
+ # real slice of the door cost; the benign race re-checks under the lock.
104
+ def functions
105
+ @functions || @mutex.synchronize { @functions ||= load_functions }
106
+ end
107
+
108
+ def load_functions
109
+ path = library_path
110
+ raise LoadError, missing_library_message if path.nil?
111
+
112
+ handle = Fiddle.dlopen(path)
113
+ plain = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
114
+ signatures = {
115
+ plain: plain,
116
+ numeric: plain.dup.insert(2, Fiddle::TYPE_VOIDP),
117
+ declared: plain.dup.insert(2, Fiddle::TYPE_UINT32_T)
118
+ }
119
+ functions = DOORS.to_h do |name, shape|
120
+ [name, Fiddle::Function.new(handle[name.to_s], signatures.fetch(shape), Fiddle::TYPE_INT32_T)]
121
+ end
122
+ functions[VERSION_PROBE] =
123
+ Fiddle::Function.new(handle[VERSION_PROBE.to_s], [], Fiddle::TYPE_UINT32_T)
124
+ functions
125
+ end
126
+ end
127
+ end
128
+ end
data/lib/hypercast.rb ADDED
@@ -0,0 +1,538 @@
1
+ require "date"
2
+ require_relative "hypercast/native_platform"
3
+ require_relative "hypercast/runtime"
4
+
5
+ # Allocation-lean scalar casts — booleans, numerics, exact decimals, UUIDs, temporals —
6
+ # from one Rust core, reached through whichever of two backends this install can load:
7
+ # the core linked straight into a Magnus native extension (shipped precompiled in the
8
+ # platform gems), or the native libhypercast shared library called through Fiddle (the
9
+ # universal gem bundles one per supported platform). HyperCast::BACKEND names the one that
10
+ # loaded; the selection logic is at the bottom of this file. Every door returns a verdict: Success or
11
+ # Fault (a closed reason plus the offending span), never an exception for bad data — the
12
+ # only exceptions here are caller bugs (a malformed NumFormat, an undeclared option, text
13
+ # that is not a String), never data, and the same exception on every backend.
14
+ #
15
+ # Consume with Ruby's own pattern matching over the two Data case types:
16
+ #
17
+ # case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
18
+ # in HyperCast::Success(value:) then puts "got #{value}" # -1234
19
+ # in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
20
+ # end
21
+ #
22
+ # Door names mirror the native ABI (i32, f64, timestamp, ...) so the polyglot surface reads
23
+ # identically across bindings. Ruby-flavored fidelity: Integer is unbounded (u64 comes back
24
+ # as the true unsigned value), Time carries full nanoseconds across the whole 0001-9999
25
+ # window, time-of-day is an exact Integer of nanoseconds since midnight, durations come
26
+ # back as exact Rational seconds, and the decimal door returns an exact Decimal (sign,
27
+ # 96-bit magnitude, base-10 scale) that never rounds — no truncation anywhere, and no
28
+ # wrapping: Ruby and the JVM are the fidelity kings of this roster.
29
+ module HyperCast
30
+ # This gem's own version — kept in lockstep with hypercast.gemspec by the
31
+ # prepare-release workflow, so the two can never drift apart again.
32
+ VERSION = "0.6.0"
33
+
34
+ # The success case of a verdict: a cast value.
35
+ Success = Data.define(:value)
36
+
37
+ # The failure case: a closed reason Symbol (:empty, :malformed, :out_of_range) plus the
38
+ # offending span, in the units String#[] slices by on the text you passed: character
39
+ # offsets for text (in any encoding — the core reads UTF-8 and the span is mapped back),
40
+ # byte offsets for a binary (ASCII-8BIT) String, whose characters are its bytes. Either
41
+ # way `text[offset, length]` is the offending text, with no mapping on your side.
42
+ # Nothing is captured — slicing it out of the input is the caller's choice.
43
+ Fault = Data.define(:reason, :offset, :length)
44
+
45
+ # The native core's failure codes, mapped to the closed reason Symbols a Fault carries.
46
+ REASONS = { 1 => :empty, 2 => :malformed, 3 => :out_of_range }.freeze
47
+
48
+ # Caller-declared numeric notation for the integer, real and decimal doors — declared out
49
+ # loud (INVARIANT, or a literal), never defaulted, the same stance every binding takes.
50
+ # The currency symbol is the one field a culture table would fill in: it is declared
51
+ # here, never looked up, and honored only while the CURRENCY flag is set — declared with
52
+ # the flag off, the symbol is a :malformed Fault at the symbol, and the flag with no
53
+ # symbol ("" — the default) matches nothing. Equal separators, or a symbol longer than
54
+ # 16 UTF-8 bytes or carrying an ASCII digit or ASCII whitespace, are a caller bug
55
+ # (ArgumentError), never a verdict.
56
+ NumFormat = Data.define(:decimal_sep, :group_sep, :flags, :currency) do
57
+ # Validates the declared separators up front — single characters, and distinct from
58
+ # each other — and the currency symbol (a String of at most 16 UTF-8 bytes with no
59
+ # ASCII digit or ASCII whitespace, since those would collide with the digit scan and
60
+ # the trimming around the symbol), so a malformed format fails loudly as the caller bug
61
+ # it is. All three are stored transcoded to UTF-8, the encoding the core reads — a
62
+ # separator handed over in another encoding (a no-break space read from a Latin-1
63
+ # file, say) is the same code point on every backend rather than whatever its bytes
64
+ # happened to spell in the encoding it arrived in.
65
+ def initialize(decimal_sep:, group_sep:, flags:, currency: "")
66
+ raise ArgumentError, "separators must be single characters" unless
67
+ decimal_sep.is_a?(String) && decimal_sep.length == 1 &&
68
+ group_sep.is_a?(String) && group_sep.length == 1
69
+
70
+ decimal_sep = decimal_sep.encode(Encoding::UTF_8)
71
+ group_sep = group_sep.encode(Encoding::UTF_8)
72
+ raise ArgumentError, "decimal and group separators must differ; both are #{decimal_sep.inspect}" if
73
+ decimal_sep == group_sep
74
+ raise ArgumentError, "currency symbol must be a String; got #{currency.inspect}" unless currency.is_a?(String)
75
+
76
+ symbol = currency.encode(Encoding::UTF_8)
77
+ limit = self.class::CURRENCY_MAX_BYTES
78
+ raise ArgumentError, "currency symbol #{currency.inspect} exceeds #{limit} UTF-8 bytes" if
79
+ symbol.bytesize > limit
80
+ raise ArgumentError, "currency symbol #{currency.inspect} must not contain an ASCII digit or whitespace" if
81
+ symbol.match?(/[0-9\t\n\f\r ]/)
82
+
83
+ super(decimal_sep: decimal_sep, group_sep: group_sep, flags: flags, currency: symbol)
84
+ end
85
+
86
+ # The 32-byte little-endian form the native ABI's NumFormat struct expects: the two
87
+ # separators as code points, the flags, the symbol's byte length, then the symbol's
88
+ # UTF-8 bytes zero-padded to 16.
89
+ def packed
90
+ [decimal_sep.ord, group_sep.ord, flags, currency.bytesize].pack("L<L<L<L<") + [currency].pack("a16")
91
+ end
92
+ end
93
+
94
+ # The widest currency symbol the native ABI carries inline, in UTF-8 bytes. Assigned out
95
+ # here rather than inside the Data.define block above: a constant written in that block
96
+ # is scoped lexically, and landed on HyperCast instead of on NumFormat.
97
+ NumFormat::CURRENCY_MAX_BYTES = 16
98
+
99
+ # Permit the group separator between digits (sizes not validated — between digits is the rule).
100
+ GROUPING = 1
101
+ # Permit accounting parentheses as negation: (1,234) is -1234.
102
+ PARENTHESES = 1 << 1
103
+ # Permit exponent notation. Integer doors reject a negative exponent.
104
+ EXPONENT = 1 << 2
105
+ # Permit 0x/&H/0b two's-complement radix prefixes (0xFF is -1 for an i8).
106
+ RADIX_PREFIXES = 1 << 3
107
+ # Permit a trailing %, dividing by 100. Real and decimal doors only.
108
+ PERCENT = 1 << 4
109
+ # Resolve the ./, roles per input from structure instead of the declared separators
110
+ # (which are ignored while this flag is set). Detection, not sniffing: a repeated
111
+ # separator is grouping ("1.234.567,89"); with both present the rightmost is the
112
+ # decimal; a single separator with a non-3-digit right run is the decimal ("3,1415");
113
+ # with exactly 3 digits right, only a 0 integer part proves decimal ("0,785").
114
+ # Genuinely ambiguous input ("12.185", "1,000") is a :malformed Fault at the separator,
115
+ # never guessed.
116
+ SEPARATOR_DETECT = 1 << 5
117
+ # Permit the format's declared currency symbol at either edge of the numeric body —
118
+ # leading ("$5", "-$5", "$ -5") or trailing ("5 €", "1.234,50 kr."), once, with optional
119
+ # ASCII whitespace between symbol and digits; accounting parentheses wrap the symbol
120
+ # along with the digits ("($5)"). With no symbol declared the flag matches nothing.
121
+ # Integer, real and decimal doors.
122
+ CURRENCY = 1 << 6
123
+ # Every lenience on (SEPARATOR_DETECT is a separator policy, not a lenience, and is
124
+ # deliberately not included).
125
+ ALL_STYLES = GROUPING | PARENTHESES | EXPONENT | RADIX_PREFIXES | PERCENT | CURRENCY
126
+
127
+ # The invariant profile — '.' decimal, ',' grouping, every lenience on, no currency
128
+ # symbol declared.
129
+ NumFormat::INVARIANT = NumFormat.new(decimal_sep: ".", group_sep: ",", flags: ALL_STYLES)
130
+
131
+ # The detection profile — every lenience on, ./, roles resolved per input by
132
+ # SEPARATOR_DETECT's structural rules.
133
+ NumFormat::DETECT = NumFormat.new(decimal_sep: ".", group_sep: ",",
134
+ flags: ALL_STYLES | SEPARATOR_DETECT)
135
+
136
+ # The declared unit of a Unix-epoch value — no magnitude guessing, ever.
137
+ UNIX_PRECISIONS = { seconds: 1, milliseconds: 2, microseconds: 3, nanoseconds: 4 }.freeze
138
+
139
+ # The date system an Excel serial number is expressed in. Spreadsheets carry no marker
140
+ # for this — it is a workbook-level setting — so the caller states it, the same way
141
+ # UNIX_PRECISIONS and DATE_ORDERS are declared rather than guessed.
142
+ EXCEL_EPOCHS = { y1900: 1, y1904: 2 }.freeze
143
+
144
+ # The declared field order of a separated calendar date — no guessing, ever: "1/7/2026"
145
+ # is January 7th (:month_day_year, the en-US order) or July 1st (:day_month_year, the
146
+ # en-GB order) only because the caller said which.
147
+ DATE_ORDERS = { year_month_day: 1, month_day_year: 2, day_month_year: 3 }.freeze
148
+
149
+ # An exact decimal, the decimal door's value: an unbounded Integer magnitude (the core's
150
+ # 96-bit unsigned, so at most 2**96 - 1), an Integer base-10 scale (0..28 — the number of
151
+ # places the magnitude is shifted right), and a negative flag. The value is
152
+ # (negative ? -1 : 1) * magnitude * 10**-scale. The triple is canonical: exact trailing
153
+ # zeros in the fraction are always trimmed, so the scale is minimal ("1.10", "1.1" and
154
+ # "1.1000" are all magnitude 11, scale 1; "100" stays magnitude 100, scale 0), and zero
155
+ # is scale 0 and never negative. Nothing but a zero is ever dropped — text carrying more
156
+ # precision than the core holds is an :out_of_range Fault, never an approximation.
157
+ # Convert with to_r (exact Rational), to_s (the canonical text), or to_d (BigDecimal).
158
+ Decimal = Data.define(:magnitude, :scale, :negative) do
159
+ # True for a negative value; zero is never negative.
160
+ def negative?
161
+ negative
162
+ end
163
+
164
+ # The value as an exact Rational.
165
+ def to_r
166
+ value = Rational(magnitude, 10**scale)
167
+ negative ? -value : value
168
+ end
169
+
170
+ # The canonical text form — "-1234.5", "-0.025", "0": the minimal scale rendered
171
+ # plainly, no exponent — the same string every binding renders and the conformance
172
+ # corpus pins as `value`.
173
+ def to_s
174
+ digits = magnitude.to_s.rjust(scale + 1, "0")
175
+ text = scale.zero? ? digits : "#{digits[0, digits.length - scale]}.#{digits[-scale, scale]}"
176
+ negative ? "-#{text}" : text
177
+ end
178
+
179
+ # The value as a BigDecimal, built exactly from the canonical text. Requires the
180
+ # bigdecimal gem (a bundled gem since Ruby 3.4, not a dependency of this one — add it
181
+ # to your Gemfile under Bundler), loaded lazily on first use so a consumer who never
182
+ # calls this pays nothing for it.
183
+ def to_d
184
+ require "bigdecimal"
185
+ BigDecimal(to_s)
186
+ end
187
+ end
188
+
189
+ class << self
190
+ # Presents a verdict optionally: an :empty fault becomes nil (Ruby's absent),
191
+ # everything else flows through untouched.
192
+ def optional(verdict)
193
+ return nil if verdict in Fault(reason: :empty)
194
+
195
+ verdict
196
+ end
197
+
198
+ # Whether a backend actually loaded and exports the ABI this binding was built against
199
+ # — what a consumer with a fallback of its own checks before committing to these doors.
200
+ # Probed once (a native_version round trip: the cheapest call the core has), cached,
201
+ # and never raises: a missing shared library, an unsupported platform, or an older core
202
+ # without the version export all answer false. The doors themselves keep their own
203
+ # behavior — the first call on an unavailable backend raises its precise LoadError —
204
+ # this only answers the question quietly.
205
+ def available?
206
+ return @available unless @available.nil?
207
+
208
+ @available = begin
209
+ native_version.is_a?(String)
210
+ rescue LoadError, StandardError
211
+ false
212
+ end
213
+ end
214
+
215
+ # Casts boolean text: true/false plus the conventions untrusted sources actually send
216
+ # (t/f, yes/no, y/n, 1/0, on/off, enabled/disabled, active/inactive,
217
+ # checked/unchecked, in/out), ASCII case-insensitive.
218
+ def bool(text)
219
+ plain(:cast_bool, text, 1) { |out| out.unpack1("C") != 0 }
220
+ end
221
+
222
+ { i8: "c", i16: "s<", i32: "l<", i64: "q<", u8: "C", u16: "S<", u32: "L<", u64: "Q<" }
223
+ .each do |door, unpack|
224
+ sizes = { "c" => 1, "C" => 1, "s<" => 2, "S<" => 2, "l<" => 4, "L<" => 4, "q<" => 8, "Q<" => 8 }
225
+ size = sizes.fetch(unpack)
226
+ # Resolved once here, not inside the method: interpolating a Symbol per call built a
227
+ # String and interned it on every integer cast.
228
+ symbol = :"cast_#{door}"
229
+ # Integer doors: the target type's own range, declared grouping, accounting parens,
230
+ # non-negative exponent, and 0x/&H/0b two's-complement radix prefixes. Ruby Integer
231
+ # is unbounded, so u64 comes back as the true unsigned value.
232
+ define_method(door) do |text, format|
233
+ numeric(symbol, text, format, size) { |out| out.unpack1(unpack) }
234
+ end
235
+ end
236
+
237
+ # Casts real text to an IEEE single (widened losslessly on the way out): finite values
238
+ # only, declared separators and grouping, parens, exponent, and trailing percent.
239
+ def f32(text, format)
240
+ numeric(:cast_f32, text, format, 4) { |out| out.unpack1("e") }
241
+ end
242
+
243
+ # Casts real text to an IEEE double. Notation rules as f32.
244
+ def f64(text, format)
245
+ numeric(:cast_f64, text, format, 8) { |out| out.unpack1("E") }
246
+ end
247
+
248
+ # Casts decimal text to an exact Decimal — the real doors' grammar (declared separators
249
+ # and grouping, parens, exponent, trailing percent, declared currency), but no float is
250
+ # ever formed: "0.1" is one tenth, "50%" is exactly 0.5 (magnitude 5, scale 1), and
251
+ # "1.10" is canonical magnitude 11, scale 1 — exact trailing zeros are always trimmed.
252
+ # A magnitude past 2**96 - 1, or more fractional precision than 28 places can hold, is
253
+ # an :out_of_range Fault — the door never rounds.
254
+ def decimal(text, format)
255
+ numeric(:cast_decimal, text, format, 16) do |out|
256
+ lo, hi, scale, negative = out.unpack("Q<L<CCx2")
257
+ Decimal.new(magnitude: (hi << 64) | lo, scale: scale, negative: negative != 0)
258
+ end
259
+ end
260
+
261
+ # Casts UUID text — all five .NET Guid formats (D/N/B/P/X) plus urn:uuid:/GUID:/UUID:
262
+ # prefixes — to Ruby's UUID lingua franca: the lowercase hyphenated String (the same
263
+ # shape SecureRandom.uuid returns).
264
+ def uuid(text)
265
+ plain(:cast_uuid, text, 16) do |out|
266
+ out.unpack("H8H4H4H4H12").join("-")
267
+ end
268
+ end
269
+
270
+ # Casts an RFC 3339 instant — zone mandatory — to a UTC Time at full nanosecond
271
+ # fidelity across the whole 0001-9999 window.
272
+ def timestamp(text)
273
+ plain(:cast_timestamp, text, 16) { |out| instant(out) }
274
+ end
275
+
276
+ # Casts an integer Unix-epoch value under a caller-declared unit Symbol
277
+ # (:seconds/:milliseconds/:microseconds/:nanoseconds) to a UTC Time. An unknown unit
278
+ # is a caller bug (KeyError), never a verdict.
279
+ def unix(text, precision)
280
+ declared(:cast_unix, text, UNIX_PRECISIONS.fetch(precision), 16) { |out| instant(out) }
281
+ end
282
+
283
+ # Casts an Excel date serial under a caller-declared epoch Symbol (:y1900/:y1904) to a
284
+ # UTC Time. The whole part counts days from the system's own day zero and the fraction
285
+ # is the time of day, so "45292.75" is 2024-01-01T18:00:00Z; a cell carries no zone and
286
+ # none is invented.
287
+ #
288
+ # The 1900 system contains a day that never existed: serial 60 is 1900-02-29, kept
289
+ # deliberately because Lotus 1-2-3 wrongly treated 1900 as a leap year and Excel copied
290
+ # the bug for file compatibility. It is :out_of_range here — the same verdict .date gives
291
+ # the text "1900-02-29" — so every serial above it is shifted one day against a naive
292
+ # count. An unknown epoch is a caller bug (KeyError), never a verdict.
293
+ def excel_serial(text, epoch)
294
+ declared(:cast_excel_serial, text, EXCEL_EPOCHS.fetch(epoch), 16) { |out| instant(out) }
295
+ end
296
+
297
+ # Casts a calendar date to a Date. With no order declared: the strict ISO 8601
298
+ # yyyy-MM-dd form only. With a declared order Symbol (:year_month_day,
299
+ # :month_day_year, :day_month_year), also the separated forms — "1/7/2026" is
300
+ # January 7th or July 1st only because the caller said which; an unknown order is a
301
+ # caller bug (KeyError), never a verdict.
302
+ def date(text, order = nil)
303
+ if order.nil?
304
+ plain(:cast_date, text, 4) do |out|
305
+ year, month, day = out.unpack("S<CC")
306
+ Date.new(year, month, day)
307
+ end
308
+ else
309
+ declared(:cast_date_ordered, text, DATE_ORDERS.fetch(order), 4) do |out|
310
+ year, month, day = out.unpack("S<CC")
311
+ Date.new(year, month, day)
312
+ end
313
+ end
314
+ end
315
+
316
+ # Casts a zone-less civil date-time — the shape untrusted feeds actually send
317
+ # ("1/7/2026 3:04 PM", "2026-01-07 15:04:05") — under a declared order Symbol to a
318
+ # stdlib DateTime with exact Rational seconds. No zone is *read*: the text names no
319
+ # instant, and the parse applies no offset. Ruby has no zone-less date-time type, so
320
+ # the value rides a DateTime, whose offset defaults to +00:00 — that zero is a carrier
321
+ # artifact, not data (the same caveat PHP's UTC-labeled DateTimeImmutable carries);
322
+ # fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
323
+ # instant door. An unknown order is a caller bug (KeyError).
324
+ def datetime(text, order)
325
+ declared(:cast_datetime, text, DATE_ORDERS.fetch(order), 16) do |out|
326
+ year, month, day, nanos = out.unpack("S<CCx4Q<")
327
+ second_of_day, frac = nanos.divmod(1_000_000_000)
328
+ hour, rest = second_of_day.divmod(3600)
329
+ minute, second = rest.divmod(60)
330
+ DateTime.new(year, month, day, hour, minute, second + Rational(frac, 1_000_000_000))
331
+ end
332
+ end
333
+
334
+ # Casts an ISO 24-hour time-of-day to an exact Integer of nanoseconds since midnight
335
+ # (Ruby has no time-of-day type; the integer keeps every digit).
336
+ def time(text)
337
+ plain(:cast_time, text, 8) { |out| out.unpack1("Q<") }
338
+ end
339
+
340
+ # Casts a duration (ISO 8601 fixed components, invariant colon form, or protobuf JSON
341
+ # seconds) to exact Rational seconds — full fidelity across the core's ±10,000-year
342
+ # window, no wrapping and no truncation.
343
+ def duration(text)
344
+ plain(:cast_duration, text, 16) do |out|
345
+ seconds, nanos = out.unpack("q<l<")
346
+ Rational(seconds * 1_000_000_000 + nanos, 1_000_000_000)
347
+ end
348
+ end
349
+
350
+ # The version of the native core actually loaded, as "major.minor.patch" — read from
351
+ # the library itself, not from this gem, so a consumer can prove the core behind the
352
+ # doors is the one this binding was built against (and name the mismatch when it is
353
+ # not). The cheapest possible probe that the backend resolved at all: takes nothing,
354
+ # cannot fail.
355
+ def native_version
356
+ word = packed_version
357
+ "#{word >> 16}.#{(word >> 8) & 0xFF}.#{word & 0xFF}"
358
+ end
359
+
360
+ private
361
+
362
+ # Encodings whose bytes already are the UTF-8 (or byte-identical) form the core reads.
363
+ BYTE_COMPATIBLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].freeze
364
+
365
+ # Presents the input as UTF-8 bytes: already-compatible text crosses as-is (Fiddle
366
+ # passes a String's own bytes for void* — no copy of them); only foreign encodings pay
367
+ # a transcode, and text that cannot be transcoded raises String#encode's own
368
+ # Encoding:: error, the same one the Magnus extension raises through the same call.
369
+ #
370
+ # Text that is not a String is a caller bug, and the same TypeError on every backend:
371
+ # the extension takes its argument through Ruby's implicit String conversion (to_str,
372
+ # or a TypeError), so this does too.
373
+ def utf8(text)
374
+ unless text.is_a?(String)
375
+ converted = String.try_convert(text) or
376
+ raise TypeError, "no implicit conversion of #{text.class} into String"
377
+ text = converted
378
+ end
379
+ BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
380
+ end
381
+
382
+ # Fiddle spells a null pointer as nil — the core's contract for empty input.
383
+ def input_ptr(bytes)
384
+ bytes.empty? ? nil : bytes
385
+ end
386
+
387
+ # One 24-byte scratch allocation per thread, reused by every call: out-value at 0
388
+ # (16 bytes covers every door, and malloc's alignment covers the decimal's 8), fault
389
+ # span at 16. Two Fiddle::Pointer.malloc(..., RUBY_FREE) calls per cast — each
390
+ # registering a GC finalizer — measured as the dominant per-call cost by an order of
391
+ # magnitude. The NumFormat no longer lives here: each format owns its own packed
392
+ # pointer (see packed_cache), so a numeric call copies nothing into scratch before
393
+ # crossing. Allocated through Runtime.buffer, which loads the library first, so a
394
+ # missing one is reported as such before Fiddle is touched.
395
+ def scratch
396
+ Thread.current[:hypercast_scratch] ||= begin
397
+ base = Runtime.buffer(24)
398
+ [base, base + 16]
399
+ end
400
+ end
401
+
402
+ # Presents a native return code as the verdict union: 0 yields a Success, a failure
403
+ # code becomes a Fault over `bytes` (the UTF-8 the core read), and -1 (contract
404
+ # violation) is a binding bug that raises.
405
+ def verdict(rc, fault, bytes)
406
+ if rc.zero?
407
+ Success.new(value: yield)
408
+ elsif rc == -1
409
+ raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
410
+ else
411
+ offset, length = characters(bytes, *fault[0, 8].unpack("L<L<"))
412
+ Fault.new(reason: REASONS.fetch(rc), offset: offset, length: length)
413
+ end
414
+ end
415
+
416
+ # The core's byte span in the units String#[] slices by: an identity for a binary
417
+ # String (its characters are its bytes) and for ASCII text (`ascii_only?` reads the
418
+ # cached coderange — no scan), a byte-to-character remap otherwise. Character counts
419
+ # survive transcoding, so a span mapped on the UTF-8 form indexes the caller's own
420
+ # String whatever encoding it arrived in. Failure path only: a Success never pays.
421
+ def characters(bytes, offset, length)
422
+ return [offset, length] if bytes.encoding == Encoding::ASCII_8BIT || bytes.ascii_only?
423
+
424
+ [bytes.byteslice(0, offset).length, bytes.byteslice(offset, length).length]
425
+ end
426
+
427
+ # The shared body of every format-free door: one native call over the scratch buffers.
428
+ def plain(symbol, text, out_size)
429
+ bytes = utf8(text)
430
+ out, fault = scratch
431
+ rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, out, fault)
432
+ verdict(rc, fault, bytes) { yield(out[0, out_size]) }
433
+ end
434
+
435
+ # The shared body of the integer/real/decimal doors: plain, plus the format's own
436
+ # packed pointer, passed straight through — no per-call copy of the 32 bytes into
437
+ # scratch.
438
+ def numeric(symbol, text, format, out_size)
439
+ bytes = utf8(text)
440
+ out, fault = scratch
441
+ rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, packed_cache[format], out, fault)
442
+ verdict(rc, fault, bytes) { yield(out[0, out_size]) }
443
+ end
444
+
445
+ # The shared body of the four doors that take a caller-declared u32 — a precision, an
446
+ # epoch, or a field order — already resolved from its Symbol by the door. These bodies
447
+ # (plain, numeric, declared, and packed_version below) are the whole Fiddle crossing.
448
+ def declared(symbol, text, code, out_size)
449
+ bytes = utf8(text)
450
+ out, fault = scratch
451
+ rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, code, out, fault)
452
+ verdict(rc, fault, bytes) { yield(out[0, out_size]) }
453
+ end
454
+
455
+ # The loaded core's version word, major << 16 | minor << 8 | patch, straight from the
456
+ # library's zero-argument hypercast_version export.
457
+ def packed_version
458
+ Runtime.function(:hypercast_version).call
459
+ end
460
+
461
+ # How many formats packed_cache remembers before it starts forgetting the oldest. Far
462
+ # more than a process that reuses its formats ever holds; what it bounds is the one
463
+ # that builds a NumFormat per request.
464
+ PACKED_CACHE_LIMIT = 64
465
+
466
+ # Identity-keyed memo (compare_by_identity — a pointer hash, not Data's structural
467
+ # #hash over three Strings and an Integer) of a native 32-byte RawNumFormat per format
468
+ # object, filled once from NumFormat#packed. Formats are reused constants in practice,
469
+ # so the common call finds its pointer in one lookup and packs nothing. The Hash holds
470
+ # the format, so a key can never be a recycled address; the race is benign (idempotent).
471
+ #
472
+ # Bounded, oldest entry out first (a Hash keeps insertion order): unbounded, it held
473
+ # every format ever cast with and its 32-byte allocation for the life of the process,
474
+ # which a consumer building a format per request experienced as a leak. A format
475
+ # evicted and then used again is simply packed again, and an evicted pointer stays
476
+ # alive for any call already holding it — the caller's own reference outlives the cache's.
477
+ def packed_cache
478
+ @packed_cache ||= Hash.new do |cache, format|
479
+ cache.shift while cache.size >= PACKED_CACHE_LIMIT
480
+ pointer = Runtime.buffer(32)
481
+ pointer[0, 32] = format.packed
482
+ cache[format] = pointer
483
+ end.compare_by_identity
484
+ end
485
+
486
+ # Builds a UTC Time from the core's protobuf-shaped {seconds, nanos} pair, exactly.
487
+ def instant(bytes)
488
+ seconds, nanos = bytes.unpack("q<l<")
489
+ Time.at(seconds, nanos, :nanosecond, in: "UTC")
490
+ end
491
+ end
492
+ end
493
+
494
+ # --- backend selection: the Magnus extension, when present, replaces the doors above in
495
+ # place on this module (no delegation layer) — Fiddle's measured 1.6 µs per-call floor
496
+ # drops to an ordinary extension call. The pure-Fiddle definitions stay the universal
497
+ # zero-compile fallback — the last resort, for a Ruby or a platform no platform gem covers;
498
+ # precompiled platform gems are how the extension ships without ever making a consumer
499
+ # compile anything, and they carry no Fiddle library at all.
500
+ #
501
+ # HYPERCAST_PURE forces Fiddle. It is a testing and diagnostic switch — CI runs the whole
502
+ # suite through it, and it is how a suspected extension bug is ruled in or out — not a
503
+ # setting a deployment needs. It is read for presence, not value, so "0" and the empty
504
+ # string force it too. Inside a platform gem there is no library for it to load: BACKEND
505
+ # still reads :fiddle, HyperCast.available? answers false, and the first door call raises a
506
+ # LoadError naming the universal gem, which is where the Fiddle backend lives.
507
+ HyperCast::BACKEND =
508
+ if ENV["HYPERCAST_PURE"]
509
+ :fiddle
510
+ else
511
+ # Two layouts, and both have to work. A released platform gem is a "fat" gem carrying one
512
+ # extension per supported Ruby ABI under lib/hypercast/<minor>/ (see the Rakefile's
513
+ # native:gem task for why an ABI-per-file is unavoidable — Magnus has no `abi3`
514
+ # equivalent), and `rake native:dev` — the local dev loop — stages its own build at that
515
+ # same versioned path. CI's in-job staging additionally drops a single extension flat at
516
+ # lib/, which is also where a hand copy of `cargo ruby-ext`'s output goes (the alias only
517
+ # builds into rust/target/ruby/release/; nothing but that rake task copies it here).
518
+ # Trying the versioned path first and the flat one second means neither has to know the
519
+ # other exists.
520
+ #
521
+ # A miss on both is not an error: it means this Ruby/platform combination has no
522
+ # precompiled extension, which is precisely what the Fiddle backend is for. A platform
523
+ # with no shared library either still lands on Fiddle, so the first call raises its own
524
+ # precise error — a "not found" LoadError naming the path, or
525
+ # NativePlatform::UnsupportedPlatformError naming the platform. HyperCast.available? is
526
+ # the quiet way to ask.
527
+ begin
528
+ require "hypercast/#{RUBY_VERSION[/\d+\.\d+/]}/hypercast_native"
529
+ :native
530
+ rescue LoadError
531
+ begin
532
+ require "hypercast_native"
533
+ :native
534
+ rescue LoadError
535
+ :fiddle
536
+ end
537
+ end
538
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hypercast
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.6.0
5
+ platform: aarch64-linux-musl
6
+ authors:
7
+ - Brian Buvinghausen
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: yard
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.9'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.9'
40
+ description: |
41
+ Booleans, numerics, exact decimals, UUIDs, and temporals cast from untrusted text by a
42
+ native Rust core — every parse returns a verdict (the value, or a reason plus the
43
+ offending span), never an exception for bad data. Two backends behind one surface,
44
+ selected automatically and both shipped prebuilt: a Magnus extension where a precompiled
45
+ platform gem matches, and stdlib Fiddle everywhere else. No runtime bridge, no
46
+ dependencies beyond Fiddle.
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - LICENSE
52
+ - README.md
53
+ - lib/hypercast.rb
54
+ - lib/hypercast/3.4/hypercast_native.so
55
+ - lib/hypercast/4.0/hypercast_native.so
56
+ - lib/hypercast/native_platform.rb
57
+ - lib/hypercast/runtime.rb
58
+ homepage: https://github.com/SkunkWerkx/HyperCast
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ source_code_uri: https://github.com/SkunkWerkx/HyperCast
63
+ changelog_uri: https://github.com/SkunkWerkx/HyperCast/blob/master/CHANGELOG.md
64
+ bug_tracker_uri: https://github.com/SkunkWerkx/HyperCast/issues
65
+ documentation_uri: https://github.com/SkunkWerkx/HyperCast/tree/master/ruby#readme
66
+ rubygems_mfa_required: 'true'
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '3.4'
75
+ - - "<"
76
+ - !ruby/object:Gem::Version
77
+ version: '4.1'
78
+ required_rubygems_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ requirements: []
84
+ rubygems_version: 4.0.20
85
+ specification_version: 4
86
+ summary: Scalar parsing as Success/Fault verdicts over a native Rust core, shipped
87
+ prebuilt
88
+ test_files: []