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 +7 -0
- data/LICENSE +21 -0
- data/README.md +79 -0
- data/lib/hypercast/native/linux-arm64/libhypercast.so +0 -0
- data/lib/hypercast/native/linux-x64/libhypercast.so +0 -0
- data/lib/hypercast/native/osx-arm64/libhypercast.dylib +0 -0
- data/lib/hypercast/native/osx-x64/libhypercast.dylib +0 -0
- data/lib/hypercast/native/win-arm64/hypercast.dll +0 -0
- data/lib/hypercast/native/win-x64/hypercast.dll +0 -0
- data/lib/hypercast/native_platform.rb +22 -0
- data/lib/hypercast/runtime.rb +68 -0
- data/lib/hypercast.rb +334 -0
- data/lib/hypercast_native.so +0 -0
- metadata +99 -0
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
|
+
[](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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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: []
|