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