hypercast-wasm 0.6.1 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25e1d8efcfd0a330dc46665c67c6ab2fea5a6739c5101694b7fffc1e96676d1e
4
- data.tar.gz: df0939ade9f6f8ce761ea44db684a3194624874a75deee25791cbf4873a4e63f
3
+ metadata.gz: 292a3d7a2650810b8c764162479aed060b9e05e04a15e12843bbe9d4a1e5f4ec
4
+ data.tar.gz: '066569a04c5f82dd1ca427c58b0483c3fa0505bd414fe80add69dea5002a9667'
5
5
  SHA512:
6
- metadata.gz: f27aa076ab1ad70fd64317a6faa7de15f422d52a4b085e455eb8a0568c0287b245b529254f59638d676c8f8530ab804a9bf999ff96d013885862ea9135e6bc0e
7
- data.tar.gz: cf3fddc48a1405b8a328f7fa5b39c15e3440d8eeda147336150faaf968610b78443c1d2455ed2050d9bab743f2429c49fbc0abc5a87e93b3a4210a604e81cf4f
6
+ metadata.gz: 4c654511c4167a2addf7cb4894811c755bd6a9a9a5b7ae9bf3c35b7d24ff36cac233af75d61e7430dc7b08ef84dacf53801bf7853415a0887cbb8a61d9839b6b
7
+ data.tar.gz: 3e7cefe5d32a2c8f0495eb37a93460499ed2673c7452b12f9eb6b2fa90ff5258607042564460e972027f23ba486b5210d758fa9efb49d4ae98534dc3b9754fa6
data/README.md CHANGED
@@ -50,6 +50,10 @@ midnight, durations come back as exact `Rational` seconds across the core's whol
50
50
  | `datetime(text, order)` | zone-less `DateTime`, exact `Rational` seconds | a field order, as `date` |
51
51
  | `time(text)` | `Integer` nanoseconds since midnight | — |
52
52
  | `duration(text)` | exact `Rational` seconds | — |
53
+ | `decimal_from_float(value)` | `HyperCast::Decimal`, the shortest that names the number (`0.1` is one tenth) | — |
54
+ | `excel_serial_from_float(value, epoch)` | zone-less `DateTime`, as `datetime` | `:y1900` / `:y1904` |
55
+ | `excel_time(value)` | `Integer` nanoseconds since midnight | — |
56
+ | `excel_duration(value)` | exact `Rational` seconds | — |
53
57
 
54
58
  Every door returns a `Success` or a `Fault`; `HyperCast.optional(verdict)` folds `:empty`
55
59
  to `nil`. Beside the doors, `native_version` returns the loaded core's
@@ -156,6 +160,38 @@ Across the ABI a `NumFormat` is a 32-byte struct — the two separators as code
156
160
  flags, and the symbol's length and UTF-8 bytes held inline — packed once per format object
157
161
  and memoized by identity on every backend, so declaring a currency costs a cast nothing.
158
162
 
163
+ ## Interop: building on HyperCast's C ABI
164
+
165
+ For a gem that carries HyperCast's verdicts across a C ABI of its own, as HyperTabular does:
166
+ it reads the core's out-values out of its own buffers and needs the values the doors would
167
+ have returned. `HyperCast::Interop` is the code the Fiddle backend's doors decode with, so
168
+ the two cannot drift. It is loaded on every backend.
169
+
170
+ - `SCALARS` — the `String#unpack` directive for one value of each scalar door (`:i32` is
171
+ `"l<"`; a bool is the byte 0 or 1).
172
+ - `RECORDS` — for each record door (`:decimal`, `:uuid`, `:timestamp`, `:date`, …), the
173
+ directive that unpacks one value, how many fields that yields, and a lambda
174
+ `(fields, at)` that builds the Ruby value from them. To decode a column, append `"*"` and
175
+ step that many fields at a time.
176
+ - `VALUE_BYTES` — the bytes one value of each door takes.
177
+ - `decode(door, bytes)` — one value, from the bytes the core wrote for it.
178
+ - `fault(code, offset, length)` — the `Fault` a nonzero verdict code names; a code that names
179
+ no reason is a `KeyError`.
180
+ - `characters(bytes, offset, length)` — the core's byte span as the character span
181
+ `String#[]` slices by.
182
+ - `version(word)` — a packed `*_version` word as `"major.minor.patch"`.
183
+ - `library_path(library, native_dir, repo_root)` — the shared library to `dlopen`: the
184
+ staged `native/{rid}/` build, else the in-repo cargo build, else `nil`.
185
+
186
+ `NativePlatform.rid_and_library_name(library: "hypertabular")` gives the `{rid}` directory
187
+ and file name for any SkunkWerkx core; `library:` defaults to `"hypercast"`.
188
+
189
+ ```ruby
190
+ HyperCast::Interop.decode(:i32, [-7].pack("l<")) # => -7
191
+ HyperCast::Interop.fault(2, 3, 1) # => Fault, reason :malformed
192
+ HyperCast::Interop.version(0x000700) # => "0.7.0"
193
+ ```
194
+
159
195
  ## Why not `Integer()` / `Time.iso8601` / `Float()`?
