hypertabular 0.7.0-aarch64-linux-musl

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.
@@ -0,0 +1,299 @@
1
+ module HyperTabular
2
+ module Runtime
3
+ # The core's workbook reader as objects: every byte of native memory a workbook read
4
+ # needs and every native call it makes — the Fiddle crossing behind Workbook and Sheet,
5
+ # which never see a pointer.
6
+ #
7
+ # A workbook call works in buffers the caller hands it (a Buffers block of seven pointer
8
+ # and size pairs), and when one is too small it says which and how large, having undone
9
+ # nothing: the buffer is grown with what it held kept, and the same call made again.
10
+ module Book
11
+ class << self
12
+ # For the specs: every buffer a workbook call works in starts with room for one
13
+ # element and no shared-strings bound is asked for, so every call that can stop and
14
+ # resume does — the grow-and-keep path exercised mid-part.
15
+ attr_accessor :stingy
16
+
17
+ # For the specs: how many times the window, the arena and the cell table were grown,
18
+ # as [window, arena, cells].
19
+ attr_accessor :grown
20
+ end
21
+ self.stingy = false
22
+ self.grown = [0, 0, 0]
23
+
24
+ OK = Delimited::OK
25
+ ERR_STRUCTURE = Delimited::ERR_STRUCTURE
26
+ ERR_ARENA = Delimited::ERR_ARENA
27
+ ERR_CELLS = Delimited::ERR_CELLS
28
+ # The window is too small to inflate and tokenize in.
29
+ ERR_WINDOW = -5
30
+
31
+ # The smallest window the core works in.
32
+ WINDOW_MIN = 64 * 1024
33
+ # Bytes in one span, and in one slot of the row a read assembles.
34
+ SPAN_BYTES = 8
35
+ SLOT_BYTES = 16
36
+ # Buffers: seven pointer and size pairs — the same block a delimited fill takes.
37
+ BUFFERS = Delimited::BUFFERS
38
+ BUFFERS_BYTES = Delimited::BUFFERS_BYTES
39
+ # Opened: format, epoch, strings_bytes, strings_count, needed, then Failure.
40
+ OPENED = "L<2Q<3L<2Q<2L<2".freeze
41
+ OPENED_BYTES = 64
42
+ # Filled: rows, consumed, arena_used, needed, then Failure.
43
+ FILLED = Delimited::FILLED
44
+ FILLED_BYTES = Delimited::FILLED_BYTES
45
+
46
+ # The workbook's tables as a sheet's calls are handed them: strings, the span of each,
47
+ # the kind of each cell format. Pointers and sizes.
48
+ Tables = Struct.new(:strings, :strings_len, :table, :table_len, :kinds, :kinds_len)
49
+
50
+ # The buffers a call to the core may ask to have grown: one set for a workbook while
51
+ # it opens, one for each sheet.
52
+ class Scratch
53
+ # The buffers, each a Fiddle::Pointer, and their sizes (in bytes, spans, slots).
54
+ attr_reader :window, :arena, :cells, :arena_cap, :cells_cap
55
+
56
+ def initialize(window, arena, cells, row)
57
+ @window_cap = window
58
+ @window = Runtime.buffer([window, 1].max)
59
+ @arena_cap = arena
60
+ @arena = Runtime.buffer([arena, 1].max)
61
+ @cells_cap = cells
62
+ @cells = Runtime.buffer([SPAN_BYTES * cells, 1].max)
63
+ @row_cap = row
64
+ @row = Runtime.buffer([SLOT_BYTES * row, 1].max)
65
+ @buffers = Runtime.buffer(BUFFERS_BYTES)
66
+ end
67
+
68
+ # This scratch as the core takes it, with the workbook's tables when a sheet is
69
+ # being read.
70
+ def buffers(tables = nil)
71
+ words = [@window.to_i, @window_cap, @arena.to_i, @arena_cap, @cells.to_i, @cells_cap, @row.to_i, @row_cap]
72
+ words.concat(tables ? tables.to_a : [0] * 6)
73
+ @buffers[0, BUFFERS_BYTES] = words.pack(BUFFERS)
74
+ @buffers
75
+ end
76
+
77
+ # Makes the window at least +needed+ bytes, what it held kept.
78
+ def grow_window(needed)
79
+ @window, @window_cap = grown(@window, @window_cap, needed, 1)
80
+ end
81
+
82
+ # Makes the arena at least +needed+ bytes, what it held kept.
83
+ def grow_arena(needed)
84
+ @arena, @arena_cap = grown(@arena, @arena_cap, needed, 1)
85
+ end
86
+
87
+ # Makes the cell table at least +needed+ spans, what it held kept.
88
+ def grow_cells(needed)
89
+ @cells, @cells_cap = grown(@cells, @cells_cap, needed, SPAN_BYTES)
90
+ end
91
+
92
+ # Makes +call+ (a block taking the Buffers block and the out block) until it stops
93
+ # asking for room, growing the buffer it names each time. Returns the code it ended
94
+ # on and the Filled fields: rows, consumed, arena_used, needed, then the failure.
95
+ def drive(out, tables = nil)
96
+ loop do
97
+ code = yield buffers(tables)
98
+ filled = out[0, FILLED_BYTES].unpack(FILLED)
99
+ case code
100
+ when ERR_WINDOW then grow_window(filled[3]).then { Book.grown[0] += 1 }
101
+ when ERR_ARENA then grow_arena(filled[3]).then { Book.grown[1] += 1 }
102
+ when ERR_CELLS then grow_cells(filled[3]).then { Book.grown[2] += 1 }
103
+ else return [code, filled]
104
+ end
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def grown(old, capacity, needed, unit)
111
+ length = [needed, capacity + 1].max
112
+ larger = Runtime.buffer(unit * length)
113
+ larger[0, unit * capacity] = old[0, unit * capacity] if capacity.positive?
114
+ [larger, length]
115
+ end
116
+ end
117
+
118
+ # A workbook opened in memory: its state, its sheets, its tables.
119
+ class Opened
120
+ CALLS = {
121
+ sheets: :hypertabular_workbook_sheets, strings: :hypertabular_workbook_strings,
122
+ styles: :hypertabular_workbook_styles
123
+ }.freeze
124
+
125
+ # :xlsx or :ods; the date system's code (1 for 1900, 2 for 1904); the sheets as
126
+ # [name, hidden, part, index]; the shared strings' bytes; the state template every
127
+ # sheet copies; and the tables a sheet's calls are handed.
128
+ attr_reader :format, :epoch, :sheets, :strings, :state, :tables, :container, :length
129
+
130
+ # Opens +container+, a String the workbook holds unmodified for its whole life. Raises
131
+ # the failure as [code, line, record, byte, expected, found] in a StructureError.
132
+ def initialize(container)
133
+ @container_string = container
134
+ @container = Runtime.pin(container)
135
+ @length = container.bytesize
136
+ @state_size = Runtime.function(:hypertabular_workbook_state_size).call
137
+ @state = Runtime.buffer(@state_size)
138
+ @out = Runtime.buffer([OPENED_BYTES, FILLED_BYTES].max)
139
+ scratch = Book.stingy ? Scratch.new(1, 1, 1, 0) : Scratch.new(WINDOW_MIN, 1024, 64, 0)
140
+ strings_bytes = open(scratch)
141
+ @sheets = listed(scratch)
142
+ load_tables(scratch, strings_bytes)
143
+ end
144
+
145
+ # A copy of the opened state, which the core allows: a sheet's read of its own.
146
+ def copy_of_state
147
+ copy = Runtime.buffer(@state_size)
148
+ copy[0, @state_size] = @state[0, @state_size]
149
+ copy
150
+ end
151
+
152
+ private
153
+
154
+ def open(scratch)
155
+ call = Runtime.function(:hypertabular_workbook_open)
156
+ loop do
157
+ code = call.call(@state, @container, @length, scratch.buffers, @out)
158
+ format, epoch, strings_bytes, _count, needed, *failure = @out[0, OPENED_BYTES].unpack(OPENED)
159
+ case code
160
+ when OK
161
+ @format = format == 2 ? :ods : :xlsx
162
+ @epoch = epoch
163
+ return strings_bytes
164
+ when ERR_WINDOW then scratch.grow_window(needed)
165
+ when ERR_ARENA then scratch.grow_arena(needed)
166
+ when ERR_STRUCTURE then raise StructureError, failure
167
+ else raise Delimited::CONTRACT
168
+ end
169
+ end
170
+ end
171
+
172
+ def run(scratch, name)
173
+ call = Runtime.function(CALLS.fetch(name))
174
+ code, filled = scratch.drive(@out) { |buffers| call.call(@state, @container, @length, buffers, @out) }
175
+ Book.settle(code, filled)
176
+ end
177
+
178
+ # The sheets the core listed: three spans each — name, part, then one whose offset's
179
+ # low bit says hidden and whose length is the sheet's index.
180
+ def listed(scratch)
181
+ rows = run(scratch, :sheets).first
182
+ spans = scratch.cells[0, SPAN_BYTES * 3 * rows].unpack("L<*")
183
+ arena = scratch.arena[0, scratch.arena_cap]
184
+ Array.new(rows) do |index|
185
+ name_at, name_len, part_at, part_len, flags, sheet = spans[index * 6, 6]
186
+ [arena.byteslice(name_at, name_len).force_encoding(Encoding::UTF_8).freeze, flags.odd?,
187
+ arena.byteslice(part_at, part_len), sheet]
188
+ end.freeze
189
+ end
190
+
191
+ def load_tables(scratch, strings_bytes)
192
+ # The shared strings take no more room than their part inflates to; asking for it
193
+ # once saves growing into it.
194
+ bound = [strings_bytes, 1 << 28].min
195
+ scratch.grow_arena(bound) if !Book.stingy && scratch.arena_cap < bound
196
+ rows, _consumed, arena_used = run(scratch, :strings)
197
+ @strings = scratch.arena[0, arena_used].force_encoding(Encoding::UTF_8).freeze
198
+ @strings_buffer = copy(scratch.arena, arena_used)
199
+ @table = copy(scratch.cells, SPAN_BYTES * rows)
200
+ table_len = rows
201
+ kinds_len = run(scratch, :styles).first
202
+ @kinds = copy(scratch.arena, kinds_len)
203
+ @tables = Tables.new(@strings_buffer.to_i, arena_used, @table.to_i, table_len, @kinds.to_i, kinds_len)
204
+ end
205
+
206
+ def copy(from, bytes)
207
+ buffer = Runtime.buffer([bytes, 1].max)
208
+ buffer[0, bytes] = from[0, bytes] if bytes.positive?
209
+ buffer
210
+ end
211
+ end
212
+
213
+ # One sheet being read: its own copy of the state, its plan's arrays, its scratch.
214
+ class Reading
215
+ # The plan's arrays, and the cell-table entries one row takes: one per plan column,
216
+ # and one for the row's number.
217
+ attr_reader :columns, :per_row, :header
218
+
219
+ def initialize(book, sheet, has_header, skip_empty_rows, specs, sizes, batch_rows, width)
220
+ @book = book
221
+ @state = book.copy_of_state
222
+ @columns = Columns.new(specs, sizes, batch_rows)
223
+ @per_row = specs.size + 1
224
+ @scratch = Book.stingy ? Scratch.new(1, 1, 1, width) : Scratch.new(0, 4096, batch_rows * @per_row, width)
225
+ @out = Runtime.buffer(FILLED_BYTES)
226
+ _name, _hidden, part, index = sheet
227
+ code = Runtime.function(:hypertabular_workbook_sheet).call(
228
+ @state, book.container, book.length, Runtime.pin(part), part.bytesize, index,
229
+ has_header ? 1 : 0, skip_empty_rows ? 1 : 0, @out
230
+ )
231
+ Book.settle(code, @out[0, FILLED_BYTES].unpack(FILLED))
232
+ @header = read_header if has_header
233
+ end
234
+
235
+ # The next batch's rows, its cell table, the arena as far as the core wrote into it
236
+ # (which is as far as any of the batch's spans reach), and the failure — [code, line,
237
+ # record, byte, expected, found] — that came with them, if one did: the caller raises
238
+ # it after them. The table and the arena are copies.
239
+ def fill
240
+ call = Runtime.function(:hypertabular_workbook_fill)
241
+ code, filled = @scratch.drive(@out, @book.tables) do |buffers|
242
+ call.call(@state, @book.container, @book.length, @columns.specs, @columns.table,
243
+ @columns.count, @columns.batch_rows, buffers, @out)
244
+ end
245
+ raise Delimited::CONTRACT unless [OK, ERR_STRUCTURE].include?(code)
246
+
247
+ rows, _consumed, arena_used = filled
248
+ [rows, @scratch.cells[0, SPAN_BYTES * @per_row * rows], @scratch.arena[0, arena_used],
249
+ code == OK ? nil : filled[4..]]
250
+ end
251
+
252
+ private
253
+
254
+ def read_header
255
+ call = Runtime.function(:hypertabular_workbook_header)
256
+ code, filled = @scratch.drive(@out, @book.tables) do |buffers|
257
+ call.call(@state, @book.container, @book.length, buffers, @out)
258
+ end
259
+ rows = Book.settle(code, filled).first
260
+ spans = @scratch.cells[0, SPAN_BYTES * rows].unpack("L<*")
261
+ arena = nil
262
+ Array.new(rows) do |index|
263
+ offset = spans[index * 2]
264
+ length = spans[index * 2 + 1]
265
+ name =
266
+ if length < Delimited::SPAN_FLAG
267
+ @book.strings.byteslice(offset, length)
268
+ else
269
+ arena ||= @scratch.arena[0, @scratch.arena_cap].force_encoding(Encoding::UTF_8)
270
+ arena.byteslice(offset, length & Delimited::SPAN_LENGTH)
271
+ end
272
+ name.freeze
273
+ end.freeze
274
+ end
275
+ end
276
+
277
+ # A structural failure the core reported, as [code, line, record, byte, expected,
278
+ # found], on its way to becoming a TabularError.
279
+ class StructureError < StandardError
280
+ attr_reader :failure
281
+
282
+ def initialize(failure)
283
+ @failure = failure
284
+ super("structural failure #{failure.first}")
285
+ end
286
+ end
287
+
288
+ # What a call's code means: the Filled fields, or a structural failure. Anything else
289
+ # is this binding's bug.
290
+ def self.settle(code, filled)
291
+ case code
292
+ when OK then filled
293
+ when ERR_STRUCTURE then raise StructureError, filled[4..]
294
+ else raise Delimited::CONTRACT
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,142 @@
1
+ # Autoloaded, not required, as in hypercast: Fiddle loads the first time the backend runs,
2
+ # and every path there goes through `functions` first, so a missing library is reported as
3
+ # missing_library_message rather than as whatever touching Fiddle raises first.
4
+ autoload :Fiddle, "fiddle"
5
+
6
+ module HyperTabular
7
+ # Fiddle plumbing for the native libhypertabular shared library — dlopen/dlsym plus raw
8
+ # C-ABI calls, no runtime bridge. A gem's files are plain files on disk once installed, so
9
+ # native/{rid}/{lib} is dlopen'ed directly — no extraction.
10
+ module Runtime
11
+ NATIVE_DIR = File.join(__dir__, "native")
12
+
13
+ # Every export of the library (rust/src/kernel/exports.rs), with its C signature spelled
14
+ # in Symbols — resolved to Fiddle types only in load_functions, so nothing here touches
15
+ # Fiddle until the backend actually runs.
16
+ EXPORTS = {
17
+ hypertabular_version: [[], :uint32],
18
+ hypertabular_delimited_state_size: [[], :size],
19
+ # (state, dialect)
20
+ hypertabular_delimited_init: [%i[pointer pointer], :int32],
21
+ # (state, input, input_len, last, names, names_cap, arena, arena_cap, out)
22
+ hypertabular_delimited_header:
23
+ [%i[pointer pointer size uint32 pointer size pointer size pointer], :int32],
24
+ # (state, input, input_len, last, specs, columns, column_count, max_rows, buffers, out) —
25
+ # buffers is the Buffers block the workbook calls take, of which a delimited fill reads
26
+ # only the arena and the cell table.
27
+ hypertabular_delimited_fill:
28
+ [%i[pointer pointer size uint32 pointer pointer size size pointer pointer], :int32],
29
+ # (cell, len, out, cap)
30
+ hypertabular_delimited_unescape: [%i[pointer size pointer size], :size],
31
+ hypertabular_workbook_state_size: [[], :size],
32
+ # (state, container, container_len, buffers, out)
33
+ hypertabular_workbook_open: [%i[pointer pointer size pointer pointer], :int32],
34
+ hypertabular_workbook_sheets: [%i[pointer pointer size pointer pointer], :int32],
35
+ hypertabular_workbook_strings: [%i[pointer pointer size pointer pointer], :int32],
36
+ hypertabular_workbook_styles: [%i[pointer pointer size pointer pointer], :int32],
37
+ # (state, container, container_len, part, part_len, index, has_header, skip_empty_rows, out)
38
+ hypertabular_workbook_sheet:
39
+ [%i[pointer pointer size pointer size uint32 uint32 uint32 pointer], :int32],
40
+ hypertabular_workbook_header: [%i[pointer pointer size pointer pointer], :int32],
41
+ # (state, container, container_len, specs, columns, column_count, max_rows, buffers, out)
42
+ hypertabular_workbook_fill: [%i[pointer pointer size pointer pointer size size pointer pointer], :int32]
43
+ }.freeze
44
+
45
+ @mutex = Mutex.new
46
+ @functions = nil
47
+
48
+ class << self
49
+ # The export's Fiddle::Function, for the caller to invoke directly.
50
+ def function(symbol)
51
+ functions.fetch(symbol)
52
+ end
53
+
54
+ # Every native allocation goes through here, and loads the library first: that is what
55
+ # keeps a missing library reported as missing_library_message instead of as whatever
56
+ # touching Fiddle raises first. Zeroed, freed with the object that holds it.
57
+ def buffer(size)
58
+ functions
59
+ Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE)
60
+ end
61
+
62
+ # A pointer to a String's own bytes — nothing is copied. While the returned object is
63
+ # alive the String is held where it is: Fiddle marks what it wraps as unmovable, so
64
+ # the garbage collector's compaction cannot relocate an embedded String under a native
65
+ # call. The address is good until the String is next modified.
66
+ def pin(string)
67
+ functions
68
+ Fiddle::Pointer[string]
69
+ end
70
+
71
+ # A quoted delimited cell — the bytes a flagged cell-table entry names — with its
72
+ # quotes resolved, as the core cast it.
73
+ def unescape(quoted)
74
+ input = pin(quoted)
75
+ out = buffer([quoted.bytesize, 1].max)
76
+ written = function(:hypertabular_delimited_unescape).call(input, quoted.bytesize, out, quoted.bytesize)
77
+ out[0, written]
78
+ end
79
+
80
+ private
81
+
82
+ # The shared library to dlopen: this install's native/{rid}/{lib}, or — the
83
+ # development loop — the in-repo cargo build, exactly what the other bindings' local
84
+ # staging does. Nil when neither exists.
85
+ def library_path
86
+ HyperCast::Interop.library_path("hypertabular", NATIVE_DIR, File.expand_path("../../..", __dir__))
87
+ end
88
+
89
+ # Why Fiddle found nothing to load. A precompiled platform gem is the one install where
90
+ # that is by design rather than a gap: it carries only its Magnus extensions, and the
91
+ # Fiddle backend is reached there only by forcing it (HYPERTABULAR_PURE) or because none
92
+ # of its extensions loaded — a gem RubyGems matched to a Ruby it was not built for. So
93
+ # that case names its fix, the universal gem, which carries every platform's library,
94
+ # instead of a missing path that reads like a packaging bug — hypercast's wording for
95
+ # the same case. Both arguments are parameters only so the specs can ask for every
96
+ # wording.
97
+ def missing_library_message(gem_platform = Gem.loaded_specs["hypertabular"]&.platform,
98
+ forced = ENV.key?("HYPERTABULAR_PURE"))
99
+ rid, lib_name = HyperCast::NativePlatform.rid_and_library_name(library: "hypertabular")
100
+ missing = File.join(NATIVE_DIR, rid, lib_name)
101
+ if gem_platform && gem_platform.to_s != Gem::Platform::RUBY
102
+ reason =
103
+ if forced
104
+ "HYPERTABULAR_PURE forces the Fiddle backend (unset it to use the extension)"
105
+ else
106
+ "none of its extensions loads on this Ruby (#{RUBY_VERSION}, #{RUBY_PLATFORM})"
107
+ end
108
+ "hypertabular: this #{gem_platform} platform gem carries only Magnus extensions, no " \
109
+ "Fiddle library, and #{reason}. The universal gem has the Fiddle backend for every " \
110
+ "platform: `gem install hypertabular --platform ruby`, or Bundler's " \
111
+ "force_ruby_platform (#{missing} not found)"
112
+ else
113
+ "hypertabular: #{missing} not found (unsupported platform, or this gem was built " \
114
+ "without a native library for it)"
115
+ end
116
+ end
117
+
118
+ # Loaded lazily and exactly once; the native library and its function pointers live
119
+ # for the process's lifetime (never dlclose'd). The unsynchronized read is the fast
120
+ # path; the benign race re-checks under the lock.
121
+ def functions
122
+ @functions || @mutex.synchronize { @functions ||= load_functions }
123
+ end
124
+
125
+ def load_functions
126
+ path = library_path
127
+ raise LoadError, missing_library_message if path.nil?
128
+
129
+ handle = Fiddle.dlopen(path)
130
+ types = {
131
+ pointer: Fiddle::TYPE_VOIDP, size: Fiddle::TYPE_SIZE_T,
132
+ uint32: Fiddle::TYPE_UINT32_T, int32: Fiddle::TYPE_INT32_T
133
+ }
134
+ EXPORTS.to_h do |name, (arguments, result)|
135
+ [name, Fiddle::Function.new(handle[name.to_s], arguments.map { |type|
136
+ types.fetch(type)
137
+ }, types.fetch(result))]
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,99 @@
1
+ module HyperTabular
2
+ # A structural failure: the input is not rows of cells — a record of the wrong width,
3
+ # input that ends inside a quoted cell, a workbook whose container or parts cannot be
4
+ # read. Never a cell's verdict: a value that does not cast is a HyperCast::Fault in its
5
+ # column, and the read goes on. A structural failure ends the input, after every intact
6
+ # row before it has been delivered.
7
+ #
8
+ # For a workbook, #record is the part the failure is in, #line the sheet row and #byte the
9
+ # offset within the part's inflated bytes.
10
+ #
11
+ # begin
12
+ # reader.read
13
+ # rescue HyperTabular::TabularError => e
14
+ # case e
15
+ # in { kind: :column_count, line:, expected:, found: }
16
+ # warn "line #{line}: #{found} cells, not #{expected}"
17
+ # in { kind: :unclosed_quote, line: }
18
+ # warn "line #{line}: a quote is never closed"
19
+ # end
20
+ # end
21
+ class TabularError < StandardError
22
+ # What the core's failure codes mean.
23
+ KINDS = {
24
+ 1 => :unclosed_quote, 2 => :column_count, 3 => :row_too_long, 16 => :not_a_zip, 17 => :container,
25
+ 18 => :encrypted, 19 => :method, 20 => :missing_part, 21 => :xml, 22 => :deflate,
26
+ 23 => :not_a_workbook, 24 => :shared_string, 25 => :too_large
27
+ }.freeze
28
+
29
+ # What is wrong: :unclosed_quote (the input ended inside a quoted cell), :column_count
30
+ # (a record's cell count disagrees with the first record's), :row_too_long (a single
31
+ # record is larger than DelimitedReader::MAX_ROW_BYTES); for a workbook :not_a_zip,
32
+ # :container (the zip's own structure is broken), :encrypted, :method (a part is
33
+ # compressed by neither stored nor deflate), :missing_part, :xml (a part ends inside an
34
+ # XML construct), :deflate (a part is not a whole deflate stream), :not_a_workbook,
35
+ # :shared_string (a cell names a shared string the table does not have) or :too_large.
36
+ attr_reader :kind
37
+
38
+ # Zero-based index of the offending record — the header and skipped blank lines
39
+ # included.
40
+ attr_reader :record
41
+
42
+ # One-based line the offending record starts on.
43
+ attr_reader :line
44
+
45
+ # Absolute byte offset of the offending record's start, a byte-order mark included.
46
+ attr_reader :byte
47
+
48
+ # Cells in the first record, for :column_count; 0 otherwise.
49
+ attr_reader :expected
50
+
51
+ # Cells in this record, for :column_count; 0 otherwise.
52
+ attr_reader :found
53
+
54
+ # The failure the core reported, as [code, line, record, byte, expected, found]. A code
55
+ # this binding does not know is the container's: the most general refusal.
56
+ def self.from(failure)
57
+ code, line, record, byte, expected, found = failure
58
+ new(KINDS.fetch(code, :container), record, line, byte, expected, found)
59
+ end
60
+
61
+ # Raised by the readers; nothing else builds one.
62
+ def initialize(kind, record, line, byte, expected = 0, found = 0)
63
+ @kind = kind
64
+ @record = record
65
+ @line = line
66
+ @byte = byte
67
+ @expected = expected
68
+ @found = found
69
+ super(describe)
70
+ end
71
+
72
+ # The failure as a Hash, which is what lets `case error in { kind:, line: }` match.
73
+ def deconstruct_keys(_keys)
74
+ { kind: kind, record: record, line: line, byte: byte, expected: expected, found: found }
75
+ end
76
+
77
+ private
78
+
79
+ # The message, in the words the other bindings use.
80
+ def describe
81
+ at = "#{record} (line #{line}, byte #{byte})"
82
+ case kind
83
+ when :column_count then "Record #{at} has #{found} cells; the first record had #{expected}."
84
+ when :unclosed_quote then "The input ended inside a quoted cell in record #{at}."
85
+ when :row_too_long then "Record #{at} exceeds the #{DelimitedReader::MAX_ROW_BYTES}-byte row ceiling."
86
+ when :not_a_zip then "The workbook is not a zip file."
87
+ when :encrypted then "The workbook is encrypted."
88
+ when :method then "Part #{record} of the workbook is compressed by method #{found}, neither stored nor deflate."
89
+ when :missing_part then "Part #{record}, which the workbook cannot be read without, is missing."
90
+ when :xml then "Part #{record} of the workbook ends inside an XML construct (byte #{byte})."
91
+ when :deflate then "Part #{record} of the workbook is not a whole deflate stream (byte #{byte})."
92
+ when :not_a_workbook then "The zip is neither an XLSX nor an ODS workbook."
93
+ when :shared_string then "Row #{line} names shared string #{found}; the table has #{expected}."
94
+ when :too_large then "The workbook holds more text than a batch can address."
95
+ else "The workbook's zip structure is broken."
96
+ end
97
+ end
98
+ end
99
+ end