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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96deee8d9c67d0451094f1d85c3219468c5491e3ff2768e52f0d3b8a9326f1bd
4
- data.tar.gz: 367b086200228d933395f3cef8c05c42f47a11aedb85eea3a491e3ae9911d3ef
3
+ metadata.gz: e7dd651d6fbef906514d4873b17dffc7e45fa80e970b6f7636110963c58e6bf1
4
+ data.tar.gz: a3e8e5b91540b87178fb5c00c6d64f54d2b010fd7ab2a2daf8427727ef33128c
5
5
  SHA512:
6
- metadata.gz: 94f6b903d7f9e7a79572ce14f89e5e22d763cedb1934cfd2f5d6d0c7b9711415f03d0098d20640ddf31fba00d15e20caa8afa81e8799b433cd7a41885073e363
7
- data.tar.gz: 3fb41a394fcd88460a417448560d911687d38a6c020c7bd7875eba64dfd2f9e9fc89e458d27f1549229484d62529b65b4ed422d3fb2d8d974267546a6c308bf7
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 decimals,
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
- | `i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64` `(text, format)` | `Integer` (unbounded — u64 is the true unsigned value) | a `NumFormat` |
43
- | `f32` `f64` `(text, format)` | `Float` (f32 widened losslessly) | a `NumFormat` |
44
- | `decimal(text, format)` | `HyperCast::Decimal` — exact sign, 96-bit magnitude, base-10 scale | a `NumFormat` |
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` for a malformed format: separators that are not single
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 **447 ns vs 2.97 µs `Time.iso8601`** (6.6x) — while
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
- 3.25 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
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 178 ns against 170 ns for the same text under a declared eurozone format. Both cost
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` | 112 ns | 2.35 µs |
217
- | `i32` | 133 ns | 2.63 µs |
218
- | `f64` | 166 ns | 2.70 µs |
219
- | `uuid` | 223 ns | 3.47 µs |
220
- | `timestamp` | 447 ns | 3.25 µs |
221
- | `datetime` (`1/7/2026 3:04 PM`) | 904 ns | 3.98 µs |
222
- | `duration` (ISO) | 632 ns | 2.91 µs |
223
- | `i32`, a fault | 225 ns | 3.18 µs |
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 push — Ruby 3.4 and 4.0 on all seven platform-gem platforms,
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 native library filename to load.
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}", "hypercast.dll"]
16
+ ["win-#{arch}", "#{library}.dll"]
15
17
  when /darwin/
16
- ["osx-#{arch}", "libhypercast.dylib"]
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}", "libhypercast.so"]
24
+ ["#{os}-#{arch}", "lib#{library}.so"]
23
25
  else
24
- raise UnsupportedPlatformError, "hypercast: unsupported platform RUBY_PLATFORM=#{platform}"
26
+ raise UnsupportedPlatformError, "#{library}: unsupported platform RUBY_PLATFORM=#{platform}"
25
27
  end
26
28
  end
27
29
  end
@@ -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
- rid, lib_name = NativePlatform.rid_and_library_name
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.6.1"
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| out.unpack1("C") != 0 }
264
+ plain(:cast_bool, text, 1) { |out| Interop.decode(:bool, out) }
220
265
  end
221
266
 
222
- { i8: "c", i16: "s<", i32: "l<", i64: "q<", u8: "C", u16: "S<", u32: "L<", u64: "Q<" }
223
- .each do |door, unpack|
224
- sizes = { "c" => 1, "C" => 1, "s<" => 2, "S<" => 2, "l<" => 4, "L<" => 4, "q<" => 8, "Q<" => 8 }
225
- size = sizes.fetch(unpack)
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, &#65; or &#x41; — 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
- define_method(door) do |text, format|
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
- def f32(text, format)
240
- numeric(:cast_f32, text, format, 4) { |out| out.unpack1("e") }
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
- numeric(:cast_f64, text, format, 8) { |out| out.unpack1("E") }
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
- numeric(:cast_decimal, text, format, 16) do |out|
256
- lo, hi, scale, negative = out.unpack("Q<L<CCx2")
257
- Decimal.new(magnitude: (hi << 64) | lo, scale: scale, negative: negative != 0)
258
- end
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) do |out|
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| instant(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| instant(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| instant(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) do |out|
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) do |out|
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) do |out|
326
- year, month, day, nanos = out.unpack("S<CCx4Q<")
327
- second_of_day, frac = nanos.divmod(1_000_000_000)
328
- hour, rest = second_of_day.divmod(3600)
329
- minute, second = rest.divmod(60)
330
- DateTime.new(year, month, day, hour, minute, second + Rational(frac, 1_000_000_000))
331
- end
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| out.unpack1("Q<") }
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) do |out|
345
- seconds, nanos = out.unpack("q<l<")
346
- Rational(seconds * 1_000_000_000 + nanos, 1_000_000_000)
347
- end
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
- word = packed_version
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
- offset, length = characters(bytes, *fault[0, 8].unpack("L<L<"))
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.6.1
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