160
196
 
161
197
  1. **Verdicts, not exceptions** — bad data is the expected case for untrusted text; a
@@ -169,12 +205,12 @@ and memoized by identity on every backend, so declaring a currency costs a cast
169
205
  boundary).
170
206
  4. **Faster than the stdlib on the Magnus backend, where the carrier is cheap** —
171
207
  benchmark-ips (`ruby benchmark/cast_benchmark.rb`, linux-x64 on an Intel Core
172
- i9-11900H, Ruby 4.0.7): timestamp **447 ns vs 2.97 µs `Time.iso8601`** (6.6x) — while
208
+ i9-11900H, Ruby 4.0.7): timestamp **404 ns vs 2.70 µs `Time.iso8601`** (6.7x) — while
173
209
  returning exact `Rational` durations on the duration door. The Fiddle fallback lands at
174
- 3.25 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
210
+ 2.85 µs: a little behind `Time.iso8601`, sitting on Fiddle's per-call marshalling floor.
175
211
 
176
212
  Separator detection is nearly free here: `1.234.567,89` under `NumFormat::DETECT` runs
177
- at 178 ns against 170 ns for the same text under a declared eurozone format. Both cost
213
+ at 154 ns against 145 ns for the same text under a declared eurozone format. Both cost
178
214
  nearly twice that in 0.1.0, for a reason that had nothing to do with parsing: every format other than `INVARIANT` paid three method dispatches and two
179
215
  `String` allocations per call to read its separators back out of the `Data`. `DETECT`
180
216
  is now identity-matched like `INVARIANT`, and any other format is resolved once per
@@ -213,14 +249,14 @@ both backends:
213
249
 
214
250
  | Door | Magnus | Fiddle |
215
251
  |---|---:|---:|
216
- | `bool` | 112 ns | 2.35 µs |
217
- | `i32` | 133 ns | 2.63 µs |
218
- | `f64` | 166 ns | 2.70 µs |
219
- | `uuid` | 223 ns | 3.47 µs |
220
- | `timestamp` | 447 ns | 3.25 µs |
221
- | `datetime` (`1/7/2026 3:04 PM`) | 904 ns | 3.98 µs |
222
- | `duration` (ISO) | 632 ns | 2.91 µs |
223
- | `i32`, a fault | 225 ns | 3.18 µs |
252
+ | `bool` | 109 ns | 2.30 µs |
253
+ | `i32` | 120 ns | 2.30 µs |
254
+ | `f64` | 134 ns | 2.44 µs |
255
+ | `uuid` | 209 ns | 3.28 µs |
256
+ | `timestamp` | 404 ns | 2.85 µs |
257
+ | `datetime` (`1/7/2026 3:04 PM`) | 877 ns | 3.71 µs |
258
+ | `duration` (ISO) | 578 ns | 2.72 µs |
259
+ | `i32`, a fault | 202 ns | 2.88 µs |
224
260
 
225
261
  A lean door on the Magnus backend is little more than the native call: the extension builds
226
262
  the `Success` or `Fault` it returns directly — allocated, its members stored, frozen —
