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.
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 — 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.
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 byte #{offset}"
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, 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.
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.2.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 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.
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 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
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 — so a malformed format fails loudly as the caller bug it is.
45
- def initialize(decimal_sep:, group_sep:, flags:)
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
- super
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 12-byte little-endian form the native ABI's NumFormat struct expects.
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
- code = UNIX_PRECISIONS.fetch(precision)
165
- bytes = utf8(text)
166
- out, fault, = scratch
167
- rc = Runtime.function(:cast_unix).call(input_ptr(bytes), bytes.bytesize, code, out, fault)
168
- verdict(rc, fault) { instant(out[0, 16]) }
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
- code = EXCEL_EPOCHS.fetch(epoch)
183
- bytes = utf8(text)
184
- out, fault, = scratch
185
- rc = Runtime.function(:cast_excel_serial).call(input_ptr(bytes), bytes.bytesize, code, out, fault)
186
- verdict(rc, fault) { instant(out[0, 16]) }
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
- code = DATE_ORDERS.fetch(order)
202
- bytes = utf8(text)
203
- out, fault, = scratch
204
- rc = Runtime.function(:cast_date_ordered).call(input_ptr(bytes), bytes.bytesize, code, out, fault)
205
- verdict(rc, fault) do
206
- year, month, day = out[0, 4].unpack("S<CC")
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
- code = DATE_ORDERS.fetch(order)
222
- bytes = utf8(text)
223
- out, fault, = scratch
224
- rc = Runtime.function(:cast_datetime).call(input_ptr(bytes), bytes.bytesize, code, out, fault)
225
- verdict(rc, fault) do
226
- year, month, day, nanos = out[0, 16].unpack("S<CCx4Q<")
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* directly — no Pointer wrapper, no dup); only
257
- # foreign encodings pay a transcode.
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), fault span at 16. Two Fiddle::Pointer.malloc(...,
269
- # RUBY_FREE) calls per cast — each registering a GC finalizer — measured as the
270
- # dominant per-call cost by an order of magnitude. The NumFormat no longer lives here:
271
- # each format owns its own packed pointer (see packed_cache), so a numeric call copies
272
- # nothing into scratch before crossing.
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, and -1 (contract violation) is a binding bug that raises.
282
- def verdict(rc, fault)
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 packed
302
- # pointer, passed straight through — no per-call copy of the 12 bytes into scratch.
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 two Strings and an Integer) of a native 12-byte RawNumFormat per format
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
- pointer = Fiddle::Pointer.malloc(12, Fiddle::RUBY_FREE)
318
- pointer[0, 12] = format.packed
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["HYPERCAST_PURE"]
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). CI's in-job staging and a local `cargo build --release --features ruby`
344
- # instead drop a single extension flat at lib/. Trying the versioned path first and the
345
- # flat one second means neither has to know the other exists.
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
- :fiddle
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.2.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: '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.'
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.16
109
+ rubygems_version: 4.0.20
100
110
  specification_version: 4
101
- summary: Allocation-free scalar parsing as Success/Fault verdicts over a native Rust
102
- core
111
+ summary: Scalar parsing as Success/Fault verdicts over a native Rust core, shipped
112
+ prebuilt
103
113
  test_files: []