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 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
+ ```
@@ -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