@@ -0,0 +1,118 @@
1
+ module HyperCast
2
+ # The native core's C ABI values, presented — for a gem that carries HyperCast's verdicts
3
+ # across a C ABI of its own (HyperTabular does): how each door's out-value unpacks and what
4
+ # builds the Ruby value from it, the verdict code and span of a fault, the packed version
5
+ # word, and where a gem's native library is. The Fiddle backend's doors decode with exactly
6
+ # these, so a value read out of another library's buffer is the value the door would have.
7
+ #
8
+ # Every directive below unpacks one value. A gem decoding a whole column appends "*" and
9
+ # steps +fields+ at a time.
10
+ module Interop
11
+ # The doors whose value is one String#unpack directive away: the directive for one value.
12
+ # A bool is the byte 0 or 1, unpacked as "C" and compared with zero.
13
+ SCALARS = {
14
+ bool: "C", i8: "c", i16: "s<", i32: "l<", i64: "q<", u8: "C", u16: "S<", u32: "L<",
15
+ u64: "Q<", f32: "e", f64: "E", time: "Q<"
16
+ }.freeze
17
+
18
+ # The doors whose value is a record: the directive that unpacks one, how many fields that
19
+ # yields, and what builds the door's Ruby value from them, starting at field +at+.
20
+ RECORDS = {
21
+ decimal: ["Q<L<CCx2", 4, ->(fields, at) { decimal(fields[at], fields[at + 1], fields[at + 2], fields[at + 3]) }],
22
+ uuid: ["H8H4H4H4H12", 5, ->(fields, at) { fields[at, 5].join("-") }],
23
+ timestamp: ["q<l<x4", 2, ->(fields, at) { instant(fields[at], fields[at + 1]) }],
24
+ date: ["S<CC", 3, ->(fields, at) { Date.new(fields[at], fields[at + 1], fields[at + 2]) }],
25
+ datetime: ["S<CCx4Q<", 4, ->(fields, at) { civil(fields[at], fields[at + 1], fields[at + 2], fields[at + 3]) }],
26
+ duration: ["q<l<x4", 2, ->(fields, at) { duration(fields[at], fields[at + 1]) }]
27
+ }.tap do |records|
28
+ records[:unix] = records[:excel_serial] = records[:timestamp]
29
+ records[:date_ordered] = records[:date]
30
+ end.freeze
31
+
32
+ # The bytes one value of each door takes.
33
+ 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
+ i64: 8, u64: 8, f64: 8, time: 8,
36
+ decimal: 16, uuid: 16, timestamp: 16, unix: 16, excel_serial: 16, datetime: 16, duration: 16
37
+ }.freeze
38
+
39
+ # Encodings whose bytes already are the UTF-8 (or byte-identical) form the core reads.
40
+ BYTE_COMPATIBLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].freeze
41
+
42
+ module_function
43
+
44
+ # One value of +door+, from the bytes the core wrote for it.
45
+ def decode(door, bytes)
46
+ if (directive = SCALARS[door])
47
+ value = bytes.unpack1(directive)
48
+ door == :bool ? value != 0 : value
49
+ else
50
+ directive, _fields, build = RECORDS.fetch(door)
51
+ build.call(bytes.unpack(directive), 0)
52
+ end
53
+ end
54
+
55
+ # A UTC Time from the core's protobuf-shaped {seconds, nanos} pair, exactly — the
56
+ # timestamp, unix and excel_serial doors' value. (Time.at then #utc is the same Time as
57
+ # Time.at(..., in: "UTC"), at half the cost: the zone's name is not read each time.)
58
+ def instant(seconds, nanos)
59
+ Time.at(seconds, nanos, :nanosecond).utc
60
+ end
61
+
62
+ # The zone-less DateTime a civil date-time names, with exact Rational seconds — the
63
+ # datetime door's value. Its +00:00 offset is a carrier artifact, not data.
64
+ def civil(year, month, day, nanos_of_day)
65
+ second_of_day, frac = nanos_of_day.divmod(1_000_000_000)
66
+ hour, rest = second_of_day.divmod(3600)
67
+ minute, second = rest.divmod(60)
68
+ DateTime.new(year, month, day, hour, minute, second + Rational(frac, 1_000_000_000))
69
+ end
70
+
71
+ # Exact Rational seconds from the core's same-signed {seconds, nanos} pair — the duration
72
+ # door's value.
73
+ def duration(seconds, nanos)
74
+ Rational(seconds * 1_000_000_000 + nanos, 1_000_000_000)
75
+ end
76
+
77
+ # A Decimal from the core's {lo, hi, scale, negative} — the decimal door's value.
78
+ def decimal(lo, hi, scale, negative)
79
+ Decimal.new(magnitude: (hi << 64) | lo, scale: scale, negative: negative != 0)
80
+ end
81
+
82
+ # The Fault a nonzero verdict code and its span name. A code that names no reason is a
83
+ # binding bug, not data: a KeyError.
84
+ def fault(code, offset, length)
85
+ Fault.new(reason: REASONS.fetch(code), offset: offset, length: length)
86
+ end
87
+
88
+ # The core's byte span over +bytes+, in the units String#[] slices by: an identity for a
89
+ # binary String (its characters are its bytes) and for ASCII text (`ascii_only?` reads the
90
+ # cached coderange — no scan), a byte-to-character remap otherwise. Character counts
91
+ # survive transcoding, so a span mapped on the UTF-8 form indexes the caller's own String
92
+ # whatever encoding it arrived in.
93
+ def characters(bytes, offset, length)
94
+ return [offset, length] if bytes.encoding == Encoding::ASCII_8BIT || bytes.ascii_only?
95
+
96
+ [bytes.byteslice(0, offset).length, bytes.byteslice(offset, length).length]
97
+ end
98
+
99
+ # A native library's packed version word — major << 16 | minor << 8 | patch, as a
100
+ # *_version export returns it — as "major.minor.patch".
101
+ def version(word)
102
+ "#{word >> 16}.#{(word >> 8) & 0xFF}.#{word & 0xFF}"
103
+ end
104
+
105
+ # The shared library to dlopen for +library+ ("hypercast" for libhypercast.so): the
106
+ # gem's native/{rid}/{file} under +native_dir+, or — the development loop — the cargo
107
+ # build under +repo_root+/rust/target/release, exactly what the other bindings' local
108
+ # staging does. Nil when neither exists.
109
+ def library_path(library, native_dir, repo_root)
110
+ rid, file = NativePlatform.rid_and_library_name(library: library)
111
+ path = File.join(native_dir, rid, file)
112
+ return path if File.exist?(path)
113
+
114
+ repo_build = File.join(repo_root, "rust", "target", "release", file)
115
+ File.exist?(repo_build) ? repo_build : nil
116
+ end
117
+ end
118
+ end
@@ -1,27 +1,29 @@
1
1
  module HyperCast
