@tabnas/transduce 0.1.0 → 0.1.1

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.
package/src/lines.ts ADDED
@@ -0,0 +1,1247 @@
1
+ /* Copyright (c) 2026 tabnas, MIT License */
2
+
3
+ // `LinesSource`: JSON Lines and CSV a record (or a chunk of records) at a
4
+ // time, from a synchronous iterable of chunks or pushed chunk by chunk.
5
+ //
6
+ // The engine parses a whole string, so a document is retained at least
7
+ // once however it is consumed. Line-delimited formats do not need to be one
8
+ // document: JSON Lines is one value per line, and CSV is a header plus
9
+ // independent records, so each can be parsed a piece at a time with one
10
+ // reused parser and the memory a run needs stops depending on the input's
11
+ // size. Its events are exactly the whole-input parse's (the array of the
12
+ // per-line values, or of the records).
13
+ //
14
+ // Both formats cut the input where the grammar ends a record, reading the
15
+ // text as the grammar's own lexer does (with the line characters, quotes,
16
+ // separators and comments its parser's resolved options set), and nothing
17
+ // read is left unparsed but a blank JSON Lines record, which the grammar
18
+ // skips too.
19
+ //
20
+ // JSON Lines: each record is parsed with one `@tabnas/json` parser. A
21
+ // record is the text between line characters outside a string, so a lone
22
+ // `\r` ends one as `\n` does. A record of nothing but the grammar's spaces
23
+ // (a space or a tab) is blank and skipped; any other is parsed, so a line
24
+ // holding a form feed or a no-break space fails as the grammar fails it.
25
+ // On the incremental path (`runIncremental`, or a writer asked for it) the
26
+ // record goes through the rule-event adapter, reset per record, so numbers
27
+ // keep their lexemes; on the walking path (`run`) the value is walked and
28
+ // numbers carry none. A record that does not parse is `INPUT_INVALID` with
29
+ // its line's number as the row.
30
+ //
31
+ // CSV: the input is cut into chunks of whole records. A quote opens a
32
+ // quoted field only where the lexer starts a token (a record's start, after
33
+ // the field separator or a space), so a quote inside a field is that
34
+ // field's text; the configured quote reads `""` as one quote; a quoted
35
+ // field, a backtick string and a block comment may span lines; and a line
36
+ // character anywhere else, a lone `\r` as much as `\n`, ends a record. The
37
+ // header record, the first record the grammar reads (a blank or comment
38
+ // line before it is none), is kept and prepended to every chunk after the
39
+ // first when `header` is on, so the reused `@tabnas/csv` parser names each
40
+ // record's fields as the whole input would. A chunk closes at the first
41
+ // record boundary at or past `DEFAULT_CHUNK_BYTES` (or the configured
42
+ // size), so one chunk, and never a fraction of a record, is what a parse
43
+ // holds. An input that ends inside a quoted field fails with the grammar's
44
+ // `unterminated_string`, in the header as anywhere. CSV is walked on both
45
+ // paths.
46
+ //
47
+ // The input is read a piece at a time, and a piece ends just past each of
48
+ // the grammar's line endings: one of its line characters (a lone `\r` or a
49
+ // configured separator as much as `\n`), a `\r\n`, or under `record.empty`
50
+ // the whole line token the lexer reads. A line character inside one of the
51
+ // grammar's fixed tokens (a field separator such as `"\n~"`) is the token's
52
+ // and ends no piece. So a record never waits for a `\n` to end, and a chunk
53
+ // can close after any record, however many of them one `\n`-terminated line
54
+ // holds. Where the input so far ends inside what may be one line ending,
55
+ // the piece waits for the next chunk of it.
56
+ //
57
+ // Every count is in UTF-8 bytes of the input as it arrives. A single
58
+ // record larger than `max_record_bytes` (a record with its line ending; for
59
+ // the CSV header, everything up to its end) fails with that limit's name
60
+ // the moment it passes the limit, whatever its length, rather than growing
61
+ // a chunk without bound.
62
+
63
+ import { make as makeCsv } from '@tabnas/csv'
64
+ import { make as makeJson } from '@tabnas/json'
65
+
66
+ import { Fail, enginePosition } from './error'
67
+ import { Ev } from './event'
68
+ import { Guarded } from './guard'
69
+ import { AbortFlag, Limits, Metrics } from './limits'
70
+ import { Adapter, notStreamable, prepare } from './rule-events'
71
+ import { Flow, Sink } from './sink'
72
+ import { Prune, Source, engineFailure, walkValue } from './source'
73
+
74
+ // How much of the input one CSV parse holds, at most one record over.
75
+ export const DEFAULT_CHUNK_BYTES = 256 * 1024
76
+
77
+ // The line-delimited format to read: one JSON value per line (`jsonl`;
78
+ // blank lines are skipped), or CSV records (`csv`; `header` says whether
79
+ // the first record names the fields and overrides `options.header`; the
80
+ // other options are the grammar's, as `@tabnas/csv`'s `make` takes them).
81
+ export type LineFormat =
82
+ | { type: 'jsonl' }
83
+ | { type: 'csv'; header: boolean; options: Record<string, unknown> }
84
+
85
+ export const LineFormat = Object.freeze({
86
+ jsonl(): LineFormat {
87
+ return { type: 'jsonl' }
88
+ },
89
+ // CSV with a header line and the grammar's default options, or the given
90
+ // ones.
91
+ csv(header = true, options: Record<string, unknown> = {}): LineFormat {
92
+ return { type: 'csv', header, options }
93
+ },
94
+ })
95
+
96
+ // One chunk of input: text, or bytes that should be UTF-8.
97
+ export type LinesChunk = string | Uint8Array
98
+
99
+ // What a `LinesSource` reads: one text, one buffer, or a synchronous
100
+ // iterable of chunks (a generator, an array, a file read in pieces).
101
+ export type LinesInput = LinesChunk | Iterable<LinesChunk>
102
+
103
+ // The push form of a line source: hand it the input chunk by chunk with
104
+ // `write`, then call `end` once. Each returns `'stop'` once the sink has
105
+ // stopped (later calls do nothing) and throws a `Fail` on failure (later
106
+ // calls throw it again).
107
+ export interface LinesWriter {
108
+ write(chunk: LinesChunk): Flow
109
+ end(): Flow
110
+ }
111
+
112
+ // A line-delimited input as a source.
113
+ export class LinesSource implements Source {
114
+ private input: LinesInput | null
115
+ private format: LineFormat
116
+ private sourceLimits: Limits = Limits.default()
117
+ private abortFlag: AbortFlag = new AbortFlag()
118
+ private sourceMetrics: Metrics = new Metrics()
119
+ private chunkSize = DEFAULT_CHUNK_BYTES
120
+ private consumed = false
121
+
122
+ // `input` may be `null` for a source used only through `writer`.
123
+ constructor(input: LinesInput | null, format: LineFormat) {
124
+ this.input = input
125
+ this.format = format
126
+ }
127
+
128
+ limits(limits: Limits): this {
129
+ this.sourceLimits = limits
130
+ return this
131
+ }
132
+
133
+ abort(abort: AbortFlag): this {
134
+ this.abortFlag = abort
135
+ return this
136
+ }
137
+
138
+ metrics(metrics: Metrics): this {
139
+ this.sourceMetrics = metrics
140
+ return this
141
+ }
142
+
143
+ // The CSV chunk size; a chunk closes at the first record boundary at or
144
+ // past it. Zero closes a chunk at every record.
145
+ chunkBytes(bytes: number): this {
146
+ this.chunkSize = bytes
147
+ return this
148
+ }
149
+
150
+ // Both formats through the walk; JSON Lines numbers carry no lexeme.
151
+ run(sink: Sink): Flow {
152
+ return this.drive(this.writer(sink, false))
153
+ }
154
+
155
+ // JSON Lines through the rule-event adapter (lexemes kept), CSV through
156
+ // the walk.
157
+ runIncremental(sink: Sink): Flow {
158
+ return this.drive(this.writer(sink, true))
159
+ }
160
+
161
+ private drive(writer: LinesWriter): Flow {
162
+ if (this.consumed) {
163
+ throw new Fail('STREAM_REUSED', 'a LinesSource reads its input once')
164
+ }
165
+ if (null === this.input) {
166
+ throw new TypeError('LinesSource: no input; use writer() to push chunks')
167
+ }
168
+ const input = this.input
169
+ this.input = null
170
+ this.consumed = true
171
+ const chunks: Iterable<LinesChunk> =
172
+ 'string' === typeof input || input instanceof Uint8Array ? [input] : input
173
+ for (const chunk of chunks) {
174
+ if ('stop' === writer.write(chunk)) return 'stop'
175
+ }
176
+ return writer.end()
177
+ }
178
+
179
+ // A writer that pushes the input into `sink`, on the incremental path
180
+ // when `incremental` is true.
181
+ writer(sink: Sink, incremental = false): LinesWriter {
182
+ const options: DriverOptions = {
183
+ limits: this.sourceLimits,
184
+ abort: this.abortFlag,
185
+ metrics: this.sourceMetrics,
186
+ chunkBytes: this.chunkSize,
187
+ }
188
+ const driver =
189
+ 'jsonl' === this.format.type
190
+ ? incremental
191
+ ? new JsonlIncremental(sink, options)
192
+ : new JsonlWalk(sink, options)
193
+ : new CsvWalk(sink, options, this.format.header, this.format.options)
194
+ return new Writer(driver)
195
+ }
196
+ }
197
+
198
+ type DriverOptions = {
199
+ limits: Limits
200
+ abort: AbortFlag
201
+ metrics: Metrics
202
+ chunkBytes: number
203
+ }
204
+
205
+ // What a format does with the input: `begin` before the first byte, a piece
206
+ // at a time, then `finish` at the end. Each returns the flow or throws.
207
+ interface Driver {
208
+ splitter: PieceSplitter
209
+ begin(): Flow
210
+ piece(row: number, text: string, bytes: number): Flow
211
+ finish(): Flow
212
+ // Called once the run is over, however it ended.
213
+ close(): void
214
+ }
215
+
216
+ // The writer's state machine: a stop or a failure ends it for good.
217
+ class Writer implements LinesWriter {
218
+ private driver: Driver
219
+ private started = false
220
+ private state: 'open' | 'stopped' | 'ended' | 'failed' = 'open'
221
+ private failure: unknown = null
222
+ private onPiece: PieceFn
223
+
224
+ constructor(driver: Driver) {
225
+ this.driver = driver
226
+ this.onPiece = (row, text, bytes) => this.driver.piece(row, text, bytes)
227
+ }
228
+
229
+ private guard(step: () => Flow): Flow {
230
+ if ('stopped' === this.state) return 'stop'
231
+ if ('failed' === this.state) throw this.failure
232
+ if ('ended' === this.state) throw Fail.protocol('the line source has already ended')
233
+ try {
234
+ if (!this.started) {
235
+ this.started = true
236
+ if ('stop' === this.driver.begin()) return this.finish('stopped')
237
+ }
238
+ const flow = step()
239
+ if ('stop' === flow) return this.finish('stopped')
240
+ return flow
241
+ } catch (err) {
242
+ this.failure = err
243
+ this.finish('failed')
244
+ throw err
245
+ }
246
+ }
247
+
248
+ private finish(state: 'stopped' | 'ended' | 'failed'): Flow {
249
+ this.state = state
250
+ this.driver.close()
251
+ return 'stop'
252
+ }
253
+
254
+ write(chunk: LinesChunk): Flow {
255
+ return this.guard(() => {
256
+ const bytes = 'string' === typeof chunk ? Buffer.from(chunk, 'utf8') : chunk
257
+ return this.driver.splitter.feed(bytes, this.onPiece)
258
+ })
259
+ }
260
+
261
+ end(): Flow {
262
+ const flow = this.guard(() => {
263
+ if ('stop' === this.driver.splitter.end(this.onPiece)) return 'stop'
264
+ return this.driver.finish()
265
+ })
266
+ if ('open' === this.state) {
267
+ this.finish('ended')
268
+ return flow
269
+ }
270
+ return flow
271
+ }
272
+ }
273
+
274
+ // One piece of the input: the row it starts on, its text, and its length
275
+ // in UTF-8 bytes.
276
+ type PieceFn = (row: number, text: string, bytes: number) => Flow
277
+
278
+ const EMPTY = new Uint8Array(0)
279
+ const ENCODER = new TextEncoder()
280
+
281
+ // The characters of one of the engine's resolved character maps while it
282
+ // lexes their kind, else none.
283
+ function lexed(on: boolean, chars: Record<string, unknown> | undefined): string[] {
284
+ return on && chars ? Object.keys(chars) : []
285
+ }
286
+
287
+ // The input in pieces, each ending just past a line ending: one of the
288
+ // grammar's line characters, a `\r\n`, or under `line.single`
289
+ // (`record.empty`) the whole line token the lexer reads, every line
290
+ // character up to a repeated one. A line character inside one of the
291
+ // grammar's fixed tokens (a field separator such as `"\n~"`) is the token's
292
+ // and ends no piece, and where a fixed token holds one, a piece takes the
293
+ // whole run of line characters the lexer reads as one token, so that no
294
+ // piece starts inside a line token. A record therefore never ends inside a
295
+ // piece, only at its end. Each piece carries the row the engine gives its
296
+ // first character: one more than the row characters before it. Where the
297
+ // input so far ends inside what may be a line ending, the piece waits for
298
+ // more.
299
+ class PieceSplitter {
300
+ // The bytes of the piece being read, and any after it not yet taken.
301
+ private pending: Uint8Array = EMPTY
302
+ // How much of `pending` has been searched for a line ending.
303
+ private scanned = 0
304
+ // Once a line ending is found: where its token ends so far, and its
305
+ // characters.
306
+ private tokenEnd = -1
307
+ private token: string[] = []
308
+ // Where the next piece starts: its row, and its byte offset in it.
309
+ private row = 1
310
+ private offset = 0
311
+ private readonly line: string[]
312
+ private readonly forms: Uint8Array[]
313
+ private readonly ends = new Uint8Array(256)
314
+ private readonly single: boolean
315
+ // The fixed tokens that hold a line character, as UTF-8.
316
+ private readonly spanning: Uint8Array[]
317
+ private readonly rows: string[]
318
+ // How many more bytes the piece being read may take, and the failure
319
+ // when it takes more, given the row it starts on: the bound is the
320
+ // record's, so the format that reads the records sets both.
321
+ budget: () => number = () => Infinity
322
+ over: (row: number) => Fail = (row) => Fail.input(`line ${row} is too long`).at(row, 1)
323
+
324
+ constructor(line: string[], rows: string[], single: boolean, fixed: string[]) {
325
+ // The engine keeps a character outside the basic plane as its two
326
+ // UTF-16 halves, each a line character; the input holds it as one
327
+ // four-byte form, which ends a piece when both halves are line
328
+ // characters.
329
+ this.line = line.filter((c) => !isHigh(c) && !isLow(c))
330
+ for (const h of line.filter(isHigh)) {
331
+ for (const l of line.filter(isLow)) this.line.push(h + l)
332
+ }
333
+ this.forms = this.line.map((c) => ENCODER.encode(c))
334
+ for (const form of this.forms) this.ends[form[form.length - 1]] = 1
335
+ this.single = single
336
+ this.spanning = fixed
337
+ .filter((token) => Array.from({ length: token.length }, (_, i) => token[i]).some((c) => line.includes(c)))
338
+ .map((token) => ENCODER.encode(token))
339
+ this.rows = rows
340
+ }
341
+
342
+ // The row the next piece starts on.
343
+ get nextRow(): number {
344
+ return this.row
345
+ }
346
+
347
+ feed(bytes: Uint8Array, piece: PieceFn): Flow {
348
+ if (0 === bytes.length) return 'continue'
349
+ this.pending = 0 === this.pending.length ? bytes : Buffer.concat([this.pending, bytes])
350
+ return this.drain(piece, false)
351
+ }
352
+
353
+ end(piece: PieceFn): Flow {
354
+ if ('stop' === this.drain(piece, true)) return 'stop'
355
+ if (0 === this.pending.length) return 'continue'
356
+ const raw = this.pending
357
+ this.pending = EMPTY
358
+ this.scanned = 0
359
+ return this.emit(raw, piece)
360
+ }
361
+
362
+ private drain(piece: PieceFn, atEnd: boolean): Flow {
363
+ for (;;) {
364
+ if (this.tokenEnd < 0 && !this.findEnding(atEnd)) {
365
+ if (this.pending.length > this.budget()) throw this.over(this.row)
366
+ return 'continue'
367
+ }
368
+ if (!this.followToken(atEnd)) return 'continue'
369
+ const raw = this.pending.subarray(0, this.tokenEnd)
370
+ this.pending = this.pending.subarray(this.tokenEnd)
371
+ this.scanned = 0
372
+ this.tokenEnd = -1
373
+ this.token = []
374
+ if ('stop' === this.emit(raw, piece)) return 'stop'
375
+ }
376
+ }
377
+
378
+ // Finds the first line ending past `scanned`, checked against the line
379
+ // character's whole UTF-8 form, and starts its token. False while the
380
+ // input so far holds none, or cannot yet tell where one ends.
381
+ private findEnding(atEnd: boolean): boolean {
382
+ const p = this.pending
383
+ for (let i = this.scanned; i < p.length; i++) {
384
+ if (!this.ends[p[i]]) continue
385
+ const k = this.formEnding(i + 1)
386
+ if (k < 0) continue
387
+ const at = i + 1 - this.forms[k].length
388
+ const inside = this.inFixed(at, k, atEnd)
389
+ if (true === inside) continue
390
+ if (null === inside) {
391
+ this.scanned = at
392
+ return false
393
+ }
394
+ this.tokenEnd = i + 1
395
+ this.token = [this.line[k]]
396
+ if (this.tokenEnd > this.budget()) throw this.over(this.row)
397
+ return true
398
+ }
399
+ this.scanned = p.length
400
+ return false
401
+ }
402
+
403
+ // Whether the `k`th line character, at `at`, lies inside one of the fixed
404
+ // tokens that hold a line character; null while the input so far cannot
405
+ // tell.
406
+ private inFixed(at: number, k: number, atEnd: boolean): boolean | null {
407
+ const p = this.pending
408
+ const form = this.forms[k]
409
+ let unsure = false
410
+ for (const token of this.spanning) {
411
+ for (let j = 0; j + form.length <= token.length; j++) {
412
+ if (!sameBytes(token, j, form, 0, form.length)) continue
413
+ const start = at - j
414
+ if (start < 0 || !sameBytes(p, start, token, 0, j)) continue
415
+ const after = at + form.length
416
+ const rest = token.length - j - form.length
417
+ const have = Math.min(rest, p.length - after)
418
+ if (!sameBytes(p, after, token, j + form.length, have)) continue
419
+ if (have === rest) return true
420
+ if (!atEnd) unsure = true
421
+ }
422
+ }
423
+ return unsure ? null : false
424
+ }
425
+
426
+ // The line character whose UTF-8 form `pending` holds just before `end`,
427
+ // by its index, or -1.
428
+ private formEnding(end: number): number {
429
+ const p = this.pending
430
+ next: for (let k = 0; k < this.forms.length; k++) {
431
+ const form = this.forms[k]
432
+ if (end < form.length) continue
433
+ for (let j = 0; j < form.length; j++) {
434
+ if (p[end - form.length + j] !== form[j]) continue next
435
+ }
436
+ return k
437
+ }
438
+ return -1
439
+ }
440
+
441
+ // Takes the rest of the line token the piece ends with: under
442
+ // `line.single`, every line character not yet in it, as the lexer reads
443
+ // one; where a fixed token holds a line character, every line character,
444
+ // as the lexer reads a run; otherwise a `\n` after a `\r`, so that `\r\n`
445
+ // is one line ending and a record is held to its own line. False while
446
+ // the input so far cannot tell whether the token goes on.
447
+ private followToken(atEnd: boolean): boolean {
448
+ const p = this.pending
449
+ for (;;) {
450
+ let next = -1
451
+ let partial = false
452
+ for (let k = 0; k < this.line.length && next < 0; k++) {
453
+ const c = this.line[k]
454
+ const goesOn = this.single
455
+ ? !this.token.includes(c)
456
+ : 0 < this.spanning.length || (1 === this.token.length && '\r' === this.token[0] && '\n' === c)
457
+ if (!goesOn) continue
458
+ const form = this.forms[k]
459
+ const have = p.length - this.tokenEnd
460
+ let prefix = true
461
+ for (let j = 0; j < Math.min(have, form.length); j++) {
462
+ if (p[this.tokenEnd + j] !== form[j]) {
463
+ prefix = false
464
+ break
465
+ }
466
+ }
467
+ if (!prefix) continue
468
+ if (have >= form.length) next = k
469
+ else partial = true
470
+ }
471
+ if (0 <= next) {
472
+ this.tokenEnd += this.forms[next].length
473
+ this.token.push(this.line[next])
474
+ if (this.tokenEnd > this.budget()) throw this.over(this.row)
475
+ continue
476
+ }
477
+ return !partial || atEnd
478
+ }
479
+ }
480
+
481
+ private emit(raw: Uint8Array, piece: PieceFn): Flow {
482
+ const row = this.row
483
+ const bad = utf8Invalid(raw)
484
+ if (0 <= bad) {
485
+ const valid = DECODER.decode(raw.subarray(0, bad))
486
+ const [badRow, offset] = advance(this.rows, this.row, this.offset, valid)
487
+ throw Fail.input(`line ${badRow} is not UTF-8 from its byte ${offset + 1}`).at(
488
+ badRow,
489
+ offset + 1,
490
+ )
491
+ }
492
+ const text = DECODER.decode(raw)
493
+ ;[this.row, this.offset] = advance(this.rows, this.row, this.offset, text)
494
+ return piece(row, text, raw.length)
495
+ }
496
+ }
497
+
498
+ // The row, and the byte offset in it, just after `text`, which starts at
499
+ // `row` and `offset`: a row character starts the next row. The engine reads
500
+ // a UTF-16 code unit at a time, so a row character outside the basic plane
501
+ // is its two halves, and counts as both.
502
+ function advance(rows: string[], row: number, offset: number, text: string): [number, number] {
503
+ for (let i = 0; i < text.length; i++) {
504
+ const c = text[i]
505
+ if (rows.includes(c)) {
506
+ row++
507
+ offset = 0
508
+ } else {
509
+ // A character outside the basic plane is four bytes, counted at its
510
+ // first half.
511
+ const u = c.charCodeAt(0)
512
+ offset += u < 0x80 ? 1 : u < 0x800 ? 2 : isHigh(c) ? 4 : isLow(c) ? 0 : 3
513
+ }
514
+ }
515
+ return [row, offset]
516
+ }
517
+
518
+ // Whether one UTF-16 code unit is the first or the second half of a
519
+ // character outside the basic plane.
520
+ function isHigh(c: string): boolean {
521
+ const u = c.charCodeAt(0)
522
+ return 1 === c.length && 0xd800 <= u && u < 0xdc00
523
+ }
524
+
525
+ function isLow(c: string): boolean {
526
+ const u = c.charCodeAt(0)
527
+ return 1 === c.length && 0xdc00 <= u && u < 0xe000
528
+ }
529
+
530
+ // Whether `n` bytes of `a` from `ai` are those of `b` from `bi`.
531
+ function sameBytes(a: Uint8Array, ai: number, b: Uint8Array, bi: number, n: number): boolean {
532
+ if (ai + n > a.length || bi + n > b.length) return false
533
+ for (let j = 0; j < n; j++) if (a[ai + j] !== b[bi + j]) return false
534
+ return true
535
+ }
536
+
537
+ // The sources of a parser's fixed tokens, while it lexes them.
538
+ function fixedTokens(config: any): string[] {
539
+ return config.fixed.lex
540
+ ? Object.values(config.fixed.token as Record<string, number>)
541
+ .map((tin) => config.fixed.ref[tin])
542
+ .filter((src: unknown): src is string => 'string' === typeof src && 0 < src.length)
543
+ : []
544
+ }
545
+
546
+ const DECODER = new TextDecoder('utf-8', { ignoreBOM: true })
547
+
548
+ // The index of the first byte of the first invalid UTF-8 sequence, or -1.
549
+ function utf8Invalid(b: Uint8Array): number {
550
+ let i = 0
551
+ const n = b.length
552
+ while (i < n) {
553
+ const c = b[i]
554
+ if (c < 0x80) {
555
+ i++
556
+ continue
557
+ }
558
+ let need: number
559
+ let min: number
560
+ if (c >= 0xc2 && c <= 0xdf) {
561
+ need = 1
562
+ min = 0x80
563
+ } else if (c >= 0xe0 && c <= 0xef) {
564
+ need = 2
565
+ min = 0x800
566
+ } else if (c >= 0xf0 && c <= 0xf4) {
567
+ need = 3
568
+ min = 0x10000
569
+ } else {
570
+ return i
571
+ }
572
+ if (i + need >= n) return i
573
+ let cp = c & (0xff >> (need + 2))
574
+ for (let k = 1; k <= need; k++) {
575
+ const d = b[i + k]
576
+ if (undefined === d || (d & 0xc0) !== 0x80) return i
577
+ cp = (cp << 6) | (d & 0x3f)
578
+ }
579
+ if (cp < min || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) return i
580
+ i += need + 1
581
+ }
582
+ return -1
583
+ }
584
+
585
+ // An engine error in the record or chunk that starts on row `start`, at
586
+ // row `line` of the input. An abort names `start` and no column: it lands
587
+ // between two of the engine's steps, where the engine often holds no token
588
+ // to place it at and falls back to 1:1 of the text it was parsing, so its
589
+ // own position says nothing.
590
+ function lineFailure(error: unknown, line: number, start: number, abort: AbortFlag): Fail {
591
+ const fail = engineFailure(error, abort)
592
+ if ('ABORTED' === fail.code) {
593
+ fail.row = start
594
+ } else {
595
+ fail.row = line
596
+ fail.col = enginePosition(error)?.col ?? 0
597
+ }
598
+ return fail
599
+ }
600
+
601
+ // A failure while the record or chunk that starts on row `start` was read:
602
+ // an abort that names no row names that one.
603
+ function atRecord(error: unknown, start: number): unknown {
604
+ if (error instanceof Fail && 'ABORTED' === error.code && undefined === error.row) {
605
+ error.row = start
606
+ }
607
+ return error
608
+ }
609
+
610
+ // JSON Lines records from the input's pieces. A record ends at a line
611
+ // character outside a string, as the grammar's lexer ends one: a line
612
+ // character inside a string is the string's, which the grammar refuses
613
+ // there, so the record goes on into the next piece and fails whole as the
614
+ // grammar fails it. A record of nothing but the grammar's spaces is blank
615
+ // and skipped.
616
+ class JsonRecords {
617
+ readonly line: string[]
618
+ readonly rows: string[]
619
+ readonly single: boolean
620
+ readonly fixed: string[]
621
+ private readonly space: string[]
622
+ private readonly quotes: string[]
623
+ private readonly escape: string
624
+ private readonly maxBytes: number
625
+
626
+ // The record being read: its text and bytes so far, the row it starts on
627
+ // (0 before its first piece), and whether a string is open in it.
628
+ private text = ''
629
+ private bytes = 0
630
+ private start = 0
631
+ private quote: string | null = null
632
+ private escaped = false
633
+
634
+ constructor(parser: any, maxBytes: number) {
635
+ const config = parser.internal().config
636
+ this.line = lexed(config.line.lex, config.line.chars)
637
+ this.rows = Object.keys(config.line.rowChars ?? {})
638
+ this.single = !!config.line.single
639
+ this.fixed = fixedTokens(config)
640
+ this.space = lexed(config.space.lex, config.space.chars)
641
+ this.quotes = lexed(config.string.lex, config.string.quoteMap)
642
+ this.escape = config.string.escChar ?? '\\'
643
+ this.maxBytes = maxBytes
644
+ }
645
+
646
+ // A splitter for this grammar's line endings, bounded by the record.
647
+ splitter(): PieceSplitter {
648
+ const splitter = new PieceSplitter(this.line, this.rows, this.single, this.fixed)
649
+ splitter.budget = () => this.maxBytes - this.bytes
650
+ splitter.over = (row) => {
651
+ const first = this.start || row
652
+ return Fail.limit(
653
+ 'max_record_bytes',
654
+ this.maxBytes,
655
+ `line ${first} is longer than ${this.maxBytes} bytes`,
656
+ ).at(first, 1)
657
+ }
658
+ return splitter
659
+ }
660
+
661
+ // Reads one piece, and hands `record` each record it ends that is not
662
+ // blank, with the row it starts on.
663
+ piece(row: number, text: string, bytes: number, record: (row: number, text: string) => Flow): Flow {
664
+ if (0 === this.start) this.start = row
665
+ let end = -1
666
+ for (let i = 0; i < text.length; i++) {
667
+ const c = text[i]
668
+ if (null !== this.quote) {
669
+ if (this.escaped) this.escaped = false
670
+ else if (c === this.escape) this.escaped = true
671
+ else if (c === this.quote) this.quote = null
672
+ } else if (this.quotes.includes(c)) {
673
+ this.quote = c
674
+ } else if (this.line.includes(c)) {
675
+ end = i
676
+ break
677
+ }
678
+ }
679
+ if (end < 0) {
680
+ this.text += text
681
+ this.bytes += bytes
682
+ return 'continue'
683
+ }
684
+ return this.take(this.text + text.slice(0, end), record)
685
+ }
686
+
687
+ // The record the input ends inside, if any.
688
+ end(record: (row: number, text: string) => Flow): Flow {
689
+ if (0 === this.start) return 'continue'
690
+ return this.take(this.text, record)
691
+ }
692
+
693
+ private take(text: string, record: (row: number, text: string) => Flow): Flow {
694
+ const start = this.start
695
+ this.text = ''
696
+ this.bytes = 0
697
+ this.start = 0
698
+ this.quote = null
699
+ this.escaped = false
700
+ for (const c of text) {
701
+ if (!this.space.includes(c)) return record(start, text)
702
+ }
703
+ return 'continue'
704
+ }
705
+ }
706
+
707
+ // JSON Lines through the walk.
708
+ class JsonlWalk implements Driver {
709
+ splitter: PieceSplitter
710
+ private records: JsonRecords
711
+ private onRecord: (row: number, text: string) => Flow
712
+ private guarded: Guarded<Sink>
713
+ private parser: any
714
+ private abort: AbortFlag
715
+
716
+ constructor(sink: Sink, options: DriverOptions) {
717
+ this.guarded = new Guarded(sink, options.limits, options.abort, options.metrics)
718
+ this.abort = options.abort
719
+ this.parser = makeJson()
720
+ const abort = options.abort
721
+ prepare(this.parser, () => !abort.isAborted())
722
+ this.records = new JsonRecords(this.parser, options.limits.max_record_bytes)
723
+ this.splitter = this.records.splitter()
724
+ this.onRecord = (row, text) => this.record(row, text)
725
+ }
726
+
727
+ begin(): Flow {
728
+ return this.guarded.event(Ev.arrayStart)
729
+ }
730
+
731
+ piece(row: number, text: string, bytes: number): Flow {
732
+ return this.records.piece(row, text, bytes, this.onRecord)
733
+ }
734
+
735
+ private record(number: number, text: string): Flow {
736
+ let value: unknown
737
+ try {
738
+ value = this.parser.parse(text)
739
+ } catch (err) {
740
+ throw lineFailure(err, number, number, this.abort)
741
+ }
742
+ try {
743
+ return walkValue(value, this.guarded)
744
+ } catch (err) {
745
+ throw atRecord(err, number)
746
+ }
747
+ }
748
+
749
+ finish(): Flow {
750
+ if ('stop' === this.records.end(this.onRecord)) return 'stop'
751
+ if ('stop' === this.guarded.event(Ev.arrayEnd)) return 'stop'
752
+ return this.guarded.event(Ev.end)
753
+ }
754
+
755
+ close(): void {
756
+ this.guarded.flush()
757
+ }
758
+ }
759
+
760
+ // JSON Lines through the rule-event adapter: one parser, one subscriber,
761
+ // the adapter reset before each record.
762
+ class JsonlIncremental implements Driver {
763
+ splitter: PieceSplitter
764
+ private records: JsonRecords
765
+ private onRecord: (row: number, text: string) => Flow
766
+ private adapter: Adapter<Sink>
767
+ private parser: any
768
+ private abort: AbortFlag
769
+ // The record being read, which an abort the adapter raised names.
770
+ private reading = 0
771
+
772
+ constructor(sink: Sink, options: DriverOptions) {
773
+ const stop = new AbortFlag()
774
+ const abort = options.abort
775
+ this.abort = abort
776
+ const adapter = new Adapter(sink, options.limits, abort, options.metrics, Prune.never(), stop)
777
+ this.adapter = adapter
778
+ this.parser = makeJson()
779
+ prepare(
780
+ this.parser,
781
+ () => !abort.isAborted() && !stop.isAborted(),
782
+ (rule, done) => adapter.onDone(rule, done),
783
+ )
784
+ this.records = new JsonRecords(this.parser, options.limits.max_record_bytes)
785
+ this.splitter = this.records.splitter()
786
+ this.onRecord = (row, text) => this.record(row, text)
787
+ }
788
+
789
+ // The adapter's own outcome, when it stopped or failed inside a parse.
790
+ private status(): Flow | null {
791
+ const status = this.adapter.status
792
+ if ('failed' === status.type) {
793
+ throw 0 < this.reading ? atRecord(status.error, this.reading) : status.error
794
+ }
795
+ if ('stopped' === status.type) return 'stop'
796
+ return null
797
+ }
798
+
799
+ begin(): Flow {
800
+ return this.adapter.send(Ev.arrayStart)
801
+ }
802
+
803
+ piece(row: number, text: string, bytes: number): Flow {
804
+ return this.records.piece(row, text, bytes, this.onRecord)
805
+ }
806
+
807
+ private record(number: number, text: string): Flow {
808
+ this.reading = number
809
+ let parsed: { ok: true; value: unknown } | { ok: false; error: unknown }
810
+ try {
811
+ parsed = { ok: true, value: this.parser.parse(text) }
812
+ } catch (error) {
813
+ parsed = { ok: false, error }
814
+ }
815
+ const status = this.status()
816
+ if (null !== status) return status
817
+ const adapter = this.adapter
818
+ if (!parsed.ok) throw lineFailure(parsed.error, number, number, this.abort)
819
+ if (adapter.complete()) {
820
+ adapter.reset()
821
+ return 'continue'
822
+ }
823
+ if (adapter.idle()) {
824
+ let flow: Flow
825
+ try {
826
+ flow = adapter.walkWhole(parsed.value)
827
+ } catch (err) {
828
+ throw atRecord(err, number)
829
+ }
830
+ if ('stop' === flow) return 'stop'
831
+ adapter.reset()
832
+ return 'continue'
833
+ }
834
+ throw notStreamable()
835
+ }
836
+
837
+ finish(): Flow {
838
+ if ('stop' === this.records.end(this.onRecord)) return 'stop'
839
+ const status = this.status()
840
+ if (null !== status) return status
841
+ if ('stop' === this.adapter.send(Ev.arrayEnd)) return 'stop'
842
+ return this.adapter.send(Ev.end)
843
+ }
844
+
845
+ close(): void {
846
+ this.adapter.sink.flush()
847
+ }
848
+ }
849
+
850
+ // What decides where the CSV grammar ends a record, read from its parser's
851
+ // resolved options and from the options the plugin builds its quote
852
+ // matcher from, so a separator, quote, record separator or comment setting
853
+ // moves the chunker's cut as it moves the grammar. Each set is empty while
854
+ // the engine does not lex its kind.
855
+ class Lexis {
856
+ // Line characters: outside a token, each ends a record.
857
+ line: string[] = []
858
+ // Whether a run of line characters stops at a repeated one
859
+ // (`record.empty`), so that `\n\n` is two line tokens.
860
+ single = false
861
+ rows: string[] = []
862
+ space: string[] = []
863
+ // The fixed tokens: the field separator, and outside strict mode the
864
+ // JSON structure characters.
865
+ fixed: string[] = []
866
+ // The RFC 4180 quote, while the grammar's own matcher reads it: a
867
+ // doubled one inside is one quote, and a line character is text.
868
+ quote: string | null = null
869
+ // The engine's own string quotes, after that one: an `escape` takes the
870
+ // next character whatever it is, and a line character is text only
871
+ // inside the `multi` ones (a backtick).
872
+ strings: string[] = []
873
+ multi: string[] = []
874
+ escape = '\\'
875
+ // The comment markers, longest first, each with its end: `null` for a
876
+ // line comment, which a line character ends without being part of.
877
+ comments: [string, string | null][] = []
878
+ // What ends a run of text, as characters and as token starts.
879
+ stops: string[] = []
880
+ stopPrefixes: string[] = []
881
+ // Whether the grammar ignores a space (outside strict mode) and a
882
+ // comment, so that a record of nothing else is blank.
883
+ spaceIgnored = false
884
+ commentIgnored = false
885
+
886
+ static csv(parser: any): Lexis {
887
+ const config = parser.internal().config
888
+ const options = parser.options.plugin?.csv ?? {}
889
+ const lexis = new Lexis()
890
+ lexis.line = lexed(config.line.lex, config.line.chars)
891
+ lexis.single = !!config.line.single
892
+ lexis.rows = Object.keys(config.line.rowChars ?? {})
893
+ lexis.space = lexed(config.space.lex, config.space.chars)
894
+ lexis.fixed = fixedTokens(config)
895
+ // The longest marker first, and a tie by name: the engine's order.
896
+ lexis.comments = config.comment.lex
897
+ ? Object.entries(config.comment.def as Record<string, any>)
898
+ .filter(([, def]) => def.lex && def.start)
899
+ .sort(([an, a], [bn, b]) => b.start.length - a.start.length || (an < bn ? -1 : an > bn ? 1 : 0))
900
+ .map(([, def]): [string, string | null] => [def.start, def.line ? null : (def.end ?? '')])
901
+ : []
902
+ // The RFC 4180 matcher as the plugin installs it: on in strict mode
903
+ // unless `string.csv` is false, off otherwise unless it is true, and
904
+ // inert for a quote that is not one UTF-16 code unit.
905
+ const matcher = options.strict ? false !== options.string?.csv : true === options.string?.csv
906
+ const quote = options.string?.quote
907
+ lexis.quote = matcher && 'string' === typeof quote && 1 === quote.length ? quote : null
908
+ lexis.strings = lexed(config.string.lex, config.string.quoteMap)
909
+ lexis.multi = Object.keys(config.string.multiChars ?? {})
910
+ lexis.escape = config.string.escChar ?? '\\'
911
+ // What the engine's text matcher stops at: its ender pattern, which is
912
+ // the space and line characters, the fixed tokens, the comment markers
913
+ // and the grammar's own enders.
914
+ lexis.stops = [...lexis.space, ...lexis.line]
915
+ const enders = parser.options.ender
916
+ lexis.stopPrefixes = [
917
+ ...lexis.fixed,
918
+ ...lexis.comments.map(([start]) => start),
919
+ ...('string' === typeof enders ? [...enders] : Array.isArray(enders) ? enders : []).filter(
920
+ (e: unknown) => 'string' === typeof e && 0 < e.length,
921
+ ),
922
+ ]
923
+ const ignored = config.tokenSetTins?.IGNORE ?? {}
924
+ lexis.spaceIgnored = !!ignored[parser.token('#SP')]
925
+ lexis.commentIgnored = !!ignored[parser.token('#CM')]
926
+ return lexis
927
+ }
928
+
929
+ // The length of the longest fixed token at `i`, or 0.
930
+ fixedAt(text: string, i: number): number {
931
+ let len = 0
932
+ for (const source of this.fixed) {
933
+ if (source.length > len && text.startsWith(source, i)) len = source.length
934
+ }
935
+ return len
936
+ }
937
+
938
+ // The comment that starts at `i`, by its index, or -1.
939
+ commentAt(text: string, i: number): number {
940
+ return this.comments.findIndex(([start]) => text.startsWith(start, i))
941
+ }
942
+
943
+ // The length of the line token at `i`.
944
+ lineRun(text: string, i: number): number {
945
+ let j = i
946
+ while (j < text.length) {
947
+ const c = text[j]
948
+ if (!this.line.includes(c) || (this.single && text.slice(i, j).includes(c))) break
949
+ j++
950
+ }
951
+ return j - i
952
+ }
953
+
954
+ // Whether a run of text stops at `i`.
955
+ endsText(text: string, i: number): boolean {
956
+ return this.stops.includes(text[i]) || this.stopPrefixes.some((p) => text.startsWith(p, i))
957
+ }
958
+ }
959
+
960
+ // Where the record scanner is, between two characters.
961
+ type At =
962
+ | { type: 'start' } // where the lexer starts a token
963
+ | { type: 'text' } // inside text, a number or a keyword
964
+ | { type: 'quoted' } // inside an RFC 4180 quoted field
965
+ | { type: 'str'; quote: string; multi: boolean } // inside one of the engine's own strings
966
+ | { type: 'comment'; index: number } // inside a comment
967
+
968
+ const START: At = { type: 'start' }
969
+ const TEXT: At = { type: 'text' }
970
+ const QUOTED: At = { type: 'quoted' }
971
+
972
+ // Follows CSV text through the grammar's tokens, a piece at a time, to
973
+ // find where its records end.
974
+ class Scanner {
975
+ readonly lexis: Lexis
976
+ private at: At = START
977
+ // Whether the record so far holds anything the grammar does not ignore,
978
+ // so that it is not blank.
979
+ private content = false
980
+
981
+ constructor(lexis: Lexis) {
982
+ this.lexis = lexis
983
+ }
984
+
985
+ // Scan one piece of the input, which ends just past a line ending unless
986
+ // it is the last, calling `end(offset, content)` where each record in it
987
+ // ends: `offset` is just past the line token that ends it, and `content`
988
+ // says whether it held anything the grammar does not ignore. Returns
989
+ // whether the piece ends a record, so that a chunk may close after it.
990
+ piece(text: string, end: (offset: number, content: boolean) => void): boolean {
991
+ const lexis = this.lexis
992
+ let ended = false
993
+ let i = 0
994
+ while (i < text.length) {
995
+ const c = text[i]
996
+ ended = false
997
+ const at = this.at
998
+ switch (at.type) {
999
+ // In the lexer's order: the RFC 4180 matcher, fixed tokens, space,
1000
+ // lines, strings, comments, and then a run of text.
1001
+ case 'start': {
1002
+ let len = 0
1003
+ if (lexis.quote === c) {
1004
+ this.at = QUOTED
1005
+ this.content = true
1006
+ i++
1007
+ } else if (0 < (len = lexis.fixedAt(text, i))) {
1008
+ this.content = true
1009
+ i += len
1010
+ } else if (lexis.space.includes(c)) {
1011
+ this.content ||= !lexis.spaceIgnored
1012
+ i++
1013
+ } else if (lexis.line.includes(c)) {
1014
+ i += lexis.lineRun(text, i)
1015
+ end(i, this.content)
1016
+ this.content = false
1017
+ ended = true
1018
+ } else if (lexis.strings.includes(c)) {
1019
+ this.at = { type: 'str', quote: c, multi: lexis.multi.includes(c) }
1020
+ this.content = true
1021
+ i++
1022
+ } else {
1023
+ const comment = lexis.commentAt(text, i)
1024
+ if (0 <= comment) {
1025
+ this.at = { type: 'comment', index: comment }
1026
+ this.content ||= !lexis.commentIgnored
1027
+ i += lexis.comments[comment][0].length
1028
+ } else {
1029
+ // A character no token starts with (an ender) is one the
1030
+ // grammar refuses, and lexing resumes after it.
1031
+ if (!lexis.endsText(text, i)) this.at = TEXT
1032
+ this.content = true
1033
+ i++
1034
+ }
1035
+ }
1036
+ break
1037
+ }
1038
+ case 'text':
1039
+ if (lexis.endsText(text, i)) this.at = START
1040
+ else i++
1041
+ break
1042
+ case 'quoted':
1043
+ i++
1044
+ if (lexis.quote === c) {
1045
+ if (text[i] === c) i++
1046
+ else this.at = START
1047
+ }
1048
+ break
1049
+ case 'str':
1050
+ if (c === at.quote) {
1051
+ this.at = START
1052
+ i++
1053
+ } else if (c === lexis.escape) {
1054
+ i += i + 1 < text.length ? 2 : 1
1055
+ } else if (!at.multi && lexis.line.includes(c)) {
1056
+ // The grammar refuses the string here, so the line character
1057
+ // is read as a line.
1058
+ this.at = START
1059
+ } else {
1060
+ i++
1061
+ }
1062
+ break
1063
+ case 'comment': {
1064
+ const close = lexis.comments[at.index][1]
1065
+ if (null !== close && '' !== close && text.startsWith(close, i)) {
1066
+ this.at = START
1067
+ i += close.length
1068
+ } else if (null === close && lexis.line.includes(c)) {
1069
+ this.at = START
1070
+ } else {
1071
+ i++
1072
+ }
1073
+ break
1074
+ }
1075
+ }
1076
+ }
1077
+ return ended
1078
+ }
1079
+ }
1080
+
1081
+ // The UTF-8 length of a string.
1082
+ function utf8Bytes(text: string): number {
1083
+ return Buffer.byteLength(text, 'utf8')
1084
+ }
1085
+
1086
+ // CSV through the walk: pieces into whole records, records into chunks,
1087
+ // each chunk parsed with one reused parser.
1088
+ class CsvWalk implements Driver {
1089
+ splitter: PieceSplitter
1090
+ private guarded: Guarded<Sink>
1091
+ private parser: any
1092
+ private abort: AbortFlag
1093
+ private scanner: Scanner
1094
+ private maxRecordBytes: number
1095
+ private chunkBytes: number
1096
+ // Whether the first record names the fields, and whether a blank line is
1097
+ // a record (`record.empty`): together, which record the header is.
1098
+ private wantHeader: boolean
1099
+ private recordEmpty: boolean
1100
+
1101
+ // The header record as the grammar reads it, once read, and its bytes:
1102
+ // every chunk after the first starts with it.
1103
+ private header: string | null = null
1104
+ private headerBytes = 0
1105
+ private started = false
1106
+
1107
+ // The chunk being filled, while one is: its text and bytes; where it may
1108
+ // first close (past the header in front of it, or in the first chunk past
1109
+ // the header record itself), as an index and in bytes; the file row its
1110
+ // own text starts on, and the rows of the header in front of it; where
1111
+ // the text it cannot close inside starts (one record, or before the
1112
+ // header everything up to its end), in bytes and as a row; and where the
1113
+ // last record ended, which is where the next one starts.
1114
+ private open = false
1115
+ private text = ''
1116
+ private bytes = 0
1117
+ private body = 0
1118
+ private bodyBytes = 0
1119
+ private firstLine = 0
1120
+ private prefixLines = 0
1121
+ private recordAt = 0
1122
+ private recordRow = 0
1123
+ private lastEnd = 0
1124
+
1125
+ constructor(
1126
+ sink: Sink,
1127
+ options: DriverOptions,
1128
+ header: boolean,
1129
+ grammar: Record<string, unknown>,
1130
+ ) {
1131
+ this.guarded = new Guarded(sink, options.limits, options.abort, options.metrics)
1132
+ this.abort = options.abort
1133
+ this.maxRecordBytes = options.limits.max_record_bytes
1134
+ this.chunkBytes = options.chunkBytes
1135
+ this.wantHeader = header
1136
+ this.parser = makeCsv({ ...grammar, header } as any)
1137
+ this.recordEmpty = !!this.parser.options.plugin?.csv?.record?.empty
1138
+ const abort = options.abort
1139
+ prepare(this.parser, () => !abort.isAborted())
1140
+ const lexis = Lexis.csv(this.parser)
1141
+ this.scanner = new Scanner(lexis)
1142
+ const splitter = new PieceSplitter(lexis.line, lexis.rows, lexis.single, lexis.fixed)
1143
+ splitter.budget = () => this.maxRecordBytes - (this.open ? this.bytes - this.recordAt : 0)
1144
+ splitter.over = (row) => {
1145
+ const first = (this.open && this.recordRow) || row
1146
+ return Fail.limit(
1147
+ 'max_record_bytes',
1148
+ this.maxRecordBytes,
1149
+ `the record starting at line ${first} is longer than ${this.maxRecordBytes} bytes`,
1150
+ ).at(first, 1)
1151
+ }
1152
+ this.splitter = splitter
1153
+ }
1154
+
1155
+ begin(): Flow {
1156
+ return this.guarded.event(Ev.arrayStart)
1157
+ }
1158
+
1159
+ // Opens a chunk: after the first, the header goes in front of it.
1160
+ private openChunk(): void {
1161
+ this.open = true
1162
+ this.text = this.started ? (this.header ?? '') : ''
1163
+ this.bytes = this.started ? this.headerBytes : 0
1164
+ this.started = true
1165
+ this.prefixLines = advance(this.scanner.lexis.rows, 0, 0, this.text)[0]
1166
+ this.body = this.text.length
1167
+ this.bodyBytes = this.bytes
1168
+ this.firstLine = 0
1169
+ this.recordAt = this.bytes
1170
+ this.recordRow = 0
1171
+ this.lastEnd = 0
1172
+ }
1173
+
1174
+ piece(row: number, text: string, bytes: number): Flow {
1175
+ if (!this.open) this.openChunk()
1176
+ if (0 === this.firstLine) this.firstLine = row
1177
+ if (0 === this.recordRow) this.recordRow = row
1178
+ const at = this.text.length
1179
+ const atBytes = this.bytes
1180
+ this.text += text
1181
+ this.bytes += bytes
1182
+ const seeking = this.wantHeader && null === this.header
1183
+ const found = { from: -1, to: -1 }
1184
+ const ended = this.scanner.piece(text, (end, content) => {
1185
+ if (seeking && found.to < 0 && (content || this.recordEmpty)) {
1186
+ found.from = this.lastEnd
1187
+ found.to = at + end
1188
+ }
1189
+ this.lastEnd = at + end
1190
+ })
1191
+ if (0 <= found.to) {
1192
+ const { from, to } = found
1193
+ this.header = this.text.slice(from, to)
1194
+ this.headerBytes = utf8Bytes(this.header)
1195
+ this.body = to
1196
+ this.bodyBytes = atBytes + utf8Bytes(text.slice(0, to - at))
1197
+ }
1198
+ // A chunk may close where a piece ends a record, once the header is
1199
+ // behind it.
1200
+ if (ended && !(this.wantHeader && null === this.header)) {
1201
+ this.recordAt = this.bytes
1202
+ this.recordRow = 0
1203
+ if (this.bytes > this.bodyBytes && this.bytes >= this.chunkBytes) return this.flush()
1204
+ }
1205
+ return 'continue'
1206
+ }
1207
+
1208
+ // Parse the chunk and walk its records.
1209
+ private flush(): Flow {
1210
+ if (!this.open) return 'continue'
1211
+ const text = this.text
1212
+ const firstLine = this.firstLine
1213
+ const prefixLines = this.prefixLines
1214
+ this.open = false
1215
+ this.text = ''
1216
+ let value: unknown
1217
+ try {
1218
+ value = this.parser.parse(text)
1219
+ } catch (err) {
1220
+ const pos = enginePosition(err)
1221
+ const rowInChunk = Math.max(pos?.row ?? 0, 1)
1222
+ // An error in the prepended header itself is the file's first line.
1223
+ const line = rowInChunk > prefixLines ? firstLine + rowInChunk - prefixLines - 1 : 1
1224
+ throw lineFailure(err, line, firstLine, this.abort)
1225
+ }
1226
+ // The grammar returns an array of records; anything else has none.
1227
+ const records = Array.isArray(value) ? value : []
1228
+ try {
1229
+ for (const record of records) {
1230
+ if ('stop' === walkValue(record, this.guarded)) return 'stop'
1231
+ }
1232
+ } catch (err) {
1233
+ throw atRecord(err, firstLine)
1234
+ }
1235
+ return 'continue'
1236
+ }
1237
+
1238
+ finish(): Flow {
1239
+ if ('stop' === this.flush()) return 'stop'
1240
+ if ('stop' === this.guarded.event(Ev.arrayEnd)) return 'stop'
1241
+ return this.guarded.event(Ev.end)
1242
+ }
1243
+
1244
+ close(): void {
1245
+ this.guarded.flush()
1246
+ }
1247
+ }