hypercast 0.6.1-x86_64-linux-gnu → 0.8.0-x86_64-linux-gnu
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 +88 -18
- data/lib/hypercast/3.4/hypercast_native.so +0 -0
- data/lib/hypercast/4.0/hypercast_native.so +0 -0
- data/lib/hypercast/interop.rb +119 -0
- data/lib/hypercast/native_platform.rb +8 -6
- data/lib/hypercast/runtime.rb +11 -10
- data/lib/hypercast.rb +148 -68
- 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: e7dd651d6fbef906514d4873b17dffc7e45fa80e970b6f7636110963c58e6bf1
|
|
4
|
+
data.tar.gz: a3e8e5b91540b87178fb5c00c6d64f54d2b010fd7ab2a2daf8427727ef33128c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 526fc92a7b0f0e1f3c8487504aa666a45a988dd39212ecc8652a126d3115b10ca533c0f7fd6604b68e56dd02be5449e4b150f683391de131ab09279d71cb12be
|
|
7
|
+
data.tar.gz: 6921bb861ac1cc13eab9f3f2a0349dc66827fb4d88eb2b79c514c5ac6f49fb53771a212a3e87e52172e250eabb87f3b06ca602ad0781ca0413e40fa0c671b333
|
data/README.md
CHANGED
|
@@ -9,8 +9,8 @@ Symbol plus the exact span that offended. Two backends, one public surface: a Ma
|
|
|
9
9
|
native extension where a precompiled platform gem covers you, stdlib Fiddle as the last
|
|
10
10
|
resort everywhere else — selected automatically, zero compiles either way.**
|
|
11
11
|
|
|
12
|
-
Allocation-lean scalar casts — booleans, the full integer family, reals, exact
|
|
13
|
-
UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
|
|
12
|
+
Allocation-lean scalar casts — booleans, characters, the full integer family, reals, exact
|
|
13
|
+
decimals, UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
|
|
14
14
|
floor. The fast path links the core straight into a Ruby extension (Magnus): on require it
|
|
15
15
|
redefines the doors in place on the `HyperCast` module — no delegation layer, no second
|
|
16
16
|
surface, which is exactly what keeps the backends provably in agreement.
|
|
@@ -39,9 +39,10 @@ midnight, durations come back as exact `Rational` seconds across the core's whol
|
|
|
39
39
|
| Door | Value | Declares |
|
|
40
40
|
|---|---|---|
|
|
41
41
|
| `bool(text)` | `true`/`false` | — |
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
42
|
+
| `char(text)` | one-character UTF-8 `String` — the text verbatim when it is one character (`" "` is a space), else a declared code point (`65`, `U+0041`, `0x41`, `&H41`, `A`, `A`) | — |
|
|
43
|
+
| `i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64` `(text, format)` [^overrides] | `Integer` (unbounded — u64 is the true unsigned value) | a `NumFormat` |
|
|
44
|
+
| `f32` `f64` `(text, format)` [^overrides] | `Float` (f32 widened losslessly) | a `NumFormat` |
|
|
45
|
+
| `decimal(text, format)` [^overrides] | `HyperCast::Decimal` — exact sign, 96-bit magnitude, base-10 scale | a `NumFormat` |
|
|
45
46
|
| `uuid(text)` | lowercase hyphenated `String` (`SecureRandom.uuid`'s shape) | — |
|
|
46
47
|
| `timestamp(text)` | UTC `Time`, full nanoseconds | — |
|
|
47
48
|
| `unix(text, precision)` | UTC `Time` | `:seconds` / `:milliseconds` / `:microseconds` / `:nanoseconds` |
|
|
@@ -50,6 +51,10 @@ midnight, durations come back as exact `Rational` seconds across the core's whol
|
|
|
50
51
|
| `datetime(text, order)` | zone-less `DateTime`, exact `Rational` seconds | a field order, as `date` |
|
|
51
52
|
| `time(text)` | `Integer` nanoseconds since midnight | — |
|
|
52
53
|
| `duration(text)` | exact `Rational` seconds | — |
|
|
54
|
+
| `decimal_from_float(value)` | `HyperCast::Decimal`, the shortest that names the number (`0.1` is one tenth) | — |
|
|
55
|
+
| `excel_serial_from_float(value, epoch)` | zone-less `DateTime`, as `datetime` | `:y1900` / `:y1904` |
|
|
56
|
+
| `excel_time(value)` | `Integer` nanoseconds since midnight | — |
|
|
57
|
+
| `excel_duration(value)` | exact `Rational` seconds | — |
|
|
53
58
|
|
|
54
59
|
Every door returns a `Success` or a `Fault`; `HyperCast.optional(verdict)` folds `:empty`
|
|
55
60
|
to `nil`. Beside the doors, `native_version` returns the loaded core's
|
|
@@ -61,7 +66,8 @@ to `nil`. Beside the doors, `native_version` returns the loaded core's
|
|
|
61
66
|
Bad data is never an exception — it is a `Fault`. What raises is a caller bug, and it raises
|
|
62
67
|
the same exception on every backend:
|
|
63
68
|
|
|
64
|
-
- `ArgumentError` from `NumFormat.new`
|
|
69
|
+
- `ArgumentError` from `NumFormat.new`, `NumFormat#override` (and so the `separator:`,
|
|
70
|
+
`delimiter:` and `unit:` keywords) or `NumFormat.from_i18n` for a malformed format: separators that are not single
|
|
65
71
|
characters or not distinct, a currency symbol that is too long or carries an ASCII digit or
|
|
66
72
|
whitespace.
|
|
67
73
|
- `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
|
|
@@ -156,6 +162,70 @@ Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code
|
|
|
156
162
|
flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
|
|
157
163
|
and memoized by identity on every backend, so declaring a currency costs a cast nothing.
|
|
158
164
|
|
|
165
|
+
### Rails' words for a format: `separator:`, `delimiter:`, `unit:`
|
|
166
|
+
|
|
167
|
+
Ruby's standard library carries no locale number data, so the vocabulary Ruby code already
|
|
168
|
+
speaks is Rails' — the number helpers' and I18n's `number.format`: `separator` (the decimal
|
|
169
|
+
mark), `delimiter` (grouping) and `unit` (the currency symbol). Every numeric door takes
|
|
170
|
+
them as keywords that replace those fields of the format for the call, and
|
|
171
|
+
`NumFormat.from_i18n` builds a format from the Hash I18n hands back:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
inv = HyperCast::NumFormat::INVARIANT
|
|
175
|
+
HyperCast.f64("1.234,5", inv, separator: ",", delimiter: ".") # => Success(value: 1234.5)
|
|
176
|
+
HyperCast.decimal("1.234,50 kr.", inv, separator: ",", delimiter: ".", unit: "kr.")
|
|
177
|
+
|
|
178
|
+
german = HyperCast::NumFormat.from_i18n(I18n.t("number.format")) # {separator: ",", delimiter: ".", ...}
|
|
179
|
+
HyperCast.i32("1.234", german) # => Success(value: 1234)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The keywords go through `NumFormat#override`, which is public: the result is validated like
|
|
183
|
+
any `NumFormat`, and the flags are left alone, so a `unit:` on a format without `CURRENCY`
|
|
184
|
+
is declared but not honored, as it would be in the constructor. `from_i18n` reads Symbol or
|
|
185
|
+
String keys and ignores every other key, so `number.currency.format` works as well. Missing
|
|
186
|
+
keys keep the invariant's fields. An empty `delimiter` (a locale that writes no grouping)
|
|
187
|
+
turns `GROUPING` off. `flags:` defaults to `ALL_STYLES`.
|
|
188
|
+
|
|
189
|
+
A call without the keywords is exactly the call it was, with no extra allocation on either
|
|
190
|
+
backend. A call with them builds a format each time (a few microseconds), so in a loop,
|
|
191
|
+
build it once with `override` or `from_i18n` and pass that instead.
|
|
192
|
+
|
|
193
|
+
[^overrides]: Plus the optional keywords `separator:`, `delimiter:` and `unit:`, which
|
|
194
|
+
override those fields of `format` for the call. See
|
|
195
|
+
[Rails' words for a format](#rails-words-for-a-format-separator-delimiter-unit).
|
|
196
|
+
|
|
197
|
+
## Interop: building on HyperCast's C ABI
|
|
198
|
+
|
|
199
|
+
For a gem that carries HyperCast's verdicts across a C ABI of its own, as HyperTabular does:
|
|
200
|
+
it reads the core's out-values out of its own buffers and needs the values the doors would
|
|
201
|
+
have returned. `HyperCast::Interop` is the code the Fiddle backend's doors decode with, so
|
|
202
|
+
the two cannot drift. It is loaded on every backend.
|
|
203
|
+
|
|
204
|
+
- `SCALARS` — the `String#unpack` directive for one value of each scalar door (`:i32` is
|
|
205
|
+
`"l<"`; a bool is the byte 0 or 1).
|
|
206
|
+
- `RECORDS` — for each record door (`:decimal`, `:uuid`, `:timestamp`, `:date`, …), the
|
|
207
|
+
directive that unpacks one value, how many fields that yields, and a lambda
|
|
208
|
+
`(fields, at)` that builds the Ruby value from them. To decode a column, append `"*"` and
|
|
209
|
+
step that many fields at a time.
|
|
210
|
+
- `VALUE_BYTES` — the bytes one value of each door takes.
|
|
211
|
+
- `decode(door, bytes)` — one value, from the bytes the core wrote for it.
|
|
212
|
+
- `fault(code, offset, length)` — the `Fault` a nonzero verdict code names; a code that names
|
|
213
|
+
no reason is a `KeyError`.
|
|
214
|
+
- `characters(bytes, offset, length)` — the core's byte span as the character span
|
|
215
|
+
`String#[]` slices by.
|
|
216
|
+
- `version(word)` — a packed `*_version` word as `"major.minor.patch"`.
|
|
217
|
+
- `library_path(library, native_dir, repo_root)` — the shared library to `dlopen`: the
|
|
218
|
+
staged `native/{rid}/` build, else the in-repo cargo build, else `nil`.
|
|
219
|
+
|
|
220
|
+
`NativePlatform.rid_and_library_name(library: "hypertabular")` gives the `{rid}` directory
|
|
221
|
+
and file name for any SkunkWerkx core; `library:` defaults to `"hypercast"`.
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
HyperCast::Interop.decode(:i32, [-7].pack("l<")) # => -7
|
|
225
|
+
HyperCast::Interop.fault(2, 3, 1) # => Fault, reason :malformed
|
|
226
|
+
HyperCast::Interop.version(0x000700) # => "0.7.0"
|
|
227
|
+
```
|
|
228
|
+
|
|
159
229
|
## Why not `Integer()` / `Time.iso8601` / `Float()`?
|
|
160
230
|
|
|
161
231
|
1. **Verdicts, not exceptions** — bad data is the expected case for untrusted text; a
|
|
@@ -169,12 +239,12 @@ and memoized by identity on every backend, so declaring a currency costs a cast
|
|
|
169
239
|
boundary).
|
|
170
240
|
4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
|
|
171
241
|
benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-x64 on an Intel Core
|
|
172
|
-
i9-11900H, Ruby 4.0.7): timestamp **
|
|
242
|
+
i9-11900H, Ruby 4.0.7): timestamp **404 ns vs 2.70 µs `Time.iso8601`** (6.7x) — while
|
|
173
243
|
returning exact `Rational` durations on the duration door. The Fiddle fallback lands at
|
|
174
|
-
|
|
244
|
+
2.85 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
|
|
175
245
|
|
|
176
246
|
Separator detection is nearly free here: `1.234.567,89` under `NumFormat::DETECT` runs
|
|
177
|
-
at
|
|
247
|
+
at 154 ns against 145 ns for the same text under a declared eurozone format. Both cost
|
|
178
248
|
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
249
|
`String` allocations per call to read its separators back out of the `Data`. `DETECT`
|
|
180
250
|
is now identity-matched like `INVARIANT`, and any other format is resolved once per
|
|
@@ -213,14 +283,14 @@ both backends:
|
|
|
213
283
|
|
|
214
284
|
| Door | Magnus | Fiddle |
|
|
215
285
|
|---|---:|---:|
|
|
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 |
|
|
286
|
+
| `bool` | 109 ns | 2.30 µs |
|
|
287
|
+
| `i32` | 120 ns | 2.30 µs |
|
|
288
|
+
| `f64` | 134 ns | 2.44 µs |
|
|
289
|
+
| `uuid` | 209 ns | 3.28 µs |
|
|
290
|
+
| `timestamp` | 404 ns | 2.85 µs |
|
|
291
|
+
| `datetime` (`1/7/2026 3:04 PM`) | 877 ns | 3.71 µs |
|
|
292
|
+
| `duration` (ISO) | 578 ns | 2.72 µs |
|
|
293
|
+
| `i32`, a fault | 202 ns | 2.88 µs |
|
|
224
294
|
|
|
225
295
|
A lean door on the Magnus backend is little more than the native call: the extension builds
|
|
226
296
|
the `Success` or `Fault` it returns directly — allocated, its members stored, frozen —
|
|
@@ -311,7 +381,7 @@ one at `require` time:
|
|
|
311
381
|
| 3.3 (the floor, until its EOL 2027-03-31) | Fiddle | universal |
|
|
312
382
|
|
|
313
383
|
What stands behind each cell: CI replays the whole suite, shared corpus included, through
|
|
314
|
-
each Magnus extension on every
|
|
384
|
+
each Magnus extension — Ruby 4.0 on every pull request and Ruby 3.4 in the weekly and release builds, on all seven platform-gem platforms,
|
|
315
385
|
the two musl ones inside each Ruby's own Alpine image — and the Fiddle suite on Ruby 4.0 on
|
|
316
386
|
every one of them, plus on Ruby 3.3 inside Alpine. Intel macOS has no CI leg: its library is
|
|
317
387
|
cross-built and tested at the core, and Ruby there runs the universal gem's Fiddle backend
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
char: ["L<", 1, ->(fields, at) { fields[at].chr(Encoding::UTF_8) }],
|
|
22
|
+
decimal: ["Q<L<CCx2", 4, ->(fields, at) { decimal(fields[at], fields[at + 1], fields[at + 2], fields[at + 3]) }],
|
|
23
|
+
uuid: ["H8H4H4H4H12", 5, ->(fields, at) { fields[at, 5].join("-") }],
|
|
24
|
+
timestamp: ["q<l<x4", 2, ->(fields, at) { instant(fields[at], fields[at + 1]) }],
|
|
25
|
+
date: ["S<CC", 3, ->(fields, at) { Date.new(fields[at], fields[at + 1], fields[at + 2]) }],
|
|
26
|
+
datetime: ["S<CCx4Q<", 4, ->(fields, at) { civil(fields[at], fields[at + 1], fields[at + 2], fields[at + 3]) }],
|
|
27
|
+
duration: ["q<l<x4", 2, ->(fields, at) { duration(fields[at], fields[at + 1]) }]
|
|
28
|
+
}.tap do |records|
|
|
29
|
+
records[:unix] = records[:excel_serial] = records[:timestamp]
|
|
30
|
+
records[:date_ordered] = records[:date]
|
|
31
|
+
end.freeze
|
|
32
|
+
|
|
33
|
+
# The bytes one value of each door takes.
|
|
34
|
+
VALUE_BYTES = {
|
|
35
|
+
bool: 1, i8: 1, u8: 1, i16: 2, u16: 2, i32: 4, u32: 4, f32: 4, char: 4, date: 4, date_ordered: 4,
|
|
36
|
+
i64: 8, u64: 8, f64: 8, time: 8,
|
|
37
|
+
decimal: 16, uuid: 16, timestamp: 16, unix: 16, excel_serial: 16, datetime: 16, duration: 16
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Encodings whose bytes already are the UTF-8 (or byte-identical) form the core reads.
|
|
41
|
+
BYTE_COMPATIBLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].freeze
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# One value of +door+, from the bytes the core wrote for it.
|
|
46
|
+
def decode(door, bytes)
|
|
47
|
+
if (directive = SCALARS[door])
|
|
48
|
+
value = bytes.unpack1(directive)
|
|
49
|
+
door == :bool ? value != 0 : value
|
|
50
|
+
else
|
|
51
|
+
directive, _fields, build = RECORDS.fetch(door)
|
|
52
|
+
build.call(bytes.unpack(directive), 0)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A UTC Time from the core's protobuf-shaped {seconds, nanos} pair, exactly — the
|
|
57
|
+
# timestamp, unix and excel_serial doors' value. (Time.at then #utc is the same Time as
|
|
58
|
+
# Time.at(..., in: "UTC"), at half the cost: the zone's name is not read each time.)
|
|
59
|
+
def instant(seconds, nanos)
|
|
60
|
+
Time.at(seconds, nanos, :nanosecond).utc
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The zone-less DateTime a civil date-time names, with exact Rational seconds — the
|
|
64
|
+
# datetime door's value. Its +00:00 offset is a carrier artifact, not data.
|
|
65
|
+
def civil(year, month, day, nanos_of_day)
|
|
66
|
+
second_of_day, frac = nanos_of_day.divmod(1_000_000_000)
|
|
67
|
+
hour, rest = second_of_day.divmod(3600)
|
|
68
|
+
minute, second = rest.divmod(60)
|
|
69
|
+
DateTime.new(year, month, day, hour, minute, second + Rational(frac, 1_000_000_000))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Exact Rational seconds from the core's same-signed {seconds, nanos} pair — the duration
|
|
73
|
+
# door's value.
|
|
74
|
+
def duration(seconds, nanos)
|
|
75
|
+
Rational(seconds * 1_000_000_000 + nanos, 1_000_000_000)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A Decimal from the core's {lo, hi, scale, negative} — the decimal door's value.
|
|
79
|
+
def decimal(lo, hi, scale, negative)
|
|
80
|
+
Decimal.new(magnitude: (hi << 64) | lo, scale: scale, negative: negative != 0)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The Fault a nonzero verdict code and its span name. A code that names no reason is a
|
|
84
|
+
# binding bug, not data: a KeyError.
|
|
85
|
+
def fault(code, offset, length)
|
|
86
|
+
Fault.new(reason: REASONS.fetch(code), offset: offset, length: length)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The core's byte span over +bytes+, in the units String#[] slices by: an identity for a
|
|
90
|
+
# binary String (its characters are its bytes) and for ASCII text (`ascii_only?` reads the
|
|
91
|
+
# cached coderange — no scan), a byte-to-character remap otherwise. Character counts
|
|
92
|
+
# survive transcoding, so a span mapped on the UTF-8 form indexes the caller's own String
|
|
93
|
+
# whatever encoding it arrived in.
|
|
94
|
+
def characters(bytes, offset, length)
|
|
95
|
+
return [offset, length] if bytes.encoding == Encoding::ASCII_8BIT || bytes.ascii_only?
|
|
96
|
+
|
|
97
|
+
[bytes.byteslice(0, offset).length, bytes.byteslice(offset, length).length]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A native library's packed version word — major << 16 | minor << 8 | patch, as a
|
|
101
|
+
# *_version export returns it — as "major.minor.patch".
|
|
102
|
+
def version(word)
|
|
103
|
+
"#{word >> 16}.#{(word >> 8) & 0xFF}.#{word & 0xFF}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The shared library to dlopen for +library+ ("hypercast" for libhypercast.so): the
|
|
107
|
+
# gem's native/{rid}/{file} under +native_dir+, or — the development loop — the cargo
|
|
108
|
+
# build under +repo_root+/rust/target/release, exactly what the other bindings' local
|
|
109
|
+
# staging does. Nil when neither exists.
|
|
110
|
+
def library_path(library, native_dir, repo_root)
|
|
111
|
+
rid, file = NativePlatform.rid_and_library_name(library: library)
|
|
112
|
+
path = File.join(native_dir, rid, file)
|
|
113
|
+
return path if File.exist?(path)
|
|
114
|
+
|
|
115
|
+
repo_build = File.join(repo_root, "rust", "target", "release", file)
|
|
116
|
+
File.exist?(repo_build) ? repo_build : nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
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,16 +17,20 @@ 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
|
-
cast_bool: :plain,
|
|
24
|
+
cast_bool: :plain, cast_char: :plain,
|
|
23
25
|
cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
|
|
24
26
|
cast_u8: :numeric, cast_u16: :numeric, cast_u32: :numeric, cast_u64: :numeric,
|
|
25
27
|
cast_f32: :numeric, cast_f64: :numeric, cast_decimal: :numeric,
|
|
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.8.0"
|
|
33
34
|
|
|
34
35
|
# The success case of a verdict: a cast value.
|
|
35
36
|
Success = Data.define(:value)
|
|
@@ -83,6 +84,50 @@ module HyperCast
|
|
|
83
84
|
super(decimal_sep: decimal_sep, group_sep: group_sep, flags: flags, currency: symbol)
|
|
84
85
|
end
|
|
85
86
|
|
|
87
|
+
# This format with the fields Ruby's number vocabulary names replaced — the words Rails'
|
|
88
|
+
# number helpers and I18n's number.format use: separator: (the decimal mark),
|
|
89
|
+
# delimiter: (grouping) and unit: (the currency symbol). A nil keyword leaves its
|
|
90
|
+
# field alone, so with none given this is the format itself, not a copy. The result is
|
|
91
|
+
# validated like any NumFormat (single, distinct separators; a declarable symbol), and
|
|
92
|
+
# the flags are untouched: a unit: declared on a format without CURRENCY is a :malformed
|
|
93
|
+
# Fault at the symbol, exactly as declaring it in the constructor would be. Every
|
|
94
|
+
# numeric door takes the same three keywords and applies them through this method.
|
|
95
|
+
def override(separator: nil, delimiter: nil, unit: nil)
|
|
96
|
+
return self unless separator || delimiter || unit
|
|
97
|
+
|
|
98
|
+
with(**{ decimal_sep: separator, group_sep: delimiter, currency: unit }.compact)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A format from a Hash shaped like I18n's number.format (or number.currency.format):
|
|
102
|
+
# :separator, :delimiter and :unit, Symbol or String keys, every other key (precision,
|
|
103
|
+
# format, significant, ...) ignored — so `NumFormat.from_i18n(I18n.t("number.format"))`
|
|
104
|
+
# drops a Rails locale straight in, with no I18n dependency here. A missing or nil key
|
|
105
|
+
# keeps the invariant's "." / "," / no symbol. An empty delimiter — the locale writes no
|
|
106
|
+
# grouping — turns GROUPING off rather than declaring a separator, since a NumFormat
|
|
107
|
+
# always carries one; the placeholder it gets is whichever of "," and "." the decimal
|
|
108
|
+
# mark is not. flags defaults to every lenience, the same as INVARIANT.
|
|
109
|
+
#
|
|
110
|
+
# Only the separators and the unit carry over; the native format has no field for the
|
|
111
|
+
# rest. The core reads ASCII "+" and "-" only, so a locale that writes negatives with
|
|
112
|
+
# another sign (U+2212 for Estonian and Basque, for instance) faults on them; currency
|
|
113
|
+
# amounts in a locale whose number.currency.format separators differ from number.format's
|
|
114
|
+
# read with whichever Hash is passed, so pass the currency one for currency columns; and
|
|
115
|
+
# percent is an ASCII "%". Every lenience is on, radix prefixes and percent included;
|
|
116
|
+
# narrow flags: for anything stricter.
|
|
117
|
+
def self.from_i18n(number_format, flags: ALL_STYLES)
|
|
118
|
+
raise ArgumentError, "expected a Hash-like number format; got #{number_format.inspect}" unless
|
|
119
|
+
number_format.respond_to?(:to_h)
|
|
120
|
+
|
|
121
|
+
entries = number_format.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
|
|
122
|
+
separator = entries[:separator] || "."
|
|
123
|
+
delimiter = entries[:delimiter] || ","
|
|
124
|
+
if delimiter == ""
|
|
125
|
+
flags &= ~GROUPING
|
|
126
|
+
delimiter = separator == "," ? "." : ","
|
|
127
|
+
end
|
|
128
|
+
new(decimal_sep: separator, group_sep: delimiter, flags: flags, currency: entries[:unit] || "")
|
|
129
|
+
end
|
|
130
|
+
|
|
86
131
|
# The 32-byte little-endian form the native ABI's NumFormat struct expects: the two
|
|
87
132
|
# separators as code points, the flags, the symbol's byte length, then the symbol's
|
|
88
133
|
# UTF-8 bytes zero-padded to 16.
|
|
@@ -216,33 +261,49 @@ module HyperCast
|
|
|
216
261
|
# (t/f, yes/no, y/n, 1/0, on/off, enabled/disabled, active/inactive,
|
|
217
262
|
# checked/unchecked, in/out), ASCII case-insensitive.
|
|
218
263
|
def bool(text)
|
|
219
|
-
plain(:cast_bool, text, 1) { |out|
|
|
264
|
+
plain(:cast_bool, text, 1) { |out| Interop.decode(:bool, out) }
|
|
220
265
|
end
|
|
221
266
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
267
|
+
# Casts char text to a one-character UTF-8 String: an input of exactly one character is
|
|
268
|
+
# that character verbatim (" " is a space), otherwise one declared code point — 65,
|
|
269
|
+
# U+0041, 0x41, &H41, A or A — surrounded by optional ASCII whitespace. A code
|
|
270
|
+
# point past U+10FFFF, or a surrogate, is :out_of_range.
|
|
271
|
+
def char(text)
|
|
272
|
+
plain(:cast_char, text, 4) { |out| Interop.decode(:char, out) }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
%i[i8 i16 i32 i64 u8 u16 u32 u64].each do |door|
|
|
276
|
+
unpack = Interop::SCALARS.fetch(door)
|
|
277
|
+
size = Interop::VALUE_BYTES.fetch(door)
|
|
226
278
|
# Resolved once here, not inside the method: interpolating a Symbol per call built a
|
|
227
279
|
# String and interned it on every integer cast.
|
|
228
280
|
symbol = :"cast_#{door}"
|
|
229
281
|
# Integer doors: the target type's own range, declared grouping, accounting parens,
|
|
230
282
|
# 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
|
-
|
|
283
|
+
# is unbounded, so u64 comes back as the true unsigned value. separator:, delimiter:
|
|
284
|
+
# and unit: override those fields of format for this call (NumFormat#override).
|
|
285
|
+
define_method(door) do |text, format, separator: nil, delimiter: nil, unit: nil|
|
|
286
|
+
format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
|
|
287
|
+
separator || delimiter || unit
|
|
233
288
|
numeric(symbol, text, format, size) { |out| out.unpack1(unpack) }
|
|
234
289
|
end
|
|
235
290
|
end
|
|
236
291
|
|
|
237
292
|
# Casts real text to an IEEE single (widened losslessly on the way out): finite values
|
|
238
293
|
# only, declared separators and grouping, parens, exponent, and trailing percent.
|
|
239
|
-
|
|
240
|
-
|
|
294
|
+
# separator:, delimiter: and unit: override those fields of format for this call
|
|
295
|
+
# (NumFormat#override), on this door and every other numeric one.
|
|
296
|
+
def f32(text, format, separator: nil, delimiter: nil, unit: nil)
|
|
297
|
+
format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
|
|
298
|
+
separator || delimiter || unit
|
|
299
|
+
numeric(:cast_f32, text, format, 4) { |out| Interop.decode(:f32, out) }
|
|
241
300
|
end
|
|
242
301
|
|
|
243
302
|
# Casts real text to an IEEE double. Notation rules as f32.
|
|
244
|
-
def f64(text, format)
|
|
245
|
-
|
|
303
|
+
def f64(text, format, separator: nil, delimiter: nil, unit: nil)
|
|
304
|
+
format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
|
|
305
|
+
separator || delimiter || unit
|
|
306
|
+
numeric(:cast_f64, text, format, 8) { |out| Interop.decode(:f64, out) }
|
|
246
307
|
end
|
|
247
308
|
|
|
248
309
|
# Casts decimal text to an exact Decimal — the real doors' grammar (declared separators
|
|
@@ -251,33 +312,30 @@ module HyperCast
|
|
|
251
312
|
# "1.10" is canonical magnitude 11, scale 1 — exact trailing zeros are always trimmed.
|
|
252
313
|
# A magnitude past 2**96 - 1, or more fractional precision than 28 places can hold, is
|
|
253
314
|
# an :out_of_range Fault — the door never rounds.
|
|
254
|
-
def decimal(text, format)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
end
|
|
315
|
+
def decimal(text, format, separator: nil, delimiter: nil, unit: nil)
|
|
316
|
+
format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
|
|
317
|
+
separator || delimiter || unit
|
|
318
|
+
numeric(:cast_decimal, text, format, 16) { |out| Interop.decode(:decimal, out) }
|
|
259
319
|
end
|
|
260
320
|
|
|
261
321
|
# Casts UUID text — all five .NET Guid formats (D/N/B/P/X) plus urn:uuid:/GUID:/UUID:
|
|
262
322
|
# prefixes — to Ruby's UUID lingua franca: the lowercase hyphenated String (the same
|
|
263
323
|
# shape SecureRandom.uuid returns).
|
|
264
324
|
def uuid(text)
|
|
265
|
-
plain(:cast_uuid, text, 16)
|
|
266
|
-
out.unpack("H8H4H4H4H12").join("-")
|
|
267
|
-
end
|
|
325
|
+
plain(:cast_uuid, text, 16) { |out| Interop.decode(:uuid, out) }
|
|
268
326
|
end
|
|
269
327
|
|
|
270
328
|
# Casts an RFC 3339 instant — zone mandatory — to a UTC Time at full nanosecond
|
|
271
329
|
# fidelity across the whole 0001-9999 window.
|
|
272
330
|
def timestamp(text)
|
|
273
|
-
plain(:cast_timestamp, text, 16) { |out|
|
|
331
|
+
plain(:cast_timestamp, text, 16) { |out| Interop.decode(:timestamp, out) }
|
|
274
332
|
end
|
|
275
333
|
|
|
276
334
|
# Casts an integer Unix-epoch value under a caller-declared unit Symbol
|
|
277
335
|
# (:seconds/:milliseconds/:microseconds/:nanoseconds) to a UTC Time. An unknown unit
|
|
278
336
|
# is a caller bug (KeyError), never a verdict.
|
|
279
337
|
def unix(text, precision)
|
|
280
|
-
declared(:cast_unix, text, UNIX_PRECISIONS.fetch(precision), 16) { |out|
|
|
338
|
+
declared(:cast_unix, text, UNIX_PRECISIONS.fetch(precision), 16) { |out| Interop.decode(:unix, out) }
|
|
281
339
|
end
|
|
282
340
|
|
|
283
341
|
# Casts an Excel date serial under a caller-declared epoch Symbol (:y1900/:y1904) to a
|
|
@@ -291,7 +349,7 @@ module HyperCast
|
|
|
291
349
|
# the text "1900-02-29" — so every serial above it is shifted one day against a naive
|
|
292
350
|
# count. An unknown epoch is a caller bug (KeyError), never a verdict.
|
|
293
351
|
def excel_serial(text, epoch)
|
|
294
|
-
declared(:cast_excel_serial, text, EXCEL_EPOCHS.fetch(epoch), 16) { |out|
|
|
352
|
+
declared(:cast_excel_serial, text, EXCEL_EPOCHS.fetch(epoch), 16) { |out| Interop.decode(:excel_serial, out) }
|
|
295
353
|
end
|
|
296
354
|
|
|
297
355
|
# Casts a calendar date to a Date. With no order declared: the strict ISO 8601
|
|
@@ -301,15 +359,9 @@ module HyperCast
|
|
|
301
359
|
# caller bug (KeyError), never a verdict.
|
|
302
360
|
def date(text, order = nil)
|
|
303
361
|
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
|
|
362
|
+
plain(:cast_date, text, 4) { |out| Interop.decode(:date, out) }
|
|
308
363
|
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
|
|
364
|
+
declared(:cast_date_ordered, text, DATE_ORDERS.fetch(order), 4) { |out| Interop.decode(:date_ordered, out) }
|
|
313
365
|
end
|
|
314
366
|
end
|
|
315
367
|
|
|
@@ -322,29 +374,51 @@ module HyperCast
|
|
|
322
374
|
# fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
|
|
323
375
|
# instant door. An unknown order is a caller bug (KeyError).
|
|
324
376
|
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
|
|
377
|
+
declared(:cast_datetime, text, DATE_ORDERS.fetch(order), 16) { |out| Interop.decode(:datetime, out) }
|
|
332
378
|
end
|
|
333
379
|
|
|
334
380
|
# Casts an ISO 24-hour time-of-day to an exact Integer of nanoseconds since midnight
|
|
335
381
|
# (Ruby has no time-of-day type; the integer keeps every digit).
|
|
336
382
|
def time(text)
|
|
337
|
-
plain(:cast_time, text, 8) { |out|
|
|
383
|
+
plain(:cast_time, text, 8) { |out| Interop.decode(:time, out) }
|
|
338
384
|
end
|
|
339
385
|
|
|
340
386
|
# Casts a duration (ISO 8601 fixed components, invariant colon form, or protobuf JSON
|
|
341
387
|
# seconds) to exact Rational seconds — full fidelity across the core's ±10,000-year
|
|
342
388
|
# window, no wrapping and no truncation.
|
|
343
389
|
def duration(text)
|
|
344
|
-
plain(:cast_duration, text, 16)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
390
|
+
plain(:cast_duration, text, 16) { |out| Interop.decode(:duration, out) }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Reads a number a caller already holds — an Integer, Float or Rational, the way Ruby's
|
|
394
|
+
# own Float conversion takes it; anything else, a String included, is a TypeError — as
|
|
395
|
+
# the exact Decimal it names: the shortest decimal that rounds back to the Float, the
|
|
396
|
+
# digits a spreadsheet writes for it. 0.1 is magnitude 1, scale 1, not the binary
|
|
397
|
+
# fraction nearest it; 0.1 + 0.2 is 0.30000000000000004. NaN is :malformed; an infinity,
|
|
398
|
+
# a magnitude past 2**96 - 1 or more than 28 places is :out_of_range. A typed door's
|
|
399
|
+
# Fault has no span: offset and length are 0.
|
|
400
|
+
def decimal_from_float(value)
|
|
401
|
+
typed(:cast_decimal_from_f64, float(value), 16) { |out| Interop.decode(:decimal, out) }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Reads an Excel serial number under a declared epoch Symbol (:y1900/:y1904) as the
|
|
405
|
+
# zone-less DateTime it names — the twin of excel_serial for a number a workbook reader
|
|
406
|
+
# already holds, on the same carrier datetime uses. The 1900 system's phantom serial 60
|
|
407
|
+
# is :out_of_range; a negative, NaN or infinite serial is :malformed.
|
|
408
|
+
def excel_serial_from_float(value, epoch)
|
|
409
|
+
number = float(value)
|
|
410
|
+
typed(:cast_excel_serial_from_f64, number, 16, EXCEL_EPOCHS.fetch(epoch)) { |out| Interop.decode(:datetime, out) }
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Reads the fraction of an Excel serial number as an exact Integer of nanoseconds since
|
|
414
|
+
# midnight, as time does; 0.75 and 45292.75 are both 18:00.
|
|
415
|
+
def excel_time(value)
|
|
416
|
+
typed(:cast_excel_time, float(value), 8) { |out| Interop.decode(:time, out) }
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Reads a number of days as exact Rational seconds, as duration does: 1.5 is 129600.
|
|
420
|
+
def excel_duration(value)
|
|
421
|
+
typed(:cast_excel_duration, float(value), 16) { |out| Interop.decode(:duration, out) }
|
|
348
422
|
end
|
|
349
423
|
|
|
350
424
|
# The version of the native core actually loaded, as "major.minor.patch" — read from
|
|
@@ -353,15 +427,11 @@ module HyperCast
|
|
|
353
427
|
# not). The cheapest possible probe that the backend resolved at all: takes nothing,
|
|
354
428
|
# cannot fail.
|
|
355
429
|
def native_version
|
|
356
|
-
|
|
357
|
-
"#{word >> 16}.#{(word >> 8) & 0xFF}.#{word & 0xFF}"
|
|
430
|
+
Interop.version(packed_version)
|
|
358
431
|
end
|
|
359
432
|
|
|
360
433
|
private
|
|
361
434
|
|
|
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
435
|
# Presents the input as UTF-8 bytes: already-compatible text crosses as-is (Fiddle
|
|
366
436
|
# passes a String's own bytes for void* — no copy of them); only foreign encodings pay
|
|
367
437
|
# a transcode, and text that cannot be transcoded raises String#encode's own
|
|
@@ -376,7 +446,7 @@ module HyperCast
|
|
|
376
446
|
raise TypeError, "no implicit conversion of #{text.class} into String"
|
|
377
447
|
text = converted
|
|
378
448
|
end
|
|
379
|
-
BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
|
|
449
|
+
Interop::BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
|
|
380
450
|
end
|
|
381
451
|
|
|
382
452
|
# Fiddle spells a null pointer as nil — the core's contract for empty input.
|
|
@@ -408,22 +478,10 @@ module HyperCast
|
|
|
408
478
|
elsif rc == -1
|
|
409
479
|
raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
|
|
410
480
|
else
|
|
411
|
-
|
|
412
|
-
Fault.new(reason: REASONS.fetch(rc), offset: offset, length: length)
|
|
481
|
+
Interop.fault(rc, *Interop.characters(bytes, *fault[0, 8].unpack("L<L<")))
|
|
413
482
|
end
|
|
414
483
|
end
|
|
415
484
|
|
|
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
485
|
# The shared body of every format-free door: one native call over the scratch buffers.
|
|
428
486
|
def plain(symbol, text, out_size)
|
|
429
487
|
bytes = utf8(text)
|
|
@@ -452,6 +510,34 @@ module HyperCast
|
|
|
452
510
|
verdict(rc, fault, bytes) { yield(out[0, out_size]) }
|
|
453
511
|
end
|
|
454
512
|
|
|
513
|
+
# A typed door's number, converted the way the extension's argument is (C's
|
|
514
|
+
# rb_num2dbl), with the same exception and message: a Numeric converts (a Complex with an
|
|
515
|
+
# imaginary part is Complex#to_f's RangeError), anything else is a TypeError — a String
|
|
516
|
+
# among them, which Kernel#Float would have parsed.
|
|
517
|
+
def float(value)
|
|
518
|
+
case value
|
|
519
|
+
when Float then value
|
|
520
|
+
when Numeric then Float(value)
|
|
521
|
+
when String then raise TypeError, "no implicit conversion to float from string"
|
|
522
|
+
when nil, true, false then raise TypeError, "no implicit conversion to float from #{value.inspect}"
|
|
523
|
+
else raise TypeError, "can't convert #{value.class} into Float"
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# The shared body of the typed doors: a double instead of the text, then the declared
|
|
528
|
+
# u32 when the door takes one.
|
|
529
|
+
def typed(symbol, number, out_size, *declared)
|
|
530
|
+
out, fault = scratch
|
|
531
|
+
rc = Runtime.function(symbol).call(number, *declared, out, fault)
|
|
532
|
+
if rc.zero?
|
|
533
|
+
Success.new(value: yield(out[0, out_size]))
|
|
534
|
+
elsif rc == -1
|
|
535
|
+
raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
|
|
536
|
+
else
|
|
537
|
+
Interop.fault(rc, 0, 0)
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
455
541
|
# The loaded core's version word, major << 16 | minor << 8 | patch, straight from the
|
|
456
542
|
# library's zero-argument hypercast_version export.
|
|
457
543
|
def packed_version
|
|
@@ -482,12 +568,6 @@ module HyperCast
|
|
|
482
568
|
cache[format] = pointer
|
|
483
569
|
end.compare_by_identity
|
|
484
570
|
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
571
|
end
|
|
492
572
|
end
|
|
493
573
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hypercast
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: x86_64-linux-gnu
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Buvinghausen
|
|
@@ -53,6 +53,7 @@ files:
|
|
|
53
53
|
- lib/hypercast.rb
|
|
54
54
|
- lib/hypercast/3.4/hypercast_native.so
|
|
55
55
|
- lib/hypercast/4.0/hypercast_native.so
|
|
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
|