hypercast 0.0.1-x86_64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 85037e8e8a6ed9d74ec0f8aef3e75d8155414ad98a4aa2e2027eaad5b67ad167
4
+ data.tar.gz: 66dc72911256b109d1eb81f657aa480baf94f19caa84941527c192df3ad173eb
5
+ SHA512:
6
+ metadata.gz: b949139ee23e8458242d4dbc23fd0bb7202a39d2099bd9a1826a7d3cff48cd473a5b33a76de97f328fbee347b6f8bb0c2554288507a4cb3d9e8ac7179e170a80
7
+ data.tar.gz: ef46835aa9fa469b0b40061f1b5e52c50f316dcca6881ada3d549295eee398b45a21471610e60c76388e267a842c682d52b97e1f4a7d08516158b138a9183490
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,79 @@
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
+
5
+ **Ruby's own pattern matching over two `Data` case types — the value, or a closed reason
6
+ Symbol plus the exact byte span that offended. Two backends, one public surface: a Magnus
7
+ native extension where a precompiled platform gem covers you, stdlib Fiddle everywhere
8
+ else — selected automatically, zero compiles either way.**
9
+
10
+ Allocation-lean scalar casts — booleans, the full integer family, reals, UUIDs, temporals —
11
+ calling directly into the native `libhypercast` Rust core. Ruby 3.2 is the floor
12
+ (`Data.define` for the verdict case types). The fast path links the core straight into a
13
+ Ruby extension (Magnus): on require it redefines the doors in place on the `HyperCast`
14
+ module — no delegation layer, no second surface, which is exactly what keeps the backends
15
+ provably in agreement. `HyperCast::BACKEND` reports which is live; `HYPERCAST_PURE=1`
16
+ forces Fiddle.
17
+
18
+ ```ruby
19
+ case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
20
+ in HyperCast::Success(value:) then puts "got #{value}" # -1234
21
+ in HyperCast::Fault(reason:, offset:) then puts "#{reason} at byte #{offset}"
22
+ end
23
+ ```
24
+
25
+ Door names mirror the native ABI (`i32`, `f64`, `timestamp`, …). Ruby-flavored fidelity,
26
+ stated proudly — nothing the core parses is lost on the way out: `Integer` is
27
+ unbounded (u64 comes back as the true unsigned value), `Time` carries full nanoseconds
28
+ across the whole 0001–9999 window, time-of-day is an exact Integer of nanoseconds since
29
+ midnight, and durations come back as exact `Rational` seconds across the core's whole
30
+ ±10,000-year window — no truncation anywhere, no wrapping.
31
+
32
+ ## Why not `Integer()` / `Time.iso8601` / `Float()`?
33
+
34
+ 1. **Verdicts, not exceptions** — bad data is the expected case for untrusted text; a
35
+ `Fault` is a Symbol and two integers, not an `ArgumentError` to rescue.
36
+ 2. **The vocabulary untrusted sources actually send** — twenty boolean lexemes, accounting
37
+ parentheses, declared separators, radix prefixes, all five .NET `Guid` text forms plus
38
+ `urn:uuid:` prefixes, protobuf JSON durations.
39
+ 3. **One engine across a polyglot system** — bit-for-bit verdicts with every other binding,
40
+ held by the shared corpus (24 examples green on *both* backends, full twelve-file corpus
41
+ replay; a cross-backend agreement spec compares Magnus and Fiddle outputs across a
42
+ subprocess boundary).
43
+ 4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
44
+ benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-arm64): timestamp **713 ns vs
45
+ 2.88 µs `Time.iso8601`** (4.0x) — while returning exact `Rational` durations on the
46
+ duration door. The Fiddle fallback lands at ~3.5 µs: parity with `Time.iso8601`, sitting
47
+ on Fiddle's measured 1.6 µs per-call marshalling floor.
48
+
49
+ Separator detection is free here: `1.234.567,89` under `NumFormat::DETECT` runs
50
+ 1.073M i/s against 1.092M i/s for the same text under a declared eurozone format —
51
+ inside the error bars.
52
+
53
+ **The honest trade-off, and Ruby's one real loss:** the civil date-time door is *slower*
54
+ than `strptime` — 1.30 µs against `DateTime.strptime`'s 1.02 µs, and the date door 1.04 µs
55
+ against `Date.strptime`'s 619 ns. The parse isn't the problem; the carrier is. Building a
56
+ stdlib `DateTime` with an exact `Rational` second costs more than the whole native call,
57
+ where the timestamp door's `Time` is built by a single cheap `rb_time_nano_new`. Printed
58
+ because it's real: if you want Ruby's fastest civil parse and don't need the verdict or the
59
+ declared order, `strptime` wins. Also note the carrier's other caveat — `DateTime`'s offset
60
+ defaults to `+00:00`, which is an artifact of the type, not a zone the parse assigned.
61
+
62
+ On the Fiddle fallback the doors are parity-at-best — Fiddle's
63
+ per-call floor is the mechanism's price, kept because it's the universal zero-compile
64
+ path. (Benchmark forensics worth knowing: the doors read 4.3 µs until per-call
65
+ `Fiddle::Pointer.malloc` finalizers were hoisted to thread-local scratch — receipts
66
+ include their own archaeology.)
67
+
68
+ ## Install
69
+
70
+ Not on RubyGems yet — the release pipeline is staged (`.github/workflows/release.yml`,
71
+ Trusted Publishing pending): one universal `ruby`-platform gem (pure Fiddle, all six
72
+ platforms' natives bundled) plus four precompiled Magnus platform gems
73
+ (`x86_64-linux`, `aarch64-linux`, `x86_64-darwin`, `arm64-darwin`) that `gem install`
74
+ auto-selects when they match — nobody ever compiles anything. Until the first tag: clone
75
+ the repo, `cargo build --release` in `rust/` (plus `--features ruby`, staged to
76
+ `lib/hypercast_native.so`, for the Magnus backend), and `bundle exec rspec`.
77
+
78
+ See [the repo root README](../README.md) for the full door table, the receipts, and the
79
+ 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,68 @@
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
+ def call(symbol, *args)
33
+ functions.fetch(symbol).call(*args)
34
+ end
35
+
36
+ private
37
+
38
+ # Loaded lazily and exactly once; the native library and its function pointers live
39
+ # for the process's lifetime, same as every other binding (never dlclose'd). The
40
+ # unsynchronized read is the fast path — a per-call mutex acquisition measured as a
41
+ # real slice of the door cost; the benign race re-checks under the lock.
42
+ def functions
43
+ @functions || @mutex.synchronize { @functions ||= load_functions }
44
+ end
45
+
46
+ def load_functions
47
+ rid, lib_name = NativePlatform.rid_and_library_name
48
+ path = File.join(NATIVE_DIR, rid, lib_name)
49
+ unless File.exist?(path)
50
+ # Development loop: fall back to the in-repo cargo build, exactly what the other
51
+ # bindings' local staging does.
52
+ repo_build = File.expand_path(File.join(__dir__, "../../../rust/target/release", lib_name))
53
+ path = repo_build if File.exist?(repo_build)
54
+ end
55
+ unless File.exist?(path)
56
+ raise LoadError,
57
+ "hypercast: #{path} not found (unsupported platform, or this gem was built " \
58
+ "without a native library for it)"
59
+ end
60
+
61
+ handle = Fiddle.dlopen(path)
62
+ DOORS.to_h do |name, signature|
63
+ [name, Fiddle::Function.new(handle[name.to_s], signature, Fiddle::TYPE_INT32_T)]
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/hypercast.rb ADDED
@@ -0,0 +1,334 @@
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.0.1"
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
+ # Integer doors: the target type's own range, declared grouping, accounting parens,
124
+ # non-negative exponent, and 0x/&H/0b two's-complement radix prefixes. Ruby Integer
125
+ # is unbounded, so u64 comes back as the true unsigned value.
126
+ define_method(door) do |text, format|
127
+ numeric(:"cast_#{door}", text, format, size) { |out| out.unpack1(unpack) }
128
+ end
129
+ end
130
+
131
+ # Casts real text to an IEEE single (widened losslessly on the way out): finite values
132
+ # only, declared separators and grouping, parens, exponent, and trailing percent.
133
+ def f32(text, format)
134
+ numeric(:cast_f32, text, format, 4) { |out| out.unpack1("e") }
135
+ end
136
+
137
+ # Casts real text to an IEEE double. Notation rules as f32.
138
+ def f64(text, format)
139
+ numeric(:cast_f64, text, format, 8) { |out| out.unpack1("E") }
140
+ end
141
+
142
+ # Casts UUID text — all five .NET Guid formats (D/N/B/P/X) plus urn:uuid:/GUID:/UUID:
143
+ # prefixes — to Ruby's UUID lingua franca: the lowercase hyphenated String (the same
144
+ # shape SecureRandom.uuid returns).
145
+ def uuid(text)
146
+ plain(:cast_uuid, text, 16) do |out|
147
+ out.unpack("H8H4H4H4H12").join("-")
148
+ end
149
+ end
150
+
151
+ # Casts an RFC 3339 instant — zone mandatory — to a UTC Time at full nanosecond
152
+ # fidelity across the whole 0001-9999 window.
153
+ def timestamp(text)
154
+ plain(:cast_timestamp, text, 16) { |out| instant(out) }
155
+ end
156
+
157
+ # Casts an integer Unix-epoch value under a caller-declared unit Symbol
158
+ # (:seconds/:milliseconds/:microseconds/:nanoseconds) to a UTC Time. An unknown unit
159
+ # is a caller bug (KeyError), never a verdict.
160
+ def unix(text, precision)
161
+ code = UNIX_PRECISIONS.fetch(precision)
162
+ bytes = utf8(text)
163
+ out, fault, = scratch
164
+ rc = Runtime.call(:cast_unix, input_ptr(bytes), bytes.bytesize, code, out, fault)
165
+ verdict(rc, fault) { instant(out[0, 16]) }
166
+ end
167
+
168
+ # Casts an Excel date serial under a caller-declared epoch Symbol (:y1900/:y1904) to a
169
+ # UTC Time. The whole part counts days from the system's own day zero and the fraction
170
+ # is the time of day, so "45292.75" is 2024-01-01T18:00:00Z; a cell carries no zone and
171
+ # none is invented.
172
+ #
173
+ # The 1900 system contains a day that never existed: serial 60 is 1900-02-29, kept
174
+ # deliberately because Lotus 1-2-3 wrongly treated 1900 as a leap year and Excel copied
175
+ # the bug for file compatibility. It is :malformed here — the same verdict .date gives
176
+ # the text "1900-02-29" — so every serial above it is shifted one day against a naive
177
+ # count. An unknown epoch is a caller bug (KeyError), never a verdict.
178
+ def excel_serial(text, epoch)
179
+ code = EXCEL_EPOCHS.fetch(epoch)
180
+ bytes = utf8(text)
181
+ out, fault, = scratch
182
+ rc = Runtime.call(:cast_excel_serial, input_ptr(bytes), bytes.bytesize, code, out, fault)
183
+ verdict(rc, fault) { instant(out[0, 16]) }
184
+ end
185
+
186
+ # Casts a calendar date to a Date. With no order declared: the strict ISO 8601
187
+ # yyyy-MM-dd form only. With a declared order Symbol (:year_month_day,
188
+ # :month_day_year, :day_month_year), also the separated forms — "1/7/2026" is
189
+ # January 7th or July 1st only because the caller said which; an unknown order is a
190
+ # caller bug (KeyError), never a verdict.
191
+ def date(text, order = nil)
192
+ if order.nil?
193
+ plain(:cast_date, text, 4) do |out|
194
+ year, month, day = out.unpack("S<CC")
195
+ Date.new(year, month, day)
196
+ end
197
+ else
198
+ code = DATE_ORDERS.fetch(order)
199
+ bytes = utf8(text)
200
+ out, fault, = scratch
201
+ rc = Runtime.call(:cast_date_ordered, input_ptr(bytes), bytes.bytesize, code, out, fault)
202
+ verdict(rc, fault) do
203
+ year, month, day = out[0, 4].unpack("S<CC")
204
+ Date.new(year, month, day)
205
+ end
206
+ end
207
+ end
208
+
209
+ # Casts a zone-less civil date-time — the shape untrusted feeds actually send
210
+ # ("1/7/2026 3:04 PM", "2026-01-07 15:04:05") — under a declared order Symbol to a
211
+ # stdlib DateTime with exact Rational seconds. No zone is *read*: the text names no
212
+ # instant, and the parse applies no offset. Ruby has no zone-less date-time type, so
213
+ # the value rides a DateTime, whose offset defaults to +00:00 — that zero is a carrier
214
+ # artifact, not data (the same caveat PHP's UTC-labeled DateTimeImmutable carries);
215
+ # fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
216
+ # instant door. An unknown order is a caller bug (KeyError).
217
+ def datetime(text, order)
218
+ code = DATE_ORDERS.fetch(order)
219
+ bytes = utf8(text)
220
+ out, fault, = scratch
221
+ rc = Runtime.call(:cast_datetime, input_ptr(bytes), bytes.bytesize, code, out, fault)
222
+ verdict(rc, fault) do
223
+ year, month, day, nanos = out[0, 16].unpack("S<CCx4Q<")
224
+ second_of_day, frac = nanos.divmod(1_000_000_000)
225
+ hour, rest = second_of_day.divmod(3600)
226
+ minute, second = rest.divmod(60)
227
+ DateTime.new(year, month, day, hour, minute, second + Rational(frac, 1_000_000_000))
228
+ end
229
+ end
230
+
231
+ # Casts an ISO 24-hour time-of-day to an exact Integer of nanoseconds since midnight
232
+ # (Ruby has no time-of-day type; the integer keeps every digit).
233
+ def time(text)
234
+ plain(:cast_time, text, 8) { |out| out.unpack1("Q<") }
235
+ end
236
+
237
+ # Casts a duration (ISO 8601 fixed components, invariant colon form, or protobuf JSON
238
+ # seconds) to exact Rational seconds — full fidelity across the core's ±10,000-year
239
+ # window, no wrapping and no truncation.
240
+ def duration(text)
241
+ plain(:cast_duration, text, 16) do |out|
242
+ seconds, nanos = out.unpack("q<l<")
243
+ Rational(seconds * 1_000_000_000 + nanos, 1_000_000_000)
244
+ end
245
+ end
246
+
247
+ private
248
+
249
+ # Encodings whose bytes already are the UTF-8 (or byte-identical) form the core reads.
250
+ BYTE_COMPATIBLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].freeze
251
+
252
+ # Presents the input as UTF-8 bytes: already-compatible text crosses as-is (Fiddle
253
+ # passes a String's bytes for void* directly — no Pointer wrapper, no dup); only
254
+ # foreign encodings pay a transcode.
255
+ def utf8(text)
256
+ BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
257
+ end
258
+
259
+ # Fiddle spells a null pointer as nil — the core's contract for empty input.
260
+ def input_ptr(bytes)
261
+ bytes.empty? ? nil : bytes
262
+ end
263
+
264
+ # One 36-byte scratch allocation per thread, reused by every call: out-value at 0
265
+ # (16 bytes covers every door), fault span at 16, NumFormat at 24. Two
266
+ # Fiddle::Pointer.malloc(..., RUBY_FREE) calls per cast — each registering a GC
267
+ # finalizer — measured as the dominant per-call cost by an order of magnitude.
268
+ def scratch
269
+ Thread.current[:hypercast_scratch] ||= begin
270
+ base = Fiddle::Pointer.malloc(36, Fiddle::RUBY_FREE)
271
+ [base, base + 16, base + 24]
272
+ end
273
+ end
274
+
275
+ # Presents a native return code as the verdict union: 0 yields a Success, a failure
276
+ # code becomes a Fault, and -1 (contract violation) is a binding bug that raises.
277
+ def verdict(rc, fault)
278
+ if rc.zero?
279
+ Success.new(value: yield)
280
+ elsif rc == -1
281
+ raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
282
+ else
283
+ offset, length = fault[0, 8].unpack("L<L<")
284
+ Fault.new(reason: REASONS.fetch(rc), offset: offset, length: length)
285
+ end
286
+ end
287
+
288
+ # The shared body of every format-free door: one native call over the scratch buffers.
289
+ def plain(symbol, text, out_size)
290
+ bytes = utf8(text)
291
+ out, fault, = scratch
292
+ rc = Runtime.call(symbol, input_ptr(bytes), bytes.bytesize, out, fault)
293
+ verdict(rc, fault) { yield(out[0, out_size]) }
294
+ end
295
+
296
+ # The shared body of the integer/real doors: plain, plus the packed NumFormat.
297
+ def numeric(symbol, text, format, out_size)
298
+ bytes = utf8(text)
299
+ out, fault, raw_format = scratch
300
+ raw_format[0, 12] = packed_cache[format]
301
+ rc = Runtime.call(symbol, input_ptr(bytes), bytes.bytesize, raw_format, out, fault)
302
+ verdict(rc, fault) { yield(out[0, out_size]) }
303
+ end
304
+
305
+ # Identity-keyed memo of NumFormat#packed — formats are reused constants in practice,
306
+ # and re-packing per call is a measurable allocation. The race is benign (idempotent).
307
+ def packed_cache
308
+ @packed_cache ||= Hash.new { |cache, format| cache[format] = format.packed }
309
+ end
310
+
311
+ # Builds a UTC Time from the core's protobuf-shaped {seconds, nanos} pair, exactly.
312
+ def instant(bytes)
313
+ seconds, nanos = bytes.unpack("q<l<")
314
+ Time.at(seconds, nanos, :nanosecond, in: "UTC")
315
+ end
316
+ end
317
+ end
318
+
319
+ # --- backend selection: the Magnus extension, when present, replaces the doors above in
320
+ # place on this module (no delegation layer) — Fiddle's measured 1.6 µs per-call floor
321
+ # drops to an ordinary extension call. The pure-Fiddle definitions stay the universal
322
+ # zero-compile fallback; precompiled platform gems are how the extension ships without
323
+ # ever making a consumer compile anything. Set HYPERCAST_PURE=1 to force Fiddle.
324
+ HyperCast::BACKEND =
325
+ if ENV["HYPERCAST_PURE"]
326
+ :fiddle
327
+ else
328
+ begin
329
+ require "hypercast_native"
330
+ :native
331
+ rescue LoadError
332
+ :fiddle
333
+ end
334
+ end
Binary file
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hypercast
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: x86_64-linux
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/native/linux-arm64/libhypercast.so
68
+ - lib/hypercast/native/linux-x64/libhypercast.so
69
+ - lib/hypercast/native/osx-arm64/libhypercast.dylib
70
+ - lib/hypercast/native/osx-x64/libhypercast.dylib
71
+ - lib/hypercast/native/win-arm64/hypercast.dll
72
+ - lib/hypercast/native/win-x64/hypercast.dll
73
+ - lib/hypercast/native_platform.rb
74
+ - lib/hypercast/runtime.rb
75
+ - lib/hypercast_native.so
76
+ homepage: https://github.com/SkunkWerkx/HyperCast
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ source_code_uri: https://github.com/SkunkWerkx/HyperCast
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '3.2'
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 3.6.9
96
+ specification_version: 4
97
+ summary: Allocation-free scalar parsing as Success/Fault verdicts over a native Rust
98
+ core
99
+ test_files: []