hypercast-wasm 0.6.1 → 0.7.0
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 +47 -11
- 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/lib/hypercast/interop.rb +118 -0
- data/lib/hypercast/native_platform.rb +8 -6
- data/lib/hypercast/runtime.rb +10 -9
- data/lib/hypercast.rb +80 -63
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 292a3d7a2650810b8c764162479aed060b9e05e04a15e12843bbe9d4a1e5f4ec
|
|
4
|
+
data.tar.gz: '066569a04c5f82dd1ca427c58b0483c3fa0505bd414fe80add69dea5002a9667'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c654511c4167a2addf7cb4894811c755bd6a9a9a5b7ae9bf3c35b7d24ff36cac233af75d61e7430dc7b08ef84dacf53801bf7853415a0887cbb8a61d9839b6b
|
|
7
|
+
data.tar.gz: 3e7cefe5d32a2c8f0495eb37a93460499ed2673c7452b12f9eb6b2fa90ff5258607042564460e972027f23ba486b5210d758fa9efb49d4ae98534dc3b9754fa6
|
data/README.md
CHANGED
|
@@ -50,6 +50,10 @@ midnight, durations come back as exact `Rational` seconds across the core's whol
|
|
|
50
50
|
| `datetime(text, order)` | zone-less `DateTime`, exact `Rational` seconds | a field order, as `date` |
|
|
51
51
|
| `time(text)` | `Integer` nanoseconds since midnight | — |
|
|
52
52
|
| `duration(text)` | exact `Rational` seconds | — |
|
|
53
|
+
| `decimal_from_float(value)` | `HyperCast::Decimal`, the shortest that names the number (`0.1` is one tenth) | — |
|
|
54
|
+
| `excel_serial_from_float(value, epoch)` | zone-less `DateTime`, as `datetime` | `:y1900` / `:y1904` |
|
|
55
|
+
| `excel_time(value)` | `Integer` nanoseconds since midnight | — |
|
|
56
|
+
| `excel_duration(value)` | exact `Rational` seconds | — |
|
|
53
57
|
|
|
54
58
|
Every door returns a `Success` or a `Fault`; `HyperCast.optional(verdict)` folds `:empty`
|
|
55
59
|
to `nil`. Beside the doors, `native_version` returns the loaded core's
|
|
@@ -156,6 +160,38 @@ Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code
|
|
|
156
160
|
flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
|
|
157
161
|
and memoized by identity on every backend, so declaring a currency costs a cast nothing.
|
|
158
162
|
|
|
163
|
+
## Interop: building on HyperCast's C ABI
|
|
164
|
+
|
|
165
|
+
For a gem that carries HyperCast's verdicts across a C ABI of its own, as HyperTabular does:
|
|
166
|
+
it reads the core's out-values out of its own buffers and needs the values the doors would
|
|
167
|
+
have returned. `HyperCast::Interop` is the code the Fiddle backend's doors decode with, so
|
|
168
|
+
the two cannot drift. It is loaded on every backend.
|
|
169
|
+
|
|
170
|
+
- `SCALARS` — the `String#unpack` directive for one value of each scalar door (`:i32` is
|
|
171
|
+
`"l<"`; a bool is the byte 0 or 1).
|
|
172
|
+
- `RECORDS` — for each record door (`:decimal`, `:uuid`, `:timestamp`, `:date`, …), the
|
|
173
|
+
directive that unpacks one value, how many fields that yields, and a lambda
|
|
174
|
+
`(fields, at)` that builds the Ruby value from them. To decode a column, append `"*"` and
|
|
175
|
+
step that many fields at a time.
|
|
176
|
+
- `VALUE_BYTES` — the bytes one value of each door takes.
|
|
177
|
+
- `decode(door, bytes)` — one value, from the bytes the core wrote for it.
|
|
178
|
+
- `fault(code, offset, length)` — the `Fault` a nonzero verdict code names; a code that names
|
|
179
|
+
no reason is a `KeyError`.
|
|
180
|
+
- `characters(bytes, offset, length)` — the core's byte span as the character span
|
|
181
|
+
`String#[]` slices by.
|
|
182
|
+
- `version(word)` — a packed `*_version` word as `"major.minor.patch"`.
|
|
183
|
+
- `library_path(library, native_dir, repo_root)` — the shared library to `dlopen`: the
|
|
184
|
+
staged `native/{rid}/` build, else the in-repo cargo build, else `nil`.
|
|
185
|
+
|
|
186
|
+
`NativePlatform.rid_and_library_name(library: "hypertabular")` gives the `{rid}` directory
|
|
187
|
+
and file name for any SkunkWerkx core; `library:` defaults to `"hypercast"`.
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
HyperCast::Interop.decode(:i32, [-7].pack("l<")) # => -7
|
|
191
|
+
HyperCast::Interop.fault(2, 3, 1) # => Fault, reason :malformed
|
|
192
|
+
HyperCast::Interop.version(0x000700) # => "0.7.0"
|
|
193
|
+
```
|
|
194
|
+
|
|
159
195
|
## Why not `Integer()` / `Time.iso8601` / `Float()`?
|
|
160
196
|
|
|
161
197
|
1. **Verdicts, not exceptions** — bad data is the expected case for untrusted text; a
|
|
@@ -169,12 +205,12 @@ and memoized by identity on every backend, so declaring a currency costs a cast
|
|
|
169
205
|
boundary).
|
|
170
206
|
4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
|
|
171
207
|
benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-x64 on an Intel Core
|
|
172
|
-
i9-11900H, Ruby 4.0.7): timestamp **
|
|
208
|
+
i9-11900H, Ruby 4.0.7): timestamp **404 ns vs 2.70 µs `Time.iso8601`** (6.7x) — while
|
|
173
209
|
returning exact `Rational` durations on the duration door. The Fiddle fallback lands at
|
|
174
|
-
|
|
210
|
+
2.85 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
|
|
175
211
|
|
|
176
212
|
Separator detection is nearly free here: `1.234.567,89` under `NumFormat::DETECT` runs
|
|
177
|
-
at
|
|
213
|
+
at 154 ns against 145 ns for the same text under a declared eurozone format. Both cost
|
|
178
214
|
nearly twice that in 0.1.0, for a reason that had nothing to do with parsing: every format other than `INVARIANT` paid three method dispatches and two
|
|
179
215
|
`String` allocations per call to read its separators back out of the `Data`. `DETECT`
|
|
180
216
|
is now identity-matched like `INVARIANT`, and any other format is resolved once per
|
|
@@ -213,14 +249,14 @@ both backends:
|
|
|
213
249
|
|
|
214
250
|
| Door | Magnus | Fiddle |
|
|
215
251
|
|---|---:|---:|
|
|
216
|
-
| `bool` |
|
|
217
|
-
| `i32` |
|
|
218
|
-
| `f64` |
|
|
219
|
-
| `uuid` |
|
|
220
|
-
| `timestamp` |
|
|
221
|
-
| `datetime` (`1/7/2026 3:04 PM`) |
|
|
222
|
-
| `duration` (ISO) |
|
|
223
|
-
| `i32`, a fault |
|
|
252
|
+
| `bool` | 109 ns | 2.30 µs |
|
|
253
|
+
| `i32` | 120 ns | 2.30 µs |
|
|
254
|
+
| `f64` | 134 ns | 2.44 µs |
|
|
255
|
+
| `uuid` | 209 ns | 3.28 µs |
|
|
256
|
+
| `timestamp` | 404 ns | 2.85 µs |
|
|
257
|
+
| `datetime` (`1/7/2026 3:04 PM`) | 877 ns | 3.71 µs |
|
|
258
|
+
| `duration` (ISO) | 578 ns | 2.72 µs |
|
|
259
|
+
| `i32`, a fault | 202 ns | 2.88 µs |
|
|
224
260
|
|
|
225
261
|
A lean door on the Magnus backend is little more than the native call: the extension builds
|
|
226
262
|
the `Success` or `Fault` it returns directly — allocated, its members stored, frozen —
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
module HyperCast
|
|
2
|
+
# The native core's C ABI values, presented — for a gem that carries HyperCast's verdicts
|
|
3
|
+
# across a C ABI of its own (HyperTabular does): how each door's out-value unpacks and what
|
|
4
|
+
# builds the Ruby value from it, the verdict code and span of a fault, the packed version
|
|
5
|
+
# word, and where a gem's native library is. The Fiddle backend's doors decode with exactly
|
|
6
|
+
# these, so a value read out of another library's buffer is the value the door would have.
|
|
7
|
+
#
|
|
8
|
+
# Every directive below unpacks one value. A gem decoding a whole column appends "*" and
|
|
9
|
+
# steps +fields+ at a time.
|
|
10
|
+
module Interop
|
|
11
|
+
# The doors whose value is one String#unpack directive away: the directive for one value.
|
|
12
|
+
# A bool is the byte 0 or 1, unpacked as "C" and compared with zero.
|
|
13
|
+
SCALARS = {
|
|
14
|
+
bool: "C", i8: "c", i16: "s<", i32: "l<", i64: "q<", u8: "C", u16: "S<", u32: "L<",
|
|
15
|
+
u64: "Q<", f32: "e", f64: "E", time: "Q<"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# The doors whose value is a record: the directive that unpacks one, how many fields that
|
|
19
|
+
# yields, and what builds the door's Ruby value from them, starting at field +at+.
|
|
20
|
+
RECORDS = {
|
|
21
|
+
decimal: ["Q<L<CCx2", 4, ->(fields, at) { decimal(fields[at], fields[at + 1], fields[at + 2], fields[at + 3]) }],
|
|
22
|
+
uuid: ["H8H4H4H4H12", 5, ->(fields, at) { fields[at, 5].join("-") }],
|
|
23
|
+
timestamp: ["q<l<x4", 2, ->(fields, at) { instant(fields[at], fields[at + 1]) }],
|
|
24
|
+
date: ["S<CC", 3, ->(fields, at) { Date.new(fields[at], fields[at + 1], fields[at + 2]) }],
|
|
25
|
+
datetime: ["S<CCx4Q<", 4, ->(fields, at) { civil(fields[at], fields[at + 1], fields[at + 2], fields[at + 3]) }],
|
|
26
|
+
duration: ["q<l<x4", 2, ->(fields, at) { duration(fields[at], fields[at + 1]) }]
|
|
27
|
+
}.tap do |records|
|
|
28
|
+
records[:unix] = records[:excel_serial] = records[:timestamp]
|
|
29
|
+
records[:date_ordered] = records[:date]
|
|
30
|
+
end.freeze
|
|
31
|
+
|
|
32
|
+
# The bytes one value of each door takes.
|
|
33
|
+
VALUE_BYTES = {
|
|
34
|
+
bool: 1, i8: 1, u8: 1, i16: 2, u16: 2, i32: 4, u32: 4, f32: 4, date: 4, date_ordered: 4,
|
|
35
|
+
i64: 8, u64: 8, f64: 8, time: 8,
|
|
36
|
+
decimal: 16, uuid: 16, timestamp: 16, unix: 16, excel_serial: 16, datetime: 16, duration: 16
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Encodings whose bytes already are the UTF-8 (or byte-identical) form the core reads.
|
|
40
|
+
BYTE_COMPATIBLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].freeze
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# One value of +door+, from the bytes the core wrote for it.
|
|
45
|
+
def decode(door, bytes)
|
|
46
|
+
if (directive = SCALARS[door])
|
|
47
|
+
value = bytes.unpack1(directive)
|
|
48
|
+
door == :bool ? value != 0 : value
|
|
49
|
+
else
|
|
50
|
+
directive, _fields, build = RECORDS.fetch(door)
|
|
51
|
+
build.call(bytes.unpack(directive), 0)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A UTC Time from the core's protobuf-shaped {seconds, nanos} pair, exactly — the
|
|
56
|
+
# timestamp, unix and excel_serial doors' value. (Time.at then #utc is the same Time as
|
|
57
|
+
# Time.at(..., in: "UTC"), at half the cost: the zone's name is not read each time.)
|
|
58
|
+
def instant(seconds, nanos)
|
|
59
|
+
Time.at(seconds, nanos, :nanosecond).utc
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The zone-less DateTime a civil date-time names, with exact Rational seconds — the
|
|
63
|
+
# datetime door's value. Its +00:00 offset is a carrier artifact, not data.
|
|
64
|
+
def civil(year, month, day, nanos_of_day)
|
|
65
|
+
second_of_day, frac = nanos_of_day.divmod(1_000_000_000)
|
|
66
|
+
hour, rest = second_of_day.divmod(3600)
|
|
67
|
+
minute, second = rest.divmod(60)
|
|
68
|
+
DateTime.new(year, month, day, hour, minute, second + Rational(frac, 1_000_000_000))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Exact Rational seconds from the core's same-signed {seconds, nanos} pair — the duration
|
|
72
|
+
# door's value.
|
|
73
|
+
def duration(seconds, nanos)
|
|
74
|
+
Rational(seconds * 1_000_000_000 + nanos, 1_000_000_000)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A Decimal from the core's {lo, hi, scale, negative} — the decimal door's value.
|
|
78
|
+
def decimal(lo, hi, scale, negative)
|
|
79
|
+
Decimal.new(magnitude: (hi << 64) | lo, scale: scale, negative: negative != 0)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The Fault a nonzero verdict code and its span name. A code that names no reason is a
|
|
83
|
+
# binding bug, not data: a KeyError.
|
|
84
|
+
def fault(code, offset, length)
|
|
85
|
+
Fault.new(reason: REASONS.fetch(code), offset: offset, length: length)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The core's byte span over +bytes+, in the units String#[] slices by: an identity for a
|
|
89
|
+
# binary String (its characters are its bytes) and for ASCII text (`ascii_only?` reads the
|
|
90
|
+
# cached coderange — no scan), a byte-to-character remap otherwise. Character counts
|
|
91
|
+
# survive transcoding, so a span mapped on the UTF-8 form indexes the caller's own String
|
|
92
|
+
# whatever encoding it arrived in.
|
|
93
|
+
def characters(bytes, offset, length)
|
|
94
|
+
return [offset, length] if bytes.encoding == Encoding::ASCII_8BIT || bytes.ascii_only?
|
|
95
|
+
|
|
96
|
+
[bytes.byteslice(0, offset).length, bytes.byteslice(offset, length).length]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A native library's packed version word — major << 16 | minor << 8 | patch, as a
|
|
100
|
+
# *_version export returns it — as "major.minor.patch".
|
|
101
|
+
def version(word)
|
|
102
|
+
"#{word >> 16}.#{(word >> 8) & 0xFF}.#{word & 0xFF}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The shared library to dlopen for +library+ ("hypercast" for libhypercast.so): the
|
|
106
|
+
# gem's native/{rid}/{file} under +native_dir+, or — the development loop — the cargo
|
|
107
|
+
# build under +repo_root+/rust/target/release, exactly what the other bindings' local
|
|
108
|
+
# staging does. Nil when neither exists.
|
|
109
|
+
def library_path(library, native_dir, repo_root)
|
|
110
|
+
rid, file = NativePlatform.rid_and_library_name(library: library)
|
|
111
|
+
path = File.join(native_dir, rid, file)
|
|
112
|
+
return path if File.exist?(path)
|
|
113
|
+
|
|
114
|
+
repo_build = File.join(repo_root, "rust", "target", "release", file)
|
|
115
|
+
File.exist?(repo_build) ? repo_build : nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -1,27 +1,29 @@
|
|
|
1
1
|
module HyperCast
|
|
2
2
|
# Maps the running RUBY_PLATFORM to the RID-style directory (matching the other bindings'
|
|
3
|
-
# runtimes/{rid}/native/ / native/{rid}/ convention) and
|
|
3
|
+
# runtimes/{rid}/native/ / native/{rid}/ convention) and file name of a native library.
|
|
4
|
+
# Every SkunkWerkx core ships the same way, so +library+ names whose: "hypercast" for this
|
|
5
|
+
# gem's libhypercast, "hypertabular" for HyperTabular's.
|
|
4
6
|
module NativePlatform
|
|
5
7
|
class UnsupportedPlatformError < StandardError; end
|
|
6
8
|
|
|
7
9
|
# +platform+ is a parameter only so the specs can walk the whole table from one host;
|
|
8
10
|
# every real caller takes the default.
|
|
9
|
-
def self.rid_and_library_name(platform = RUBY_PLATFORM)
|
|
11
|
+
def self.rid_and_library_name(platform = RUBY_PLATFORM, library: "hypercast")
|
|
10
12
|
arch = platform.match?(/arm64|aarch64/) ? "arm64" : "x64"
|
|
11
13
|
|
|
12
14
|
case platform
|
|
13
15
|
when /mingw|mswin|windows/
|
|
14
|
-
["win-#{arch}", "
|
|
16
|
+
["win-#{arch}", "#{library}.dll"]
|
|
15
17
|
when /darwin/
|
|
16
|
-
["osx-#{arch}", "
|
|
18
|
+
["osx-#{arch}", "lib#{library}.dylib"]
|
|
17
19
|
when /linux/
|
|
18
20
|
# Two C libraries, two builds: a glibc-linked library cannot be relied on to dlopen
|
|
19
21
|
# into a musl process (Alpine), so musl gets RIDs of its own. Ruby names the libc
|
|
20
22
|
# in its platform string there ("x86_64-linux-musl") and leaves it off on glibc.
|
|
21
23
|
os = platform.include?("musl") ? "linux-musl" : "linux"
|
|
22
|
-
["#{os}-#{arch}", "
|
|
24
|
+
["#{os}-#{arch}", "lib#{library}.so"]
|
|
23
25
|
else
|
|
24
|
-
raise UnsupportedPlatformError, "
|
|
26
|
+
raise UnsupportedPlatformError, "#{library}: unsupported platform RUBY_PLATFORM=#{platform}"
|
|
25
27
|
end
|
|
26
28
|
end
|
|
27
29
|
end
|
data/lib/hypercast/runtime.rb
CHANGED
|
@@ -17,7 +17,9 @@ module HyperCast
|
|
|
17
17
|
# Each door's C signature, by shape — resolved to Fiddle types only in load_functions,
|
|
18
18
|
# so nothing here touches Fiddle until the Fiddle backend actually runs: plain is
|
|
19
19
|
# (text, len, out, fault), numeric adds the packed NumFormat pointer before out, and
|
|
20
|
-
# declared a caller-declared u32 (precision, epoch, field order) in the same place.
|
|
20
|
+
# declared a caller-declared u32 (precision, epoch, field order) in the same place. The
|
|
21
|
+
# typed doors take a double instead of the text and its length: typed is (value, out,
|
|
22
|
+
# fault), typed_declared adds the u32 after the value.
|
|
21
23
|
DOORS = {
|
|
22
24
|
cast_bool: :plain,
|
|
23
25
|
cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
|
|
@@ -26,7 +28,9 @@ module HyperCast
|
|
|
26
28
|
cast_uuid: :plain,
|
|
27
29
|
cast_timestamp: :plain, cast_unix: :declared, cast_excel_serial: :declared,
|
|
28
30
|
cast_date: :plain, cast_date_ordered: :declared, cast_datetime: :declared,
|
|
29
|
-
cast_time: :plain, cast_duration: :plain
|
|
31
|
+
cast_time: :plain, cast_duration: :plain,
|
|
32
|
+
cast_decimal_from_f64: :typed, cast_excel_serial_from_f64: :typed_declared,
|
|
33
|
+
cast_excel_time: :typed, cast_excel_duration: :typed
|
|
30
34
|
}.freeze
|
|
31
35
|
|
|
32
36
|
# The one export that is not a door: the zero-argument version probe, returning the
|
|
@@ -60,12 +64,7 @@ module HyperCast
|
|
|
60
64
|
# development loop — the in-repo cargo build, exactly what the other bindings' local
|
|
61
65
|
# staging does. Nil when neither exists.
|
|
62
66
|
def library_path
|
|
63
|
-
|
|
64
|
-
path = File.join(NATIVE_DIR, rid, lib_name)
|
|
65
|
-
return path if File.exist?(path)
|
|
66
|
-
|
|
67
|
-
repo_build = File.expand_path(File.join(__dir__, "../../../rust/target/release", lib_name))
|
|
68
|
-
File.exist?(repo_build) ? repo_build : nil
|
|
67
|
+
Interop.library_path("hypercast", NATIVE_DIR, File.expand_path("../../..", __dir__))
|
|
69
68
|
end
|
|
70
69
|
|
|
71
70
|
# Why Fiddle found nothing to load. A precompiled platform gem is the one install where
|
|
@@ -114,7 +113,9 @@ module HyperCast
|
|
|
114
113
|
signatures = {
|
|
115
114
|
plain: plain,
|
|
116
115
|
numeric: plain.dup.insert(2, Fiddle::TYPE_VOIDP),
|
|
117
|
-
declared: plain.dup.insert(2, Fiddle::TYPE_UINT32_T)
|
|
116
|
+
declared: plain.dup.insert(2, Fiddle::TYPE_UINT32_T),
|
|
117
|
+
typed: [Fiddle::TYPE_DOUBLE, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP],
|
|
118
|
+
typed_declared: [Fiddle::TYPE_DOUBLE, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
|
|
118
119
|
}
|
|
119
120
|
functions = DOORS.to_h do |name, shape|
|
|
120
121
|
[name, Fiddle::Function.new(handle[name.to_s], signatures.fetch(shape), Fiddle::TYPE_INT32_T)]
|
data/lib/hypercast.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "date"
|
|
2
2
|
require_relative "hypercast/native_platform"
|
|
3
|
+
require_relative "hypercast/interop"
|
|
3
4
|
require_relative "hypercast/runtime"
|
|
4
5
|
|
|
5
6
|
# Allocation-lean scalar casts — booleans, numerics, exact decimals, UUIDs, temporals —
|
|
@@ -29,7 +30,7 @@ require_relative "hypercast/runtime"
|
|
|
29
30
|
module HyperCast
|
|
30
31
|
# This gem's own version — kept in lockstep with hypercast.gemspec by the
|
|
31
32
|
# prepare-release workflow, so the two can never drift apart again.
|
|
32
|
-
VERSION = "0.
|
|
33
|
+
VERSION = "0.7.0"
|
|
33
34
|
|
|
34
35
|
# The success case of a verdict: a cast value.
|
|
35
36
|
Success = Data.define(:value)
|
|
@@ -216,13 +217,12 @@ module HyperCast
|
|
|
216
217
|
# (t/f, yes/no, y/n, 1/0, on/off, enabled/disabled, active/inactive,
|
|
217
218
|
# checked/unchecked, in/out), ASCII case-insensitive.
|
|
218
219
|
def bool(text)
|
|
219
|
-
plain(:cast_bool, text, 1) { |out|
|
|
220
|
+
plain(:cast_bool, text, 1) { |out| Interop.decode(:bool, out) }
|
|
220
221
|
end
|
|
221
222
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
size = sizes.fetch(unpack)
|
|
223
|
+
%i[i8 i16 i32 i64 u8 u16 u32 u64].each do |door|
|
|
224
|
+
unpack = Interop::SCALARS.fetch(door)
|
|
225
|
+
size = Interop::VALUE_BYTES.fetch(door)
|
|
226
226
|
# Resolved once here, not inside the method: interpolating a Symbol per call built a
|
|
227
227
|
# String and interned it on every integer cast.
|
|
228
228
|
symbol = :"cast_#{door}"
|
|
@@ -237,12 +237,12 @@ module HyperCast
|
|
|
237
237
|
# Casts real text to an IEEE single (widened losslessly on the way out): finite values
|
|
238
238
|
# only, declared separators and grouping, parens, exponent, and trailing percent.
|
|
239
239
|
def f32(text, format)
|
|
240
|
-
numeric(:cast_f32, text, format, 4) { |out|
|
|
240
|
+
numeric(:cast_f32, text, format, 4) { |out| Interop.decode(:f32, out) }
|
|
241
241
|
end
|
|
242
242
|
|
|
243
243
|
# Casts real text to an IEEE double. Notation rules as f32.
|
|
244
244
|
def f64(text, format)
|
|
245
|
-
numeric(:cast_f64, text, format, 8) { |out|
|
|
245
|
+
numeric(:cast_f64, text, format, 8) { |out| Interop.decode(:f64, out) }
|
|
246
246
|
end
|
|
247
247
|
|
|
248
248
|
# Casts decimal text to an exact Decimal — the real doors' grammar (declared separators
|
|
@@ -252,32 +252,27 @@ module HyperCast
|
|
|
252
252
|
# A magnitude past 2**96 - 1, or more fractional precision than 28 places can hold, is
|
|
253
253
|
# an :out_of_range Fault — the door never rounds.
|
|
254
254
|
def decimal(text, format)
|
|
255
|
-
numeric(:cast_decimal, text, format, 16)
|
|
256
|
-
lo, hi, scale, negative = out.unpack("Q<L<CCx2")
|
|
257
|
-
Decimal.new(magnitude: (hi << 64) | lo, scale: scale, negative: negative != 0)
|
|
258
|
-
end
|
|
255
|
+
numeric(:cast_decimal, text, format, 16) { |out| Interop.decode(:decimal, out) }
|
|
259
256
|
end
|
|
260
257
|
|
|
261
258
|
# Casts UUID text — all five .NET Guid formats (D/N/B/P/X) plus urn:uuid:/GUID:/UUID:
|
|
262
259
|
# prefixes — to Ruby's UUID lingua franca: the lowercase hyphenated String (the same
|
|
263
260
|
# shape SecureRandom.uuid returns).
|
|
264
261
|
def uuid(text)
|
|
265
|
-
plain(:cast_uuid, text, 16)
|
|
266
|
-
out.unpack("H8H4H4H4H12").join("-")
|
|
267
|
-
end
|
|
262
|
+
plain(:cast_uuid, text, 16) { |out| Interop.decode(:uuid, out) }
|
|
268
263
|
end
|
|
269
264
|
|
|
270
265
|
# Casts an RFC 3339 instant — zone mandatory — to a UTC Time at full nanosecond
|
|
271
266
|
# fidelity across the whole 0001-9999 window.
|
|
272
267
|
def timestamp(text)
|
|
273
|
-
plain(:cast_timestamp, text, 16) { |out|
|
|
268
|
+
plain(:cast_timestamp, text, 16) { |out| Interop.decode(:timestamp, out) }
|
|
274
269
|
end
|
|
275
270
|
|
|
276
271
|
# Casts an integer Unix-epoch value under a caller-declared unit Symbol
|
|
277
272
|
# (:seconds/:milliseconds/:microseconds/:nanoseconds) to a UTC Time. An unknown unit
|
|
278
273
|
# is a caller bug (KeyError), never a verdict.
|
|
279
274
|
def unix(text, precision)
|
|
280
|
-
declared(:cast_unix, text, UNIX_PRECISIONS.fetch(precision), 16) { |out|
|
|
275
|
+
declared(:cast_unix, text, UNIX_PRECISIONS.fetch(precision), 16) { |out| Interop.decode(:unix, out) }
|
|
281
276
|
end
|
|
282
277
|
|
|
283
278
|
# Casts an Excel date serial under a caller-declared epoch Symbol (:y1900/:y1904) to a
|
|
@@ -291,7 +286,7 @@ module HyperCast
|
|
|
291
286
|
# the text "1900-02-29" — so every serial above it is shifted one day against a naive
|
|
292
287
|
# count. An unknown epoch is a caller bug (KeyError), never a verdict.
|
|
293
288
|
def excel_serial(text, epoch)
|
|
294
|
-
declared(:cast_excel_serial, text, EXCEL_EPOCHS.fetch(epoch), 16) { |out|
|
|
289
|
+
declared(:cast_excel_serial, text, EXCEL_EPOCHS.fetch(epoch), 16) { |out| Interop.decode(:excel_serial, out) }
|
|
295
290
|
end
|
|
296
291
|
|
|
297
292
|
# Casts a calendar date to a Date. With no order declared: the strict ISO 8601
|
|
@@ -301,15 +296,9 @@ module HyperCast
|
|
|
301
296
|
# caller bug (KeyError), never a verdict.
|
|
302
297
|
def date(text, order = nil)
|
|
303
298
|
if order.nil?
|
|
304
|
-
plain(:cast_date, text, 4)
|
|
305
|
-
year, month, day = out.unpack("S<CC")
|
|
306
|
-
Date.new(year, month, day)
|
|
307
|
-
end
|
|
299
|
+
plain(:cast_date, text, 4) { |out| Interop.decode(:date, out) }
|
|
308
300
|
else
|
|
309
|
-
declared(:cast_date_ordered, text, DATE_ORDERS.fetch(order), 4)
|
|
310
|
-
year, month, day = out.unpack("S<CC")
|
|
311
|
-
Date.new(year, month, day)
|
|
312
|
-
end
|
|
301
|
+
declared(:cast_date_ordered, text, DATE_ORDERS.fetch(order), 4) { |out| Interop.decode(:date_ordered, out) }
|
|
313
302
|
end
|
|
314
303
|
end
|
|
315
304
|
|
|
@@ -322,29 +311,51 @@ module HyperCast
|
|
|
322
311
|
# fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
|
|
323
312
|
# instant door. An unknown order is a caller bug (KeyError).
|
|
324
313
|
def datetime(text, order)
|
|
325
|
-
declared(:cast_datetime, text, DATE_ORDERS.fetch(order), 16)
|
|
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
|
|
314
|
+
declared(:cast_datetime, text, DATE_ORDERS.fetch(order), 16) { |out| Interop.decode(:datetime, out) }
|
|
332
315
|
end
|
|
333
316
|
|
|
334
317
|
# Casts an ISO 24-hour time-of-day to an exact Integer of nanoseconds since midnight
|
|
335
318
|
# (Ruby has no time-of-day type; the integer keeps every digit).
|
|
336
319
|
def time(text)
|
|
337
|
-
plain(:cast_time, text, 8) { |out|
|
|
320
|
+
plain(:cast_time, text, 8) { |out| Interop.decode(:time, out) }
|
|
338
321
|
end
|
|
339
322
|
|
|
340
323
|
# Casts a duration (ISO 8601 fixed components, invariant colon form, or protobuf JSON
|
|
341
324
|
# seconds) to exact Rational seconds — full fidelity across the core's ±10,000-year
|
|
342
325
|
# window, no wrapping and no truncation.
|
|
343
326
|
def duration(text)
|
|
344
|
-
plain(:cast_duration, text, 16)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
327
|
+
plain(:cast_duration, text, 16) { |out| Interop.decode(:duration, out) }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Reads a number a caller already holds — an Integer, Float or Rational, the way Ruby's
|
|
331
|
+
# own Float conversion takes it; anything else, a String included, is a TypeError — as
|
|
332
|
+
# the exact Decimal it names: the shortest decimal that rounds back to the Float, the
|
|
333
|
+
# digits a spreadsheet writes for it. 0.1 is magnitude 1, scale 1, not the binary
|
|
334
|
+
# fraction nearest it; 0.1 + 0.2 is 0.30000000000000004. NaN is :malformed; an infinity,
|
|
335
|
+
# a magnitude past 2**96 - 1 or more than 28 places is :out_of_range. A typed door's
|
|
336
|
+
# Fault has no span: offset and length are 0.
|
|
337
|
+
def decimal_from_float(value)
|
|
338
|
+
typed(:cast_decimal_from_f64, float(value), 16) { |out| Interop.decode(:decimal, out) }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Reads an Excel serial number under a declared epoch Symbol (:y1900/:y1904) as the
|
|
342
|
+
# zone-less DateTime it names — the twin of excel_serial for a number a workbook reader
|
|
343
|
+
# already holds, on the same carrier datetime uses. The 1900 system's phantom serial 60
|
|
344
|
+
# is :out_of_range; a negative, NaN or infinite serial is :malformed.
|
|
345
|
+
def excel_serial_from_float(value, epoch)
|
|
346
|
+
number = float(value)
|
|
347
|
+
typed(:cast_excel_serial_from_f64, number, 16, EXCEL_EPOCHS.fetch(epoch)) { |out| Interop.decode(:datetime, out) }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Reads the fraction of an Excel serial number as an exact Integer of nanoseconds since
|
|
351
|
+
# midnight, as time does; 0.75 and 45292.75 are both 18:00.
|
|
352
|
+
def excel_time(value)
|
|
353
|
+
typed(:cast_excel_time, float(value), 8) { |out| Interop.decode(:time, out) }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Reads a number of days as exact Rational seconds, as duration does: 1.5 is 129600.
|
|
357
|
+
def excel_duration(value)
|
|
358
|
+
typed(:cast_excel_duration, float(value), 16) { |out| Interop.decode(:duration, out) }
|
|
348
359
|
end
|
|
349
360
|
|
|
350
361
|
# The version of the native core actually loaded, as "major.minor.patch" — read from
|
|
@@ -353,15 +364,11 @@ module HyperCast
|
|
|
353
364
|
# not). The cheapest possible probe that the backend resolved at all: takes nothing,
|
|
354
365
|
# cannot fail.
|
|
355
366
|
def native_version
|
|
356
|
-
|
|
357
|
-
"#{word >> 16}.#{(word >> 8) & 0xFF}.#{word & 0xFF}"
|
|
367
|
+
Interop.version(packed_version)
|
|
358
368
|
end
|
|
359
369
|
|
|
360
370
|
private
|
|
361
371
|
|
|
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
372
|
# Presents the input as UTF-8 bytes: already-compatible text crosses as-is (Fiddle
|
|
366
373
|
# passes a String's own bytes for void* — no copy of them); only foreign encodings pay
|
|
367
374
|
# a transcode, and text that cannot be transcoded raises String#encode's own
|
|
@@ -376,7 +383,7 @@ module HyperCast
|
|
|
376
383
|
raise TypeError, "no implicit conversion of #{text.class} into String"
|
|
377
384
|
text = converted
|
|
378
385
|
end
|
|
379
|
-
BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
|
|
386
|
+
Interop::BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
|
|
380
387
|
end
|
|
381
388
|
|
|
382
389
|
# Fiddle spells a null pointer as nil — the core's contract for empty input.
|
|
@@ -408,22 +415,10 @@ module HyperCast
|
|
|
408
415
|
elsif rc == -1
|
|
409
416
|
raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
|
|
410
417
|
else
|
|
411
|
-
|
|
412
|
-
Fault.new(reason: REASONS.fetch(rc), offset: offset, length: length)
|
|
418
|
+
Interop.fault(rc, *Interop.characters(bytes, *fault[0, 8].unpack("L<L<")))
|
|
413
419
|
end
|
|
414
420
|
end
|
|
415
421
|
|
|
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
422
|
# The shared body of every format-free door: one native call over the scratch buffers.
|
|
428
423
|
def plain(symbol, text, out_size)
|
|
429
424
|
bytes = utf8(text)
|
|
@@ -452,6 +447,34 @@ module HyperCast
|
|
|
452
447
|
verdict(rc, fault, bytes) { yield(out[0, out_size]) }
|
|
453
448
|
end
|
|
454
449
|
|
|
450
|
+
# A typed door's number, converted the way the extension's argument is (C's
|
|
451
|
+
# rb_num2dbl), with the same exception and message: a Numeric converts (a Complex with an
|
|
452
|
+
# imaginary part is Complex#to_f's RangeError), anything else is a TypeError — a String
|
|
453
|
+
# among them, which Kernel#Float would have parsed.
|
|
454
|
+
def float(value)
|
|
455
|
+
case value
|
|
456
|
+
when Float then value
|
|
457
|
+
when Numeric then Float(value)
|
|
458
|
+
when String then raise TypeError, "no implicit conversion to float from string"
|
|
459
|
+
when nil, true, false then raise TypeError, "no implicit conversion to float from #{value.inspect}"
|
|
460
|
+
else raise TypeError, "can't convert #{value.class} into Float"
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# The shared body of the typed doors: a double instead of the text, then the declared
|
|
465
|
+
# u32 when the door takes one.
|
|
466
|
+
def typed(symbol, number, out_size, *declared)
|
|
467
|
+
out, fault = scratch
|
|
468
|
+
rc = Runtime.function(symbol).call(number, *declared, out, fault)
|
|
469
|
+
if rc.zero?
|
|
470
|
+
Success.new(value: yield(out[0, out_size]))
|
|
471
|
+
elsif rc == -1
|
|
472
|
+
raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
|
|
473
|
+
else
|
|
474
|
+
Interop.fault(rc, 0, 0)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
455
478
|
# The loaded core's version word, major << 16 | minor << 8 | patch, straight from the
|
|
456
479
|
# library's zero-argument hypercast_version export.
|
|
457
480
|
def packed_version
|
|
@@ -482,12 +505,6 @@ module HyperCast
|
|
|
482
505
|
cache[format] = pointer
|
|
483
506
|
end.compare_by_identity
|
|
484
507
|
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
508
|
end
|
|
492
509
|
end
|
|
493
510
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hypercast-wasm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Buvinghausen
|
|
@@ -53,6 +53,7 @@ files:
|
|
|
53
53
|
- ext/hypercast_native/4.0/hypercast_native.a.gz
|
|
54
54
|
- ext/hypercast_native/extconf.rb
|
|
55
55
|
- lib/hypercast.rb
|
|
56
|
+
- lib/hypercast/interop.rb
|
|
56
57
|
- lib/hypercast/native_platform.rb
|
|
57
58
|
- lib/hypercast/runtime.rb
|
|
58
59
|
homepage: https://github.com/SkunkWerkx/HyperCast
|