2
2
  # Maps the running RUBY_PLATFORM to the RID-style directory (matching the other bindings'
3
- # runtimes/{rid}/native/ / native/{rid}/ convention) and native library filename to load.
3
+ # runtimes/{rid}/native/ / native/{rid}/ convention) and file name of a native library.
4
+ # Every SkunkWerkx core ships the same way, so +library+ names whose: "hypercast" for this
5
+ # gem's libhypercast, "hypertabular" for HyperTabular's.
4
6
  module NativePlatform
5
7
  class UnsupportedPlatformError < StandardError; end
6
8
 
7
9
  # +platform+ is a parameter only so the specs can walk the whole table from one host;
8
10
  # every real caller takes the default.
9
- def self.rid_and_library_name(platform = RUBY_PLATFORM)
11
+ def self.rid_and_library_name(platform = RUBY_PLATFORM, library: "hypercast")
10
12
  arch = platform.match?(/arm64|aarch64/) ? "arm64" : "x64"
11
13
 
12
14
  case platform
13
15
  when /mingw|mswin|windows/
14
- ["win-#{arch}", "hypercast.dll"]
16
+ ["win-#{arch}", "#{library}.dll"]
15
17
  when /darwin/
16
- ["osx-#{arch}", "libhypercast.dylib"]
18
+ ["osx-#{arch}", "lib#{library}.dylib"]
17
19
  when /linux/
18
20
  # Two C libraries, two builds: a glibc-linked library cannot be relied on to dlopen
19
21
  # into a musl process (Alpine), so musl gets RIDs of its own. Ruby names the libc
20
22
  # in its platform string there ("x86_64-linux-musl") and leaves it off on glibc.
21
23
  os = platform.include?("musl") ? "linux-musl" : "linux"
22
- ["#{os}-#{arch}", "libhypercast.so"]
24
+ ["#{os}-#{arch}", "lib#{library}.so"]
23
25
  else
24
- raise UnsupportedPlatformError, "hypercast: unsupported platform RUBY_PLATFORM=#{platform}"
26
+ raise UnsupportedPlatformError, "#{library}: unsupported platform RUBY_PLATFORM=#{platform}"
25
27
  end
26
28
  end
27
29
  end
@@ -17,7 +17,9 @@ module HyperCast
17
17
  # Each door's C signature, by shape — resolved to Fiddle types only in load_functions,
18
18
  # so nothing here touches Fiddle until the Fiddle backend actually runs: plain is
19
19
  # (text, len, out, fault), numeric adds the packed NumFormat pointer before out, and
20
- # declared a caller-declared u32 (precision, epoch, field order) in the same place.
20
+ # declared a caller-declared u32 (precision, epoch, field order) in the same place. The
21
+ # typed doors take a double instead of the text and its length: typed is (value, out,
22
+ # fault), typed_declared adds the u32 after the value.
21
23
  DOORS = {
22
24
  cast_bool: :plain,
23
25
  cast_i8: :numeric, cast_i16: :numeric, cast_i32: :numeric, cast_i64: :numeric,
@@ -26,7 +28,9 @@ module HyperCast
26
28
  cast_uuid: :plain,
27
29
  cast_timestamp: :plain, cast_unix: :declared, cast_excel_serial: :declared,
28
30
  cast_date: :plain, cast_date_ordered: :declared, cast_datetime: :declared,
29
- cast_time: :plain, cast_duration: :plain
31
+ cast_time: :plain, cast_duration: :plain,
32
+ cast_decimal_from_f64: :typed, cast_excel_serial_from_f64: :typed_declared,
33
+ cast_excel_time: :typed, cast_excel_duration: :typed
30
34
  }.freeze
