@tabnas/transduce 0.1.0 → 0.1.2
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/README.md +14 -0
- package/dist/transduce.d.ts +1 -1
- package/dist/transduce.js +1 -1
- package/package.json +3 -1
- package/src/capability.ts +52 -0
- package/src/datum.ts +407 -0
- package/src/error.ts +198 -0
- package/src/event.ts +134 -0
- package/src/guard.ts +112 -0
- package/src/json.ts +82 -0
- package/src/limits.ts +165 -0
- package/src/lines.ts +1247 -0
- package/src/matcher.ts +287 -0
- package/src/parser-source.ts +225 -0
- package/src/route.ts +275 -0
- package/src/rule-events.ts +661 -0
- package/src/scan.ts +83 -0
- package/src/selector.ts +191 -0
- package/src/sink.ts +199 -0
- package/src/source.ts +135 -0
- package/src/table-from-json.ts +296 -0
- package/src/table.ts +213 -0
- package/src/transduce.ts +72 -0
- package/src/tsconfig.json +16 -0
- package/src/value.ts +93 -0
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
|
+
}
|