hypercast 0.7.0-x86_64-linux-gnu → 0.8.0-x86_64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fee680307c3ad213317072a6c9a744f05334f59f17012d3759148824c4e08022
4
- data.tar.gz: 2f242520639a02ce03704ac32001e00267ed88f9aba166bb4158a3050e8b61f7
3
+ metadata.gz: e7dd651d6fbef906514d4873b17dffc7e45fa80e970b6f7636110963c58e6bf1
4
+ data.tar.gz: a3e8e5b91540b87178fb5c00c6d64f54d2b010fd7ab2a2daf8427727ef33128c
5
5
  SHA512:
6
- metadata.gz: '06639e126ad2769dadfa5bc3c01e8a4711e802fcce7c064c11886f72e1fbdf7af10fb1f07bbd25bf010fde51f4d74ac23da048c0c4778acf50f4e89effb150f5'
7
- data.tar.gz: 2c1a9b1e20fd6563c2e82b996cd994b3a72a34aebcf0c8ebb76e06e16b0be3ec6acecc17ec14a78deb48b0ffac3472bb5d6cc9a65e04df7347c8cdfbf881c9e0
6
+ metadata.gz: 526fc92a7b0f0e1f3c8487504aa666a45a988dd39212ecc8652a126d3115b10ca533c0f7fd6604b68e56dd02be5449e4b150f683391de131ab09279d71cb12be
7
+ data.tar.gz: 6921bb861ac1cc13eab9f3f2a0349dc66827fb4d88eb2b79c514c5ac6f49fb53771a212a3e87e52172e250eabb87f3b06ca602ad0781ca0413e40fa0c671b333
data/README.md CHANGED
@@ -9,8 +9,8 @@ Symbol plus the exact span that offended. Two backends, one public surface: a Ma
9
9
  native extension where a precompiled platform gem covers you, stdlib Fiddle as the last
10
10
  resort everywhere else — selected automatically, zero compiles either way.**
11
11
 
12
- Allocation-lean scalar casts — booleans, the full integer family, reals, exact decimals,
13
- UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
12
+ Allocation-lean scalar casts — booleans, characters, the full integer family, reals, exact
13
+ decimals, UUIDs, temporals — calling directly into the native `libhypercast` Rust core. Ruby 3.3 is the
14
14
  floor. The fast path links the core straight into a Ruby extension (Magnus): on require it
15
15
  redefines the doors in place on the `HyperCast` module — no delegation layer, no second
16
16
  surface, which is exactly what keeps the backends provably in agreement.
@@ -39,9 +39,10 @@ midnight, durations come back as exact `Rational` seconds across the core's whol
39
39
  | Door | Value | Declares |
40
40
  |---|---|---|
41
41
  | `bool(text)` | `true`/`false` | — |