31
35
 
32
36
  # The one export that is not a door: the zero-argument version probe, returning the
@@ -60,12 +64,7 @@ module HyperCast
60
64
  # development loop — the in-repo cargo build, exactly what the other bindings' local
61
65
  # staging does. Nil when neither exists.
62
66
  def library_path
63
- rid, lib_name = NativePlatform.rid_and_library_name
64
- path = File.join(NATIVE_DIR, rid, lib_name)
65
- return path if File.exist?(path)
66
-
67
- repo_build = File.expand_path(File.join(__dir__, "../../../rust/target/release", lib_name))
68
- File.exist?(repo_build) ? repo_build : nil
67
+ Interop.library_path("hypercast", NATIVE_DIR, File.expand_path("../../..", __dir__))
69
68
  end
70
69
 
71
70
  # Why Fiddle found nothing to load. A precompiled platform gem is the one install where
@@ -114,7 +113,9 @@ module HyperCast
114
113
  signatures = {
115
114
  plain: plain,
116
115
  numeric: plain.dup.insert(2, Fiddle::TYPE_VOIDP),
117
- declared: plain.dup.insert(2, Fiddle::TYPE_UINT32_T)
116
+ declared: plain.dup.insert(2, Fiddle::TYPE_UINT32_T),
117
+ typed: [Fiddle::TYPE_DOUBLE, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP],
118
+ typed_declared: [Fiddle::TYPE_DOUBLE, Fiddle::TYPE_UINT32_T, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
118
119
  }
119
120
  functions = DOORS.to_h do |name, shape|
120
121
  [name, Fiddle::Function.new(handle[name.to_s], signatures.fetch(shape), Fiddle::TYPE_INT32_T)]
data/lib/hypercast.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "date"
2
2
  require_relative "hypercast/native_platform"
3
+ require_relative "hypercast/interop"
3
4
  require_relative "hypercast/runtime"
4
5
 
5
6
  # Allocation-lean scalar casts — booleans, numerics, exact decimals, UUIDs, temporals —
@@ -29,7 +30,7 @@ require_relative "hypercast/runtime"
29
30
  module HyperCast
30
31
  # This gem's own version — kept in lockstep with hypercast.gemspec by the
31
32
  # prepare-release workflow, so the two can never drift apart again.
32
- VERSION = "0.6.1"
33
+ VERSION = "0.7.0"
33
34
 
34
35
  # The success case of a verdict: a cast value.
35
36
  Success = Data.define(:value)
@@ -216,13 +217,12 @@ module HyperCast
216
217
  # (t/f, yes/no, y/n, 1/0, on/off, enabled/disabled, active/inactive,
217
218
  # checked/unchecked, in/out), ASCII case-insensitive.
218
219
  def bool(text)
219
- plain(:cast_bool, text, 1) { |out| out.unpack1("C") != 0 }
220
+ plain(:cast_bool, text, 1) { |out| Interop.decode(:bool, out) }
220
221
  end
221
222
 
222
- { i8: "c", i16: "s<", i32: "l<", i64: "q<", u8: "C", u16: "S<", u32: "L<", u64: "Q<" }
223
- .each do |door, unpack|
224
- sizes = { "c" => 1, "C" => 1, "s<" => 2, "S<" => 2, "l<" => 4, "L<" => 4, "q<" => 8, "Q<" => 8 }
225
- size = sizes.fetch(unpack)
223
+ %i[i8 i16 i32 i64 u8 u16 u32 u64].each do |door|
224
+ unpack = Interop::SCALARS.fetch(door)
225
+ size = Interop::VALUE_BYTES.fetch(door)
226
226
  # Resolved once here, not inside the method: interpolating a Symbol per call built a
227
227
  # String and interned it on every integer cast.
228
228
  symbol = :"cast_#{door}"
@@ -237,12 +237,12 @@ module HyperCast
237
237
  # Casts real text to an IEEE single (widened losslessly on the way out): finite values
238
238
  # only, declared separators and grouping, parens, exponent, and trailing percent.
239
239
  def f32(text, format)
240
- numeric(:cast_f32, text, format, 4) { |out| out.unpack1("e") }
240
+ numeric(:cast_f32, text, format, 4) { |out| Interop.decode(:f32, out) }
241
241
  end
242
242
 
243
243
  # Casts real text to an IEEE double. Notation rules as f32.
244
244
  def f64(text, format)
245
- numeric(:cast_f64, text, format, 8) { |out| out.unpack1("E") }
245
+ numeric(:cast_f64, text, format, 8) { |out| Interop.decode(:f64, out) }
246
246
  end
247
247
 
248
248
  # Casts decimal text to an exact Decimal — the real doors' grammar (declared separators
@@ -252,32 +252,27 @@ module HyperCast
252
252
  # A magnitude past 2**96 - 1, or more fractional precision than 28 places can hold, is
253
253
  # an :out_of_range Fault — the door never rounds.
254
254
  def decimal(text, format)
255
- numeric(:cast_decimal, text, format, 16) do |out|
256
- lo, hi, scale, negative = out.unpack("Q<L<CCx2")
257
- Decimal.new(magnitude: (hi << 64) | lo, scale: scale, negative: negative != 0)
258
- end
255
+ numeric(:cast_decimal, text, format, 16) { |out| Interop.decode(:decimal, out) }
259
256
  end
260
257
 
261
258
  # Casts UUID text — all five .NET Guid formats (D/N/B/P/X) plus urn:uuid:/GUID:/UUID:
262
259
  # prefixes — to Ruby's UUID lingua franca: the lowercase hyphenated String (the same
263
260
  # shape SecureRandom.uuid returns).
264
261
  def uuid(text)
265
- plain(:cast_uuid, text, 16) do |out|
266
- out.unpack("H8H4H4H4H12").join("-")
267
- end
262
+ plain(:cast_uuid, text, 16) { |out| Interop.decode(:uuid, out) }
268
263
  end
269
264
 
270
265
  # Casts an RFC 3339 instant — zone mandatory — to a UTC Time at full nanosecond
271
266
  # fidelity across the whole 0001-9999 window.
272
267
  def timestamp(text)
273
- plain(:cast_timestamp, text, 16) { |out| instant(out) }
268
+ plain(:cast_timestamp, text, 16) { |out| Interop.decode(:timestamp, out) }
274
269
  end
275
270
 
276
271
  # Casts an integer Unix-epoch value under a caller-declared unit Symbol
277
272
  # (:seconds/:milliseconds/:microseconds/:nanoseconds) to a UTC Time. An unknown unit
278
273
  # is a caller bug (KeyError), never a verdict.
279
274
  def unix(text, precision)
280
- declared(:cast_unix, text, UNIX_PRECISIONS.fetch(precision), 16) { |out| instant(out) }
275
+ declared(:cast_unix, text, UNIX_PRECISIONS.fetch(precision), 16) { |out| Interop.decode(:unix, out) }
281
276
  end
282
277
 
283
278
  # Casts an Excel date serial under a caller-declared epoch Symbol (:y1900/:y1904) to a
@@ -291,7 +286,7 @@ module HyperCast
291
286
  # the text "1900-02-29" — so every serial above it is shifted one day against a naive
292
287
  # count. An unknown epoch is a caller bug (KeyError), never a verdict.
293
288
  def excel_serial(text, epoch)
294
- declared(:cast_excel_serial, text, EXCEL_EPOCHS.fetch(epoch), 16) { |out| instant(out) }
289
+ declared(:cast_excel_serial, text, EXCEL_EPOCHS.fetch(epoch), 16) { |out| Interop.decode(:excel_serial, out) }
295
290
  end
296
291
 
297
292
  # Casts a calendar date to a Date. With no order declared: the strict ISO 8601
@@ -301,15 +296,9 @@ module HyperCast
301
296
  # caller bug (KeyError), never a verdict.
302
297
  def date(text, order = nil)
303
298
  if order.nil?
304
- plain(:cast_date, text, 4) do |out|
305
- year, month, day = out.unpack("S<CC")
306
- Date.new(year, month, day)
307
- end
299
+ plain(:cast_date, text, 4) { |out| Interop.decode(:date, out) }
308
300
  else
309
- declared(:cast_date_ordered, text, DATE_ORDERS.fetch(order), 4) do |out|
310
- year, month, day = out.unpack("S<CC")
311
- Date.new(year, month, day)
312
- end
301
+ declared(:cast_date_ordered, text, DATE_ORDERS.fetch(order), 4) { |out| Interop.decode(:date_ordered, out) }
313
302
  end
314
303
  end
315
304
 
@@ -322,29 +311,51 @@ module HyperCast
322
311
  # fusing a real zone is the caller's job, and timestamp stays the strict RFC 3339
323
312
  # instant door. An unknown order is a caller bug (KeyError).
324
313
  def datetime(text, order)
325
- declared(:cast_datetime, text, DATE_ORDERS.fetch(order), 16) do |out|
326
- year, month, day, nanos = out.unpack("S<CCx4Q<")
327
- second_of_day, frac = nanos.divmod(1_000_000_000)
328
- hour, rest = second_of_day.divmod(3600)
329
- minute, second = rest.divmod(60)
330
- DateTime.new(year, month, day, hour, minute, second + Rational(frac, 1_000_000_000))
331
- end
314
+ declared(:cast_datetime, text, DATE_ORDERS.fetch(order), 16) { |out| Interop.decode(:datetime, out) }
332
315
  end
333
316
 
334
317
  # Casts an ISO 24-hour time-of-day to an exact Integer of nanoseconds since midnight
335
318
  # (Ruby has no time-of-day type; the integer keeps every digit).
336
319
  def time(text)
337
- plain(:cast_time, text, 8) { |out| out.unpack1("Q<") }
320
+ plain(:cast_time, text, 8) { |out| Interop.decode(:time, out) }
338
321
  end
339
322
 
340
323
  # Casts a duration (ISO 8601 fixed components, invariant colon form, or protobuf JSON
341
324
  # seconds) to exact Rational seconds — full fidelity across the core's ±10,000-year
342
325
  # window, no wrapping and no truncation.
343
326
  def duration(text)
344
- plain(:cast_duration, text, 16) do |out|
345
- seconds, nanos = out.unpack("q<l<")
346
- Rational(seconds * 1_000_000_000 + nanos, 1_000_000_000)
347
- end
327
+ plain(:cast_duration, text, 16) { |out| Interop.decode(:duration, out) }
328
+ end
329
+
330
+ # Reads a number a caller already holds — an Integer, Float or Rational, the way Ruby's
331
+ # own Float conversion takes it; anything else, a String included, is a TypeError — as
332
+ # the exact Decimal it names: the shortest decimal that rounds back to the Float, the
333
+ # digits a spreadsheet writes for it. 0.1 is magnitude 1, scale 1, not the binary
334
+ # fraction nearest it; 0.1 + 0.2 is 0.30000000000000004. NaN is :malformed; an infinity,
335
+ # a magnitude past 2**96 - 1 or more than 28 places is :out_of_range. A typed door's
336
+ # Fault has no span: offset and length are 0.
337
+ def decimal_from_float(value)
338
+ typed(:cast_decimal_from_f64, float(value), 16) { |out| Interop.decode(:decimal, out) }
339
+ end
340
+
341
+ # Reads an Excel serial number under a declared epoch Symbol (:y1900/:y1904) as the
342
+ # zone-less DateTime it names — the twin of excel_serial for a number a workbook reader
343
+ # already holds, on the same carrier datetime uses. The 1900 system's phantom serial 60
344
+ # is :out_of_range; a negative, NaN or infinite serial is :malformed.
345
+ def excel_serial_from_float(value, epoch)
346
+ number = float(value)
347
+ typed(:cast_excel_serial_from_f64, number, 16, EXCEL_EPOCHS.fetch(epoch)) { |out| Interop.decode(:datetime, out) }
348
+ end
349
+
350
+ # Reads the fraction of an Excel serial number as an exact Integer of nanoseconds since
351
+ # midnight, as time does; 0.75 and 45292.75 are both 18:00.
352
+ def excel_time(value)
353
+ typed(:cast_excel_time, float(value), 8) { |out| Interop.decode(:time, out) }
354
+ end
355
+
356
+ # Reads a number of days as exact Rational seconds, as duration does: 1.5 is 129600.
357
+ def excel_duration(value)
358
+ typed(:cast_excel_duration, float(value), 16) { |out| Interop.decode(:duration, out) }
348
359
  end
349
360
 
350
361
  # The version of the native core actually loaded, as "major.minor.patch" — read from
@@ -353,15 +364,11 @@ module HyperCast
353
364
  # not). The cheapest possible probe that the backend resolved at all: takes nothing,
354
365
  # cannot fail.
355
366
  def native_version
356
- word = packed_version
357
- "#{word >> 16}.#{(word >> 8) & 0xFF}.#{word & 0xFF}"
367
+ Interop.version(packed_version)
358
368
  end
359
369
 
360
370
  private
361
371
 
362
- # Encodings whose bytes already are the UTF-8 (or byte-identical) form the core reads.
363
- BYTE_COMPATIBLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::ASCII_8BIT].freeze
364
-
365
372
  # Presents the input as UTF-8 bytes: already-compatible text crosses as-is (Fiddle
