hypertabular-wasm 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 +7 -0
- data/LICENSE +21 -0
- data/README.md +179 -0
- data/ext/hypertabular_native/3.4/hypertabular_native.a.gz +0 -0
- data/ext/hypertabular_native/4.0/hypertabular_native.a.gz +0 -0
- data/ext/hypertabular_native/extconf.rb +108 -0
- data/lib/hypertabular/batch.rb +204 -0
- data/lib/hypertabular/column.rb +170 -0
- data/lib/hypertabular/delimited_reader.rb +309 -0
- data/lib/hypertabular/dialect.rb +42 -0
- data/lib/hypertabular/runtime/columns.rb +46 -0
- data/lib/hypertabular/runtime/delimited.rb +204 -0
- data/lib/hypertabular/runtime/workbook.rb +299 -0
- data/lib/hypertabular/runtime.rb +142 -0
- data/lib/hypertabular/tabular_error.rb +99 -0
- data/lib/hypertabular/workbook.rb +173 -0
- data/lib/hypertabular.rb +103 -0
- metadata +107 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 45321a399589d7b434c5e4567e0f8b687b4629a54410fd617007e2eecec592c8
|
|
4
|
+
data.tar.gz: 28e88b7113da58fcc27d699e27cb936478e334130a0dd4646457b57a2e016f99
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 134a1f69690ab5c38230bc080fbcd8574cdbad03e8a218f0e9826835649fa2b8da4390bba0b4c228ef7f6abe34e0e6fbae1b8f893e8708b4a52877ab89fb74f8
|
|
7
|
+
data.tar.gz: 2e7172f099dedb51cfa2f3bf7cf28a49e5d3da94ab1998009972e554cb75711c5e35dd24c84c2fbae8ffd24b5b439da43be393d14cfa5032cc0895bc29dc958f
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Skunk Werkx
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# hypertabular
|
|
2
|
+
|
|
3
|
+
Delimited text — CSV, TSV, any single-byte ASCII separator — and workbooks — XLSX and ODS —
|
|
4
|
+
read a batch at a time into typed columns, with a
|
|
5
|
+
[HyperCast](https://github.com/SkunkWerkx/HyperCast) verdict for every cell.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "hypertabular"
|
|
9
|
+
|
|
10
|
+
plan = [
|
|
11
|
+
HyperTabular::Column.i32(0),
|
|
12
|
+
HyperTabular::Column.text(1),
|
|
13
|
+
HyperTabular::Column.f64(2)
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
HyperTabular::DelimitedReader.open("orders.csv", HyperTabular::Dialect::CSV, plan) do |reader|
|
|
17
|
+
reader.header # => ["id", "name", "score"]
|
|
18
|
+
|
|
19
|
+
while (batch = reader.read)
|
|
20
|
+
# A column at a time, decoded in one pass — nil where a cell did not cast…
|
|
21
|
+
ids = batch.values(0)
|
|
22
|
+
|
|
23
|
+
# …or a cell at a time, as HyperCast's union.
|
|
24
|
+
batch.rows.times do |row|
|
|
25
|
+
case batch.get(2, row)
|
|
26
|
+
in HyperCast::Success(value:)
|
|
27
|
+
puts "#{batch.values(1)[row]}: #{value}"
|
|
28
|
+
in HyperCast::Fault(reason:, offset:, length:)
|
|
29
|
+
warn "line #{batch.line(row)}: #{reason} in #{batch.raw(2, row).inspect}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A workbook reads into the same batch.
|
|
36
|
+
book = HyperTabular::Workbook.open("orders.xlsx")
|
|
37
|
+
book.sheets # => [#<data HyperTabular::SheetInfo name="Orders", hidden=false>]
|
|
38
|
+
sheet = book.sheet("Orders", HyperTabular::SheetOptions::DEFAULT, plan)
|
|
39
|
+
while (batch = sheet.read)
|
|
40
|
+
# …
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`reader.each_row` walks every remaining row as an Array of verdicts, for when a row at a
|
|
45
|
+
time is what the caller wants; `sheet.each_batch` walks a sheet's batches.
|
|
46
|
+
|
|
47
|
+
## The shape
|
|
48
|
+
|
|
49
|
+
The native core (`libhypertabular`) owns no memory and reads no files. `DelimitedReader`
|
|
50
|
+
and `Sheet` allocate the buffers — one value array and one verdict array per column, the
|
|
51
|
+
table that locates each cell — once, and reuse them for every batch. The core fills them in one
|
|
52
|
+
native call per batch, and a column comes out of its buffer in one `String#unpack`: the
|
|
53
|
+
boundary is crossed once per few thousand rows, not once per cell.
|
|
54
|
+
|
|
55
|
+
- **One batch class.** `read` returns a `HyperTabular::Batch` — `rows`, `columns`,
|
|
56
|
+
`line(row)`, `values(column)`, `verdicts(column)`, `get(column, row)` and `raw(column,
|
|
57
|
+
row)` — or `nil` once there are no more rows, for delimited text and a sheet alike. A
|
|
58
|
+
batch owns what it shows: it stays good after the reader has moved on.
|
|
59
|
+
- **Nothing is sniffed.** The `Dialect` states the separator, the quoting and the header;
|
|
60
|
+
`SheetOptions` states a sheet's header and whether empty rows are skipped; the plan
|
|
61
|
+
states each column's door and, for numbers, its `HyperCast::NumFormat`.
|
|
62
|
+
- **HyperCast is the judge.** `HyperCast::Success`, `HyperCast::Fault`,
|
|
63
|
+
`HyperCast::NumFormat`, `HyperCast::Decimal` and the declared options (`:milliseconds`,
|
|
64
|
+
`:day_month_year`, `:y1904`) are the `hypercast` gem's own. A text cell means exactly
|
|
65
|
+
what HyperCast's door would say of the same text, a typed workbook cell is converted by
|
|
66
|
+
the door directly, and either comes back as the Ruby type that gem returns for it.
|
|
67
|
+
- **A bad value is a verdict; a broken file is an exception.** A cell that does not cast
|
|
68
|
+
is a `Fault` in its column and the read goes on. A record of the wrong width, input that
|
|
69
|
+
ends inside a quoted cell, a workbook whose container or parts cannot be read, is a
|
|
70
|
+
`HyperTabular::TabularError` carrying `kind`, `record`, `line`, `byte`, `expected` and
|
|
71
|
+
`found`, raised after every intact row before it.
|
|
72
|
+
- **The text of any cell is to hand.** `batch.raw(column, row)` is what the cell was cast
|
|
73
|
+
from, whatever its door and verdict, and a `Fault`'s span indexes it in the units
|
|
74
|
+
`String#[]` slices by, as HyperCast's gem does: `raw[fault.offset, fault.length]` is the
|
|
75
|
+
offending text.
|
|
76
|
+
- **A String is read in place.** An IO is read forward only, through a buffer that grows
|
|
77
|
+
when a record does not fit it.
|
|
78
|
+
|
|
79
|
+
## The doors
|
|
80
|
+
|
|
81
|
+
One factory per door, named as HyperCast names them.
|
|
82
|
+
|
|
83
|
+
| Factory | Value |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `Column.bool(n)` | `true` / `false` |
|
|
86
|
+
| `Column.i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64(n, format = INVARIANT)` | `Integer` |
|
|
87
|
+
| `Column.f32` `f64(n, format = INVARIANT)` | `Float` |
|
|
88
|
+
| `Column.decimal(n, format = INVARIANT)` | `HyperCast::Decimal`, exact |
|
|
89
|
+
| `Column.uuid(n)` | lowercase hyphenated `String` |
|
|
90
|
+
| `Column.timestamp(n)` | UTC `Time`, nanosecond fidelity |
|
|
91
|
+
| `Column.unix(n, :seconds / :milliseconds / :microseconds / :nanoseconds)` | UTC `Time` |
|
|
92
|
+
| `Column.excel_serial(n, :y1900 / :y1904)` | UTC `Time` |
|
|
93
|
+
| `Column.date(n)` | `Date`, strict `yyyy-MM-dd` |
|
|
94
|
+
| `Column.date(n, order)` / `Column.date_ordered(n, order)` | `Date`, under `:year_month_day`, `:month_day_year` or `:day_month_year` |
|
|
95
|
+
| `Column.datetime(n, order)` | `DateTime`, zone-less |
|
|
96
|
+
| `Column.time(n)` | `Integer` nanoseconds since midnight |
|
|
97
|
+
| `Column.duration(n)` | `Rational` seconds |
|
|
98
|
+
| `Column.text(n)` | UTF-8 `String`, untrimmed |
|
|
99
|
+
|
|
100
|
+
## Backends
|
|
101
|
+
|
|
102
|
+
| `HyperTabular::BACKEND` | What runs | Chosen when |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `:native` | the core linked into a Magnus extension | a precompiled platform gem is installed — each carries an extension for Ruby 3.4 and 4.0 — or the `hypertabular-wasm` gem is linked into a ruby.wasm interpreter; see [Ruby in the browser](#ruby-in-the-browser) |
|
|
105
|
+
| `:fiddle` | `libhypertabular` for this platform, `dlopen`ed through Fiddle | no extension loads: the universal gem, on a Ruby or platform no platform gem covers (Intel macOS, Ruby 3.3, Ruby 4.1 and later) |
|
|
106
|
+
|
|
107
|
+
Selection happens once, at `require`, in that order. Both backends read a batch in one native
|
|
108
|
+
call and hand back the same bytes; everything above that crossing — the readers, the
|
|
109
|
+
workbook, `Batch` and every value and verdict it builds — is the same Ruby on either, and
|
|
110
|
+
`spec/native_backend_spec.rb` holds the two to the same answer over every cell of both corpora.
|
|
111
|
+
So the extension is not where a read gets faster (the crossing was already once per batch);
|
|
112
|
+
what it buys is a platform gem with no shared library for Fiddle to find, and Ruby in the
|
|
113
|
+
browser, where there is no Fiddle at all.
|
|
114
|
+
|
|
115
|
+
`HYPERTABULAR_PURE` forces `:fiddle`, as `HYPERCAST_PURE` does for hypercast. It is a testing
|
|
116
|
+
and diagnostic switch — CI runs the whole suite through it — read for presence, not value. A
|
|
117
|
+
forced backend with nothing to load does not fall through: the first reader raises, and
|
|
118
|
+
`HyperTabular.available?` answers `false`. Inside a platform gem, which carries no Fiddle
|
|
119
|
+
library, that `LoadError` names the universal gem (`gem install hypertabular --platform ruby`,
|
|
120
|
+
or Bundler's `force_ruby_platform`). `HyperTabular.available?` and `HyperTabular.native_version`
|
|
121
|
+
answer whether the core resolved, without the first read being what finds out.
|
|
122
|
+
|
|
123
|
+
**Threads.** A reader or a sheet is one thread's at a time, on either backend. The extension
|
|
124
|
+
runs under the GVL; Fiddle releases it for each call.
|
|
125
|
+
|
|
126
|
+
## Ruby in the browser
|
|
127
|
+
|
|
128
|
+
ruby.wasm cannot load an extension at runtime: `rbwasm build` links the extension of every gem
|
|
129
|
+
in a Gemfile into the one interpreter it builds. So the browser gets a gem of its own,
|
|
130
|
+
`hypertabular-wasm` — the same library and the same Magnus extension, prebuilt for
|
|
131
|
+
`wasm32-wasip1` — which brings `hypercast-wasm`, HyperCast's own, with it. List it **instead
|
|
132
|
+
of** `hypertabular` in the Gemfile you build the interpreter from:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
source "https://rubygems.org"
|
|
136
|
+
|
|
137
|
+
gem "hypertabular-wasm"
|
|
138
|
+
gem "js" # JavaScript interop, which a browser app almost always wants
|
|
139
|
+
|
|
140
|
+
group :development do
|
|
141
|
+
gem "ruby_wasm"
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
bundle install
|
|
147
|
+
bundle exec rbwasm build --ruby-version 4.0 -o ruby.wasm
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Load `ruby.wasm` with [`@ruby/wasm-wasi`](https://www.npmjs.com/package/@ruby/wasm-wasi), then
|
|
151
|
+
`require "/bundle/setup"` and `require "hypertabular"` as anywhere else. A workbook is read
|
|
152
|
+
from its bytes (`HyperTabular::Workbook.new(bytes)`) — a `fetch`ed file, an upload — and
|
|
153
|
+
delimited text from a String or any IO.
|
|
154
|
+
|
|
155
|
+
- **Ruby 3.4 and 4.0**, one archive each, picked by `--ruby-version`; any other minor stops the
|
|
156
|
+
build naming the ones the gem carries. Built and tested against ruby_wasm 2.10.
|
|
157
|
+
- **No Rust toolchain.** The gem's `extconf.rb` only hands rbwasm the prebuilt archive. The
|
|
158
|
+
first `rbwasm build` compiles Ruby itself and takes 15–20 minutes; later builds reuse it.
|
|
159
|
+
- **Static linking only,** into ruby.wasm's default `wasm32-unknown-wasip1` interpreter.
|
|
160
|
+
- **Beside HyperCast.** Every Rust extension that carries std defines a few of the same
|
|
161
|
+
symbols (`rust_eh_personality`, rb-sys's `ruby_abi_version`, one of std's); this gem and
|
|
162
|
+
`hypercast-wasm` each rename them to names of their own when CI builds the archive, and the
|
|
163
|
+
build fails if a newer Rust starts exporting another.
|
|
164
|
+
|
|
165
|
+
What stands behind it: CI builds each minor's archive from the commit, packs the gem the way
|
|
166
|
+
it ships, links it into a fresh interpreter and runs [`wasm-smoke/test.rb`](wasm-smoke/test.rb)
|
|
167
|
+
under Node and in headless Chrome (the forge's `hyper-build-wasm.yml`). The published gem is
|
|
168
|
+
packed around those attested archives.
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
cd rust && cargo cdylib # builds rust/target/release/libhypertabular.*
|
|
174
|
+
cd ../ruby && bundle install
|
|
175
|
+
HYPERTABULAR_PURE=1 bundle exec rspec # the Fiddle backend: replays both corpora
|
|
176
|
+
bundle exec rake native:dev # builds the Magnus extension for this Ruby, staged
|
|
177
|
+
bundle exec rspec # the extension, and its agreement with Fiddle
|
|
178
|
+
bundle exec rake docs:check
|
|
179
|
+
```
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# The hypertabular-wasm gem's extension: the Magnus extension, linked statically into a
|
|
2
|
+
# ruby.wasm interpreter by `rbwasm build` — hypercast-wasm's extconf, for this crate. ruby.wasm
|
|
3
|
+
# cannot load an extension at runtime, so a consumer puts this gem in the Gemfile they hand to
|
|
4
|
+
# rbwasm and gets one interpreter with HyperTabular compiled in, beside HyperCast's own. Only
|
|
5
|
+
# this companion gem ships this file; the hypertabular gems do not, because an `extensions`
|
|
6
|
+
# entry there would run extconf on every host `gem install`.
|
|
7
|
+
#
|
|
8
|
+
# Bundler installs the gem on the host first, where there is nothing to build. Then rbwasm runs
|
|
9
|
+
# this again with the cross-compiled Ruby's RbConfig, `make clean`, `make static`, and links
|
|
10
|
+
# every *.a left in the build directory. Both paths below end in the same place: the archive
|
|
11
|
+
# saved as hypertabular_native.prebuilt (not *.a, which `make clean` removes), copied into place
|
|
12
|
+
# by `make static`.
|
|
13
|
+
#
|
|
14
|
+
# * Prebuilt, what a consumer gets: the gem carries one gzipped archive per Ruby minor (Rakefile,
|
|
15
|
+
# wasm:gem), built and attested in CI. No Rust toolchain on the consumer's machine.
|
|
16
|
+
# * From source, how CI makes those archives: HYPERTABULAR_RUST_DIR names the crate, and
|
|
17
|
+
# HYPERTABULAR_WASM_OUT, if set, receives a copy of the finished archive.
|
|
18
|
+
require "mkmf"
|
|
19
|
+
require "rbconfig"
|
|
20
|
+
require "zlib"
|
|
21
|
+
require "fileutils"
|
|
22
|
+
|
|
23
|
+
unless RbConfig::CONFIG["host_os"].include?("wasi")
|
|
24
|
+
File.write("Makefile", dummy_makefile($srcdir).join)
|
|
25
|
+
exit
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# rbwasm reads $target back out to name the extension's Init function in its extinit table.
|
|
29
|
+
$target = "hypertabular_native"
|
|
30
|
+
minor = "#{RbConfig::CONFIG["MAJOR"]}.#{RbConfig::CONFIG["MINOR"]}"
|
|
31
|
+
prebuilt = "hypertabular_native.prebuilt"
|
|
32
|
+
|
|
33
|
+
if (rust_dir = ENV["HYPERTABULAR_RUST_DIR"])
|
|
34
|
+
# rb-sys binds against the target Ruby through RBCONFIG_* variables. Ruby is not installed
|
|
35
|
+
# yet when rbwasm builds extensions, so the two header directories point at the source and
|
|
36
|
+
# build trees instead (rbwasm sets top_srcdir and extout for exactly this).
|
|
37
|
+
RbConfig::CONFIG.merge(
|
|
38
|
+
"rubyhdrdir" => File.join(ENV.fetch("top_srcdir"), "include"),
|
|
39
|
+
"rubyarchhdrdir" => File.join(ENV.fetch("extout"), "include", RbConfig::CONFIG["arch"])
|
|
40
|
+
).each { |key, value| ENV["RBCONFIG_#{key}"] = value.to_s }
|
|
41
|
+
wasi_sdk = File.dirname(File.dirname(RbConfig::CONFIG["CC"].split.first))
|
|
42
|
+
ENV["BINDGEN_EXTRA_CLANG_ARGS"] = "--sysroot=#{wasi_sdk}/share/wasi-sysroot " \
|
|
43
|
+
"-D_WASI_EMULATED_SIGNAL -D_WASI_EMULATED_PROCESS_CLOCKS -D_WASI_EMULATED_MMAN"
|
|
44
|
+
ENV["CC_wasm32_wasip1"] = "#{wasi_sdk}/bin/clang"
|
|
45
|
+
target_dir = File.expand_path("target")
|
|
46
|
+
system("cargo", "rustc", "--manifest-path", File.join(rust_dir, "Cargo.toml"), "--release",
|
|
47
|
+
"--target", "wasm32-wasip1", "--crate-type", "staticlib", "--features", "ruby",
|
|
48
|
+
"--target-dir", target_dir, exception: true)
|
|
49
|
+
archive = File.binread(File.join(target_dir, "wasm32-wasip1/release/libhypertabular.a"))
|
|
50
|
+
# The crate links into one object (release LTO), and besides Init_hypertabular_native and
|
|
51
|
+
# the C ABI it exports three symbols that every Rust extension carrying std defines too:
|
|
52
|
+
# rust_eh_personality, which wasi-vfs (linked into every ruby.wasm) also defines;
|
|
53
|
+
# ruby_abi_version, rb-sys's ABI stamp for a dynamically loaded extension, which a static
|
|
54
|
+
# one never needs; and std's EMPTY_PANIC. Two definitions of any of them stop the link, so
|
|
55
|
+
# each is renamed to one this crate owns, and the HyperCast extension this gem always runs
|
|
56
|
+
# beside, renamed the same way to names of its own, can share the interpreter. wasm
|
|
57
|
+
# llvm-objcopy can neither localize nor rename a symbol, so the names are rewritten in place
|
|
58
|
+
# at the same length, which leaves every offset in the archive and its objects valid (and
|
|
59
|
+
# keeps EMPTY_PANIC's v0 mangling well-formed). Nothing calls the first two: a static
|
|
60
|
+
# extension's ABI stamp is never looked up, and wasm32-wasip1 builds with panic=abort.
|
|
61
|
+
{
|
|
62
|
+
"rust_eh_personality" => "hypertab_eh_persona",
|
|
63
|
+
"ruby_abi_version" => "hypertabl_abiver",
|
|
64
|
+
"9panicking11EMPTY_PANIC" => "9panicking11HTABL_PANIC"
|
|
65
|
+
}.each { |from, to| archive.gsub!(from, to) }
|
|
66
|
+
File.binwrite(prebuilt, archive)
|
|
67
|
+
system("#{wasi_sdk}/bin/llvm-ranlib", prebuilt, exception: true)
|
|
68
|
+
# And a newer Rust that exports another of std's symbols fails here, by name, rather than in
|
|
69
|
+
# the link of an interpreter that also carries the HyperCast extension.
|
|
70
|
+
member = nil
|
|
71
|
+
exported = IO.popen(["#{wasi_sdk}/bin/llvm-nm", "--defined-only", "--extern-only", prebuilt], err: File::NULL, &:read)
|
|
72
|
+
.each_line(chomp: true).filter_map do |line|
|
|
73
|
+
if line.end_with?(".o:")
|
|
74
|
+
member = line
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
line.split.last if member&.start_with?("hypertabular-") && line.match?(/\A\h+ [A-Z] /)
|
|
78
|
+
end
|
|
79
|
+
shared = exported.grep(/\A(_R|_ZN|rust_|__rust|ruby_abi_version\z)/).grep_v(/HTABL_PANIC\z/)
|
|
80
|
+
shared.empty? or abort "hypertabular-wasm: the extension exports #{shared.join(", ")}, which " \
|
|
81
|
+
"another Rust extension in the same interpreter would define too"
|
|
82
|
+
if (out = ENV["HYPERTABULAR_WASM_OUT"])
|
|
83
|
+
FileUtils.mkdir_p(File.join(out, minor))
|
|
84
|
+
FileUtils.cp(prebuilt, File.join(out, minor, "hypertabular_native.a"))
|
|
85
|
+
end
|
|
86
|
+
else
|
|
87
|
+
gz = File.join(__dir__, minor, "hypertabular_native.a.gz")
|
|
88
|
+
unless File.exist?(gz)
|
|
89
|
+
shipped = Dir[File.join(__dir__, "*", "hypertabular_native.a.gz")].map { |f| File.basename(File.dirname(f)) }
|
|
90
|
+
abort "hypertabular-wasm: no prebuilt extension for Ruby #{minor} (this gem carries #{shipped.sort.join(", ")})"
|
|
91
|
+
end
|
|
92
|
+
File.binwrite(prebuilt, Zlib::GzipReader.open(gz, &:read))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# install-so is rbwasm's dynamic-linking path (a pic target, for the component model), which
|
|
96
|
+
# a static archive cannot serve.
|
|
97
|
+
File.write("Makefile", <<~MAKE)
|
|
98
|
+
all: static
|
|
99
|
+
static:
|
|
100
|
+
\tcp #{prebuilt} hypertabular_native.a
|
|
101
|
+
install-so:
|
|
102
|
+
\t@echo "hypertabular-wasm links statically: build a wasm32-unknown-wasip1 interpreter" >&2; exit 1
|
|
103
|
+
install-rb:
|
|
104
|
+
\t@true
|
|
105
|
+
clean:
|
|
106
|
+
\trm -f hypertabular_native.a
|
|
107
|
+
.PHONY: all static install-so install-rb clean
|
|
108
|
+
MAKE
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
module HyperTabular
|
|
2
|
+
# The rows one DelimitedReader#read or Sheet#read delivered, as typed columns: the same
|
|
3
|
+
# class for delimited text and for a sheet of a workbook.
|
|
4
|
+
#
|
|
5
|
+
# while (batch = reader.read)
|
|
6
|
+
# ids = batch.values(0) # a column at a time: [1, 2, nil, 4, ...]
|
|
7
|
+
# batch.rows.times do |row|
|
|
8
|
+
# case batch.get(2, row) # or a cell at a time, as HyperCast's union
|
|
9
|
+
# in HyperCast::Success(value:) then total += value
|
|
10
|
+
# in HyperCast::Fault(reason:) then warn "line #{batch.line(row)}: #{reason} in #{batch.raw(2, row).inspect}"
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# A batch owns what it shows — the arrays the core wrote are copied out of the reader's
|
|
16
|
+
# buffers as it is made — so it stays good after the reader has moved on. Values are
|
|
17
|
+
# HyperCast's gem's own for the same doors: true/false, Integer, Float, HyperCast::Decimal,
|
|
18
|
+
# a hyphenated UUID String, a UTC Time, Date, DateTime, Integer nanoseconds since midnight,
|
|
19
|
+
# Rational seconds, and a UTF-8 String for text.
|
|
20
|
+
class Batch
|
|
21
|
+
# The verdict of every cell that had no bytes at all: one shared, frozen Fault.
|
|
22
|
+
EMPTY = HyperCast::Fault.new(reason: :empty, offset: 0, length: 0)
|
|
23
|
+
|
|
24
|
+
# The doors whose column is one String#unpack directive away from its values: HyperCast's
|
|
25
|
+
# directive for one value, repeated down the column.
|
|
26
|
+
SCALARS = HyperCast::Interop::SCALARS.transform_values { |directive| "#{directive}*" }.freeze
|
|
27
|
+
|
|
28
|
+
# The doors whose value is a record: HyperCast's directive for one, how many fields it
|
|
29
|
+
# yields, and what builds the Ruby value HyperCast's own door returns from them.
|
|
30
|
+
RECORDS = HyperCast::Interop::RECORDS
|
|
31
|
+
|
|
32
|
+
# How many rows the batch holds — never zero.
|
|
33
|
+
attr_reader :rows
|
|
34
|
+
|
|
35
|
+
# The plan the batch was read through: column +i+ of the batch is +columns[i]+.
|
|
36
|
+
attr_reader :columns
|
|
37
|
+
|
|
38
|
+
# Made by a reader from what the core just wrote, copied: +values+ and +verdicts+ per
|
|
39
|
+
# plan column, the cell table (+per_row+ spans a row), what an unflagged span indexes
|
|
40
|
+
# (+base+, from +origin+) and what a flagged one does (+arena+).
|
|
41
|
+
def initialize(plan, rows, values, verdicts, cells, per_row, base, origin, arena, workbook:)
|
|
42
|
+
@columns = plan
|
|
43
|
+
@rows = rows
|
|
44
|
+
@bytes = values
|
|
45
|
+
@verdict_bytes = verdicts
|
|
46
|
+
@cells = cells
|
|
47
|
+
@per_row = per_row
|
|
48
|
+
@base = base
|
|
49
|
+
@origin = origin
|
|
50
|
+
@arena = arena.force_encoding(Encoding::UTF_8)
|
|
51
|
+
@workbook = workbook
|
|
52
|
+
@values = Array.new(plan.size)
|
|
53
|
+
@verdicts = Array.new(plan.size)
|
|
54
|
+
@faults = Array.new(plan.size)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Where row +row+ came from: for delimited text the 1-based line its record starts on,
|
|
58
|
+
# for a sheet its 1-based row number. IndexError for a row outside the batch.
|
|
59
|
+
def line(row)
|
|
60
|
+
offset, length = @cells.unpack("L<L<", offset: (at(row) * @per_row + @per_row - 1) * 8)
|
|
61
|
+
@workbook ? offset : length
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A column's values, one per row, as a frozen Array: the Ruby value HyperCast's gem
|
|
65
|
+
# returns from the same door, and nil for a cell that did not cast. Decoded in one pass
|
|
66
|
+
# the first time it is asked for.
|
|
67
|
+
def values(column)
|
|
68
|
+
@values[column] ||= decode(@columns.fetch(column), column)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A column's verdicts, one per row, as a frozen Array of HyperCast::Success and
|
|
72
|
+
# HyperCast::Fault. A Fault's span is in the units String#[] slices by on the cell's own
|
|
73
|
+
# text, as HyperCast's are: `raw(column, row)[offset, length]` is the offending text.
|
|
74
|
+
def verdicts(column)
|
|
75
|
+
@verdicts[column] ||= judge(column)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The cell at (+column+, +row+) as HyperCast judged it — made for `case`/`in`:
|
|
79
|
+
#
|
|
80
|
+
# case batch.get(0, row)
|
|
81
|
+
# in HyperCast::Success(value:) then value
|
|
82
|
+
# in HyperCast::Fault(reason: :empty) then nil
|
|
83
|
+
# in HyperCast::Fault(reason:, offset:, length:) then raise "#{reason} at #{offset}"
|
|
84
|
+
# end
|
|
85
|
+
#
|
|
86
|
+
# Only this cell's verdict is built. IndexError for a column outside the plan or a row
|
|
87
|
+
# outside the batch.
|
|
88
|
+
def get(column, row)
|
|
89
|
+
index = at(row)
|
|
90
|
+
judged = @verdicts[column]
|
|
91
|
+
return judged[index] if judged
|
|
92
|
+
|
|
93
|
+
cell(column, index, values(column), faults(column))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The text the cell at (+column+, +row+) was cast from, whatever its door and whatever
|
|
97
|
+
# its verdict — what a Fault's span indexes, and what to show for a value that did not
|
|
98
|
+
# cast. A UTF-8 String, quotes resolved. For a sheet this is a text cell's own text, and
|
|
99
|
+
# what a typed cell was said as when it failed its door (or went through the text door);
|
|
100
|
+
# a typed cell that cast has none. IndexError for a column outside the plan or a row
|
|
101
|
+
# outside the batch.
|
|
102
|
+
def raw(column, row)
|
|
103
|
+
plan = @columns.fetch(column)
|
|
104
|
+
entry = at(row) * @per_row + (@workbook ? column : plan.ordinal)
|
|
105
|
+
offset, length = @cells.unpack("L<L<", offset: entry * 8)
|
|
106
|
+
return slice(offset, length) if @workbook || length < Runtime::Delimited::SPAN_FLAG
|
|
107
|
+
|
|
108
|
+
# A quoted cell with an escaped quote in it: unescaped, as the core cast it.
|
|
109
|
+
quoted = @base.byteslice(@origin + offset, length & Runtime::Delimited::SPAN_LENGTH)
|
|
110
|
+
Runtime.unescape(quoted).force_encoding(Encoding::UTF_8)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The batch in a line — not its cells.
|
|
114
|
+
def inspect
|
|
115
|
+
"#<#{self.class.name} rows=#{@rows} columns=#{@columns.size}>"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# The bytes a span names: in the arena when it is flagged, in the base when not.
|
|
121
|
+
def slice(offset, length)
|
|
122
|
+
return @arena.byteslice(offset, length & Runtime::Delimited::SPAN_LENGTH) if length >= Runtime::Delimited::SPAN_FLAG
|
|
123
|
+
|
|
124
|
+
@base.byteslice(@origin + offset, length)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# +row+ as an index into the batch, or an IndexError.
|
|
128
|
+
def at(row)
|
|
129
|
+
return row if row.is_a?(Integer) && row >= 0 && row < @rows
|
|
130
|
+
|
|
131
|
+
raise IndexError, "row #{row.inspect} is outside the batch (#{@rows} rows)"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A column's verdict array as the core wrote it — offset, length, reason per row, flat
|
|
135
|
+
# — or nil when every cell cast, which one pass over the bytes answers without
|
|
136
|
+
# unpacking anything.
|
|
137
|
+
def faults(index)
|
|
138
|
+
flat = @faults[index]
|
|
139
|
+
if flat.nil?
|
|
140
|
+
bytes = @verdict_bytes.fetch(index)
|
|
141
|
+
flat = @faults[index] = bytes.count("\0") == bytes.bytesize ? false : bytes.unpack("L<*")
|
|
142
|
+
end
|
|
143
|
+
flat || nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A column's values out of its array: one String#unpack over the whole column, then the
|
|
147
|
+
# door's Ruby type built for each row that cast.
|
|
148
|
+
def decode(column, index)
|
|
149
|
+
door = column.door
|
|
150
|
+
bytes = @bytes.fetch(index)
|
|
151
|
+
faults = faults(index)
|
|
152
|
+
if (directive = SCALARS[door])
|
|
153
|
+
values = bytes.unpack(directive)
|
|
154
|
+
values.map! { |byte| byte != 0 } if door == :bool
|
|
155
|
+
@rows.times { |row| values[row] = nil unless faults[row * 3 + 2].zero? } if faults
|
|
156
|
+
elsif door == :text
|
|
157
|
+
values = texts(bytes.unpack("L<*"), faults)
|
|
158
|
+
else
|
|
159
|
+
directive, width, build = RECORDS.fetch(door)
|
|
160
|
+
fields = bytes.unpack(directive * @rows)
|
|
161
|
+
values = Array.new(@rows) do |row|
|
|
162
|
+
build.call(fields, row * width) if faults.nil? || faults[row * 3 + 2].zero?
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
values.freeze
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A text column's values: each span sliced out of the input or the shared strings, or —
|
|
169
|
+
# flagged — out of the arena.
|
|
170
|
+
def texts(spans, faults)
|
|
171
|
+
Array.new(@rows) do |row|
|
|
172
|
+
slice(spans[row * 2], spans[row * 2 + 1]) if faults.nil? || faults[row * 3 + 2].zero?
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# A column's verdicts: a Success around each value, a Fault where the core says so.
|
|
177
|
+
def judge(column)
|
|
178
|
+
values = values(column)
|
|
179
|
+
faults = faults(column)
|
|
180
|
+
return values.map { |value| HyperCast::Success.new(value: value) }.freeze if faults.nil?
|
|
181
|
+
|
|
182
|
+
Array.new(@rows) { |row| cell(column, row, values, faults) }.freeze
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# One cell's verdict, from its column's values and the core's verdict array.
|
|
186
|
+
def cell(column, row, values, faults)
|
|
187
|
+
reason = faults.nil? ? 0 : faults[row * 3 + 2]
|
|
188
|
+
return HyperCast::Success.new(value: values[row]) if reason.zero?
|
|
189
|
+
|
|
190
|
+
fault(column, row, reason, faults[row * 3], faults[row * 3 + 1])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# One Fault, its span moved from the bytes the core counts in to the characters
|
|
194
|
+
# String#[] slices by — an identity unless the cell's text has a multi-byte character.
|
|
195
|
+
def fault(column, row, reason, offset, length)
|
|
196
|
+
return EMPTY if reason == 1 && offset.zero? && length.zero?
|
|
197
|
+
|
|
198
|
+
unless offset.zero? && length.zero?
|
|
199
|
+
offset, length = HyperCast::Interop.characters(raw(column, row), offset, length)
|
|
200
|
+
end
|
|
201
|
+
HyperCast::Interop.fault(reason, offset, length)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|