hypercast 0.2.0-aarch64-linux → 0.4.0-aarch64-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 +4 -4
- data/README.md +385 -55
- data/lib/hypercast/3.4/hypercast_native.so +0 -0
- data/lib/hypercast/4.0/hypercast_native.so +0 -0
- data/lib/hypercast/native/linux-arm64/libhypercast.so +0 -0
- data/lib/hypercast/native/linux-musl-arm64/libhypercast.so +0 -0
- data/lib/hypercast/native/linux-musl-x64/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/wasm32-wasip1/hypercast.wasm +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 +13 -7
- data/lib/hypercast/runtime.rb +40 -15
- data/lib/hypercast/wasm_runtime.rb +169 -0
- data/lib/hypercast.rb +271 -67
- metadata +20 -10
data/lib/hypercast.rb
CHANGED
|
@@ -2,61 +2,101 @@ require "date"
|
|
|
2
2
|
require_relative "hypercast/native_platform"
|
|
3
3
|
require_relative "hypercast/runtime"
|
|
4
4
|
|
|
5
|
-
# Allocation-lean scalar casts — booleans, numerics, UUIDs, temporals —
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# Allocation-lean scalar casts — booleans, numerics, exact decimals, UUIDs, temporals —
|
|
6
|
+
# from one Rust core, reached through whichever of three backends this install can load:
|
|
7
|
+
# the core linked straight into a Magnus native extension (shipped precompiled in the
|
|
8
|
+
# platform gems), the native libhypercast shared library called through Fiddle (the
|
|
9
|
+
# universal gem bundles one per supported platform), or the same core as a WebAssembly
|
|
10
|
+
# module inside the wasmtime gem. HyperCast::BACKEND names the one that loaded; the
|
|
11
|
+
# selection logic is at the bottom of this file. Every door returns a verdict: Success or
|
|
12
|
+
# Fault (a closed reason plus the offending span), never an exception for bad data — the
|
|
13
|
+
# only exceptions here are caller bugs (a malformed NumFormat, an undeclared option, text
|
|
14
|
+
# that is not a String), never data, and the same exception on every backend.
|
|
9
15
|
#
|
|
10
16
|
# Consume with Ruby's own pattern matching over the two Data case types:
|
|
11
17
|
#
|
|
12
18
|
# case HyperCast.i32("(1,234)", HyperCast::NumFormat::INVARIANT)
|
|
13
19
|
# in HyperCast::Success(value:) then puts "got #{value}" # -1234
|
|
14
|
-
# in HyperCast::Fault(reason:, offset:) then puts "#{reason} at
|
|
20
|
+
# in HyperCast::Fault(reason:, offset:) then puts "#{reason} at #{offset}"
|
|
15
21
|
# end
|
|
16
22
|
#
|
|
17
23
|
# Door names mirror the native ABI (i32, f64, timestamp, ...) so the polyglot surface reads
|
|
18
24
|
# identically across bindings. Ruby-flavored fidelity: Integer is unbounded (u64 comes back
|
|
19
25
|
# 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,
|
|
21
|
-
# back as exact Rational seconds
|
|
22
|
-
#
|
|
26
|
+
# window, time-of-day is an exact Integer of nanoseconds since midnight, durations come
|
|
27
|
+
# back as exact Rational seconds, and the decimal door returns an exact Decimal (sign,
|
|
28
|
+
# 96-bit magnitude, base-10 scale) that never rounds — no truncation anywhere, and no
|
|
29
|
+
# wrapping: Ruby and the JVM are the fidelity kings of this roster.
|
|
23
30
|
module HyperCast
|
|
24
31
|
# This gem's own version — kept in lockstep with hypercast.gemspec by the
|
|
25
32
|
# prepare-release workflow, so the two can never drift apart again.
|
|
26
|
-
VERSION = "0.
|
|
33
|
+
VERSION = "0.4.0"
|
|
27
34
|
|
|
28
35
|
# The success case of a verdict: a cast value.
|
|
29
36
|
Success = Data.define(:value)
|
|
30
37
|
|
|
31
38
|
# The failure case: a closed reason Symbol (:empty, :malformed, :out_of_range) plus the
|
|
32
|
-
# offending span
|
|
33
|
-
#
|
|
39
|
+
# offending span, in the units String#[] slices by on the text you passed: character
|
|
40
|
+
# offsets for text (in any encoding — the core reads UTF-8 and the span is mapped back),
|
|
41
|
+
# byte offsets for a binary (ASCII-8BIT) String, whose characters are its bytes. Either
|
|
42
|
+
# way `text[offset, length]` is the offending text, with no mapping on your side.
|
|
43
|
+
# Nothing is captured — slicing it out of the input is the caller's choice.
|
|
34
44
|
Fault = Data.define(:reason, :offset, :length)
|
|
35
45
|
|
|
36
46
|
# The native core's failure codes, mapped to the closed reason Symbols a Fault carries.
|
|
37
47
|
REASONS = { 1 => :empty, 2 => :malformed, 3 => :out_of_range }.freeze
|
|
38
48
|
|
|
39
|
-
# Caller-declared numeric notation for the integer and
|
|
40
|
-
# (INVARIANT, or a literal), never defaulted, the same stance every binding takes.
|
|
41
|
-
#
|
|
42
|
-
|
|
49
|
+
# Caller-declared numeric notation for the integer, real and decimal doors — declared out
|
|
50
|
+
# loud (INVARIANT, or a literal), never defaulted, the same stance every binding takes.
|
|
51
|
+
# The currency symbol is the one field a culture table would fill in: it is declared
|
|
52
|
+
# here, never looked up, and honored only while the CURRENCY flag is set — declared with
|
|
53
|
+
# the flag off, the symbol is a :malformed Fault at the symbol, and the flag with no
|
|
54
|
+
# symbol ("" — the default) matches nothing. Equal separators, or a symbol longer than
|
|
55
|
+
# 16 UTF-8 bytes or carrying an ASCII digit or ASCII whitespace, are a caller bug
|
|
56
|
+
# (ArgumentError), never a verdict.
|
|
57
|
+
NumFormat = Data.define(:decimal_sep, :group_sep, :flags, :currency) do
|
|
43
58
|
# Validates the declared separators up front — single characters, and distinct from
|
|
44
|
-
# each other —
|
|
45
|
-
|
|
59
|
+
# each other — and the currency symbol (a String of at most 16 UTF-8 bytes with no
|
|
60
|
+
# ASCII digit or ASCII whitespace, since those would collide with the digit scan and
|
|
61
|
+
# the trimming around the symbol), so a malformed format fails loudly as the caller bug
|
|
62
|
+
# it is. All three are stored transcoded to UTF-8, the encoding the core reads — a
|
|
63
|
+
# separator handed over in another encoding (a no-break space read from a Latin-1
|
|
64
|
+
# file, say) is the same code point on every backend rather than whatever its bytes
|
|
65
|
+
# happened to spell in the encoding it arrived in.
|
|
66
|
+
def initialize(decimal_sep:, group_sep:, flags:, currency: "")
|
|
46
67
|
raise ArgumentError, "separators must be single characters" unless
|
|
47
68
|
decimal_sep.is_a?(String) && decimal_sep.length == 1 &&
|
|
48
69
|
group_sep.is_a?(String) && group_sep.length == 1
|
|
70
|
+
|
|
71
|
+
decimal_sep = decimal_sep.encode(Encoding::UTF_8)
|
|
72
|
+
group_sep = group_sep.encode(Encoding::UTF_8)
|
|
49
73
|
raise ArgumentError, "decimal and group separators must differ; both are #{decimal_sep.inspect}" if
|
|
50
74
|
decimal_sep == group_sep
|
|
51
|
-
|
|
75
|
+
raise ArgumentError, "currency symbol must be a String; got #{currency.inspect}" unless currency.is_a?(String)
|
|
76
|
+
|
|
77
|
+
symbol = currency.encode(Encoding::UTF_8)
|
|
78
|
+
limit = self.class::CURRENCY_MAX_BYTES
|
|
79
|
+
raise ArgumentError, "currency symbol #{currency.inspect} exceeds #{limit} UTF-8 bytes" if
|
|
80
|
+
symbol.bytesize > limit
|
|
81
|
+
raise ArgumentError, "currency symbol #{currency.inspect} must not contain an ASCII digit or whitespace" if
|
|
82
|
+
symbol.match?(/[0-9\t\n\f\r ]/)
|
|
83
|
+
|
|
84
|
+
super(decimal_sep: decimal_sep, group_sep: group_sep, flags: flags, currency: symbol)
|
|
52
85
|
end
|
|
53
86
|
|
|
54
|
-
# The
|
|
87
|
+
# The 32-byte little-endian form the native ABI's NumFormat struct expects: the two
|
|
88
|
+
# separators as code points, the flags, the symbol's byte length, then the symbol's
|
|
89
|
+
# UTF-8 bytes zero-padded to 16.
|
|
55
90
|
def packed
|
|
56
|
-
[decimal_sep.ord, group_sep.ord, flags].pack("L<L<L<")
|
|
91
|
+
[decimal_sep.ord, group_sep.ord, flags, currency.bytesize].pack("L<L<L<L<") + [currency].pack("a16")
|
|
57
92
|
end
|
|
58
93
|
end
|
|
59
94
|
|
|
95
|
+
# The widest currency symbol the native ABI carries inline, in UTF-8 bytes. Assigned out
|
|
96
|
+
# here rather than inside the Data.define block above: a constant written in that block
|
|
97
|
+
# is scoped lexically, and landed on HyperCast instead of on NumFormat.
|
|
98
|
+
NumFormat::CURRENCY_MAX_BYTES = 16
|
|
99
|
+
|
|
60
100
|
# Permit the group separator between digits (sizes not validated — between digits is the rule).
|
|
61
101
|
GROUPING = 1
|
|
62
102
|
# Permit accounting parentheses as negation: (1,234) is -1234.
|
|
@@ -65,7 +105,7 @@ module HyperCast
|
|
|
65
105
|
EXPONENT = 1 << 2
|
|
66
106
|
# Permit 0x/&H/0b two's-complement radix prefixes (0xFF is -1 for an i8).
|
|
67
107
|
RADIX_PREFIXES = 1 << 3
|
|
68
|
-
# Permit a trailing %, dividing by 100. Real doors only.
|
|
108
|
+
# Permit a trailing %, dividing by 100. Real and decimal doors only.
|
|
69
109
|
PERCENT = 1 << 4
|
|
70
110
|
# Resolve the ./, roles per input from structure instead of the declared separators
|
|
71
111
|
# (which are ignored while this flag is set). Detection, not sniffing: a repeated
|
|
@@ -75,11 +115,18 @@ module HyperCast
|
|
|
75
115
|
# Genuinely ambiguous input ("12.185", "1,000") is a :malformed Fault at the separator,
|
|
76
116
|
# never guessed.
|
|
77
117
|
SEPARATOR_DETECT = 1 << 5
|
|
118
|
+
# Permit the format's declared currency symbol at either edge of the numeric body —
|
|
119
|
+
# leading ("$5", "-$5", "$ -5") or trailing ("5 €", "1.234,50 kr."), once, with optional
|
|
120
|
+
# ASCII whitespace between symbol and digits; accounting parentheses wrap the symbol
|
|
121
|
+
# along with the digits ("($5)"). With no symbol declared the flag matches nothing.
|
|
122
|
+
# Integer, real and decimal doors.
|
|
123
|
+
CURRENCY = 1 << 6
|
|
78
124
|
# Every lenience on (SEPARATOR_DETECT is a separator policy, not a lenience, and is
|
|
79
125
|
# deliberately not included).
|
|
80
|
-
ALL_STYLES = GROUPING | PARENTHESES | EXPONENT | RADIX_PREFIXES | PERCENT
|
|
126
|
+
ALL_STYLES = GROUPING | PARENTHESES | EXPONENT | RADIX_PREFIXES | PERCENT | CURRENCY
|
|
81
127
|
|
|
82
|
-
# The invariant profile — '.' decimal, ',' grouping, every lenience on
|
|
128
|
+
# The invariant profile — '.' decimal, ',' grouping, every lenience on, no currency
|
|
129
|
+
# symbol declared.
|
|
83
130
|
NumFormat::INVARIANT = NumFormat.new(decimal_sep: ".", group_sep: ",", flags: ALL_STYLES)
|
|
84
131
|
|
|
85
132
|
# The detection profile — every lenience on, ./, roles resolved per input by
|
|
@@ -100,6 +147,46 @@ module HyperCast
|
|
|
100
147
|
# en-GB order) only because the caller said which.
|
|
101
148
|
DATE_ORDERS = { year_month_day: 1, month_day_year: 2, day_month_year: 3 }.freeze
|
|
102
149
|
|
|
150
|
+
# An exact decimal, the decimal door's value: an unbounded Integer magnitude (the core's
|
|
151
|
+
# 96-bit unsigned, so at most 2**96 - 1), an Integer base-10 scale (0..28 — the number of
|
|
152
|
+
# places the magnitude is shifted right), and a negative flag. The value is
|
|
153
|
+
# (negative ? -1 : 1) * magnitude * 10**-scale. The triple is canonical: exact trailing
|
|
154
|
+
# zeros in the fraction are always trimmed, so the scale is minimal ("1.10", "1.1" and
|
|
155
|
+
# "1.1000" are all magnitude 11, scale 1; "100" stays magnitude 100, scale 0), and zero
|
|
156
|
+
# is scale 0 and never negative. Nothing but a zero is ever dropped — text carrying more
|
|
157
|
+
# precision than the core holds is an :out_of_range Fault, never an approximation.
|
|
158
|
+
# Convert with to_r (exact Rational), to_s (the canonical text), or to_d (BigDecimal).
|
|
159
|
+
Decimal = Data.define(:magnitude, :scale, :negative) do
|
|
160
|
+
# True for a negative value; zero is never negative.
|
|
161
|
+
def negative?
|
|
162
|
+
negative
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The value as an exact Rational.
|
|
166
|
+
def to_r
|
|
167
|
+
value = Rational(magnitude, 10**scale)
|
|
168
|
+
negative ? -value : value
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The canonical text form — "-1234.5", "-0.025", "0": the minimal scale rendered
|
|
172
|
+
# plainly, no exponent — the same string every binding renders and the conformance
|
|
173
|
+
# corpus pins as `value`.
|
|
174
|
+
def to_s
|
|
175
|
+
digits = magnitude.to_s.rjust(scale + 1, "0")
|
|
176
|
+
text = scale.zero? ? digits : "#{digits[0, digits.length - scale]}.#{digits[-scale, scale]}"
|
|
177
|
+
negative ? "-#{text}" : text
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The value as a BigDecimal, built exactly from the canonical text. Requires the
|
|
181
|
+
# bigdecimal gem (a bundled gem since Ruby 3.4, not a dependency of this one — add it
|
|
182
|
+
# to your Gemfile under Bundler), loaded lazily on first use so a consumer who never
|
|
183
|
+
# calls this pays nothing for it.
|
|
184
|
+
def to_d
|
|
185
|
+
require "bigdecimal"
|
|
186
|
+
BigDecimal(to_s)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
103
190
|
class << self
|
|
104
191
|
# Presents a verdict optionally: an :empty fault becomes nil (Ruby's absent),
|
|
105
192
|
# everything else flows through untouched.
|
|
@@ -109,6 +196,24 @@ module HyperCast
|
|
|
109
196
|
verdict
|
|
110
197
|
end
|
|
111
198
|
|
|
199
|
+
# Whether a backend actually loaded and exports the ABI this binding was built against
|
|
200
|
+
# — what a consumer with a fallback of its own checks before committing to these doors.
|
|
201
|
+
# Probed once (a native_version round trip: the cheapest call the core has), cached,
|
|
202
|
+
# and never raises: a missing shared library, an older core without the version
|
|
203
|
+
# export, or a wasm module the wasmtime gem cannot instantiate all answer false. The
|
|
204
|
+
# doors themselves keep their own behavior — the first call on an unavailable backend
|
|
205
|
+
# raises its precise LoadError, exactly as before — and so does require-time selection
|
|
206
|
+
# under HYPERCAST_WASM=1 without wasmtime; this only answers the question quietly.
|
|
207
|
+
def available?
|
|
208
|
+
return @available unless @available.nil?
|
|
209
|
+
|
|
210
|
+
@available = begin
|
|
211
|
+
native_version.is_a?(String)
|
|
212
|
+
rescue LoadError, StandardError
|
|
213
|
+
false
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
112
217
|
# Casts boolean text: true/false plus the conventions untrusted sources actually send
|
|
113
218
|
# (t/f, yes/no, y/n, 1/0, on/off, enabled/disabled, active/inactive,
|
|
114
219
|
# checked/unchecked, in/out), ASCII case-insensitive.
|
|
@@ -142,6 +247,19 @@ module HyperCast
|
|
|
142
247
|
numeric(:cast_f64, text, format, 8) { |out| out.unpack1("E") }
|
|
143
248
|
end
|
|
144
249
|
|
|
250
|
+
# Casts decimal text to an exact Decimal — the real doors' grammar (declared separators
|
|
251
|
+
# and grouping, parens, exponent, trailing percent, declared currency), but no float is
|
|
252
|
+
# ever formed: "0.1" is one tenth, "50%" is exactly 0.5 (magnitude 5, scale 1), and
|
|
253
|
+
# "1.10" is canonical magnitude 11, scale 1 — exact trailing zeros are always trimmed.
|
|
254
|
+
# A magnitude past 2**96 - 1, or more fractional precision than 28 places can hold, is
|
|
255
|
+
# an :out_of_range Fault — the door never rounds.
|
|
256
|
+
def decimal(text, format)
|
|
257
|
+
numeric(:cast_decimal, text, format, 16) do |out|
|
|
258
|
+
lo, hi, scale, negative = out.unpack("Q<L<CCx2")
|
|
259
|
+
Decimal.new(magnitude: (hi << 64) | lo, scale: scale, negative: negative != 0)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
145
263
|
# Casts UUID text — all five .NET Guid formats (D/N/B/P/X) plus urn:uuid:/GUID:/UUID:
|
|
146
264
|
# prefixes — to Ruby's UUID lingua franca: the lowercase hyphenated String (the same
|
|
147
265
|
# shape SecureRandom.uuid returns).
|
|
@@ -161,11 +279,7 @@ module HyperCast
|
|
|
161
279
|
# (:seconds/:milliseconds/:microseconds/:nanoseconds) to a UTC Time. An unknown unit
|
|
162
280
|
# is a caller bug (KeyError), never a verdict.
|
|
163
281
|
def unix(text, precision)
|
|
164
|
-
|
|
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]) }
|
|
282
|
+
declared(:cast_unix, text, UNIX_PRECISIONS.fetch(precision), 16) { |out| instant(out) }
|
|
169
283
|
end
|
|
170
284
|
|
|
171
285
|
# Casts an Excel date serial under a caller-declared epoch Symbol (:y1900/:y1904) to a
|
|
@@ -179,11 +293,7 @@ module HyperCast
|
|
|
179
293
|
# the text "1900-02-29" — so every serial above it is shifted one day against a naive
|
|
180
294
|
# count. An unknown epoch is a caller bug (KeyError), never a verdict.
|
|
181
295
|
def excel_serial(text, epoch)
|
|
182
|
-
|
|
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]) }
|
|
296
|
+
declared(:cast_excel_serial, text, EXCEL_EPOCHS.fetch(epoch), 16) { |out| instant(out) }
|
|
187
297
|
end
|
|
188
298
|
|
|
189
299
|
# Casts a calendar date to a Date. With no order declared: the strict ISO 8601
|
|
@@ -198,12 +308,8 @@ module HyperCast
|
|
|
198
308
|
Date.new(year, month, day)
|
|
199
309
|
end
|
|
200
310
|
else
|
|
201
|
-
|
|
202
|
-
|
|
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")
|
|
311
|
+
declared(:cast_date_ordered, text, DATE_ORDERS.fetch(order), 4) do |out|
|
|
312
|
+
year, month, day = out.unpack("S<CC")
|
|
207
313
|
Date.new(year, month, day)
|
|
208
314
|
end
|
|
209
315
|
end
|
|
@@ -218,12 +324,8 @@ module HyperCast
|
|
|
218
324
|
# fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
|
|
219
325
|
# instant door. An unknown order is a caller bug (KeyError).
|
|
220
326
|
def datetime(text, order)
|
|
221
|
-
|
|
222
|
-
|
|
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<")
|
|
327
|
+
declared(:cast_datetime, text, DATE_ORDERS.fetch(order), 16) do |out|
|
|
328
|
+
year, month, day, nanos = out.unpack("S<CCx4Q<")
|
|
227
329
|
second_of_day, frac = nanos.divmod(1_000_000_000)
|
|
228
330
|
hour, rest = second_of_day.divmod(3600)
|
|
229
331
|
minute, second = rest.divmod(60)
|
|
@@ -247,15 +349,35 @@ module HyperCast
|
|
|
247
349
|
end
|
|
248
350
|
end
|
|
249
351
|
|
|
352
|
+
# The version of the native core actually loaded, as "major.minor.patch" — read from
|
|
353
|
+
# the library itself, not from this gem, so a consumer can prove the core behind the
|
|
354
|
+
# doors is the one this binding was built against (and name the mismatch when it is
|
|
355
|
+
# not). The cheapest possible probe that the backend resolved at all: takes nothing,
|
|
356
|
+
# cannot fail.
|
|
357
|
+
def native_version
|
|
358
|
+
word = packed_version
|
|
359
|
+
"#{word >> 16}.#{(word >> 8) & 0xFF}.#{word & 0xFF}"
|
|
360
|
+
end
|
|
361
|
+
|
|
250
362
|
private
|
|
251
363
|
|
|
252
364
|
# Encodings whose bytes already are the UTF-8 (or byte-identical) form the core reads.
|
|
253
365
|
BYTE_COMPATIBLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].freeze
|
|
254
366
|
|
|
255
367
|
# Presents the input as UTF-8 bytes: already-compatible text crosses as-is (Fiddle
|
|
256
|
-
# passes a String's bytes for void*
|
|
257
|
-
#
|
|
368
|
+
# passes a String's own bytes for void* — no copy of them); only foreign encodings pay
|
|
369
|
+
# a transcode, and text that cannot be transcoded raises String#encode's own
|
|
370
|
+
# Encoding:: error, the same one the Magnus extension raises through the same call.
|
|
371
|
+
#
|
|
372
|
+
# Text that is not a String is a caller bug, and the same TypeError on every backend:
|
|
373
|
+
# the extension takes its argument through Ruby's implicit String conversion (to_str,
|
|
374
|
+
# or a TypeError), so this does too.
|
|
258
375
|
def utf8(text)
|
|
376
|
+
unless text.is_a?(String)
|
|
377
|
+
converted = String.try_convert(text) or
|
|
378
|
+
raise TypeError, "no implicit conversion of #{text.class} into String"
|
|
379
|
+
text = converted
|
|
380
|
+
end
|
|
259
381
|
BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
|
|
260
382
|
end
|
|
261
383
|
|
|
@@ -265,11 +387,12 @@ module HyperCast
|
|
|
265
387
|
end
|
|
266
388
|
|
|
267
389
|
# One 24-byte scratch allocation per thread, reused by every call: out-value at 0
|
|
268
|
-
# (16 bytes covers every door
|
|
269
|
-
# RUBY_FREE) calls per cast — each
|
|
270
|
-
# dominant per-call cost by an order of
|
|
271
|
-
#
|
|
272
|
-
# nothing into scratch before
|
|
390
|
+
# (16 bytes covers every door, and malloc's alignment covers the decimal's 8), fault
|
|
391
|
+
# span at 16. Two Fiddle::Pointer.malloc(..., RUBY_FREE) calls per cast — each
|
|
392
|
+
# registering a GC finalizer — measured as the dominant per-call cost by an order of
|
|
393
|
+
# magnitude. The NumFormat no longer lives here: each format owns its own packed
|
|
394
|
+
# pointer (see packed_cache), so a numeric call copies nothing into scratch before
|
|
395
|
+
# crossing.
|
|
273
396
|
def scratch
|
|
274
397
|
Thread.current[:hypercast_scratch] ||= begin
|
|
275
398
|
base = Fiddle::Pointer.malloc(24, Fiddle::RUBY_FREE)
|
|
@@ -278,44 +401,87 @@ module HyperCast
|
|
|
278
401
|
end
|
|
279
402
|
|
|
280
403
|
# Presents a native return code as the verdict union: 0 yields a Success, a failure
|
|
281
|
-
# code becomes a Fault
|
|
282
|
-
|
|
404
|
+
# code becomes a Fault over `bytes` (the UTF-8 the core read), and -1 (contract
|
|
405
|
+
# violation) is a binding bug that raises.
|
|
406
|
+
def verdict(rc, fault, bytes)
|
|
283
407
|
if rc.zero?
|
|
284
408
|
Success.new(value: yield)
|
|
285
409
|
elsif rc == -1
|
|
286
410
|
raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
|
|
287
411
|
else
|
|
288
|
-
offset, length = fault[0, 8].unpack("L<L<")
|
|
412
|
+
offset, length = characters(bytes, *fault[0, 8].unpack("L<L<"))
|
|
289
413
|
Fault.new(reason: REASONS.fetch(rc), offset: offset, length: length)
|
|
290
414
|
end
|
|
291
415
|
end
|
|
292
416
|
|
|
417
|
+
# The core's byte span in the units String#[] slices by: an identity for a binary
|
|
418
|
+
# String (its characters are its bytes) and for ASCII text (`ascii_only?` reads the
|
|
419
|
+
# cached coderange — no scan), a byte-to-character remap otherwise. Character counts
|
|
420
|
+
# survive transcoding, so a span mapped on the UTF-8 form indexes the caller's own
|
|
421
|
+
# String whatever encoding it arrived in. Failure path only: a Success never pays.
|
|
422
|
+
def characters(bytes, offset, length)
|
|
423
|
+
return [offset, length] if bytes.encoding == Encoding::ASCII_8BIT || bytes.ascii_only?
|
|
424
|
+
|
|
425
|
+
[bytes.byteslice(0, offset).length, bytes.byteslice(offset, length).length]
|
|
426
|
+
end
|
|
427
|
+
|
|
293
428
|
# The shared body of every format-free door: one native call over the scratch buffers.
|
|
294
429
|
def plain(symbol, text, out_size)
|
|
295
430
|
bytes = utf8(text)
|
|
296
431
|
out, fault = scratch
|
|
297
432
|
rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, out, fault)
|
|
298
|
-
verdict(rc, fault) { yield(out[0, out_size]) }
|
|
433
|
+
verdict(rc, fault, bytes) { yield(out[0, out_size]) }
|
|
299
434
|
end
|
|
300
435
|
|
|
301
|
-
# The shared body of the integer/real doors: plain, plus the format's own
|
|
302
|
-
# pointer, passed straight through — no per-call copy of the
|
|
436
|
+
# The shared body of the integer/real/decimal doors: plain, plus the format's own
|
|
437
|
+
# packed pointer, passed straight through — no per-call copy of the 32 bytes into
|
|
438
|
+
# scratch.
|
|
303
439
|
def numeric(symbol, text, format, out_size)
|
|
304
440
|
bytes = utf8(text)
|
|
305
441
|
out, fault = scratch
|
|
306
442
|
rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, packed_cache[format], out, fault)
|
|
307
|
-
verdict(rc, fault) { yield(out[0, out_size]) }
|
|
443
|
+
verdict(rc, fault, bytes) { yield(out[0, out_size]) }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# The shared body of the four doors that take a caller-declared u32 — a precision, an
|
|
447
|
+
# epoch, or a field order — already resolved from its Symbol by the door. These bodies
|
|
448
|
+
# (plain, numeric, declared, and packed_version below) are the whole native crossing:
|
|
449
|
+
# the wasm backend (wasm_runtime.rb) redefines exactly these four in place and nothing
|
|
450
|
+
# above them.
|
|
451
|
+
def declared(symbol, text, code, out_size)
|
|
452
|
+
bytes = utf8(text)
|
|
453
|
+
out, fault = scratch
|
|
454
|
+
rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, code, out, fault)
|
|
455
|
+
verdict(rc, fault, bytes) { yield(out[0, out_size]) }
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# The loaded core's version word, major << 16 | minor << 8 | patch, straight from the
|
|
459
|
+
# library's zero-argument hypercast_version export.
|
|
460
|
+
def packed_version
|
|
461
|
+
Runtime.function(:hypercast_version).call
|
|
308
462
|
end
|
|
309
463
|
|
|
464
|
+
# How many formats packed_cache remembers before it starts forgetting the oldest. Far
|
|
465
|
+
# more than a process that reuses its formats ever holds; what it bounds is the one
|
|
466
|
+
# that builds a NumFormat per request.
|
|
467
|
+
PACKED_CACHE_LIMIT = 64
|
|
468
|
+
|
|
310
469
|
# Identity-keyed memo (compare_by_identity — a pointer hash, not Data's structural
|
|
311
|
-
# #hash over
|
|
470
|
+
# #hash over three Strings and an Integer) of a native 32-byte RawNumFormat per format
|
|
312
471
|
# object, filled once from NumFormat#packed. Formats are reused constants in practice,
|
|
313
472
|
# so the common call finds its pointer in one lookup and packs nothing. The Hash holds
|
|
314
473
|
# the format, so a key can never be a recycled address; the race is benign (idempotent).
|
|
474
|
+
#
|
|
475
|
+
# Bounded, oldest entry out first (a Hash keeps insertion order): unbounded, it held
|
|
476
|
+
# every format ever cast with and its 32-byte allocation for the life of the process,
|
|
477
|
+
# which a consumer building a format per request experienced as a leak. A format
|
|
478
|
+
# evicted and then used again is simply packed again, and an evicted pointer stays
|
|
479
|
+
# alive for any call already holding it — the caller's own reference outlives the cache's.
|
|
315
480
|
def packed_cache
|
|
316
481
|
@packed_cache ||= Hash.new do |cache, format|
|
|
317
|
-
|
|
318
|
-
pointer
|
|
482
|
+
cache.shift while cache.size >= PACKED_CACHE_LIMIT
|
|
483
|
+
pointer = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
|
|
484
|
+
pointer[0, 32] = format.packed
|
|
319
485
|
cache[format] = pointer
|
|
320
486
|
end.compare_by_identity
|
|
321
487
|
end
|
|
@@ -333,16 +499,39 @@ end
|
|
|
333
499
|
# drops to an ordinary extension call. The pure-Fiddle definitions stay the universal
|
|
334
500
|
# zero-compile fallback; precompiled platform gems are how the extension ships without
|
|
335
501
|
# ever making a consumer compile anything. Set HYPERCAST_PURE=1 to force Fiddle.
|
|
502
|
+
#
|
|
503
|
+
# The third backend is WebAssembly (lib/hypercast/wasm_runtime.rb): the same core as a
|
|
504
|
+
# wasm32-wasip1 module, run in-process by the `wasmtime` gem, which is deliberately not a
|
|
505
|
+
# runtime dependency of this gem — a consumer who wants it installs it. HYPERCAST_WASM=1
|
|
506
|
+
# forces it (and fails loudly if wasmtime is missing); otherwise it is only ever chosen when
|
|
507
|
+
# there is no native library for this platform at all and wasmtime happens to be available,
|
|
508
|
+
# so no supported platform's behavior changes by its existence.
|
|
509
|
+
#
|
|
510
|
+
# Both variables are read for presence, not value — set to anything at all, "0" and the
|
|
511
|
+
# empty string included, they force their backend — and HYPERCAST_WASM wins when both are
|
|
512
|
+
# set.
|
|
336
513
|
HyperCast::BACKEND =
|
|
337
|
-
if ENV["
|
|
514
|
+
if ENV["HYPERCAST_WASM"]
|
|
515
|
+
begin
|
|
516
|
+
require "wasmtime"
|
|
517
|
+
rescue LoadError
|
|
518
|
+
raise LoadError,
|
|
519
|
+
"hypercast: HYPERCAST_WASM=1 needs the wasmtime gem — `gem install wasmtime` (or add it to your Gemfile)"
|
|
520
|
+
end
|
|
521
|
+
require_relative "hypercast/wasm_runtime"
|
|
522
|
+
:wasm
|
|
523
|
+
elsif ENV["HYPERCAST_PURE"]
|
|
338
524
|
:fiddle
|
|
339
525
|
else
|
|
340
526
|
# Two layouts, and both have to work. A released platform gem is a "fat" gem carrying one
|
|
341
527
|
# extension per supported Ruby ABI under lib/hypercast/<minor>/ (see the Rakefile's
|
|
342
528
|
# native:gem task for why an ABI-per-file is unavoidable — Magnus has no `abi3`
|
|
343
|
-
# equivalent)
|
|
344
|
-
#
|
|
345
|
-
#
|
|
529
|
+
# equivalent), and `rake native:dev` — the local dev loop — stages its own build at that
|
|
530
|
+
# same versioned path. CI's in-job staging additionally drops a single extension flat at
|
|
531
|
+
# lib/, which is also where a hand copy of `cargo ruby-ext`'s output goes (the alias only
|
|
532
|
+
# builds into rust/target/ruby/release/; nothing but that rake task copies it here).
|
|
533
|
+
# Trying the versioned path first and the flat one second means neither has to know the
|
|
534
|
+
# other exists.
|
|
346
535
|
#
|
|
347
536
|
# A miss on both is not an error: it means this Ruby/platform combination has no
|
|
348
537
|
# precompiled extension, which is precisely what the Fiddle backend is for.
|
|
@@ -354,7 +543,22 @@ HyperCast::BACKEND =
|
|
|
354
543
|
require "hypercast_native"
|
|
355
544
|
:native
|
|
356
545
|
rescue LoadError
|
|
357
|
-
|
|
546
|
+
if HyperCast::Runtime.fiddle_library_available?
|
|
547
|
+
:fiddle
|
|
548
|
+
else
|
|
549
|
+
# No shared library for this platform either. wasmtime, if the consumer has it,
|
|
550
|
+
# is the only backend left that can run here; without it, stay on Fiddle so the
|
|
551
|
+
# first call raises its own precise error — a "not found" LoadError naming the
|
|
552
|
+
# path, or NativePlatform::UnsupportedPlatformError naming the platform — rather
|
|
553
|
+
# than a vaguer one from here. HyperCast.available? is the quiet way to ask.
|
|
554
|
+
begin
|
|
555
|
+
require "wasmtime"
|
|
556
|
+
require_relative "hypercast/wasm_runtime"
|
|
557
|
+
:wasm
|
|
558
|
+
rescue LoadError
|
|
559
|
+
:fiddle
|
|
560
|
+
end
|
|
561
|
+
end
|
|
358
562
|
end
|
|
359
563
|
end
|
|
360
564
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hypercast
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: aarch64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Buvinghausen
|
|
@@ -51,12 +51,14 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '0.9'
|
|
54
|
-
description:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
description: |
|
|
55
|
+
Booleans, numerics, exact decimals, UUIDs, and temporals cast from untrusted text by a
|
|
56
|
+
native Rust core — every parse returns a verdict (the value, or a reason plus the
|
|
57
|
+
offending span), never an exception for bad data. Three backends behind one surface,
|
|
58
|
+
selected automatically and all shipped prebuilt: a Magnus extension where a precompiled
|
|
59
|
+
platform gem matches, stdlib Fiddle everywhere else, and the same core as a WebAssembly
|
|
60
|
+
module for anyone who installs the wasmtime gem. No runtime bridge, no dependencies
|
|
61
|
+
beyond Fiddle.
|
|
60
62
|
executables: []
|
|
61
63
|
extensions: []
|
|
62
64
|
extra_rdoc_files: []
|
|
@@ -67,18 +69,26 @@ files:
|
|
|
67
69
|
- lib/hypercast/3.4/hypercast_native.so
|
|
68
70
|
- lib/hypercast/4.0/hypercast_native.so
|
|
69
71
|
- lib/hypercast/native/linux-arm64/libhypercast.so
|
|
72
|
+
- lib/hypercast/native/linux-musl-arm64/libhypercast.so
|
|
73
|
+
- lib/hypercast/native/linux-musl-x64/libhypercast.so
|
|
70
74
|
- lib/hypercast/native/linux-x64/libhypercast.so
|
|
71
75
|
- lib/hypercast/native/osx-arm64/libhypercast.dylib
|
|
72
76
|
- lib/hypercast/native/osx-x64/libhypercast.dylib
|
|
77
|
+
- lib/hypercast/native/wasm32-wasip1/hypercast.wasm
|
|
73
78
|
- lib/hypercast/native/win-arm64/hypercast.dll
|
|
74
79
|
- lib/hypercast/native/win-x64/hypercast.dll
|
|
75
80
|
- lib/hypercast/native_platform.rb
|
|
76
81
|
- lib/hypercast/runtime.rb
|
|
82
|
+
- lib/hypercast/wasm_runtime.rb
|
|
77
83
|
homepage: https://github.com/SkunkWerkx/HyperCast
|
|
78
84
|
licenses:
|
|
79
85
|
- MIT
|
|
80
86
|
metadata:
|
|
81
87
|
source_code_uri: https://github.com/SkunkWerkx/HyperCast
|
|
88
|
+
changelog_uri: https://github.com/SkunkWerkx/HyperCast/blob/master/CHANGELOG.md
|
|
89
|
+
bug_tracker_uri: https://github.com/SkunkWerkx/HyperCast/issues
|
|
90
|
+
documentation_uri: https://github.com/SkunkWerkx/HyperCast/tree/master/ruby#readme
|
|
91
|
+
rubygems_mfa_required: 'true'
|
|
82
92
|
rdoc_options: []
|
|
83
93
|
require_paths:
|
|
84
94
|
- lib
|
|
@@ -96,8 +106,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
96
106
|
- !ruby/object:Gem::Version
|
|
97
107
|
version: '0'
|
|
98
108
|
requirements: []
|
|
99
|
-
rubygems_version: 4.0.
|
|
109
|
+
rubygems_version: 4.0.20
|
|
100
110
|
specification_version: 4
|
|
101
|
-
summary:
|
|
102
|
-
|
|
111
|
+
summary: Scalar parsing as Success/Fault verdicts over a native Rust core, shipped
|
|
112
|
+
prebuilt
|
|
103
113
|
test_files: []
|