366
373
  # passes a String's own bytes for void* — no copy of them); only foreign encodings pay
367
374
  # a transcode, and text that cannot be transcoded raises String#encode's own
@@ -376,7 +383,7 @@ module HyperCast
376
383
  raise TypeError, "no implicit conversion of #{text.class} into String"
377
384
  text = converted
378
385
  end
379
- BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
386
+ Interop::BYTE_COMPATIBLE.include?(text.encoding) ? text : text.encode(Encoding::UTF_8)
380
387
  end
381
388
 
382
389
  # Fiddle spells a null pointer as nil — the core's contract for empty input.
@@ -408,22 +415,10 @@ module HyperCast
408
415
  elsif rc == -1
409
416
  raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
410
417
  else
411
- offset, length = characters(bytes, *fault[0, 8].unpack("L<L<"))
412
- Fault.new(reason: REASONS.fetch(rc), offset: offset, length: length)
418
+ Interop.fault(rc, *Interop.characters(bytes, *fault[0, 8].unpack("L<L<")))
413
419
  end
414
420
  end
415
421
 
416
- # The core's byte span in the units String#[] slices by: an identity for a binary
417
- # String (its characters are its bytes) and for ASCII text (`ascii_only?` reads the
418
- # cached coderange — no scan), a byte-to-character remap otherwise. Character counts
419
- # survive transcoding, so a span mapped on the UTF-8 form indexes the caller's own
420
- # String whatever encoding it arrived in. Failure path only: a Success never pays.
421
- def characters(bytes, offset, length)
422
- return [offset, length] if bytes.encoding == Encoding::ASCII_8BIT || bytes.ascii_only?
423
-
424
- [bytes.byteslice(0, offset).length, bytes.byteslice(offset, length).length]
425
- end
426
-
427
422
  # The shared body of every format-free door: one native call over the scratch buffers.
