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.
@@ -0,0 +1,204 @@
1
+ module HyperTabular
2
+ module Runtime
3
+ # The core's delimited reader as one object: every byte of native memory a read needs
4
+ # and every native call it makes. This class is the whole Fiddle crossing — the reader
5
+ # above it (DelimitedReader) is plain Ruby that never sees a pointer — and so it is the
6
+ # one thing a compiled extension would replace: same methods, bytes in and bytes out.
7
+ #
8
+ # The memory is allocated once and reused for every batch: the state block, the plan,
9
+ # one value array and one verdict array per column, the cell table, the arena. The core
10
+ # keeps none of it between calls beyond what it writes into the state block.
11
+ class Delimited
12
+ # The call did what it could; the result says how far it got.
13
+ OK = 0
14
+ # A caller bug, never a data verdict.
15
+ ERR_CONTRACT = -1
16
+ # The data is structurally broken; the failure says where.
17
+ ERR_STRUCTURE = -2
18
+ # The arena cannot hold what one row needs.
19
+ ERR_ARENA = -3
20
+ # The cell table (or the header's name table) cannot hold one row.
21
+ ERR_CELLS = -4
22
+
23
+ # The flag in the top bit of a span's length. On a cell-table entry: the cell has ""
24
+ # inside and has to be unescaped to be read. On a text value or a header name: the
25
+ # bytes are in the arena rather than in the input.
26
+ SPAN_FLAG = 1 << 31
27
+ # A span's length without its flag.
28
+ SPAN_LENGTH = SPAN_FLAG - 1
29
+
30
+ # Bytes in one span.
31
+ SPAN_BYTES = 8
32
+ # Filled: rows, consumed, arena_used, needed, then Failure (code, line, record, byte,
33
+ # expected, found).
34
+ FILLED_BYTES = 64
35
+ FILLED = "Q<4L<2Q<2L<2".freeze
36
+ # Buffers: seven pointer and size pairs (window, arena, cells, row, strings, table,
37
+ # kinds), as every workbook call takes them. A delimited fill reads the arena and the
38
+ # cell table from it and nothing else.
39
+ BUFFERS = "Q<14".freeze
40
+ BUFFERS_BYTES = 112
41
+
42
+ ARENA_BYTES = 4096
43
+ NAMES = 64
44
+
45
+ CONTRACT = "hypertabular: libhypertabular reported a contract violation — a binding bug, " \
46
+ "please report it".freeze
47
+
48
+ # What the last call wrote: rows (or header names), input bytes finished with, arena
49
+ # bytes written, and — after ERR_STRUCTURE — the failure as
50
+ # [code, line, record, byte, expected, found].
51
+ attr_reader :rows, :consumed, :arena_used, :failure
52
+
53
+ # The plan's arrays (Columns), and the cell-table entries one row takes: the widest
54
+ # ordinal the plan reads, plus two — or more, if the core asked for more.
55
+ attr_reader :columns, :per_row
56
+
57
+ # The loaded core's version word, major << 16 | minor << 8 | patch.
58
+ def self.version
59
+ Runtime.function(:hypertabular_version).call
60
+ end
61
+
62
+ # +dialect+ is the four bytes of a RawDialect; +specs+ one packed ColumnSpec per plan
63
+ # column and +sizes+ the bytes one value of each takes; +per_row+ the cell-table
64
+ # entries one row takes. Nil when the core refuses the dialect.
65
+ def self.start(dialect, specs, sizes, batch_rows, per_row)
66
+ reader = new(specs, sizes, batch_rows, per_row)
67
+ reader.send(:init, dialect) ? reader : nil
68
+ end
69
+
70
+ def initialize(specs, sizes, batch_rows, per_row)
71
+ @header = Runtime.function(:hypertabular_delimited_header)
72
+ @fill = Runtime.function(:hypertabular_delimited_fill)
73
+ @state = Runtime.buffer(Runtime.function(:hypertabular_delimited_state_size).call)
74
+ @batch_rows = batch_rows
75
+ @columns = Columns.new(specs, sizes, batch_rows)
76
+ @per_row = per_row
77
+ @cramped = false
78
+ @cells_cap = per_row * batch_rows
79
+ @cells = Runtime.buffer(SPAN_BYTES * @cells_cap)
80
+ @arena_cap = ARENA_BYTES
81
+ @arena = Runtime.buffer(@arena_cap)
82
+ @out = Runtime.buffer(FILLED_BYTES)
83
+ @buffers = Runtime.buffer(BUFFERS_BYTES)
84
+ @rows = @consumed = @arena_used = 0
85
+ end
86
+
87
+ # Names the String the calls that follow read: +start+ and +offset+ below index its
88
+ # bytes. It is held where it is, not copied, and must not be modified until another
89
+ # one is attached.
90
+ def attach(input)
91
+ @pin = Runtime.pin(input)
92
+ @base = @pin.to_i
93
+ end
94
+
95
+ # Reads the next record of the attached input, +length+ bytes from +start+, as a
96
+ # header. OK or ERR_STRUCTURE; +rows+ is then the number of names.
97
+ def header(start, length, last)
98
+ @names_cap ||= NAMES
99
+ @names ||= Runtime.buffer(SPAN_BYTES * @names_cap)
100
+ loop do
101
+ code = @header.call(@state, @base + start, length, last ? 1 : 0,
102
+ @names, @names_cap, @arena, @arena_cap, @out)
103
+ needed = finished
104
+ case code
105
+ when OK, ERR_STRUCTURE then return code
106
+ when ERR_CELLS
107
+ @names_cap = needed
108
+ @names = Runtime.buffer(SPAN_BYTES * @names_cap)
109
+ when ERR_ARENA then grow_arena(needed)
110
+ else raise CONTRACT
111
+ end
112
+ end
113
+ end
114
+
115
+ # The header's names as the core located them: offset and flagged length, a pair per
116
+ # name, offsets relative to the +start+ the header was read at (or into the arena,
117
+ # for a flagged one).
118
+ def names
119
+ @names[0, SPAN_BYTES * @rows].unpack("L<*")
120
+ end
121
+
122
+ # Fills every column from the attached input, +length+ bytes from +start+ — the one
123
+ # native call a batch makes. OK or ERR_STRUCTURE.
124
+ #
125
+ # The core ends a batch early when the arena fills, so a batch that came back short
126
+ # with the arena half used or more has the next one start with it doubled: escaped
127
+ # text costs a few batches, not one per row.
128
+ def fill(start, length, last)
129
+ grow_arena(@arena_cap * 2) if @cramped
130
+ @cramped = false
131
+ loop do
132
+ code = @fill.call(@state, @base + start, length, last ? 1 : 0,
133
+ @columns.specs, @columns.table, @columns.count, @batch_rows,
134
+ buffers, @out)
135
+ needed = finished
136
+ case code
137
+ when OK
138
+ @cramped = @rows.positive? && @rows < @batch_rows && @arena_used * 2 >= @arena_cap
139
+ return code
140
+ when ERR_STRUCTURE then return code
141
+ when ERR_CELLS
142
+ @per_row = [@per_row, needed].max
143
+ @cells_cap = @per_row * @batch_rows
144
+ @cells = Runtime.buffer(SPAN_BYTES * @cells_cap)
145
+ when ERR_ARENA then grow_arena(needed)
146
+ else raise CONTRACT
147
+ end
148
+ end
149
+ end
150
+
151
+ # The cell table for the batch in hand — #per_row spans a row — copied out.
152
+ def cells
153
+ @cells[0, SPAN_BYTES * @per_row * @rows]
154
+ end
155
+
156
+ # The arena as the last call left it: the unescaped text of every flagged span.
157
+ def arena
158
+ @arena[0, @arena_used]
159
+ end
160
+
161
+ # Records finished so far — the header and skipped blank lines included.
162
+ def records
163
+ @state[8, 8].unpack1("Q<")
164
+ end
165
+
166
+ # One-based line number of the next unread byte.
167
+ def line
168
+ @state[4, 4].unpack1("L<")
169
+ end
170
+
171
+ # Absolute byte offset of the next unread byte.
172
+ def offset
173
+ @state[16, 8].unpack1("Q<")
174
+ end
175
+
176
+ private
177
+
178
+ def init(dialect)
179
+ raw = Runtime.buffer(4)
180
+ raw[0, 4] = dialect
181
+ Runtime.function(:hypertabular_delimited_init).call(@state, raw) == OK
182
+ end
183
+
184
+ # The arena and the cell table as the Buffers block a fill takes them — packed again
185
+ # for every call, since either may have been grown since the last.
186
+ def buffers
187
+ @buffers[0, BUFFERS_BYTES] = [0, 0, @arena.to_i, @arena_cap, @cells.to_i, @cells_cap, 0, 0, 0, 0, 0, 0, 0, 0]
188
+ .pack(BUFFERS)
189
+ @buffers
190
+ end
191
+
192
+ # Reads what the call wrote to its Filled block; returns `needed`.
193
+ def finished
194
+ @rows, @consumed, @arena_used, needed, *@failure = @out[0, FILLED_BYTES].unpack(FILLED)
195
+ needed
196
+ end
197
+
198
+ def grow_arena(needed)
199
+ @arena_cap = [needed, @arena_cap * 2].max
200
+ @arena = Runtime.buffer(@arena_cap)
201
+ end
202
+ end
203
+ end
204
+ end
@@ -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