hypercast 0.7.0 → 0.8.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 +41 -7
- data/lib/hypercast/interop.rb +2 -1
- data/lib/hypercast/native/linux-arm64/libhypercast.so +0 -0
- data/lib/hypercast/native/linux-musl-arm64/libhypercast.so +0 -0
- data/lib/hypercast/native/linux-musl-x64/libhypercast.so +0 -0
- data/lib/hypercast/native/linux-x64/libhypercast.so +0 -0
- data/lib/hypercast/native/osx-arm64/libhypercast.dylib +0 -0
- data/lib/hypercast/native/osx-x64/libhypercast.dylib +0 -0
- data/lib/hypercast/native/win-arm64/hypercast.dll +0 -0
- data/lib/hypercast/native/win-x64/hypercast.dll +0 -0
- data/lib/hypercast/runtime.rb +1 -1
- data/lib/hypercast.rb +69 -6
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eaba9476c698240d6ef0a3ac0a98b11030a016740972914e2f3c13b3aa05d203
|
|
4
|
+
data.tar.gz: 536c6ba3793ad95387972a08eebaf010ab4bad460a89d3b7f4cdde6252a877c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1188eefa9d4b79d00fa93dbf18c7e0b81377c9e9c5006a0a141c8dcff53b5ab8b7521f1d2411e9474f07b46e563c0f2075db57ad7234a10d3cfdb77e56bd0d6
|
|
7
|
+
data.tar.gz: 5136f110fd9356546c15174aa5fa667300d7ba29cc34daa4c62132c01e1e60cd1fbb00402cffeb58a30faf9120e568f75c44eafd1315d8d81e4042d6d9bcec7f
|
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` |
|
|
@@ -65,7 +66,8 @@ to `nil`. Beside the doors, `native_version` returns the loaded core's
|
|
|
65
66
|
Bad data is never an exception — it is a `Fault`. What raises is a caller bug, and it raises
|
|
66
67
|
the same exception on every backend:
|
|
67
68
|
|
|
68
|
-
- `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
|
|
69
71
|
characters or not distinct, a currency symbol that is too long or carries an ASCII digit or
|
|
70
72
|
whitespace.
|
|
71
73
|
- `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
|
|
@@ -160,6 +162,38 @@ Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code
|
|
|
160
162
|
flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
|
|
161
163
|
and memoized by identity on every backend, so declaring a currency costs a cast nothing.
|
|
162
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
|
+
|
|
163
197
|
## Interop: building on HyperCast's C ABI
|
|
164
198
|
|
|
165
199
|
For a gem that carries HyperCast's verdicts across a C ABI of its own, as HyperTabular does:
|
|
@@ -347,7 +381,7 @@ one at `require` time:
|
|
|
347
381
|
| 3.3 (the floor, until its EOL 2027-03-31) | Fiddle | universal |
|
|
348
382
|
|
|
349
383
|
What stands behind each cell: CI replays the whole suite, shared corpus included, through
|
|
350
|
-
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,
|
|
351
385
|
the two musl ones inside each Ruby's own Alpine image — and the Fiddle suite on Ruby 4.0 on
|
|
352
386
|
every one of them, plus on Ruby 3.3 inside Alpine. Intel macOS has no CI leg: its library is
|
|
353
387
|
cross-built and tested at the core, and Ruby there runs the universal gem's Fiddle backend
|
data/lib/hypercast/interop.rb
CHANGED
|
@@ -18,6 +18,7 @@ module HyperCast
|
|
|
18
18
|
# The doors whose value is a record: the directive that unpacks one, how many fields that
|
|
19
19
|
# yields, and what builds the door's Ruby value from them, starting at field +at+.
|
|
20
20
|
RECORDS = {
|
|
21
|
+
char: ["L<", 1, ->(fields, at) { fields[at].chr(Encoding::UTF_8) }],
|
|
21
22
|
decimal: ["Q<L<CCx2", 4, ->(fields, at) { decimal(fields[at], fields[at + 1], fields[at + 2], fields[at + 3]) }],
|
|
22
23
|
uuid: ["H8H4H4H4H12", 5, ->(fields, at) { fields[at, 5].join("-") }],
|
|
23
24
|
timestamp: ["q<l<x4", 2, ->(fields, at) { instant(fields[at], fields[at + 1]) }],
|
|
@@ -31,7 +32,7 @@ module HyperCast
|
|
|
31
32
|
|
|
32
33
|
# The bytes one value of each door takes.
|
|
33
34
|
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
|
+
bool: 1, i8: 1, u8: 1, i16: 2, u16: 2, i32: 4, u32: 4, f32: 4, char: 4, date: 4, date_ordered: 4,
|
|
35
36
|
i64: 8, u64: 8, f64: 8, time: 8,
|
|
36
37
|
decimal: 16, uuid: 16, timestamp: 16, unix: 16, excel_serial: 16, datetime: 16, duration: 16
|
|
37
38
|
}.freeze
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/hypercast/runtime.rb
CHANGED
|
@@ -21,7 +21,7 @@ module HyperCast
|
|
|
21
21
|
# typed doors take a double instead of the text and its length: typed is (value, out,
|
|
22
22
|
# fault), typed_declared adds the u32 after the value.
|
|
23
23
|
DOORS = {
|
|
24
|
-
cast_bool: :plain,
|
|
24
|
+
cast_bool: :plain, cast_char: :plain,
|
|
25
25
|
cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
|
|
26
26
|
cast_u8: :numeric, cast_u16: :numeric, cast_u32: :numeric, cast_u64: :numeric,
|
|
27
27
|
cast_f32: :numeric, cast_f64: :numeric, cast_decimal: :numeric,
|
data/lib/hypercast.rb
CHANGED
|
@@ -30,7 +30,7 @@ require_relative "hypercast/runtime"
|
|
|
30
30
|
module HyperCast
|
|
31
31
|
# This gem's own version — kept in lockstep with hypercast.gemspec by the
|
|
32
32
|
# prepare-release workflow, so the two can never drift apart again.
|
|
33
|
-
VERSION = "0.
|
|
33
|
+
VERSION = "0.8.0"
|
|
34
34
|
|
|
35
35
|
# The success case of a verdict: a cast value.
|
|
36
36
|
Success = Data.define(:value)
|
|
@@ -84,6 +84,50 @@ module HyperCast
|
|
|
84
84
|
super(decimal_sep: decimal_sep, group_sep: group_sep, flags: flags, currency: symbol)
|
|
85
85
|
end
|
|
86
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
|
+
|
|
87
131
|
# The 32-byte little-endian form the native ABI's NumFormat struct expects: the two
|
|
88
132
|
# separators as code points, the flags, the symbol's byte length, then the symbol's
|
|
89
133
|
# UTF-8 bytes zero-padded to 16.
|
|
@@ -220,6 +264,14 @@ module HyperCast
|
|
|
220
264
|
plain(:cast_bool, text, 1) { |out| Interop.decode(:bool, out) }
|
|
221
265
|
end
|
|
222
266
|
|
|
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
|
+
|
|
223
275
|
%i[i8 i16 i32 i64 u8 u16 u32 u64].each do |door|
|
|
224
276
|
unpack = Interop::SCALARS.fetch(door)
|
|
225
277
|
size = Interop::VALUE_BYTES.fetch(door)
|
|
@@ -228,20 +280,29 @@ module HyperCast
|
|
|
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
|
-
|
|
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
|
|
240
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)
|
|
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
|
|
245
306
|
numeric(:cast_f64, text, format, 8) { |out| Interop.decode(:f64, out) }
|
|
246
307
|
end
|
|
247
308
|
|
|
@@ -251,7 +312,9 @@ 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)
|
|
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
|
|
255
318
|
numeric(:cast_decimal, text, format, 16) { |out| Interop.decode(:decimal, out) }
|
|
256
319
|
end
|
|
257
320
|
|