428
423
  def plain(symbol, text, out_size)
429
424
  bytes = utf8(text)
@@ -452,6 +447,34 @@ module HyperCast
452
447
  verdict(rc, fault, bytes) { yield(out[0, out_size]) }
453
448
  end
454
449
 
450
+ # A typed door's number, converted the way the extension's argument is (C's
451
+ # rb_num2dbl), with the same exception and message: a Numeric converts (a Complex with an
452
+ # imaginary part is Complex#to_f's RangeError), anything else is a TypeError — a String
453
+ # among them, which Kernel#Float would have parsed.
454
+ def float(value)
455
+ case value
456
+ when Float then value
457
+ when Numeric then Float(value)
458
+ when String then raise TypeError, "no implicit conversion to float from string"
459
+ when nil, true, false then raise TypeError, "no implicit conversion to float from #{value.inspect}"
460
+ else raise TypeError, "can't convert #{value.class} into Float"
461
+ end
462
+ end
463
+
464
+ # The shared body of the typed doors: a double instead of the text, then the declared
465
+ # u32 when the door takes one.
466
+ def typed(symbol, number, out_size, *declared)
467
+ out, fault = scratch
468
+ rc = Runtime.function(symbol).call(number, *declared, out, fault)
469
+ if rc.zero?
470
+ Success.new(value: yield(out[0, out_size]))
471
+ elsif rc == -1
472
+ raise "hypercast: libhypercast reported a contract violation — a binding bug, please report it"
473
+ else
474
+ Interop.fault(rc, 0, 0)
475
+ end
476
+ end
477
+
455
478
  # The loaded core's version word, major << 16 | minor << 8 | patch, straight from the
456
479
  # library's zero-argument hypercast_version export.
457
480
  def packed_version
@@ -482,12 +505,6 @@ module HyperCast
482
505
  cache[format] = pointer
483
506
  end.compare_by_identity
484
507
  end
485
-
486
- # Builds a UTC Time from the core's protobuf-shaped {seconds, nanos} pair, exactly.
487
- def instant(bytes)
488
- seconds, nanos = bytes.unpack("q<l<")
489
- Time.at(seconds, nanos, :nanosecond, in: "UTC")
490
- end
491
508
  end
492
509
  end
493
510
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hypercast-wasm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Buvinghausen
@@ -53,6 +53,7 @@ files:
53
53
  - ext/hypercast_native/4.0/hypercast_native.a.gz
54
54
  - ext/hypercast_native/extconf.rb
55
55
  - lib/hypercast.rb
56
+ - lib/hypercast/interop.rb
56
57
  - lib/hypercast/native_platform.rb
57
58
  - lib/hypercast/runtime.rb
58
59
  homepage: https://github.com/SkunkWerkx/HyperCast