42
- | `i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64` `(text, format)` | `Integer` (unbounded — u64 is the true unsigned value) | a `NumFormat` |
43
- | `f32` `f64` `(text, format)` | `Float` (f32 widened losslessly) | a `NumFormat` |
44
- | `decimal(text, format)` | `HyperCast::Decimal` — exact sign, 96-bit magnitude, base-10 scale | a `NumFormat` |
42
+ | `char(text)` | one-character UTF-8 `String` — the text verbatim when it is one character (`" "` is a space), else a declared code point (`65`, `U+0041`, `0x41`, `&H41`, `A`, `A`) | — |
43
+ | `i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64` `(text, format)` [^overrides] | `Integer` (unbounded — u64 is the true unsigned value) | a `NumFormat` |
44
+ | `f32` `f64` `(text, format)` [^overrides] | `Float` (f32 widened losslessly) | a `NumFormat` |
45
+ | `decimal(text, format)` [^overrides] | `HyperCast::Decimal` — exact sign, 96-bit magnitude, base-10 scale | a `NumFormat` |
45
46
  | `uuid(text)` | lowercase hyphenated `String` (`SecureRandom.uuid`'s shape) | — |
46
47
  | `timestamp(text)` | UTC `Time`, full nanoseconds | — |
47
48
  | `unix(text, precision)` | UTC `Time` | `:seconds` / `:milliseconds` / `:microseconds` / `:nanoseconds` |
@@ -65,7 +66,8 @@ to `nil`. Beside the doors, `native_version` returns the loaded core's
65
66
  Bad data is never an exception — it is a `Fault`. What raises is a caller bug, and it raises
66
67
  the same exception on every backend:
67
68
 
68
- - `ArgumentError` from `NumFormat.new` for a malformed format: separators that are not single
69
+ - `ArgumentError` from `NumFormat.new`, `NumFormat#override` (and so the `separator:`,
70
+ `delimiter:` and `unit:` keywords) or `NumFormat.from_i18n` for a malformed format: separators that are not single
69
71
  characters or not distinct, a currency symbol that is too long or carries an ASCII digit or
70
72
  whitespace.
71
73
  - `KeyError` — `Hash#fetch`'s own, `key not found: …` — for a precision, epoch or date order
@@ -160,6 +162,38 @@ Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code
160
162
  flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
161
163
  and memoized by identity on every backend, so declaring a currency costs a cast nothing.
162
164
 
165
+ ### Rails' words for a format: `separator:`, `delimiter:`, `unit:`
166
+
167
+ Ruby's standard library carries no locale number data, so the vocabulary Ruby code already
168
+ speaks is Rails' — the number helpers' and I18n's `number.format`: `separator` (the decimal
169
+ mark), `delimiter` (grouping) and `unit` (the currency symbol). Every numeric door takes
170
+ them as keywords that replace those fields of the format for the call, and
171
+ `NumFormat.from_i18n` builds a format from the Hash I18n hands back:
172
+
173
+ ```ruby
174
+ inv = HyperCast::NumFormat::INVARIANT
175
+ HyperCast.f64("1.234,5", inv, separator: ",", delimiter: ".") # => Success(value: 1234.5)
176
+ HyperCast.decimal("1.234,50 kr.", inv, separator: ",", delimiter: ".", unit: "kr.")
177
+
178
+ german = HyperCast::NumFormat.from_i18n(I18n.t("number.format")) # {separator: ",", delimiter: ".", ...}
179
+ HyperCast.i32("1.234", german) # => Success(value: 1234)
180
+ ```
181
+
182
+ The keywords go through `NumFormat#override`, which is public: the result is validated like
183
+ any `NumFormat`, and the flags are left alone, so a `unit:` on a format without `CURRENCY`
184
+ is declared but not honored, as it would be in the constructor. `from_i18n` reads Symbol or
185
+ String keys and ignores every other key, so `number.currency.format` works as well. Missing
186
+ keys keep the invariant's fields. An empty `delimiter` (a locale that writes no grouping)
187
+ turns `GROUPING` off. `flags:` defaults to `ALL_STYLES`.
188
+
189
+ A call without the keywords is exactly the call it was, with no extra allocation on either
190
+ backend. A call with them builds a format each time (a few microseconds), so in a loop,
191
+ build it once with `override` or `from_i18n` and pass that instead.
192
+
193
+ [^overrides]: Plus the optional keywords `separator:`, `delimiter:` and `unit:`, which
194
+ override those fields of `format` for the call. See
195
+ [Rails' words for a format](#rails-words-for-a-format-separator-delimiter-unit).
196
+
163
197
  ## Interop: building on HyperCast's C ABI
164
198
 
165
199
  For a gem that carries HyperCast's verdicts across a C ABI of its own, as HyperTabular does:
@@ -347,7 +381,7 @@ one at `require` time:
347
381
  | 3.3 (the floor, until its EOL 2027-03-31) | Fiddle | universal |
348
382
 
349
383
  What stands behind each cell: CI replays the whole suite, shared corpus included, through
350
- each Magnus extension on every push — Ruby 3.4 and 4.0 on all seven platform-gem platforms,
384
+ each Magnus extension — Ruby 4.0 on every pull request and Ruby 3.4 in the weekly and release builds, on all seven platform-gem platforms,
351
385
  the two musl ones inside each Ruby's own Alpine image — and the Fiddle suite on Ruby 4.0 on
352
386
  every one of them, plus on Ruby 3.3 inside Alpine. Intel macOS has no CI leg: its library is
353
387
  cross-built and tested at the core, and Ruby there runs the universal gem's Fiddle backend
Binary file
Binary file
@@ -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
@@ -21,7 +21,7 @@ module HyperCast
21
21
  # typed doors take a double instead of the text and its length: typed is (value, out,
22
22
  # fault), typed_declared adds the u32 after the value.
23
23
  DOORS = {
24
- cast_bool: :plain,
24
+ cast_bool: :plain, cast_char: :plain,
25
25
  cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
26
26
  cast_u8: :numeric, cast_u16: :numeric, cast_u32: :numeric, cast_u64: :numeric,
27
27
  cast_f32: :numeric, cast_f64: :numeric, cast_decimal: :numeric,
data/lib/hypercast.rb CHANGED
@@ -30,7 +30,7 @@ require_relative "hypercast/runtime"
30
30
  module HyperCast
31
31
  # This gem's own version — kept in lockstep with hypercast.gemspec by the
32
32
  # prepare-release workflow, so the two can never drift apart again.
33
- VERSION = "0.7.0"
33
+ VERSION = "0.8.0"
34
34
 
35
35
  # The success case of a verdict: a cast value.
36
36
  Success = Data.define(:value)
@@ -84,6 +84,50 @@ module HyperCast
84
84
  super(decimal_sep: decimal_sep, group_sep: group_sep, flags: flags, currency: symbol)
85
85
  end
86
86
 
87
+ # This format with the fields Ruby's number vocabulary names replaced — the words Rails'
88
+ # number helpers and I18n's number.format use: separator: (the decimal mark),
89
+ # delimiter: (grouping) and unit: (the currency symbol). A nil keyword leaves its
90
+ # field alone, so with none given this is the format itself, not a copy. The result is
91
+ # validated like any NumFormat (single, distinct separators; a declarable symbol), and
92
+ # the flags are untouched: a unit: declared on a format without CURRENCY is a :malformed
93
+ # Fault at the symbol, exactly as declaring it in the constructor would be. Every
94
+ # numeric door takes the same three keywords and applies them through this method.
95
+ def override(separator: nil, delimiter: nil, unit: nil)
96
+ return self unless separator || delimiter || unit
97
+
98
+ with(**{ decimal_sep: separator, group_sep: delimiter, currency: unit }.compact)
99
+ end
100
+
101
+ # A format from a Hash shaped like I18n's number.format (or number.currency.format):
102
+ # :separator, :delimiter and :unit, Symbol or String keys, every other key (precision,
103
+ # format, significant, ...) ignored — so `NumFormat.from_i18n(I18n.t("number.format"))`
104
+ # drops a Rails locale straight in, with no I18n dependency here. A missing or nil key
105
+ # keeps the invariant's "." / "," / no symbol. An empty delimiter — the locale writes no
106
+ # grouping — turns GROUPING off rather than declaring a separator, since a NumFormat
107
+ # always carries one; the placeholder it gets is whichever of "," and "." the decimal
108
+ # mark is not. flags defaults to every lenience, the same as INVARIANT.
109
+ #
110
+ # Only the separators and the unit carry over; the native format has no field for the
111
+ # rest. The core reads ASCII "+" and "-" only, so a locale that writes negatives with
112
+ # another sign (U+2212 for Estonian and Basque, for instance) faults on them; currency
113
+ # amounts in a locale whose number.currency.format separators differ from number.format's
114
+ # read with whichever Hash is passed, so pass the currency one for currency columns; and
115
+ # percent is an ASCII "%". Every lenience is on, radix prefixes and percent included;
116
+ # narrow flags: for anything stricter.
117
+ def self.from_i18n(number_format, flags: ALL_STYLES)
118
+ raise ArgumentError, "expected a Hash-like number format; got #{number_format.inspect}" unless
119
+ number_format.respond_to?(:to_h)
120
+
121
+ entries = number_format.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
122
+ separator = entries[:separator] || "."
123
+ delimiter = entries[:delimiter] || ","
124
+ if delimiter == ""
125
+ flags &= ~GROUPING
126
+ delimiter = separator == "," ? "." : ","
127
+ end
128
+ new(decimal_sep: separator, group_sep: delimiter, flags: flags, currency: entries[:unit] || "")
129
+ end
130
+
87
131
  # The 32-byte little-endian form the native ABI's NumFormat struct expects: the two
88
132
  # separators as code points, the flags, the symbol's byte length, then the symbol's
89
133
  # UTF-8 bytes zero-padded to 16.
@@ -220,6 +264,14 @@ module HyperCast
220
264
  plain(:cast_bool, text, 1) { |out| Interop.decode(:bool, out) }
221
265
  end
222
266
 
267
+ # Casts char text to a one-character UTF-8 String: an input of exactly one character is
268
+ # that character verbatim (" " is a space), otherwise one declared code point — 65,
269
+ # U+0041, 0x41, &H41, &#65; or &#x41; — surrounded by optional ASCII whitespace. A code
270
+ # point past U+10FFFF, or a surrogate, is :out_of_range.
271
+ def char(text)
272
+ plain(:cast_char, text, 4) { |out| Interop.decode(:char, out) }
273
+ end
274
+
223
275
  %i[i8 i16 i32 i64 u8 u16 u32 u64].each do |door|
224
276
  unpack = Interop::SCALARS.fetch(door)
225
277
  size = Interop::VALUE_BYTES.fetch(door)
@@ -228,20 +280,29 @@ module HyperCast
228
280
  symbol = :"cast_#{door}"
229
281
  # Integer doors: the target type's own range, declared grouping, accounting parens,
230
282
  # non-negative exponent, and 0x/&H/0b two's-complement radix prefixes. Ruby Integer
231
- # is unbounded, so u64 comes back as the true unsigned value.
232
- define_method(door) do |text, format|
283
+ # is unbounded, so u64 comes back as the true unsigned value. separator:, delimiter:
284
+ # and unit: override those fields of format for this call (NumFormat#override).
285
+ define_method(door) do |text, format, separator: nil, delimiter: nil, unit: nil|
286
+ format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
287
+ separator || delimiter || unit
233
288
  numeric(symbol, text, format, size) { |out| out.unpack1(unpack) }
234
289
  end
235
290
  end
236
291
 
237
292
  # Casts real text to an IEEE single (widened losslessly on the way out): finite values
238
293
  # only, declared separators and grouping, parens, exponent, and trailing percent.
239
- def f32(text, format)
294
+ # separator:, delimiter: and unit: override those fields of format for this call
295
+ # (NumFormat#override), on this door and every other numeric one.
296
+ def f32(text, format, separator: nil, delimiter: nil, unit: nil)
297
+ format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
298
+ separator || delimiter || unit
240
299
  numeric(:cast_f32, text, format, 4) { |out| Interop.decode(:f32, out) }
241
300
  end
242
301
 
243
302
  # Casts real text to an IEEE double. Notation rules as f32.
244
- def f64(text, format)
303
+ def f64(text, format, separator: nil, delimiter: nil, unit: nil)
304
+ format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
305
+ separator || delimiter || unit
245
306
  numeric(:cast_f64, text, format, 8) { |out| Interop.decode(:f64, out) }
246
307
  end
247
308
 
@@ -251,7 +312,9 @@ module HyperCast
251
312
  # "1.10" is canonical magnitude 11, scale 1 — exact trailing zeros are always trimmed.
252
313
  # A magnitude past 2**96 - 1, or more fractional precision than 28 places can hold, is
253
314
  # an :out_of_range Fault — the door never rounds.
254
- def decimal(text, format)
315
+ def decimal(text, format, separator: nil, delimiter: nil, unit: nil)
316
+ format = format.override(separator: separator, delimiter: delimiter, unit: unit) if
317
+ separator || delimiter || unit
255
318
  numeric(:cast_decimal, text, format, 16) { |out| Interop.decode(:decimal, out) }
256
319
  end
257
320
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hypercast
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Brian Buvinghausen