hypercast 0.2.0-aarch64-mingw-ucrt

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: b5854bf2c44466f015a6d7a3be2638666676b3da2dd94ec122623873f6028fbc
4
+ data.tar.gz: 491e38ce3e41dca9da6b3d423c9781781691f0c751d4ef091a3fe0366dad9982
5
+ SHA512:
6
+ metadata.gz: f9f7abd169594ec16b7c8b793b69a916765fbb6805c243eb9df219253c46817ee47bcf5fbe1df43a2e3e22dc77561e2cd758ce478f34f07d312bdf94ab77b127
7
+ data.tar.gz: 853a3b9083791629c3301830c4286de12f37619cab6c36f890ac5012142813d3a3da39ca0303339777e82ee8ca5ed99caefcf3c6359c5a41a6f7dd70f12eb333
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,150 @@
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
+ [![Gem](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 byte span that offended. Two backends, one public surface: a Magnus
9
+ native extension where a precompiled platform gem covers you, stdlib Fiddle everywhere
10
+ else — selected automatically, zero compiles either way.**
11
+
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
14
+ (`Data.define` for the verdict case types). The fast path links the core straight into a
15
+ Ruby extension (Magnus): on require it redefines the doors in place on the `HyperCast`
16
+ module — no delegation layer, no second surface, which is exactly what keeps the backends
17
+ provably in agreement. `HyperCast::BACKEND` reports which is live; `HYPERCAST_PURE=1`
18
+ forces Fiddle.
19
+
20
+ ```ruby
21
+ case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
22
+ in HyperCast::Success(value:) then puts "got #{value}" # -1234
23
+ in HyperCast::Fault(reason:, offset:) then puts "#{reason} at byte #{offset}"
24
+ end
25
+ ```
26
+
27
+ Door names mirror the native ABI (`i32`, `f64`, `timestamp`, …). Ruby-flavored fidelity,
28
+ stated proudly — nothing the core parses is lost on the way out: `Integer` is
29
+ unbounded (u64 comes back as the true unsigned value), `Time` carries full nanoseconds
30
+ 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
+
34
+ ## Why not `Integer()` / `Time.iso8601` / `Float()`?
35
+
36
+ 1. **Verdicts, not exceptions** — bad data is the expected case for untrusted text; a
37
+ `Fault` is a Symbol and two integers, not an `ArgumentError` to rescue.
38
+ 2. **The vocabulary untrusted sources actually send** — twenty boolean lexemes, accounting
39
+ parentheses, declared separators, radix prefixes, all five .NET `Guid` text forms plus
40
+ `urn:uuid:` prefixes, protobuf JSON durations.
41
+ 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).
45
+ 4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
46
+ benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-arm64): timestamp **713 ns vs
47
+ 2.88 µs `Time.iso8601`** (4.0x) — while returning exact `Rational` durations on the
48
+ duration door. The Fiddle fallback lands at ~3.5 µs: parity with `Time.iso8601`, sitting
49
+ on Fiddle's measured 1.6 µs per-call marshalling floor.
50
+
51
+ Separator detection is free here: `1.234.567,89` under `NumFormat::DETECT` runs at
52
+ 591 ns against 570 ns for the same text under a declared eurozone format — inside the
53
+ error bars. Both of those were ~990 ns in 0.1.0, for a reason that had nothing to do
54
+ with parsing: every format other than `INVARIANT` paid three method dispatches and two
55
+ `String` allocations per call to read its separators back out of the `Data`. `DETECT`
56
+ is now identity-matched like `INVARIANT`, and any other format is resolved once per
57
+ thread and memoized by identity — anchored in a thread-variable so the memo's key can
58
+ never be a recycled address — which turned the per-call cost into one pointer compare.
59
+ The three reason Symbols and the option Symbols (`:seconds`, `:month_day_year`, …) are
60
+ cached the same way, so a fault or a declared option is a pointer compare too, never a
61
+ `Symbol#name` materialization.
62
+
63
+ **The honest trade-off, and Ruby's one real loss:** the civil date-time door is *slower*
64
+ than `strptime` — 1.30 µs against `DateTime.strptime`'s 1.02 µs, and the date door 1.04 µs
65
+ against `Date.strptime`'s 619 ns. The parse isn't the problem; the carrier is. Building a
66
+ stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
67
+ where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`. Printed
68
+ because it's real: if you want Ruby's fastest civil parse and don't need the verdict or the
69
+ declared order, `strptime` wins. Also note the carrier's other caveat — `DateTime`'s offset
70
+ defaults to `+00:00`, which is an artifact of the type, not a zone the parse assigned.
71
+
72
+ On the Fiddle fallback the doors are parity-at-best — Fiddle's
73
+ per-call floor is the mechanism's price, kept because it's the universal zero-compile
74
+ path. 0.2.0 still took ~20% off its numeric doors (`i32` 3.37 → 2.62 µs, `f64` 3.31 →
75
+ 2.76 µs, same session) by not building things per call that never changed: the integer
76
+ doors interpolated and interned their `:cast_*` Symbol on every call, every door went
77
+ through a splat-and-resplat dispatcher, and every numeric call copied the format's 12
78
+ packed bytes into scratch — each format now owns one native pointer, memoized by identity,
79
+ passed straight through. (Benchmark forensics worth knowing: the doors read 4.3 µs until
80
+ per-call `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch —
81
+ receipts include their own archaeology.)
82
+
83
+ ## Verifying provenance
84
+
85
+ Every gem RubyGems.org serves — the universal fallback and each of the six precompiled
86
+ platform gems — carries its own GitHub build-provenance attestation, signed directly by
87
+ this repo's own `release.yml` (the `rubygems-publish` job attests `ruby/pkg/*.gem` right
88
+ before the push), so plain `--repo` verifies any of them:
89
+
90
+ ```sh
91
+ gem fetch hypercast -v X.Y.Z --platform <platform> # or omit --platform for the universal gem
92
+ gh attestation verify hypercast-X.Y.Z-<platform>.gem --repo SkunkWerkx/HyperCast
93
+ ```
94
+
95
+ That's the release's second layer of checking, not the only one: before any gem gets built,
96
+ the same job verifies every native binary it packs (six FFI libs, twelve Magnus extensions —
97
+ two ABIs per platform) against *their own* attestations — those are signed from
98
+ `SkunkWerkx/.github` by `hyper-build-native.yml`, so that check needs
99
+ `--signer-repo SkunkWerkx/.github` added — and refuses to proceed on an unverified one.
100
+ RubyGems.org has no unpublish and no duplicate-version overwrite, so this all happens while a
101
+ bad artifact is still reversible. The release run's job summary then re-fetches every gem
102
+ from the CDN and records attested-vs-served digests, turning "rubygems.org stores an upload
103
+ verbatim" into a per-release measurement rather than an assumption — see
104
+ [csharp/README.md's provenance section](../csharp/README.md#native-binary-provenance) for
105
+ more on why `--signer-repo` is needed for some artifacts here and not others.
106
+
107
+ ## Install
108
+
109
+ ```sh
110
+ gem install hypercast
111
+ ```
112
+
113
+ Seven gems are published per release: one universal `ruby`-platform gem (pure Fiddle, all
114
+ six platforms' natives bundled) plus six precompiled Magnus platform gems (`x86_64-linux`,
115
+ `aarch64-linux`, `x86_64-darwin`, `arm64-darwin`, `x64-mingw-ucrt`, `aarch64-mingw-ucrt`)
116
+ that `gem install` auto-selects when they match. Nobody ever compiles anything.
117
+
118
+ Selection has **two** axes here, unlike every other binding in this repo. A Magnus extension
119
+ is bound to one Ruby minor ABI — there is no `abi3` equivalent to collapse the version axis
120
+ the way [the Python binding's](../python/) wheels do — so each platform gem is a "fat" gem
121
+ carrying one compiled extension per supported Ruby, under `lib/hypercast/<minor>/`, and picks
122
+ one at `require` time:
123
+
124
+ | Ruby | the six platforms above | anywhere else (musl/Alpine, …) |
125
+ | --- | --- | --- |
126
+ | 4.0 (primary) | Magnus, `backend: :native` | Fiddle |
127
+ | 3.4 (floor, until its EOL 2028-03-31) | Magnus, `backend: :native` | Fiddle |
128
+ | 3.2 / 3.3 | Fiddle | Fiddle |
129
+
130
+ The platform gems declare `required_ruby_version >= 3.4, < 4.1` precisely so RubyGems
131
+ *declines* them outside that range and resolves the universal gem instead — a wrong-ABI
132
+ extension must never be installed in the first place. On Windows it would at least fail to
133
+ load cleanly (the extension imports `<arch>-ucrt-ruby<minor>.dll` by name —
134
+ `x64-ucrt-ruby400.dll` on x64, `aarch64-ucrt-ruby400.dll` on ARM), but Linux extensions don't
135
+ link libruby at all, so one can load successfully against the wrong ABI and misbehave later.
136
+ Every cell in that table replays the same corpus green.
137
+
138
+ Both Windows architectures get a Magnus gem, and the reasoning that once kept them on Fiddle
139
+ was backwards: MinGW is the *only* Windows flavour `rb-sys` targets (`x64-mingw-ucrt` and
140
+ `aarch64-mingw-ucrt`, both `supported: true` in its own `data/toolchains.json`); the one it
141
+ has no support for is MSVC. Windows is also where the Fiddle fallback cost the most, so these
142
+ are the most worthwhile gems in the set. Both extensions are built for the `gnullvm` Rust
143
+ targets rather than `gnu` — the same mingw-w64/UCRT ABI RubyInstaller's Ruby uses, linked
144
+ with LLVM and compiler-rt instead of GCC and a statically-linked libgcc, which is what keeps
145
+ the shipped extension small. The build script, and the two flags that are load-bearing on
146
+ the ARM leg (a static libunwind, and a clang-spelled `--target` for bindgen), live in the
147
+ forge's `hyper-build-native.yml`, shared with every other Hyper* repo.
148
+
149
+ See [the repo root README](../README.md) for the full door table, the receipts, and the
150
+ state of every other language binding.
@@ -0,0 +1,22 @@
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
+ def self.rid_and_library_name
8
+ is_arm = RUBY_PLATFORM.match?(/arm64|aarch64/)
9
+
10
+ case RUBY_PLATFORM
11
+ when /mingw|mswin|windows/
12
+ is_arm ? ["win-arm64", "hypercast.dll"] : ["win-x64", "hypercast.dll"]
13
+ when /darwin/
14
+ is_arm ? ["osx-arm64", "libhypercast.dylib"] : ["osx-x64", "libhypercast.dylib"]
15
+ when /linux/
16
+ is_arm ? ["linux-arm64", "libhypercast.so"] : ["linux-x64", "libhypercast.so"]
17
+ else
18
+ raise UnsupportedPlatformError, "hypercast: unsupported platform RUBY_PLATFORM=#{RUBY_PLATFORM}"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,70 @@
1
+ require "fiddle"
2
+
3
+ module HyperCast
4
+ # Fiddle plumbing for the native libhypercast shared library — dlopen/dlsym plus raw
5
+ # C-ABI calls, no runtime bridge. Fiddle ships with every Ruby install; it's a plain gem
6
+ # dependency here rather than a third-party one. A Ruby gem's files are already plain
7
+ # files on disk once installed, so native/{rid}/{lib} dlopen's directly — no extraction.
8
+ module Runtime
9
+ NATIVE_DIR = File.join(__dir__, "native")
10
+
11
+ PLAIN = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP].freeze
12
+ NUMERIC = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
13
+ Fiddle::TYPE_VOIDP].freeze
14
+ UNIX = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP,
15
+ Fiddle::TYPE_VOIDP].freeze
16
+
17
+ DOORS = {
18
+ cast_bool: PLAIN,
19
+ cast_i8: NUMERIC, cast_i16: NUMERIC, cast_i32: NUMERIC, cast_i64: NUMERIC,
20
+ cast_u8: NUMERIC, cast_u16: NUMERIC, cast_u32: NUMERIC, cast_u64: NUMERIC,
21
+ cast_f32: NUMERIC, cast_f64: NUMERIC,
22
+ cast_uuid: PLAIN,
23
+ cast_timestamp: PLAIN, cast_unix: UNIX, cast_excel_serial: UNIX,
24
+ cast_date: PLAIN, cast_date_ordered: UNIX, cast_datetime: UNIX,
25
+ cast_time: PLAIN, cast_duration: PLAIN
26
+ }.freeze
27
+
28
+ @mutex = Mutex.new
29
+ @functions = nil
30
+
31
+ 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.
34
+ def function(symbol)
35
+ functions.fetch(symbol)
36
+ end
37
+
38
+ private
39
+
40
+ # Loaded lazily and exactly once; the native library and its function pointers live
41
+ # for the process's lifetime, same as every other binding (never dlclose'd). The
42
+ # unsynchronized read is the fast path — a per-call mutex acquisition measured as a
43
+ # real slice of the door cost; the benign race re-checks under the lock.
44
+ def functions
45
+ @functions || @mutex.synchronize { @functions ||= load_functions }
46
+ end
47
+
48
+ 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)
58
+ raise LoadError,
59
+ "hypercast: #{path} not found (unsupported platform, or this gem was built " \
60
+ "without a native library for it)"
61
+ end
62
+
63
+ handle = Fiddle.dlopen(path)
64
+ DOORS.to_h do |name, signature|
65
+ [name, Fiddle::Function.new(handle[name.to_s], signature, Fiddle::TYPE_INT32_T)]
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
data/lib/hypercast.rb ADDED
@@ -0,0 +1,360 @@
1
+ require "date"
2
+ require_relative "hypercast/native_platform"
3
+ require_relative "hypercast/runtime"
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.
9
+ #
10
+ # Consume with Ruby's own pattern matching over the two Data case types:
11
+ #
12
+ # case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
13
+ # in HyperCast::Success(value:) then puts "got #{value}" # -1234
14
+ # in HyperCast::Fault(reason:, offset:) then puts "#{reason} at byte #{offset}"
15
+ # end
16
+ #
17
+ # Door names mirror the native ABI (i32, f64, timestamp, ...) so the polyglot surface reads
18
+ # identically across bindings. Ruby-flavored fidelity: Integer is unbounded (u64 comes back
19
+ # 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.
23
+ module HyperCast
24
+ # This gem's own version — kept in lockstep with hypercast.gemspec by the
25
+ # prepare-release workflow, so the two can never drift apart again.
26
+ VERSION = "0.2.0"
27
+
28
+ # The success case of a verdict: a cast value.
29
+ Success = Data.define(:value)
30
+
31
+ # 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
+ Fault = Data.define(:reason, :offset, :length)
35
+
36
+ # The native core's failure codes, mapped to the closed reason Symbols a Fault carries.
37
+ REASONS = { 1 => :empty, 2 => :malformed, 3 => :out_of_range }.freeze
38
+
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
43
+ # 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:)
46
+ raise ArgumentError, "separators must be single characters" unless
47
+ decimal_sep.is_a?(String) && decimal_sep.length == 1 &&
48
+ group_sep.is_a?(String) && group_sep.length == 1
49
+ raise ArgumentError, "decimal and group separators must differ; both are #{decimal_sep.inspect}" if
50
+ decimal_sep == group_sep
51
+ super
52
+ end
53
+
54
+ # The 12-byte little-endian form the native ABI's NumFormat struct expects.
55
+ def packed
56
+ [decimal_sep.ord, group_sep.ord, flags].pack("L<L<L<")
57
+ end
58
+ end
59
+
60
+ # Permit the group separator between digits (sizes not validated — between digits is the rule).
61
+ GROUPING = 1
62
+ # Permit accounting parentheses as negation: (1,234) is -1234.
63
+ PARENTHESES = 1 << 1
64
+ # Permit exponent notation. Integer doors reject a negative exponent.
65
+ EXPONENT = 1 << 2
66
+ # Permit 0x/&H/0b two's-complement radix prefixes (0xFF is -1 for an i8).
67
+ RADIX_PREFIXES = 1 << 3
68
+ # Permit a trailing %, dividing by 100. Real doors only.
69
+ PERCENT = 1 << 4
70
+ # Resolve the ./, roles per input from structure instead of the declared separators
71
+ # (which are ignored while this flag is set). Detection, not sniffing: a repeated
72
+ # separator is grouping ("1.234.567,89"); with both present the rightmost is the
73
+ # decimal; a single separator with a non-3-digit right run is the decimal ("3,1415");
74
+ # with exactly 3 digits right, only a 0 integer part proves decimal ("0,785").
75
+ # Genuinely ambiguous input ("12.185", "1,000") is a :malformed Fault at the separator,
76
+ # never guessed.
77
+ SEPARATOR_DETECT = 1 << 5
78
+ # Every lenience on (SEPARATOR_DETECT is a separator policy, not a lenience, and is
79
+ # deliberately not included).
80
+ ALL_STYLES = GROUPING | PARENTHESES | EXPONENT | RADIX_PREFIXES | PERCENT
81
+
82
+ # The invariant profile — '.' decimal, ',' grouping, every lenience on.
83
+ NumFormat::INVARIANT = NumFormat.new(decimal_sep: ".", group_sep: ",", flags: ALL_STYLES)
84
+
85
+ # The detection profile — every lenience on, ./, roles resolved per input by
86
+ # SEPARATOR_DETECT's structural rules.
87
+ NumFormat::DETECT = NumFormat.new(decimal_sep: ".", group_sep: ",",
88
+ flags: ALL_STYLES | SEPARATOR_DETECT)
89
+
90
+ # The declared unit of a Unix-epoch value — no magnitude guessing, ever.
91
+ UNIX_PRECISIONS = { seconds: 1, milliseconds: 2, microseconds: 3, nanoseconds: 4 }.freeze
92
+
93
+ # The date system an Excel serial number is expressed in. Spreadsheets carry no marker
94
+ # for this — it is a workbook-level setting — so the caller states it, the same way
95
+ # UNIX_PRECISIONS and DATE_ORDERS are declared rather than guessed.
96
+ EXCEL_EPOCHS = { y1900: 1, y1904: 2 }.freeze
97
+
98
+ # The declared field order of a separated calendar date — no guessing, ever: "1/7/2026"
99
+ # is January 7th (:month_day_year, the en-US order) or July 1st (:day_month_year, the
100
+ # en-GB order) only because the caller said which.
101
+ DATE_ORDERS = { year_month_day: 1, month_day_year: 2, day_month_year: 3 }.freeze
102
+
103
+ class << self
104
+ # Presents a verdict optionally: an :empty fault becomes nil (Ruby's absent),
105
+ # everything else flows through untouched.
106
+ def optional(verdict)
107
+ return nil if verdict in Fault(reason: :empty)
108
+
109
+ verdict
110
+ end
111
+
112
+ # Casts boolean text: true/false plus the conventions untrusted sources actually send
113
+ # (t/f, yes/no, y/n, 1/0, on/off, enabled/disabled, active/inactive,
114
+ # checked/unchecked, in/out), ASCII case-insensitive.
115
+ def bool(text)
116
+ plain(:cast_bool, text, 1) { |out| out.unpack1("C") != 0 }
117
+ end
118
+
119
+ { i8: "c", i16: "s<", i32: "l<", i64: "q<", u8: "C", u16: "S<", u32: "L<", u64: "Q<" }
120
+ .each do |door, unpack|
121
+ sizes = { "c" => 1, "C" => 1, "s<" => 2, "S<" => 2, "l<" => 4, "L<" => 4, "q<" => 8, "Q<" => 8 }
122
+ size = sizes.fetch(unpack)
123
+ # Resolved once here, not inside the method: interpolating a Symbol per call built a
124
+ # String and interned it on every integer cast.
125
+ symbol = :"cast_#{door}"
126
+ # Integer doors: the target type's own range, declared grouping, accounting parens,
127
+ # non-negative exponent, and 0x/&H/0b two's-complement radix prefixes. Ruby Integer
128
+ # is unbounded, so u64 comes back as the true unsigned value.
129
+ define_method(door) do |text, format|
130
+ numeric(symbol, text, format, size) { |out| out.unpack1(unpack) }
131
+ end
132
+ end
133
+
134
+ # Casts real text to an IEEE single (widened losslessly on the way out): finite values
135
+ # only, declared separators and grouping, parens, exponent, and trailing percent.
136
+ def f32(text, format)
137
+ numeric(:cast_f32, text, format, 4) { |out| out.unpack1("e") }
138
+ end
139
+
140
+ # Casts real text to an IEEE double. Notation rules as f32.
141
+ def f64(text, format)
142
+ numeric(:cast_f64, text, format, 8) { |out| out.unpack1("E") }
143
+ end
144
+
145
+ # Casts UUID text — all five .NET Guid formats (D/N/B/P/X) plus urn:uuid:/GUID:/UUID:
146
+ # prefixes — to Ruby's UUID lingua franca: the lowercase hyphenated String (the same
147
+ # shape SecureRandom.uuid returns).
148
+ def uuid(text)
149
+ plain(:cast_uuid, text, 16) do |out|
150
+ out.unpack("H8H4H4H4H12").join("-")
151
+ end
152
+ end
153
+
154
+ # Casts an RFC 3339 instant — zone mandatory — to a UTC Time at full nanosecond
155
+ # fidelity across the whole 0001-9999 window.
156
+ def timestamp(text)
157
+ plain(:cast_timestamp, text, 16) { |out| instant(out) }
158
+ end
159
+
160
+ # Casts an integer Unix-epoch value under a caller-declared unit Symbol
161
+ # (:seconds/:milliseconds/:microseconds/:nanoseconds) to a UTC Time. An unknown unit
162
+ # is a caller bug (KeyError), never a verdict.
163
+ 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]) }
169
+ end
170
+
171
+ # Casts an Excel date serial under a caller-declared epoch Symbol (:y1900/:y1904) to a
172
+ # UTC Time. The whole part counts days from the system's own day zero and the fraction
173
+ # is the time of day, so "45292.75" is 2024-01-01T18:00:00Z; a cell carries no zone and
174
+ # none is invented.
175
+ #
176
+ # The 1900 system contains a day that never existed: serial 60 is 1900-02-29, kept
177
+ # deliberately because Lotus 1-2-3 wrongly treated 1900 as a leap year and Excel copied
178
+ # the bug for file compatibility. It is :malformed here — the same verdict .date gives
179
+ # the text "1900-02-29" — so every serial above it is shifted one day against a naive
180
+ # count. An unknown epoch is a caller bug (KeyError), never a verdict.
181
+ 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]) }
187
+ end
188
+
189
+ # Casts a calendar date to a Date. With no order declared: the strict ISO 8601
190
+ # yyyy-MM-dd form only. With a declared order Symbol (:year_month_day,
191
+ # :month_day_year, :day_month_year), also the separated forms — "1/7/2026" is
192
+ # January 7th or July 1st only because the caller said which; an unknown order is a
193
+ # caller bug (KeyError), never a verdict.
194
+ def date(text, order = nil)
195
+ if order.nil?
196
+ plain(:cast_date, text, 4) do |out|
197
+ year, month, day = out.unpack("S<CC")
198
+ Date.new(year, month, day)
199
+ end
200
+ 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")
207
+ Date.new(year, month, day)
208
+ end
209
+ end
210
+ end
211
+
212
+ # Casts a zone-less civil date-time — the shape untrusted feeds actually send
213
+ # ("1/7/2026 3:04 PM", "2026-01-07 15:04:05") — under a declared order Symbol to a
214
+ # stdlib DateTime with exact Rational seconds. No zone is *read*: the text names no
215
+ # instant, and the parse applies no offset. Ruby has no zone-less date-time type, so
216
+ # the value rides a DateTime, whose offset defaults to +00:00 — that zero is a carrier
217
+ # artifact, not data (the same caveat PHP's UTC-labeled DateTimeImmutable carries);
218
+ # fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
219
+ # instant door. An unknown order is a caller bug (KeyError).
220
+ 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<")
227
+ second_of_day, frac = nanos.divmod(1_000_000_000)
228
+ hour, rest = second_of_day.divmod(3600)
229
+ minute, second = rest.divmod(60)
230
+ DateTime.new(year, month, day, hour, minute, second + Rational(frac, 1_000_000_000))
231
+ end
232
+ end
233
+
234
+ # Casts an ISO 24-hour time-of-day to an exact Integer of nanoseconds since midnight
235
+ # (Ruby has no time-of-day type; the integer keeps every digit).
236
+ def time(text)
237
+ plain(:cast_time, text, 8) { |out| out.unpack1("Q<") }
238
+ end
239
+
240
+ # Casts a duration (ISO 8601 fixed components, invariant colon form, or protobuf JSON
241
+ # seconds) to exact Rational seconds — full fidelity across the core's ±10,000-year
242
+ # window, no wrapping and no truncation.
243
+ def duration(text)
244
+ plain(:cast_duration, text, 16) do |out|
245
+ seconds, nanos = out.unpack("q<l<")
246
+ Rational(seconds * 1_000_000_000 + nanos, 1_000_000_000)
247
+ end
248
+ end
249
+
250
+ private
251
+
252
+ # Encodings whose bytes already are the UTF-8 (or byte-identical) form the core reads.
253
+ BYTE_COMPATIBLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].freeze
254
+
255
+ # Presents the input as UTF-8 bytes: already-compatible text crosses as-is (Fiddle
256
+ # passes a String's bytes for void* directly — no Pointer wrapper, no dup); only
257
+ # foreign encodings pay a transcode.
258
+ def utf8(text)
259
+ BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
260
+ end
261
+
262
+ # Fiddle spells a null pointer as nil — the core's contract for empty input.
263
+ def input_ptr(bytes)
264
+ bytes.empty? ? nil : bytes
265
+ end
266
+
267
+ # 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.
273
+ def scratch
274
+ Thread.current[:hypercast_scratch] ||= begin
275
+ base = Fiddle::Pointer.malloc(24, Fiddle::RUBY_FREE)
276
+ [base, base + 16]
277
+ end
278
+ end
279
+
280
+ # 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)
283
+ if rc.zero?
284
+ Success.new(value: yield)
285
+ elsif rc == -1
286
+ raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
287
+ else
288
+ offset, length = fault[0, 8].unpack("L<L<")
289
+ Fault.new(reason: REASONS.fetch(rc), offset: offset, length: length)
290
+ end
291
+ end
292
+
293
+ # The shared body of every format-free door: one native call over the scratch buffers.
294
+ def plain(symbol, text, out_size)
295
+ bytes = utf8(text)
296
+ out, fault = scratch
297
+ rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, out, fault)
298
+ verdict(rc, fault) { yield(out[0, out_size]) }
299
+ end
300
+
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.
303
+ def numeric(symbol, text, format, out_size)
304
+ bytes = utf8(text)
305
+ out, fault = scratch
306
+ rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, packed_cache[format], out, fault)
307
+ verdict(rc, fault) { yield(out[0, out_size]) }
308
+ end
309
+
310
+ # 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
312
+ # object, filled once from NumFormat#packed. Formats are reused constants in practice,
313
+ # so the common call finds its pointer in one lookup and packs nothing. The Hash holds
314
+ # the format, so a key can never be a recycled address; the race is benign (idempotent).
315
+ def packed_cache
316
+ @packed_cache ||= Hash.new do |cache, format|
317
+ pointer = Fiddle::Pointer.malloc(12, Fiddle::RUBY_FREE)
318
+ pointer[0, 12] = format.packed
319
+ cache[format] = pointer
320
+ end.compare_by_identity
321
+ end
322
+
323
+ # Builds a UTC Time from the core's protobuf-shaped {seconds, nanos} pair, exactly.
324
+ def instant(bytes)
325
+ seconds, nanos = bytes.unpack("q<l<")
326
+ Time.at(seconds, nanos, :nanosecond, in: "UTC")
327
+ end
328
+ end
329
+ end
330
+
331
+ # --- backend selection: the Magnus extension, when present, replaces the doors above in
332
+ # place on this module (no delegation layer) — Fiddle's measured 1.6 µs per-call floor
333
+ # drops to an ordinary extension call. The pure-Fiddle definitions stay the universal
334
+ # zero-compile fallback; precompiled platform gems are how the extension ships without
335
+ # ever making a consumer compile anything. Set HYPERCAST_PURE=1 to force Fiddle.
336
+ HyperCast::BACKEND =
337
+ if ENV["HYPERCAST_PURE"]
338
+ :fiddle
339
+ else
340
+ # Two layouts, and both have to work. A released platform gem is a "fat" gem carrying one
341
+ # extension per supported Ruby ABI under lib/hypercast/<minor>/ (see the Rakefile's
342
+ # native:gem task for why an ABI-per-file is unavoidable — Magnus has no `abi3`
343
+ # equivalent). CI's in-job staging and a local `cargo build --release --features ruby`
344
+ # instead drop a single extension flat at lib/. Trying the versioned path first and the
345
+ # flat one second means neither has to know the other exists.
346
+ #
347
+ # A miss on both is not an error: it means this Ruby/platform combination has no
348
+ # precompiled extension, which is precisely what the Fiddle backend is for.
349
+ begin
350
+ require "hypercast/#{RUBY_VERSION[/\d+\.\d+/]}/hypercast_native"
351
+ :native
352
+ rescue LoadError
353
+ begin
354
+ require "hypercast_native"
355
+ :native
356
+ rescue LoadError
357
+ :fiddle
358
+ end
359
+ end
360
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hypercast
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: aarch64-mingw-ucrt
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: fiddle
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: yard
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.9'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.9'
54
+ description: 'Booleans, numerics, UUIDs, and temporals cast from untrusted text by
55
+ a native Rust core — every parse returns a verdict (the value, or a reason plus
56
+ the offending span), never an exception for bad data. Two backends behind one surface,
57
+ selected automatically with nothing ever compiled: a Magnus extension where a precompiled
58
+ platform gem matches, stdlib Fiddle everywhere else. No runtime bridge, no dependencies
59
+ beyond Fiddle.'
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - LICENSE
65
+ - README.md
66
+ - lib/hypercast.rb
67
+ - lib/hypercast/3.4/hypercast_native.so
68
+ - lib/hypercast/4.0/hypercast_native.so
69
+ - lib/hypercast/native/linux-arm64/libhypercast.so
70
+ - lib/hypercast/native/linux-x64/libhypercast.so
71
+ - lib/hypercast/native/osx-arm64/libhypercast.dylib
72
+ - lib/hypercast/native/osx-x64/libhypercast.dylib
73
+ - lib/hypercast/native/win-arm64/hypercast.dll
74
+ - lib/hypercast/native/win-x64/hypercast.dll
75
+ - lib/hypercast/native_platform.rb
76
+ - lib/hypercast/runtime.rb
77
+ homepage: https://github.com/SkunkWerkx/HyperCast
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ source_code_uri: https://github.com/SkunkWerkx/HyperCast
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '3.4'
90
+ - - "<"
91
+ - !ruby/object:Gem::Version
92
+ version: '4.1'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 4.0.16
100
+ specification_version: 4
101
+ summary: Allocation-free scalar parsing as Success/Fault verdicts over a native Rust
102
+ core
103
+ test_files: []