hypercast-wasm 0.8.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 +136 -25
- 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/runtime.rb +7 -5
- data/lib/hypercast.rb +259 -77
- 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
|
@@ -48,7 +48,7 @@ midnight, durations come back as exact `Rational` seconds across the core's whol
|
|
|
48
48
|
| `unix(text, precision)` | UTC `Time` | `:seconds` / `:milliseconds` / `:microseconds` / `:nanoseconds` |
|
|
49
49
|
| `excel_serial(text, epoch)` | UTC `Time` | `:y1900` / `:y1904` |
|
|
50
50
|
| `date(text, order = nil)` | `Date` | strict ISO, or `:year_month_day` / `:month_day_year` / `:day_month_year` |
|
|
51
|
-
| `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) |
|
|
52
52
|
| `time(text)` | `Integer` nanoseconds since midnight | — |
|
|
53
53
|
| `duration(text)` | exact `Rational` seconds | — |
|
|
54
54
|
| `decimal_from_float(value)` | `HyperCast::Decimal`, the shortest that names the number (`0.1` is one tenth) | — |
|
|
@@ -67,9 +67,13 @@ Bad data is never an exception — it is a `Fault`. What raises is a caller bug,
|
|
|
67
67
|
the same exception on every backend:
|
|
68
68
|
|
|
69
69
|
- `ArgumentError` from `NumFormat.new`, `NumFormat#override` (and so the `separator:`,
|
|
70
|
-
`delimiter:` and `unit:` keywords) or `NumFormat.from_i18n` for a malformed format:
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
73
77
|
- `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
|
|
74
78
|
that names nothing: an unknown Symbol, or anything that is not a Symbol (`"seconds"`
|
|
75
79
|
included). `date(text, nil)` is not one of these: an explicit `nil` order is the strict ISO
|
|
@@ -134,41 +138,98 @@ value.to_d # => BigDecimal("-1234.5")
|
|
|
134
138
|
`to_d` requires the `bigdecimal` gem lazily on first call — a bundled gem since Ruby 3.4,
|
|
135
139
|
deliberately not a dependency of this one, so under Bundler add it to your own Gemfile.
|
|
136
140
|
|
|
137
|
-
### `NumFormat`: declared separators, and
|
|
141
|
+
### `NumFormat`: declared separators, symbols and digits
|
|
138
142
|
|
|
139
143
|
```ruby
|
|
140
144
|
HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
|
|
141
145
|
```
|
|
142
146
|
|
|
143
147
|
The flags are `GROUPING`, `PARENTHESES`, `EXPONENT`, `RADIX_PREFIXES`, `PERCENT` and
|
|
144
|
-
`CURRENCY` (`ALL_STYLES` is all six), plus
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
symbol
|
|
152
|
-
`
|
|
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.
|
|
153
186
|
|
|
154
187
|
```ruby
|
|
155
188
|
dollars = HyperCast::NumFormat.new(decimal_sep: ".", group_sep: ",", flags: HyperCast::ALL_STYLES, currency: "$")
|
|
156
189
|
HyperCast.i32("($1,234)", dollars) # => Success(value: -1234)
|
|
157
190
|
HyperCast.decimal("$1,234.50", dollars) # => Success(value: Decimal(magnitude: 12345, scale: 1, negative: false))
|
|
158
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))
|
|
159
205
|
```
|
|
160
206
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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.
|
|
164
224
|
|
|
165
225
|
### Rails' words for a format: `separator:`, `delimiter:`, `unit:`
|
|
166
226
|
|
|
167
227
|
Ruby's standard library carries no locale number data, so the vocabulary Ruby code already
|
|
168
228
|
speaks is Rails' — the number helpers' and I18n's `number.format`: `separator` (the decimal
|
|
169
|
-
mark), `delimiter` (grouping)
|
|
170
|
-
|
|
171
|
-
`NumFormat.from_i18n` builds a format from the Hash
|
|
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:
|
|
172
233
|
|
|
173
234
|
```ruby
|
|
174
235
|
inv = HyperCast::NumFormat::INVARIANT
|
|
@@ -177,19 +238,63 @@ HyperCast.decimal("1.234,50 kr.", inv, separator: ",", delimiter: ".", unit: "kr
|
|
|
177
238
|
|
|
178
239
|
german = HyperCast::NumFormat.from_i18n(I18n.t("number.format")) # {separator: ",", delimiter: ".", ...}
|
|
179
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)
|
|
180
246
|
```
|
|
181
247
|
|
|
182
248
|
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
|
|
184
|
-
is declared but not honored, as it would be in the constructor.
|
|
185
|
-
String keys and ignores every other key, so
|
|
186
|
-
|
|
187
|
-
turns `GROUPING` off.
|
|
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.
|
|
188
265
|
|
|
189
266
|
A call without the keywords is exactly the call it was, with no extra allocation on either
|
|
190
267
|
backend. A call with them builds a format each time (a few microseconds), so in a loop,
|
|
191
268
|
build it once with `override` or `from_i18n` and pass that instead.
|
|
192
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
|
+
|
|
193
298
|
[^overrides]: Plus the optional keywords `separator:`, `delimiter:` and `unit:`, which
|
|
194
299
|
override those fields of `format` for the call. See
|
|
195
300
|
[Rails' words for a format](#rails-words-for-a-format-separator-delimiter-unit).
|
|
@@ -250,6 +355,12 @@ HyperCast::Interop.version(0x000700) # => "0.7.0"
|
|
|
250
355
|
is now identity-matched like `INVARIANT`, and any other format is resolved once per
|
|
251
356
|
thread and memoized by identity — anchored in a thread-variable so the memo's key can
|
|
252
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.
|
|
253
364
|
The three reason Symbols and the option Symbols (`:seconds`, `:month_day_year`, …) are
|
|
254
365
|
cached the same way, so a fault or a declared option is a pointer compare too, never a
|
|
255
366
|
`Symbol#name` materialization.
|
|
Binary file
|
|
Binary file
|
data/lib/hypercast/runtime.rb
CHANGED
|
@@ -16,10 +16,11 @@ 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
25
|
cast_bool: :plain, cast_char: :plain,
|
|
25
26
|
cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
|
|
@@ -27,7 +28,7 @@ module HyperCast
|
|
|
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,50 +48,72 @@ 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
|
|
83
96
|
|
|
84
|
-
super(decimal_sep: decimal_sep, group_sep: group_sep, flags: flags,
|
|
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))
|
|
85
104
|
end
|
|
86
105
|
|
|
87
106
|
# This format with the fields Ruby's number vocabulary names replaced — the words Rails'
|
|
88
107
|
# number helpers and I18n's number.format use: separator: (the decimal mark),
|
|
89
108
|
# delimiter: (grouping) and unit: (the currency symbol). A nil keyword leaves its
|
|
90
109
|
# field alone, so with none given this is the format itself, not a copy. The result is
|
|
91
|
-
# validated like any NumFormat (
|
|
92
|
-
# the flags are untouched: a unit: declared on a format
|
|
93
|
-
# Fault at the symbol, exactly as declaring it in the
|
|
94
|
-
# numeric door takes the same three keywords and applies
|
|
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.
|
|
95
117
|
def override(separator: nil, delimiter: nil, unit: nil)
|
|
96
118
|
return self unless separator || delimiter || unit
|
|
97
119
|
|
|
@@ -99,40 +121,129 @@ module HyperCast
|
|
|
99
121
|
end
|
|
100
122
|
|
|
101
123
|
# A format from a Hash shaped like I18n's number.format (or number.currency.format):
|
|
102
|
-
# :separator, :delimiter and :
|
|
103
|
-
# format, significant, ...) ignored — so
|
|
104
|
-
# drops a Rails locale straight in, with
|
|
105
|
-
# keeps the invariant's "." / "," / no
|
|
106
|
-
#
|
|
107
|
-
# always carries one; the placeholder it
|
|
108
|
-
#
|
|
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.
|
|
109
133
|
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
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;
|
|
116
149
|
# narrow flags: for anything stricter.
|
|
117
|
-
def self.from_i18n(number_format, flags: ALL_STYLES)
|
|
118
|
-
|
|
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 }
|
|
150
|
+
def self.from_i18n(number_format, currency_format: nil, flags: ALL_STYLES)
|
|
151
|
+
entries = i18n_entries(number_format)
|
|
122
152
|
separator = entries[:separator] || "."
|
|
123
153
|
delimiter = entries[:delimiter] || ","
|
|
124
154
|
if delimiter == ""
|
|
125
155
|
flags &= ~GROUPING
|
|
126
156
|
delimiter = separator == "," ? "." : ","
|
|
127
157
|
end
|
|
128
|
-
|
|
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
|
|
172
|
+
|
|
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 }
|
|
129
179
|
end
|
|
180
|
+
private_class_method :i18n_entries
|
|
130
181
|
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
|
|
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)
|
|
186
|
+
|
|
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.
|
|
134
200
|
def packed
|
|
135
|
-
[
|
|
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
|
|
136
247
|
end
|
|
137
248
|
end
|
|
138
249
|
|
|
@@ -141,6 +252,13 @@ module HyperCast
|
|
|
141
252
|
# is scoped lexically, and landed on HyperCast instead of on NumFormat.
|
|
142
253
|
NumFormat::CURRENCY_MAX_BYTES = 16
|
|
143
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
|
+
|
|
144
262
|
# Permit the group separator between digits (sizes not validated — between digits is the rule).
|
|
145
263
|
GROUPING = 1
|
|
146
264
|
# Permit accounting parentheses as negation: (1,234) is -1234.
|
|
@@ -149,7 +267,8 @@ module HyperCast
|
|
|
149
267
|
EXPONENT = 1 << 2
|
|
150
268
|
# Permit 0x/&H/0b two's-complement radix prefixes (0xFF is -1 for an i8).
|
|
151
269
|
RADIX_PREFIXES = 1 << 3
|
|
152
|
-
# Permit a trailing
|
|
270
|
+
# Permit a trailing % (or the format's declared percent symbol), dividing by 100. Real
|
|
271
|
+
# and decimal doors only.
|
|
153
272
|
PERCENT = 1 << 4
|
|
154
273
|
# Resolve the ./, roles per input from structure instead of the declared separators
|
|
155
274
|
# (which are ignored while this flag is set). Detection, not sniffing: a repeated
|
|
@@ -165,8 +284,15 @@ module HyperCast
|
|
|
165
284
|
# along with the digits ("($5)"). With no symbol declared the flag matches nothing.
|
|
166
285
|
# Integer, real and decimal doors.
|
|
167
286
|
CURRENCY = 1 << 6
|
|
168
|
-
#
|
|
169
|
-
#
|
|
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).
|
|
170
296
|
ALL_STYLES = GROUPING | PARENTHESES | EXPONENT | RADIX_PREFIXES | PERCENT | CURRENCY
|
|
171
297
|
|
|
172
298
|
# The invariant profile — '.' decimal, ',' grouping, every lenience on, no currency
|
|
@@ -373,8 +499,21 @@ module HyperCast
|
|
|
373
499
|
# artifact, not data (the same caveat PHP's UTC-labeled DateTimeImmutable carries);
|
|
374
500
|
# fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
|
|
375
501
|
# instant door. An unknown order is a caller bug (KeyError).
|
|
376
|
-
|
|
377
|
-
|
|
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) }
|
|
378
517
|
end
|
|
379
518
|
|
|
380
519
|
# Casts an ISO 24-hour time-of-day to an exact Integer of nanoseconds since midnight
|
|
@@ -454,18 +593,19 @@ module HyperCast
|
|
|
454
593
|
bytes.empty? ? nil : bytes
|
|
455
594
|
end
|
|
456
595
|
|
|
457
|
-
# One
|
|
596
|
+
# One 64-byte scratch allocation per thread, reused by every call: out-value at 0
|
|
458
597
|
# (16 bytes covers every door, and malloc's alignment covers the decimal's 8), fault
|
|
459
|
-
# span at 16
|
|
460
|
-
#
|
|
461
|
-
#
|
|
462
|
-
#
|
|
463
|
-
#
|
|
464
|
-
#
|
|
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.
|
|
465
605
|
def scratch
|
|
466
606
|
Thread.current[:hypercast_scratch] ||= begin
|
|
467
|
-
base = Runtime.buffer(
|
|
468
|
-
[base, base + 16]
|
|
607
|
+
base = Runtime.buffer(64)
|
|
608
|
+
[base, base + 16, base + 24]
|
|
469
609
|
end
|
|
470
610
|
end
|
|
471
611
|
|
|
@@ -491,7 +631,7 @@ module HyperCast
|
|
|
491
631
|
end
|
|
492
632
|
|
|
493
633
|
# The shared body of the integer/real/decimal doors: plain, plus the format's own
|
|
494
|
-
# 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
|
|
495
635
|
# scratch.
|
|
496
636
|
def numeric(symbol, text, format, out_size)
|
|
497
637
|
bytes = utf8(text)
|
|
@@ -500,6 +640,47 @@ module HyperCast
|
|
|
500
640
|
verdict(rc, fault, bytes) { yield(out[0, out_size]) }
|
|
501
641
|
end
|
|
502
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
|
+
|
|
503
684
|
# The shared body of the four doors that take a caller-declared u32 — a precision, an
|
|
504
685
|
# epoch, or a field order — already resolved from its Symbol by the door. These bodies
|
|
505
686
|
# (plain, numeric, declared, and packed_version below) are the whole Fiddle crossing.
|
|
@@ -550,21 +731,22 @@ module HyperCast
|
|
|
550
731
|
PACKED_CACHE_LIMIT = 64
|
|
551
732
|
|
|
552
733
|
# Identity-keyed memo (compare_by_identity — a pointer hash, not Data's structural
|
|
553
|
-
# #hash over
|
|
734
|
+
# #hash over nine Strings and an Integer) of a native 92-byte RawNumFormat per format
|
|
554
735
|
# object, filled once from NumFormat#packed. Formats are reused constants in practice,
|
|
555
736
|
# so the common call finds its pointer in one lookup and packs nothing. The Hash holds
|
|
556
737
|
# the format, so a key can never be a recycled address; the race is benign (idempotent).
|
|
557
738
|
#
|
|
558
739
|
# Bounded, oldest entry out first (a Hash keeps insertion order): unbounded, it held
|
|
559
|
-
# every format ever cast with and its
|
|
740
|
+
# every format ever cast with and its 92-byte allocation for the life of the process,
|
|
560
741
|
# which a consumer building a format per request experienced as a leak. A format
|
|
561
742
|
# evicted and then used again is simply packed again, and an evicted pointer stays
|
|
562
743
|
# alive for any call already holding it — the caller's own reference outlives the cache's.
|
|
563
744
|
def packed_cache
|
|
564
745
|
@packed_cache ||= Hash.new do |cache, format|
|
|
565
746
|
cache.shift while cache.size >= PACKED_CACHE_LIMIT
|
|
566
|
-
|
|
567
|
-
pointer
|
|
747
|
+
size = NumFormat::PACKED_BYTES
|
|
748
|
+
pointer = Runtime.buffer(size)
|
|
749
|
+
pointer[0, size] = format.packed
|
|
568
750
|
cache[format] = pointer
|
|
569
751
|
end.compare_by_identity
|
|
570
752
|
end
|