hypercast-wasm 0.7.0 → 0.9.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 +168 -23
- data/ext/hypercast_native/3.4/hypercast_native.a.gz +0 -0
- data/ext/hypercast_native/4.0/hypercast_native.a.gz +0 -0
- data/lib/hypercast/interop.rb +2 -1
- data/lib/hypercast/runtime.rb +8 -6
- data/lib/hypercast.rb +304 -59
- 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: 10c626d762b241c551b0120df1d77ec78d4831a9328c670d2a7bc50c4f3cdf42
|
|
4
|
+
data.tar.gz: 3fbb217bfe1717648b5a579e034894c2eb55fc797f232b19d2f41aaf950299c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 952da6145e72b6d45cda1cb1e56351941cd19f20008a5963ab0452c8c7eec2e6e4204de281d704f2dcf8967d2a8c4a12f0c69984528cd8fdd12e26daded490a2
|
|
7
|
+
data.tar.gz: e924d2a7f955fd4f748bea86a9e11b8a05343608e4eda4358c9a324aa225088b045a6b569d318fd2b4acc443c847c5809d2ddd91fdbd5fe0aeae19bfcdd56d23
|
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,15 +39,16 @@ 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` |
|
|
48
49
|
| `excel_serial(text, epoch)` | UTC `Time` | `:y1900` / `:y1904` |
|
|
49
50
|
| `date(text, order = nil)` | `Date` | strict ISO, or `:year_month_day` / `:month_day_year` / `:day_month_year` |
|
|
50
|
-
| `datetime(text, order)` | zone-less `DateTime`, exact `Rational` seconds | a field order, as `date` |
|
|
51
|
+
| `datetime(text, order, am: nil, pm: nil)` | zone-less `DateTime`, exact `Rational` seconds | a field order, as `date`; optionally a culture's own AM/PM words, read beside the English ones — see [`datetime`: a culture's AM/PM](#datetime-a-cultures-ampm) |
|
|
51
52
|
| `time(text)` | `Integer` nanoseconds since midnight | — |
|
|
52
53
|
| `duration(text)` | exact `Rational` seconds | — |
|
|
53
54
|
| `decimal_from_float(value)` | `HyperCast::Decimal`, the shortest that names the number (`0.1` is one tenth) | — |
|
|
@@ -65,9 +66,14 @@ 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
|
-
|
|
70
|
-
|
|
69
|
+
- `ArgumentError` from `NumFormat.new`, `NumFormat#override` (and so the `separator:`,
|
|
70
|
+
`delimiter:` and `unit:` keywords) or `NumFormat.from_i18n` for a malformed format: a
|
|
71
|
+
separator that is empty, longer than 8 UTF-8 bytes or not distinct from its partner
|
|
72
|
+
(the pair in force for currency amounts included), any symbol carrying an ASCII digit, a
|
|
73
|
+
sign, percent or currency symbol carrying ASCII whitespace, a currency symbol longer than
|
|
74
|
+
16 bytes, or a digit zero that is ASCII or starts no digit block. And from `datetime` for
|
|
75
|
+
a day-period designator that is empty, longer than 16 bytes, carries an ASCII digit or
|
|
76
|
+
starts or ends in ASCII whitespace.
|
|
71
77
|
- `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
|
|
72
78
|
that names nothing: an unknown Symbol, or anything that is not a Symbol (`"seconds"`
|
|
73
79
|
included). `date(text, nil)` is not one of these: an explicit `nil` order is the strict ISO
|
|
@@ -132,33 +138,166 @@ value.to_d # => BigDecimal("-1234.5")
|
|
|
132
138
|
`to_d` requires the `bigdecimal` gem lazily on first call — a bundled gem since Ruby 3.4,
|
|
133
139
|
deliberately not a dependency of this one, so under Bundler add it to your own Gemfile.
|
|
134
140
|
|
|
135
|
-
### `NumFormat`: declared separators, and
|
|
141
|
+
### `NumFormat`: declared separators, symbols and digits
|
|
136
142
|
|
|
137
143
|
```ruby
|
|
138
144
|
HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
|
|
139
145
|
```
|
|
140
146
|
|
|
141
147
|
The flags are `GROUPING`, `PARENTHESES`, `EXPONENT`, `RADIX_PREFIXES`, `PERCENT` and
|
|
142
|
-
`CURRENCY` (`ALL_STYLES` is all six), plus
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
symbol
|
|
150
|
-
`
|
|
148
|
+
`CURRENCY` (`ALL_STYLES` is all six), plus `TRAILING_SIGN` — deliberately not in
|
|
149
|
+
`ALL_STYLES`; see below — and the `SEPARATOR_DETECT` policy. A separator is one or two
|
|
150
|
+
characters (`", "`, comma then space, is a separator), up to 8 UTF-8 bytes.
|
|
151
|
+
|
|
152
|
+
Everything past the separators and the flags is what a culture table would fill in, and
|
|
153
|
+
every such field is declared, never looked up — `from_i18n` fills what Rails' locale data
|
|
154
|
+
offers, and the rest is a keyword on `new` or `Data#with`. The rule for all of them: **a
|
|
155
|
+
declared symbol is read beside its ASCII default, never instead of it.** ASCII `-`, `+`,
|
|
156
|
+
`%` and `0`–`9` are always read, so a format that declares nothing (`""` everywhere, the
|
|
157
|
+
default — `INVARIANT` and `DETECT` declare none) reads exactly as the invariant culture,
|
|
158
|
+
and nothing that parsed before stops parsing when a symbol is declared.
|
|
159
|
+
|
|
160
|
+
- `currency:` — honored only while `CURRENCY` is set: once, leading (`$5`, `-$5`, `$ -5`)
|
|
161
|
+
or trailing (`5 €`, `1.234,50 kr.`), with optional ASCII whitespace between symbol and
|
|
162
|
+
digits, and accounting parentheses wrapping symbol and digits together (`($5)`). Declared
|
|
163
|
+
with the flag off, the symbol is a `:malformed` `Fault` at the symbol; the flag with
|
|
164
|
+
nothing declared matches nothing.
|
|
165
|
+
- `currency_decimal_sep:`, `currency_group_sep:` — the separators a currency amount is
|
|
166
|
+
written with, for a culture whose plain numbers and amounts differ (Swiss German writes
|
|
167
|
+
`1.234,5` and `CHF 1'234.50`). Read in place of the number separators when `CURRENCY` is
|
|
168
|
+
set, the symbol is declared, and the symbol is found in the text at either edge; each
|
|
169
|
+
`""` keeps its number counterpart. Under `SEPARATOR_DETECT`, detection governs the whole
|
|
170
|
+
token and these are ignored.
|
|
171
|
+
- `minus_sign:`, `plus_sign:` — further spellings of the signs (`"−"`, U+2212, for Estonian
|
|
172
|
+
and Basque; `"+"` fullwidth), read wherever ASCII `-`/`+` are: ahead of the digits, before
|
|
173
|
+
or after a leading currency symbol. Two signs are `:malformed` at the later one.
|
|
174
|
+
- `TRAILING_SIGN` — permits the sign *after* the digits, the way a locale whose negative
|
|
175
|
+
pattern is `n-` writes it: `5-`, `5 -`, `5+`, `5 €-`, `5- €`, `2.5%-`, ASCII or
|
|
176
|
+
declared, once, never beside a leading sign nor inside parentheses. It is a flag, not a
|
|
177
|
+
symbol, and it is not in `ALL_STYLES`: `from_i18n` turns it on from the locale's own
|
|
178
|
+
`negative_format` rather than assuming it.
|
|
179
|
+
- `percent:` — a further spelling of the percent symbol (`"٪"` in Arabic-script cultures,
|
|
180
|
+
`"%"` fullwidth), read as a trailing `%` is while `PERCENT` is set, on the real and
|
|
181
|
+
decimal doors; integer doors never read a percent.
|
|
182
|
+
- `digit_zero:` — the culture's own digits, as their zero: `"٠"` (U+0660) declares the ten
|
|
183
|
+
Arabic-Indic digits, `"0"` the fullwidth ones. They are read beside ASCII `0`–`9`
|
|
184
|
+
everywhere digits are — integer part, fraction, exponent — and may be mixed with them;
|
|
185
|
+
radix-prefixed (`0x`, `0b`) digits stay ASCII.
|
|
151
186
|
|
|
152
187
|
```ruby
|
|
153
188
|
dollars = HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
|
|
154
189
|
HyperCast.i32("($1,234)", dollars) # => Success(value: -1234)
|
|
155
190
|
HyperCast.decimal("$1,234.50", dollars) # => Success(value: Decimal(magnitude: 12345, scale: 1, negative: false))
|
|
156
191
|
HyperCast.i32("$5", HyperCast::NumFormat::INVARIANT) # => Fault(reason: :malformed, offset: 0, length: 1)
|
|
192
|
+
|
|
193
|
+
estonian = HyperCast::NumFormat.new(decimal_sep: ",", group_sep: " ", flags: HyperCast::ALL_STYLES, minus_sign: "−")
|
|
194
|
+
HyperCast.f64("−1 234,5", estonian) # => Success(value: -1234.5) (U+2212 minus; ASCII "-" still reads)
|
|
195
|
+
|
|
196
|
+
francs = HyperCast::NumFormat.new(decimal_sep: ",", group_sep: ".", flags: HyperCast::ALL_STYLES | HyperCast::TRAILING_SIGN,
|
|
197
|
+
currency: "CHF", currency_decimal_sep: ".", currency_group_sep: "'")
|
|
198
|
+
HyperCast.f64("CHF 1'234.50", francs) # => Success(value: 1234.5) (the currency separators, the symbol being present)
|
|
199
|
+
HyperCast.f64("1.234,50", francs) # => Success(value: 1234.5) (the number separators)
|
|
200
|
+
HyperCast.i32("5 CHF-", francs) # => Success(value: -5) (TRAILING_SIGN)
|
|
201
|
+
|
|
202
|
+
arabic = HyperCast::NumFormat.new(decimal_sep: "٫", group_sep: "٬", flags: HyperCast::ALL_STYLES, percent: "٪", digit_zero: "٠")
|
|
203
|
+
HyperCast.f64("١٬٢٣٤٫٥", arabic) # => Success(value: 1234.5)
|
|
204
|
+
HyperCast.decimal("٥٠٪", arabic) # => Success(value: Decimal(magnitude: 5, scale: 1, negative: false))
|
|
157
205
|
```
|
|
158
206
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
207
|
+
A malformed field is an `ArgumentError` at construction, naming the field — the same
|
|
208
|
+
caller-bug treatment equal separators have always had: an empty or over-long separator, an
|
|
209
|
+
equal pair (the number pair, or the pair in force for currency amounts), a symbol carrying
|
|
210
|
+
an ASCII digit, a sign, percent or currency symbol carrying ASCII whitespace, a currency
|
|
211
|
+
symbol over 16 bytes, a digit zero that is ASCII or does not start a digit block. The
|
|
212
|
+
limits are `NumFormat::SYMBOL_MAX_BYTES` (8) and `NumFormat::CURRENCY_MAX_BYTES` (16).
|
|
213
|
+
|
|
214
|
+
Across the ABI a `NumFormat` is a 92-byte, size-tagged struct that the core reads in
|
|
215
|
+
place — `NumFormat#packed` is exactly those bytes: the size word, the flags, the digit
|
|
216
|
+
zero's code point, then each symbol's UTF-8 bytes and length held inline. It is packed
|
|
217
|
+
once per format object and memoized by identity on every backend, so declaring a currency
|
|
218
|
+
or a culture's digits costs a cast nothing.
|
|
219
|
+
|
|
220
|
+
**What stays English: the boolean lexicon.** `bool` reads its twenty lexemes
|
|
221
|
+
(`true`/`false`, `yes`/`no`, `on`/`off`, …) in English only. No platform carries culture
|
|
222
|
+
data for boolean words, so there is no bridge that could fill them; a caller with a
|
|
223
|
+
localized source maps its words before the door.
|
|
224
|
+
|
|
225
|
+
### Rails' words for a format: `separator:`, `delimiter:`, `unit:`
|
|
226
|
+
|
|
227
|
+
Ruby's standard library carries no locale number data, so the vocabulary Ruby code already
|
|
228
|
+
speaks is Rails' — the number helpers' and I18n's `number.format`: `separator` (the decimal
|
|
229
|
+
mark), `delimiter` (grouping), `unit` (the currency symbol) and `negative_format` (where
|
|
230
|
+
the sign goes). Every numeric door takes the first three as keywords that replace those
|
|
231
|
+
fields of the format for the call, and `NumFormat.from_i18n` builds a format from the Hash
|
|
232
|
+
I18n hands back:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
inv = HyperCast::NumFormat::INVARIANT
|
|
236
|
+
HyperCast.f64("1.234,5", inv, separator: ",", delimiter: ".") # => Success(value: 1234.5)
|
|
237
|
+
HyperCast.decimal("1.234,50 kr.", inv, separator: ",", delimiter: ".", unit: "kr.")
|
|
238
|
+
|
|
239
|
+
german = HyperCast::NumFormat.from_i18n(I18n.t("number.format")) # {separator: ",", delimiter: ".", ...}
|
|
240
|
+
HyperCast.i32("1.234", german) # => Success(value: 1234)
|
|
241
|
+
|
|
242
|
+
# A locale whose currency amounts use other separators than its numbers: both Hashes.
|
|
243
|
+
swiss = HyperCast::NumFormat.from_i18n(I18n.t("number.format"), currency_format: I18n.t("number.currency.format"))
|
|
244
|
+
HyperCast.f64("CHF 1'234.50", swiss) # => Success(value: 1234.5)
|
|
245
|
+
HyperCast.f64("1.234,50", swiss) # => Success(value: 1234.5)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The keywords go through `NumFormat#override`, which is public: the result is validated like
|
|
249
|
+
any `NumFormat`, and the flags and every other field are left alone, so a `unit:` on a
|
|
250
|
+
format without `CURRENCY` is declared but not honored, as it would be in the constructor.
|
|
251
|
+
`from_i18n` reads Symbol or String keys and ignores every other key, so
|
|
252
|
+
`number.currency.format` passed alone works as well. Missing keys keep the invariant's
|
|
253
|
+
fields. An empty `delimiter` (a locale that writes no grouping) turns `GROUPING` off. A
|
|
254
|
+
`negative_format` that writes the sign after the number (`"%n-"`, `"%n %u-"`) turns
|
|
255
|
+
`TRAILING_SIGN` on; `"-%n"`, the Rails default, leaves it off. `currency_format:` takes
|
|
256
|
+
`number.currency.format` beside `number.format`: its `separator` and `delimiter` become
|
|
257
|
+
`currency_decimal_sep` and `currency_group_sep` (a key it lacks declares nothing, so the
|
|
258
|
+
number separator stands in), its `unit` the currency symbol, and its `negative_format`
|
|
259
|
+
counts the same way. `flags:` defaults to `ALL_STYLES`.
|
|
260
|
+
|
|
261
|
+
That is everything the Hash offers. Rails carries no minus or plus sign, no percent symbol
|
|
262
|
+
and no digits, so `from_i18n` leaves those undeclared — the core reads ASCII `-`, `+`, `%`
|
|
263
|
+
and `0`–`9` — and a locale that writes them differently declares them on the result:
|
|
264
|
+
`from_i18n(I18n.t("number.format")).with(minus_sign: "−")` for Estonian.
|
|
265
|
+
|
|
266
|
+
A call without the keywords is exactly the call it was, with no extra allocation on either
|
|
267
|
+
backend. A call with them builds a format each time (a few microseconds), so in a loop,
|
|
268
|
+
build it once with `override` or `from_i18n` and pass that instead.
|
|
269
|
+
|
|
270
|
+
### `datetime`: a culture's AM/PM
|
|
271
|
+
|
|
272
|
+
The civil date-time door reads a 12-hour clock's `AM`/`PM` ASCII case-insensitively, after
|
|
273
|
+
the clock (`3:04 PM`) or ahead of it (`PM 3:04`). `am:` and `pm:` declare a culture's own
|
|
274
|
+
words beside the English ones — German's `vorm.`/`nachm.`, Korean's `오전`/`오후`
|
|
275
|
+
(written ahead of the clock), Spanish's `a. m.`/`p. m.` (a space inside is fine) — matched
|
|
276
|
+
exactly, with or without a space between word and clock. English is still read whatever is
|
|
277
|
+
declared; `nil`, the default, declares nothing, so a call without the keywords is exactly
|
|
278
|
+
the call it was. Like a `NumFormat`'s symbols the words are declared, never looked up:
|
|
279
|
+
Ruby's stdlib has no day-period data, and `I18n.t("time.am")`/`("time.pm")` are where a
|
|
280
|
+
Rails app finds them.
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
HyperCast.datetime("1/7/2026 3:04 nachm.", :day_month_year, am: "vorm.", pm: "nachm.")
|
|
284
|
+
# => Success(value: #<DateTime: 2026-07-01T15:04:00+00:00 ...>)
|
|
285
|
+
HyperCast.datetime("2026-01-07 오후 3:04", :year_month_day, am: "오전", pm: "오후")
|
|
286
|
+
# => Success(value: #<DateTime: 2026-01-07T15:04:00+00:00 ...>)
|
|
287
|
+
HyperCast.datetime("1/7/2026 3:04 nachm.", :day_month_year) # => Fault(reason: :malformed, offset: 13, length: 1)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
A designator is 1 to 16 UTF-8 bytes with no ASCII digit and no ASCII whitespace at either
|
|
291
|
+
end; anything else is an `ArgumentError`. Only `datetime` reads them — `date` has no time
|
|
292
|
+
part. Across the ABI they are a 40-byte, size-tagged struct passed beside the order, null
|
|
293
|
+
when nothing is declared. The designators are keywords, so they are read, validated and
|
|
294
|
+
packed per call — the Fiddle backend packs them into its per-thread scratch — and a call
|
|
295
|
+
that declares them costs about twice the plain call on the Magnus backend (the keyword
|
|
296
|
+
scan is the cost: `am: nil, pm: nil` pays the same) and about half again on Fiddle.
|
|
297
|
+
|
|
298
|
+
[^overrides]: Plus the optional keywords `separator:`, `delimiter:` and `unit:`, which
|
|
299
|
+
override those fields of `format` for the call. See
|
|
300
|
+
[Rails' words for a format](#rails-words-for-a-format-separator-delimiter-unit).
|
|
162
301
|
|
|
163
302
|
## Interop: building on HyperCast's C ABI
|
|
164
303
|
|
|
@@ -216,6 +355,12 @@ HyperCast::Interop.version(0x000700) # => "0.7.0"
|
|
|
216
355
|
is now identity-matched like `INVARIANT`, and any other format is resolved once per
|
|
217
356
|
thread and memoized by identity — anchored in a thread-variable so the memo's key can
|
|
218
357
|
never be a recycled address — which turned the per-call cost into one pointer compare.
|
|
358
|
+
|
|
359
|
+
A declared currency symbol costs text that does not carry one about 6 ns on the Magnus
|
|
360
|
+
backend: in one run, `1,234,567` took 151 ns under `INVARIANT` and 157 ns under a format
|
|
361
|
+
declaring `$`. A fault costs more than a success: the same run returned the `Fault` for
|
|
362
|
+
`12345x7` in 222 ns, against 101 ns for `Integer("12345x7", exception: false)` returning
|
|
363
|
+
`nil`. The door builds a reason and a span where `Integer()` hands back nothing.
|
|
219
364
|
The three reason Symbols and the option Symbols (`:seconds`, `:month_day_year`, …) are
|
|
220
365
|
cached the same way, so a fault or a declared option is a pointer compare too, never a
|
|
221
366
|
`Symbol#name` materialization.
|
|
@@ -347,7 +492,7 @@ one at `require` time:
|
|
|
347
492
|
| 3.3 (the floor, until its EOL 2027-03-31) | Fiddle | universal |
|
|
348
493
|
|
|
349
494
|
What stands behind each cell: CI replays the whole suite, shared corpus included, through
|
|
350
|
-
each Magnus extension on every
|
|
495
|
+
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
496
|
the two musl ones inside each Ruby's own Alpine image — and the Fiddle suite on Ruby 4.0 on
|
|
352
497
|
every one of them, plus on Ruby 3.3 inside Alpine. Intel macOS has no CI leg: its library is
|
|
353
498
|
cross-built and tested at the core, and Ruby there runs the universal gem's Fiddle backend
|
|
Binary file
|
|
Binary file
|
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
|
data/lib/hypercast/runtime.rb
CHANGED
|
@@ -16,18 +16,19 @@ module HyperCast
|
|
|
16
16
|
|
|
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
|
-
# (text, len, out, fault), numeric adds the packed NumFormat pointer before out,
|
|
20
|
-
# declared a caller-declared u32 (precision, epoch, field order) in the same place
|
|
21
|
-
#
|
|
22
|
-
#
|
|
19
|
+
# (text, len, out, fault), numeric adds the packed NumFormat pointer before out,
|
|
20
|
+
# declared a caller-declared u32 (precision, epoch, field order) in the same place, and
|
|
21
|
+
# civil the field order then the packed DayPeriods pointer (null for none). The typed
|
|
22
|
+
# doors take a double instead of the text and its length: typed is (value, out, fault),
|
|
23
|
+
# typed_declared adds the u32 after the value.
|
|
23
24
|
DOORS = {
|
|
24
|
-
cast_bool: :plain,
|
|
25
|
+
cast_bool: :plain, cast_char: :plain,
|
|
25
26
|
cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
|
|
26
27
|
cast_u8: :numeric, cast_u16: :numeric, cast_u32: :numeric, cast_u64: :numeric,
|
|
27
28
|
cast_f32: :numeric, cast_f64: :numeric, cast_decimal: :numeric,
|
|
28
29
|
cast_uuid: :plain,
|
|
29
30
|
cast_timestamp: :plain, cast_unix: :declared, cast_excel_serial: :declared,
|
|
30
|
-
cast_date: :plain, cast_date_ordered: :declared, cast_datetime: :
|
|
31
|
+
cast_date: :plain, cast_date_ordered: :declared, cast_datetime: :civil,
|
|
31
32
|
cast_time: :plain, cast_duration: :plain,
|
|
32
33
|
cast_decimal_from_f64: :typed, cast_excel_serial_from_f64: :typed_declared,
|
|
33
34
|
cast_excel_time: :typed, cast_excel_duration: :typed
|
|
@@ -114,6 +115,7 @@ module HyperCast
|
|
|
114
115
|
plain: plain,
|
|
115
116
|
numeric: plain.dup.insert(2, Fiddle::TYPE_VOIDP),
|
|
116
117
|
declared: plain.dup.insert(2, Fiddle::TYPE_UINT32_T),
|
|
118
|
+
civil: plain.dup.insert(2, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP),
|
|
117
119
|
typed: [Fiddle::TYPE_DOUBLE, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP],
|
|
118
120
|
typed_declared: [Fiddle::TYPE_DOUBLE, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
|
|
119
121
|
}
|
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.9.0"
|
|
34
34
|
|
|
35
35
|
# The success case of a verdict: a cast value.
|
|
36
36
|
Success = Data.define(:value)
|
|
@@ -48,47 +48,202 @@ module HyperCast
|
|
|
48
48
|
|
|
49
49
|
# Caller-declared numeric notation for the integer, real and decimal doors — declared out
|
|
50
50
|
# loud (INVARIANT, or a literal), never defaulted, the same stance every binding takes.
|
|
51
|
-
#
|
|
52
|
-
# here, never looked up
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
# (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
51
|
+
# Past the separators and the flags, every field is one a culture table would fill in,
|
|
52
|
+
# and each is declared here, never looked up: the currency symbol; the separators a
|
|
53
|
+
# currency amount is written with (currency_decimal_sep, currency_group_sep — read in
|
|
54
|
+
# place of the number separators when the symbol is found in the text, each "" keeping
|
|
55
|
+
# its number counterpart); further spellings of the minus and plus signs and of the
|
|
56
|
+
# percent symbol; and the culture's own digits (digit_zero — the ten code points from
|
|
57
|
+
# that zero up, "٠" for the Arabic-Indic digits). A declared symbol is read *beside* its
|
|
58
|
+
# ASCII default, never instead of it — ASCII "-", "+", "%" and 0-9 are always read — so a
|
|
59
|
+
# format that declares nothing ("" everywhere; the default) reads exactly as the
|
|
60
|
+
# invariant culture, and nothing that parsed before stops parsing. The currency symbol is
|
|
61
|
+
# honored only while the CURRENCY flag is set — declared with the flag off, the symbol is
|
|
62
|
+
# a :malformed Fault at the symbol, and the flag with no symbol matches nothing — and the
|
|
63
|
+
# percent symbol while PERCENT is. A separator may be one or two characters (", " —
|
|
64
|
+
# comma, space — is a separator), up to 8 UTF-8 bytes. A malformed format is a caller bug
|
|
65
|
+
# (ArgumentError), never a verdict: an empty or over-long separator, equal separators (or
|
|
66
|
+
# an equal pair in force for currency amounts), a symbol carrying an ASCII digit, a sign
|
|
67
|
+
# or percent symbol carrying ASCII whitespace, a currency symbol longer than 16 bytes or
|
|
68
|
+
# carrying a digit or whitespace, or a digit zero that is ASCII or starts no digit block.
|
|
69
|
+
NumFormat = Data.define(:decimal_sep, :group_sep, :flags, :currency,
|
|
70
|
+
:currency_decimal_sep, :currency_group_sep,
|
|
71
|
+
:minus_sign, :plus_sign, :percent, :digit_zero) do
|
|
72
|
+
# Validates every declared field up front — the separators (1 to 8 UTF-8 bytes each,
|
|
73
|
+
# distinct from each other, and so the pair in force for currency amounts), the signs,
|
|
74
|
+
# the percent and currency symbols (no ASCII digit, since a digit is a digit before it
|
|
75
|
+
# is anything else, and no ASCII whitespace, which would collide with the trimming
|
|
76
|
+
# around them) and the digit zero (one non-ASCII character whose ten successors share
|
|
77
|
+
# its UTF-8 prefix, which every Unicode digit block's zero does) — so a malformed
|
|
78
|
+
# format fails loudly as the caller bug it is. Every field is stored transcoded to
|
|
79
|
+
# UTF-8, the encoding the core reads — a separator handed over in another encoding (a
|
|
80
|
+
# no-break space read from a Latin-1 file, say) is the same code point on every backend
|
|
81
|
+
# rather than whatever its bytes happened to spell in the encoding it arrived in.
|
|
82
|
+
def initialize(decimal_sep:, group_sep:, flags:, currency: "", currency_decimal_sep: "",
|
|
83
|
+
currency_group_sep: "", minus_sign: "", plus_sign: "", percent: "", digit_zero: "")
|
|
84
|
+
limit = self.class::SYMBOL_MAX_BYTES
|
|
85
|
+
decimal_sep = declared("decimal separator", decimal_sep, limit, required: true)
|
|
86
|
+
group_sep = declared("group separator", group_sep, limit, required: true)
|
|
73
87
|
raise ArgumentError, "decimal and group separators must differ; both are #{decimal_sep.inspect}" if
|
|
74
88
|
decimal_sep == group_sep
|
|
75
|
-
raise ArgumentError, "currency symbol must be a String; got #{currency.inspect}" unless currency.is_a?(String)
|
|
76
89
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
raise ArgumentError, "currency
|
|
82
|
-
|
|
90
|
+
currency_decimal_sep = declared("currency decimal separator", currency_decimal_sep, limit)
|
|
91
|
+
currency_group_sep = declared("currency group separator", currency_group_sep, limit)
|
|
92
|
+
money_decimal = currency_decimal_sep.empty? ? decimal_sep : currency_decimal_sep
|
|
93
|
+
money_group = currency_group_sep.empty? ? group_sep : currency_group_sep
|
|
94
|
+
raise ArgumentError, "currency decimal and group separators must differ; both are #{money_decimal.inspect}" if
|
|
95
|
+
money_decimal == money_group
|
|
96
|
+
|
|
97
|
+
super(decimal_sep: decimal_sep, group_sep: group_sep, flags: flags,
|
|
98
|
+
currency: declared("currency symbol", currency, self.class::CURRENCY_MAX_BYTES, flush: true),
|
|
99
|
+
currency_decimal_sep: currency_decimal_sep, currency_group_sep: currency_group_sep,
|
|
100
|
+
minus_sign: declared("minus sign", minus_sign, limit, flush: true),
|
|
101
|
+
plus_sign: declared("plus sign", plus_sign, limit, flush: true),
|
|
102
|
+
percent: declared("percent symbol", percent, limit, flush: true),
|
|
103
|
+
digit_zero: digit_zero_of(digit_zero))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# This format with the fields Ruby's number vocabulary names replaced — the words Rails'
|
|
107
|
+
# number helpers and I18n's number.format use: separator: (the decimal mark),
|
|
108
|
+
# delimiter: (grouping) and unit: (the currency symbol). A nil keyword leaves its
|
|
109
|
+
# field alone, so with none given this is the format itself, not a copy. The result is
|
|
110
|
+
# validated like any NumFormat (declarable, distinct separators; a declarable symbol),
|
|
111
|
+
# and the flags and every other field are untouched: a unit: declared on a format
|
|
112
|
+
# without CURRENCY is a :malformed Fault at the symbol, exactly as declaring it in the
|
|
113
|
+
# constructor would be. Every numeric door takes the same three keywords and applies
|
|
114
|
+
# them through this method; the fields Rails has no word for (the signs, the percent
|
|
115
|
+
# symbol, the digits, the currency separators) are declared in the constructor or
|
|
116
|
+
# through Data#with.
|
|
117
|
+
def override(separator: nil, delimiter: nil, unit: nil)
|
|
118
|
+
return self unless separator || delimiter || unit
|
|
119
|
+
|
|
120
|
+
with(**{ decimal_sep: separator, group_sep: delimiter, currency: unit }.compact)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A format from a Hash shaped like I18n's number.format (or number.currency.format):
|
|
124
|
+
# :separator, :delimiter, :unit and :negative_format, Symbol or String keys, every other
|
|
125
|
+
# key (precision, format, significant, ...) ignored — so
|
|
126
|
+
# `NumFormat.from_i18n(I18n.t("number.format"))` drops a Rails locale straight in, with
|
|
127
|
+
# no I18n dependency here. A missing or nil key keeps the invariant's "." / "," / no
|
|
128
|
+
# symbol. An empty delimiter — the locale writes no grouping — turns GROUPING off rather
|
|
129
|
+
# than declaring a separator, since a NumFormat always carries one; the placeholder it
|
|
130
|
+
# gets is whichever of "," and "." the decimal mark is not. A negative_format that writes
|
|
131
|
+
# the sign after the number ("%n-", "%n %u-") turns TRAILING_SIGN on; "-%n", the Rails
|
|
132
|
+
# default, leaves it off. flags defaults to every lenience, the same as INVARIANT.
|
|
133
|
+
#
|
|
134
|
+
# currency_format: takes the locale's number.currency.format Hash beside number.format,
|
|
135
|
+
# for a column that mixes plain numbers and currency amounts in a locale whose two
|
|
136
|
+
# differ (the Swiss German franc, say — "1'234.50" beside "1.234,50"): its separator and
|
|
137
|
+
# delimiter become the currency separators, read in place of the number ones when the
|
|
138
|
+
# unit is found in the text, and its unit the currency symbol. A missing key there
|
|
139
|
+
# declares nothing, keeping the number separator it stands in for; an explicitly empty
|
|
140
|
+
# delimiter gets the same placeholder rule as above; its negative_format turns
|
|
141
|
+
# TRAILING_SIGN on the same way. Passed alone, number.currency.format reads as the one
|
|
142
|
+
# number format, as before.
|
|
143
|
+
#
|
|
144
|
+
# The rest of the format is what the Hash cannot offer: Rails carries no minus or plus
|
|
145
|
+
# sign, no percent symbol and no digits, so those stay undeclared here — the core reads
|
|
146
|
+
# ASCII "-", "+", "%" and 0-9 — and a locale that writes negatives with another sign
|
|
147
|
+
# (U+2212 for Estonian and Basque, for instance) declares it on the result with
|
|
148
|
+
# Data#with (minus_sign: "−"). Every lenience is on, radix prefixes and percent included;
|
|
149
|
+
# narrow flags: for anything stricter.
|
|
150
|
+
def self.from_i18n(number_format, currency_format: nil, flags: ALL_STYLES)
|
|
151
|
+
entries = i18n_entries(number_format)
|
|
152
|
+
separator = entries[:separator] || "."
|
|
153
|
+
delimiter = entries[:delimiter] || ","
|
|
154
|
+
if delimiter == ""
|
|
155
|
+
flags &= ~GROUPING
|
|
156
|
+
delimiter = separator == "," ? "." : ","
|
|
157
|
+
end
|
|
158
|
+
flags |= TRAILING_SIGN if trailing_sign?(entries[:negative_format])
|
|
159
|
+
currency = entries[:unit] || ""
|
|
160
|
+
currency_separator = currency_delimiter = ""
|
|
161
|
+
if currency_format
|
|
162
|
+
money = i18n_entries(currency_format)
|
|
163
|
+
currency = money[:unit] || currency
|
|
164
|
+
currency_separator = money[:separator] || ""
|
|
165
|
+
currency_delimiter = money[:delimiter] || ""
|
|
166
|
+
currency_delimiter = (money[:separator] || separator) == "," ? "." : "," if money[:delimiter] == ""
|
|
167
|
+
flags |= TRAILING_SIGN if trailing_sign?(money[:negative_format])
|
|
168
|
+
end
|
|
169
|
+
new(decimal_sep: separator, group_sep: delimiter, flags: flags, currency: currency,
|
|
170
|
+
currency_decimal_sep: currency_separator, currency_group_sep: currency_delimiter)
|
|
171
|
+
end
|
|
83
172
|
|
|
84
|
-
|
|
173
|
+
# The I18n Hash's entries under Symbol keys, whatever keys it arrived with.
|
|
174
|
+
def self.i18n_entries(number_format)
|
|
175
|
+
raise ArgumentError, "expected a Hash-like number format; got #{number_format.inspect}" unless
|
|
176
|
+
number_format.respond_to?(:to_h)
|
|
177
|
+
|
|
178
|
+
number_format.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
|
|
85
179
|
end
|
|
180
|
+
private_class_method :i18n_entries
|
|
181
|
+
|
|
182
|
+
# Whether an I18n negative_format ("-%n", "%n-", "-%u%n", "%n %u-") writes the sign
|
|
183
|
+
# after the number: a "-" to the right of the "%n" placeholder.
|
|
184
|
+
def self.trailing_sign?(negative_format)
|
|
185
|
+
return false unless negative_format.is_a?(String)
|
|
86
186
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
187
|
+
number = negative_format.index("%n")
|
|
188
|
+
sign = negative_format.rindex("-")
|
|
189
|
+
!number.nil? && !sign.nil? && sign > number
|
|
190
|
+
end
|
|
191
|
+
private_class_method :trailing_sign?
|
|
192
|
+
|
|
193
|
+
# The 92-byte little-endian form of the native ABI's size-tagged NumFormat struct, the
|
|
194
|
+
# layout the core reads in place: the size word (92), the flags, the digit zero's code
|
|
195
|
+
# point (0 for none), then each symbol as its UTF-8 bytes zero-padded to its field
|
|
196
|
+
# followed by its byte length — eight bytes and a length for each of the two number
|
|
197
|
+
# separators, the two currency separators, the two signs and the percent symbol, in
|
|
198
|
+
# that order; sixteen and a length for the currency symbol. A length of 0 declares
|
|
199
|
+
# nothing.
|
|
90
200
|
def packed
|
|
91
|
-
[
|
|
201
|
+
head = [self.class::PACKED_BYTES, flags, digit_zero.empty? ? 0 : digit_zero.ord].pack("L<L<L<")
|
|
202
|
+
symbols = [decimal_sep, group_sep, currency_decimal_sep, currency_group_sep, minus_sign, plus_sign, percent]
|
|
203
|
+
head + symbols.map { |symbol| [symbol, symbol.bytesize].pack("a8C") }.join +
|
|
204
|
+
[currency, currency.bytesize].pack("a16C")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
# The declared text of one field as the UTF-8 the core reads: a String of 1 to `limit`
|
|
210
|
+
# UTF-8 bytes with no ASCII digit — nor, for a symbol that sits flush against the
|
|
211
|
+
# digits (the signs, the percent and currency symbols), any ASCII whitespace — or ""
|
|
212
|
+
# where the field may be left undeclared. Anything else is the caller bug it names.
|
|
213
|
+
def declared(field, text, limit, flush: false, required: false)
|
|
214
|
+
raise ArgumentError, "#{field} must be a String; got #{text.inspect}" unless text.is_a?(String)
|
|
215
|
+
|
|
216
|
+
symbol = text.encode(Encoding::UTF_8)
|
|
217
|
+
if symbol.empty?
|
|
218
|
+
raise ArgumentError, "#{field} must not be empty" if required
|
|
219
|
+
|
|
220
|
+
return symbol
|
|
221
|
+
end
|
|
222
|
+
raise ArgumentError, "#{field} #{text.inspect} exceeds #{limit} UTF-8 bytes" if symbol.bytesize > limit
|
|
223
|
+
|
|
224
|
+
if flush
|
|
225
|
+
raise ArgumentError, "#{field} #{text.inspect} must not contain an ASCII digit or whitespace" if
|
|
226
|
+
symbol.match?(/[0-9\t\n\f\r ]/)
|
|
227
|
+
elsif symbol.match?(/[0-9]/)
|
|
228
|
+
raise ArgumentError, "#{field} #{text.inspect} must not contain an ASCII digit"
|
|
229
|
+
end
|
|
230
|
+
symbol
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The declared digit zero as UTF-8: "" for none, else exactly one character that is not
|
|
234
|
+
# ASCII and whose code point modulo 64 is at most 54 — true of the zero of every decimal
|
|
235
|
+
# digit block Unicode defines, and the shape the core recognises a digit by its last
|
|
236
|
+
# byte from.
|
|
237
|
+
def digit_zero_of(text)
|
|
238
|
+
raise ArgumentError, "digit zero must be a String; got #{text.inspect}" unless text.is_a?(String)
|
|
239
|
+
|
|
240
|
+
zero = text.encode(Encoding::UTF_8)
|
|
241
|
+
return zero if zero.empty?
|
|
242
|
+
|
|
243
|
+
raise ArgumentError, "digit zero #{text.inspect} must be one non-ASCII character that starts a digit block" unless
|
|
244
|
+
zero.length == 1 && zero.ord >= 0x80 && zero.ord % 64 <= 54
|
|
245
|
+
|
|
246
|
+
zero
|
|
92
247
|
end
|
|
93
248
|
end
|
|
94
249
|
|
|
@@ -97,6 +252,13 @@ module HyperCast
|
|
|
97
252
|
# is scoped lexically, and landed on HyperCast instead of on NumFormat.
|
|
98
253
|
NumFormat::CURRENCY_MAX_BYTES = 16
|
|
99
254
|
|
|
255
|
+
# The widest separator, sign or percent symbol the native ABI carries inline, in UTF-8
|
|
256
|
+
# bytes: one or two characters of any script.
|
|
257
|
+
NumFormat::SYMBOL_MAX_BYTES = 8
|
|
258
|
+
|
|
259
|
+
# The size of NumFormat#packed — the native ABI's size-tagged NumFormat layout — in bytes.
|
|
260
|
+
NumFormat::PACKED_BYTES = 92
|
|
261
|
+
|
|
100
262
|
# Permit the group separator between digits (sizes not validated — between digits is the rule).
|
|
101
263
|
GROUPING = 1
|
|
102
264
|
# Permit accounting parentheses as negation: (1,234) is -1234.
|
|
@@ -105,7 +267,8 @@ module HyperCast
|
|
|
105
267
|
EXPONENT = 1 << 2
|
|
106
268
|
# Permit 0x/&H/0b two's-complement radix prefixes (0xFF is -1 for an i8).
|
|
107
269
|
RADIX_PREFIXES = 1 << 3
|
|
108
|
-
# Permit a trailing
|
|
270
|
+
# Permit a trailing % (or the format's declared percent symbol), dividing by 100. Real
|
|
271
|
+
# and decimal doors only.
|
|
109
272
|
PERCENT = 1 << 4
|
|
110
273
|
# Resolve the ./, roles per input from structure instead of the declared separators
|
|
111
274
|
# (which are ignored while this flag is set). Detection, not sniffing: a repeated
|
|
@@ -121,8 +284,15 @@ module HyperCast
|
|
|
121
284
|
# along with the digits ("($5)"). With no symbol declared the flag matches nothing.
|
|
122
285
|
# Integer, real and decimal doors.
|
|
123
286
|
CURRENCY = 1 << 6
|
|
124
|
-
#
|
|
125
|
-
#
|
|
287
|
+
# Permit the sign after the digits ("5-", "5 -", "5 €-", "5- €"), the way a locale whose
|
|
288
|
+
# negative_format is "%n-" writes it: ASCII - and + or the format's declared signs, once,
|
|
289
|
+
# with optional ASCII whitespace before it, and never beside a leading sign or inside
|
|
290
|
+
# accounting parentheses. Not part of ALL_STYLES — NumFormat.from_i18n sets it from the
|
|
291
|
+
# locale's own negative_format rather than assuming it.
|
|
292
|
+
TRAILING_SIGN = 1 << 7
|
|
293
|
+
# Every lenience on — every one there was before TRAILING_SIGN, which is declared from a
|
|
294
|
+
# locale's pattern rather than assumed (SEPARATOR_DETECT is a separator policy, not a
|
|
295
|
+
# lenience, and is deliberately not included either).
|
|
126
296
|
ALL_STYLES = GROUPING | PARENTHESES | EXPONENT | RADIX_PREFIXES | PERCENT | CURRENCY
|
|
127
297
|
|
|
128
298
|
# The invariant profile — '.' decimal, ',' grouping, every lenience on, no currency
|
|
@@ -220,6 +390,14 @@ module HyperCast
|
|
|
220
390
|
plain(:cast_bool, text, 1) { |out| Interop.decode(:bool, out) }
|
|
221
391
|
end
|
|
222
392
|
|
|
393
|
+
# Casts char text to a one-character UTF-8 String: an input of exactly one character is
|
|
394
|
+
# that character verbatim (" " is a space), otherwise one declared code point — 65,
|
|
395
|
+
# U+0041, 0x41, &H41, A or A — surrounded by optional ASCII whitespace. A code
|
|
396
|
+
# point past U+10FFFF, or a surrogate, is :out_of_range.
|
|
397
|
+
def char(text)
|
|
398
|
+
plain(:cast_char, text, 4) { |out| Interop.decode(:char, out) }
|
|
399
|
+
end
|
|
400
|
+
|
|
223
401
|
%i[i8 i16 i32 i64 u8 u16 u32 u64].each do |door|
|
|
224
402
|
unpack = Interop::SCALARS.fetch(door)
|
|
225
403
|
size = Interop::VALUE_BYTES.fetch(door)
|
|
@@ -228,20 +406,29 @@ module HyperCast
|
|
|
228
406
|
symbol = :"cast_#{door}"
|
|
229
407
|
# Integer doors: the target type's own range, declared grouping, accounting parens,
|
|
230
408
|
# 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
|
-
|
|
409
|
+
# is unbounded, so u64 comes back as the true unsigned value. separator:, delimiter:
|
|
410
|
+
# and unit: override those fields of format for this call (NumFormat#override).
|
|
411
|
+
define_method(door) do |text, format, separator: nil, delimiter: nil, unit: nil|
|
|
412
|
+
format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
|
|
413
|
+
separator || delimiter || unit
|
|
233
414
|
numeric(symbol, text, format, size) { |out| out.unpack1(unpack) }
|
|
234
415
|
end
|
|
235
416
|
end
|
|
236
417
|
|
|
237
418
|
# Casts real text to an IEEE single (widened losslessly on the way out): finite values
|
|
238
419
|
# only, declared separators and grouping, parens, exponent, and trailing percent.
|
|
239
|
-
|
|
420
|
+
# separator:, delimiter: and unit: override those fields of format for this call
|
|
421
|
+
# (NumFormat#override), on this door and every other numeric one.
|
|
422
|
+
def f32(text, format, separator: nil, delimiter: nil, unit: nil)
|
|
423
|
+
format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
|
|
424
|
+
separator || delimiter || unit
|
|
240
425
|
numeric(:cast_f32, text, format, 4) { |out| Interop.decode(:f32, out) }
|
|
241
426
|
end
|
|
242
427
|
|
|
243
428
|
# Casts real text to an IEEE double. Notation rules as f32.
|
|
244
|
-
def f64(text, format)
|
|
429
|
+
def f64(text, format, separator: nil, delimiter: nil, unit: nil)
|
|
430
|
+
format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
|
|
431
|
+
separator || delimiter || unit
|
|
245
432
|
numeric(:cast_f64, text, format, 8) { |out| Interop.decode(:f64, out) }
|
|
246
433
|
end
|
|
247
434
|
|
|
@@ -251,7 +438,9 @@ module HyperCast
|
|
|
251
438
|
# "1.10" is canonical magnitude 11, scale 1 — exact trailing zeros are always trimmed.
|
|
252
439
|
# A magnitude past 2**96 - 1, or more fractional precision than 28 places can hold, is
|
|
253
440
|
# an :out_of_range Fault — the door never rounds.
|
|
254
|
-
def decimal(text, format)
|
|
441
|
+
def decimal(text, format, separator: nil, delimiter: nil, unit: nil)
|
|
442
|
+
format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
|
|
443
|
+
separator || delimiter || unit
|
|
255
444
|
numeric(:cast_decimal, text, format, 16) { |out| Interop.decode(:decimal, out) }
|
|
256
445
|
end
|
|
257
446
|
|
|
@@ -310,8 +499,21 @@ module HyperCast
|
|
|
310
499
|
# artifact, not data (the same caveat PHP's UTC-labeled DateTimeImmutable carries);
|
|
311
500
|
# fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
|
|
312
501
|
# instant door. An unknown order is a caller bug (KeyError).
|
|
313
|
-
|
|
314
|
-
|
|
502
|
+
#
|
|
503
|
+
# A 12-hour clock's AM/PM is read, before or after the clock ("3:04 PM", "PM 3:04"),
|
|
504
|
+
# ASCII case-insensitively; am: and pm: declare a culture's own designators beside
|
|
505
|
+
# them — "vorm."/"nachm.", "오전"/"오후", "a. m."/"p. m." — matched exactly, after the
|
|
506
|
+
# clock ("3:04 nachm.") or ahead of it ("오후 3:04"), with or without a space between.
|
|
507
|
+
# Declared, never looked up, like a NumFormat's symbols; nil (the default) declares
|
|
508
|
+
# nothing, so a call without them is exactly the call it was. A designator is 1 to 16
|
|
509
|
+
# UTF-8 bytes with no ASCII digit and no ASCII whitespace at either end (a space inside
|
|
510
|
+
# is fine); anything else is a caller bug (ArgumentError). The designators are read,
|
|
511
|
+
# validated and packed per call: a call that declares them costs about twice the plain
|
|
512
|
+
# call on the Magnus backend (the keyword scan itself — nil keywords pay the same) and
|
|
513
|
+
# about half again here on Fiddle.
|
|
514
|
+
def datetime(text, order, am: nil, pm: nil)
|
|
515
|
+
periods = packed_day_periods(am, pm) if am || pm
|
|
516
|
+
civil(:cast_datetime, text, DATE_ORDERS.fetch(order), periods, 16) { |out| Interop.decode(:datetime, out) }
|
|
315
517
|
end
|
|
316
518
|
|
|
317
519
|
# Casts an ISO 24-hour time-of-day to an exact Integer of nanoseconds since midnight
|
|
@@ -391,18 +593,19 @@ module HyperCast
|
|
|
391
593
|
bytes.empty? ? nil : bytes
|
|
392
594
|
end
|
|
393
595
|
|
|
394
|
-
# One
|
|
596
|
+
# One 64-byte scratch allocation per thread, reused by every call: out-value at 0
|
|
395
597
|
# (16 bytes covers every door, and malloc's alignment covers the decimal's 8), fault
|
|
396
|
-
# span at 16
|
|
397
|
-
#
|
|
398
|
-
#
|
|
399
|
-
#
|
|
400
|
-
#
|
|
401
|
-
#
|
|
598
|
+
# span at 16, and the datetime door's packed day periods at 24 (40 bytes, 4-aligned,
|
|
599
|
+
# written only by a call that declares them). Two Fiddle::Pointer.malloc(..., RUBY_FREE)
|
|
600
|
+
# calls per cast — each registering a GC finalizer — measured as the dominant per-call
|
|
601
|
+
# cost by an order of magnitude. The NumFormat does not live here: each format owns
|
|
602
|
+
# its own packed pointer (see packed_cache), so a numeric call copies nothing into
|
|
603
|
+
# scratch before crossing. Allocated through Runtime.buffer, which loads the library
|
|
604
|
+
# first, so a missing one is reported as such before Fiddle is touched.
|
|
402
605
|
def scratch
|
|
403
606
|
Thread.current[:hypercast_scratch] ||= begin
|
|
404
|
-
base = Runtime.buffer(
|
|
405
|
-
[base, base + 16]
|
|
607
|
+
base = Runtime.buffer(64)
|
|
608
|
+
[base, base + 16, base + 24]
|
|
406
609
|
end
|
|
407
610
|
end
|
|
408
611
|
|
|
@@ -428,7 +631,7 @@ module HyperCast
|
|
|
428
631
|
end
|
|
429
632
|
|
|
430
633
|
# The shared body of the integer/real/decimal doors: plain, plus the format's own
|
|
431
|
-
# packed pointer, passed straight through — no per-call copy of the
|
|
634
|
+
# packed pointer, passed straight through — no per-call copy of the 92 bytes into
|
|
432
635
|
# scratch.
|
|
433
636
|
def numeric(symbol, text, format, out_size)
|
|
434
637
|
bytes = utf8(text)
|
|
@@ -437,6 +640,47 @@ module HyperCast
|
|
|
437
640
|
verdict(rc, fault, bytes) { yield(out[0, out_size]) }
|
|
438
641
|
end
|
|
439
642
|
|
|
643
|
+
# The civil date-time door's body: declared, plus the day periods after the order — the
|
|
644
|
+
# 40 packed bytes copied into this thread's scratch and passed from there, or nil
|
|
645
|
+
# (Fiddle's null) when none are declared, which the core reads as English AM/PM only.
|
|
646
|
+
def civil(symbol, text, code, periods, out_size)
|
|
647
|
+
bytes = utf8(text)
|
|
648
|
+
out, fault, slot = scratch
|
|
649
|
+
if periods
|
|
650
|
+
slot[0, periods.bytesize] = periods
|
|
651
|
+
else
|
|
652
|
+
slot = nil
|
|
653
|
+
end
|
|
654
|
+
rc = Runtime.function(symbol).call(input_ptr(bytes), bytes.bytesize, code, slot, out, fault)
|
|
655
|
+
verdict(rc, fault, bytes) { yield(out[0, out_size]) }
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# The 40-byte little-endian form of the native ABI's size-tagged DayPeriods struct: the
|
|
659
|
+
# size word (40), then each designator as its UTF-8 bytes zero-padded to 16 followed by
|
|
660
|
+
# its byte length — am, then pm — and two bytes of padding. A nil designator is a
|
|
661
|
+
# length of 0: nothing declared beside AM or PM.
|
|
662
|
+
def packed_day_periods(am, pm)
|
|
663
|
+
am = designator("am", am)
|
|
664
|
+
pm = designator("pm", pm)
|
|
665
|
+
[40].pack("L<") + [am, am.bytesize, pm, pm.bytesize].pack("a16Ca16C") + "\0\0"
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# A declared day-period designator as the UTF-8 the core reads — 1 to 16 bytes, no
|
|
669
|
+
# ASCII digit, no ASCII whitespace at either end (the grammar around it reads that) —
|
|
670
|
+
# or "" for nil. Anything else is the caller bug it names, the same ArgumentError on
|
|
671
|
+
# every backend.
|
|
672
|
+
def designator(name, text)
|
|
673
|
+
return "" if text.nil?
|
|
674
|
+
raise ArgumentError, "#{name} designator must be a String; got #{text.inspect}" unless text.is_a?(String)
|
|
675
|
+
|
|
676
|
+
designator = text.encode(Encoding::UTF_8)
|
|
677
|
+
unless designator.bytesize.between?(1, 16) && !designator.match?(/[0-9]|\A[\t\n\f\r ]|[\t\n\f\r ]\z/)
|
|
678
|
+
raise ArgumentError, "#{name} designator #{text.inspect} must be 1 to 16 UTF-8 bytes with no ASCII digit " \
|
|
679
|
+
"and no ASCII whitespace at either end"
|
|
680
|
+
end
|
|
681
|
+
designator
|
|
682
|
+
end
|
|
683
|
+
|
|
440
684
|
# The shared body of the four doors that take a caller-declared u32 — a precision, an
|
|
441
685
|
# epoch, or a field order — already resolved from its Symbol by the door. These bodies
|
|
442
686
|
# (plain, numeric, declared, and packed_version below) are the whole Fiddle crossing.
|
|
@@ -487,21 +731,22 @@ module HyperCast
|
|
|
487
731
|
PACKED_CACHE_LIMIT = 64
|
|
488
732
|
|
|
489
733
|
# Identity-keyed memo (compare_by_identity — a pointer hash, not Data's structural
|
|
490
|
-
# #hash over
|
|
734
|
+
# #hash over nine Strings and an Integer) of a native 92-byte RawNumFormat per format
|
|
491
735
|
# object, filled once from NumFormat#packed. Formats are reused constants in practice,
|
|
492
736
|
# so the common call finds its pointer in one lookup and packs nothing. The Hash holds
|
|
493
737
|
# the format, so a key can never be a recycled address; the race is benign (idempotent).
|
|
494
738
|
#
|
|
495
739
|
# Bounded, oldest entry out first (a Hash keeps insertion order): unbounded, it held
|
|
496
|
-
# every format ever cast with and its
|
|
740
|
+
# every format ever cast with and its 92-byte allocation for the life of the process,
|
|
497
741
|
# which a consumer building a format per request experienced as a leak. A format
|
|
498
742
|
# evicted and then used again is simply packed again, and an evicted pointer stays
|
|
499
743
|
# alive for any call already holding it — the caller's own reference outlives the cache's.
|
|
500
744
|
def packed_cache
|
|
501
745
|
@packed_cache ||= Hash.new do |cache, format|
|
|
502
746
|
cache.shift while cache.size >= PACKED_CACHE_LIMIT
|
|
503
|
-
|
|
504
|
-
pointer
|
|
747
|
+
size = NumFormat::PACKED_BYTES
|
|
748
|
+
pointer = Runtime.buffer(size)
|
|
749
|
+
pointer[0, size] = format.packed
|
|
505
750
|
cache[format] = pointer
|
|
506
751
|
end.compare_by_identity
|
|
507
752
|
end
|