@jarenjs/josl 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,908 @@
1
+ //#region CSV incremental reader
2
+ // CSV (RFC 4180, and the dialects the world actually writes); this machine
3
+ // is both the full parser and the streaming reader — `parseAll()` and
4
+ // `feed()`/`end()` run the same record parser, so there is a single
5
+ // grammar code path.
6
+ //
7
+ // The design exploits CSV's record orientation, the way the JOSL machine
8
+ // exploits TOML's line orientation. A *logical record* is a physical line
9
+ // extended across the newlines that appear inside a quoted field. With
10
+ // the whole text in hand `parseRecord` finds each record's end as it
11
+ // parses. A chunk stream cannot: a chunk may stop mid-field, so `feed`
12
+ // first runs a cutter FSM that tracks just enough state (field start,
13
+ // quoted, pending quote) to decide whether a complete record is buffered.
14
+ // The cutter never allocates and has no effect on the document, so a scan
15
+ // that runs out of input simply resumes when the next chunk arrives.
16
+ //
17
+ // Two passes for streaming, one for wholesale, is the deliberate trade:
18
+ // the wholesale path is what a 100 MB file goes through.
19
+ //
20
+ // **The parser is authoritative, the cutter only permissive.** The cutter
21
+ // resolves an ambiguous quote by staying inside the field, which is the
22
+ // latest any reading could close it; the parser may close earlier. So a
23
+ // cut span can contain more than one record but never less than one, and
24
+ // the cut path reads a span in a loop rather than assuming it holds
25
+ // exactly one record. If that invariant were reversed — a cut earlier
26
+ // than the parser's — the text after the cut would be silently dropped.
27
+ //
28
+ // Self-healing (`repair: true`) is the second reason this exists. Real
29
+ // CSV is damaged constantly: an unclosed quote, a bare quote inside a
30
+ // value, a row with the wrong number of columns. Each has one reading
31
+ // that loses the least, and the machine takes it *and says so* — every
32
+ // repair lands in a log with a stable code, a line and a column. Strict
33
+ // mode throws `CsvSyntaxError` with the same code, so moving between the
34
+ // two modes never means re-learning the diagnosis.
35
+
36
+ import {
37
+ CC_TAB,
38
+ CC_LF,
39
+ CC_CR,
40
+ CC_SPACE,
41
+ CC_DQUOTE,
42
+ CC_COMMA,
43
+ CC_MINUS,
44
+ CC_DOT,
45
+ CC_PLUS,
46
+ CC_0,
47
+ isDigitCode,
48
+ } from '@jarenjs/core/scan';
49
+
50
+ import { CsvSyntaxError } from './errors.js';
51
+ import { columnOf, feedMachine, beginParseAll } from './util.js';
52
+ import { setObjectMember } from '@jarenjs/core/object';
53
+ import {
54
+ LocalDate,
55
+ LocalTime,
56
+ LocalDateTime,
57
+ isValidDateParts,
58
+ isValidTimeParts,
59
+ } from './values.js';
60
+
61
+ //#region diagnosis codes
62
+
63
+ /**
64
+ * The conditions the reader can diagnose. Each is a strict-mode error
65
+ * code and a repair-mode log code; the wording is the same either way.
66
+ */
67
+ export const CSV_CODES = {
68
+ CSV1001: 'a quoted field is never closed',
69
+ CSV1002: 'text after a closing quote',
70
+ CSV1003: 'an unescaped quote inside a quoted field',
71
+ CSV1004: 'a record has fewer fields than the header',
72
+ CSV1005: 'a record has more fields than the header',
73
+ CSV1006: 'a bare carriage return ends a record',
74
+ CSV1007: 'a duplicate header name',
75
+ CSV1008: 'an empty header name',
76
+ };
77
+
78
+ const HINTS = {
79
+ CSV1001: 'close the field with a quote, or double any quote meant literally',
80
+ CSV1002: 'double a quote meant literally, or remove the trailing text',
81
+ CSV1003: 'double a quote meant literally ("" inside a quoted field)',
82
+ CSV1004: 'pad the record, or leave the missing columns out deliberately',
83
+ CSV1005: 'remove the extra columns, or widen the header',
84
+ CSV1006: 'use \\n or \\r\\n to end a record',
85
+ CSV1007: 'give each column a distinct name',
86
+ CSV1008: 'give every column a name',
87
+ };
88
+
89
+ //#endregion
90
+
91
+ //#region cutter states
92
+
93
+ const S_START = 0; // at a field start: a quote here opens a quoted field
94
+ const S_PLAIN = 1; // inside an unquoted field: a quote is ordinary text
95
+ const S_QUOTED = 2; // inside a quoted field
96
+ const S_QUOTE = 3; // saw a quote inside a quoted field; needs one lookahead
97
+
98
+ //#endregion
99
+
100
+ //#region typed values
101
+
102
+ // Only these first characters can begin a non-string value, so a text
103
+ // column costs one comparison per cell instead of a regex.
104
+ function canBeTyped(c) {
105
+ return isDigitCode(c)
106
+ || c === CC_MINUS || c === CC_PLUS || c === CC_DOT
107
+ || c === 0x74 || c === 0x54 // t T
108
+ || c === 0x66 || c === 0x46 // f F
109
+ || c === 0x6E || c === 0x4E; // n N
110
+ }
111
+
112
+ const RE_INT = /^[+-]?\d+$/;
113
+ const RE_FLOAT = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
114
+ const RE_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
115
+ const RE_TIME = /^(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/;
116
+ const RE_DATETIME =
117
+ /^(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:([Zz])|([+-])(\d{2}):(\d{2}))?$/;
118
+
119
+ // A leading zero marks an identifier, not a number: postcodes, phone
120
+ // numbers and zero-padded ids lose their meaning the moment they become
121
+ // Numbers, and the loss is not recoverable downstream.
122
+ function hasLeadingZero(s) {
123
+ const c0 = s.charCodeAt(0);
124
+ const start = c0 === CC_MINUS || c0 === CC_PLUS ? 1 : 0;
125
+ return s.charCodeAt(start) === CC_0 && s.length > start + 1
126
+ && isDigitCode(s.charCodeAt(start + 1));
127
+ }
128
+
129
+ /**
130
+ * Coerce one cell to a JOSL value, or return the string unchanged.
131
+ *
132
+ * The value model is the package's, not JSON's: an integer too large for
133
+ * a Number becomes a BigInt rather than silently losing digits, and an
134
+ * unambiguous ISO-8601 date becomes the same `LocalDate`/`LocalDateTime`
135
+ * a JOSL document would produce. Anything the grammar does not match
136
+ * exactly stays a string — CSV has no types, so a reader must not guess
137
+ * beyond what the text says outright.
138
+ * @param {string} s - The raw cell text
139
+ * @returns {*} The coerced value, or `s` unchanged
140
+ */
141
+ export function coerceCsvValue(s) {
142
+ if (s.length === 0)
143
+ return s;
144
+ const c = s.charCodeAt(0);
145
+ if (!canBeTyped(c))
146
+ return s;
147
+
148
+ if (c === 0x74 || c === 0x54 || c === 0x66 || c === 0x46 || c === 0x6E || c === 0x4E) {
149
+ const lower = s.toLowerCase();
150
+ if (lower === 'true') return true;
151
+ if (lower === 'false') return false;
152
+ if (lower === 'null') return null;
153
+ return s;
154
+ }
155
+
156
+ if (RE_INT.test(s)) {
157
+ if (hasLeadingZero(s))
158
+ return s;
159
+ const n = Number(s);
160
+ // past 2^53 a Number silently rounds; bigint is a first-class value
161
+ // in this package, so use it rather than lose the digits
162
+ return Number.isSafeInteger(n) ? n : BigInt(s);
163
+ }
164
+ if (RE_FLOAT.test(s)) {
165
+ if (hasLeadingZero(s))
166
+ return s;
167
+ const n = Number(s);
168
+ return Number.isFinite(n) ? n : s;
169
+ }
170
+
171
+ // datetime before date: the date pattern is a prefix of it
172
+ let m = RE_DATETIME.exec(s);
173
+ if (m !== null) {
174
+ const year = +m[1];
175
+ const month = +m[2];
176
+ const day = +m[3];
177
+ const hour = +m[4];
178
+ const min = +m[5];
179
+ const sec = +m[6];
180
+ if (!isValidDateParts(year, month, day) || !isValidTimeParts(hour, min, sec))
181
+ return s;
182
+ const frac = m[7] === undefined ? '' : '.' + m[7];
183
+ if (m[8] !== undefined || m[9] !== undefined) {
184
+ // an offset date-time is an instant, which `Date` holds faithfully
185
+ const offset = m[8] !== undefined ? 0 : (m[9] === '-' ? -1 : 1) * (+m[10] * 60 + +m[11]);
186
+ const ms = frac === '' ? 0 : Math.round(Number('0' + frac) * 1000);
187
+ return new Date(Date.UTC(year, month - 1, day, hour, min, sec, ms) - offset * 60000);
188
+ }
189
+ return new LocalDateTime(
190
+ new LocalDate(year, month, day),
191
+ new LocalTime(hour, min, sec, frac));
192
+ }
193
+ m = RE_DATE.exec(s);
194
+ if (m !== null)
195
+ return isValidDateParts(+m[1], +m[2], +m[3]) ? new LocalDate(+m[1], +m[2], +m[3]) : s;
196
+ m = RE_TIME.exec(s);
197
+ if (m !== null) {
198
+ if (!isValidTimeParts(+m[1], +m[2], +m[3]))
199
+ return s;
200
+ return new LocalTime(+m[1], +m[2], +m[3], m[4] === undefined ? '' : '.' + m[4]);
201
+ }
202
+ return s;
203
+ }
204
+
205
+ //#endregion
206
+
207
+ //#region the machine
208
+
209
+ function charCodeOption(value, name, fallback) {
210
+ if (value === undefined || value === null)
211
+ return fallback;
212
+ if (typeof value !== 'string' || value.length !== 1)
213
+ throw new TypeError(`options.${name} must be a single character`);
214
+ return value.charCodeAt(0);
215
+ }
216
+
217
+ export class CsvMachine {
218
+ /**
219
+ * @param {object} [options] - Reader options; see `parseCsv`
220
+ */
221
+ constructor(options = {}) {
222
+ this.delimiter = charCodeOption(options.delimiter, 'delimiter', CC_COMMA);
223
+ this.delimChar = String.fromCharCode(this.delimiter);
224
+ this.quote = options.quote === null || options.quote === ''
225
+ ? -1
226
+ : charCodeOption(options.quote, 'quote', CC_DQUOTE);
227
+ this.quoteChar = this.quote < 0 ? '' : String.fromCharCode(this.quote);
228
+ this.comment = options.comment === undefined || options.comment === null
229
+ ? -1
230
+ : charCodeOption(options.comment, 'comment', -1);
231
+ this.repair = options.repair === true;
232
+ this.trim = options.trim === true;
233
+ this.typed = options.typed === true;
234
+ this.emptyAsNull = options.emptyAsNull === true;
235
+ // A blank line is a one-empty-field record by the letter of RFC 4180.
236
+ // Repair mode drops it, because in a table of N columns a blank line
237
+ // is damage rather than a row; either way the caller can override.
238
+ this.skipEmptyLines = options.skipEmptyLines === undefined
239
+ ? this.repair
240
+ : options.skipEmptyLines === true;
241
+ this.onEvent = options.onEvent ?? null;
242
+ this.onRepair = options.onRepair ?? null;
243
+
244
+ const headers = options.headers;
245
+ this.wantHeader = headers === true;
246
+ this.headerFields = Array.isArray(headers)
247
+ ? this.#nameHeader(headers.map(String), 1)
248
+ : null;
249
+ this.objectRows = this.wantHeader || this.headerFields !== null;
250
+ // With no trimming, coercion or empty-as-null, a cell is its own
251
+ // slice: worth knowing once, because `finish` would otherwise be a
252
+ // call per cell that decides nothing.
253
+ this.plainCells = !this.trim && !this.typed && !this.emptyAsNull;
254
+ this.protoSafe = this.headerFields !== null && this.headerFields.includes('__proto__');
255
+
256
+ // cutter state
257
+ this.buf = '';
258
+ this.scanPos = 0;
259
+ this.scanState = S_START;
260
+ this.started = false;
261
+ this.ended = false;
262
+
263
+ // Next-occurrence cursors for the plain-field scanner, absolute in
264
+ // the span text and found lazily: -1 means "none in the rest of the
265
+ // text" and stays valid, -2 means not looked yet. They belong to one
266
+ // span's text, so `readSpan` resets them.
267
+ this.nextDelim = -2;
268
+ this.nextLf = -2;
269
+ this.nextCr = -2;
270
+
271
+ // document state
272
+ this.outRows = [];
273
+ this.repairLog = [];
274
+ this.cells = [];
275
+ this.recordIndex = 0;
276
+ this.dropRecord = false; // set for a line that carries no record (a comment)
277
+ this.line = 1; // physical line currently being read (1-based)
278
+ this.recordOrigin = 1; // physical line the current record starts on
279
+ }
280
+
281
+ //#region public surface
282
+
283
+ /**
284
+ * Feed the next chunk of source text; chunks may split any field.
285
+ * @param {string} chunk - Next piece of the document
286
+ * @returns {this} The machine, for chaining
287
+ */
288
+ feed(chunk) {
289
+ // Growth is append-only, so a found cursor position stays valid; but
290
+ // a cursor that had run off the end must look again in the new data.
291
+ if (this.nextDelim === -1)
292
+ this.nextDelim = -2;
293
+ if (this.nextLf === -1)
294
+ this.nextLf = -2;
295
+ if (this.nextCr === -1)
296
+ this.nextCr = -2;
297
+ return feedMachine(this, chunk);
298
+ }
299
+
300
+ /**
301
+ * Finish the document, flushing any pending record.
302
+ * @returns {Array} The completed rows
303
+ */
304
+ end() {
305
+ if (this.ended)
306
+ return this.outRows;
307
+ this.ended = true;
308
+ this.scan();
309
+ if (this.buf.length !== 0) {
310
+ const rest = this.buf;
311
+ this.buf = '';
312
+ this.scanPos = 0;
313
+ this.readSpan(rest, 0, rest.length);
314
+ }
315
+ return this.outRows;
316
+ }
317
+
318
+ /**
319
+ * Parse a complete document in one pass. `parseRecord` finds each
320
+ * record's end as it goes, so with the whole text in hand the cutter's
321
+ * separate pass is not needed; `feed`/`end` keep it because a chunk can
322
+ * stop mid-field, where only a side-effect-free pre-pass can decide
323
+ * whether a record is complete.
324
+ * @param {string} text - The entire document
325
+ * @returns {Array} The completed rows
326
+ */
327
+ parseAll(text) {
328
+ text = beginParseAll(this, text);
329
+ this.readSpan(text, 0, text.length);
330
+ return this.outRows;
331
+ }
332
+
333
+ /** @returns {Array} The rows read so far. */
334
+ rows() {
335
+ return this.outRows;
336
+ }
337
+
338
+ /** @returns {string[]|null} The header names, once known. */
339
+ fields() {
340
+ return this.headerFields;
341
+ }
342
+
343
+ /** @returns {object[]} The repair log, in document order. */
344
+ repairs() {
345
+ return this.repairLog;
346
+ }
347
+
348
+ //#endregion
349
+
350
+ //#region diagnosis
351
+
352
+ // Strict mode throws, repair mode logs and carries on; both carry the
353
+ // same code so a caller can switch modes without re-reading the docs.
354
+ heal(code, line, column, detail = undefined) {
355
+ const message = detail === undefined ? CSV_CODES[code] : `${CSV_CODES[code]} (${detail})`;
356
+ if (!this.repair)
357
+ throw new CsvSyntaxError(code, message, line, column, HINTS[code]);
358
+ const entry = { code, message, line, column };
359
+ this.repairLog.push(entry);
360
+ if (this.onRepair !== null)
361
+ this.onRepair(entry);
362
+ if (this.onEvent !== null)
363
+ this.onEvent({ type: 'repair', ...entry });
364
+ }
365
+
366
+ //#endregion
367
+
368
+ //#region chunk cutter
369
+
370
+ // Advance the cutter over the buffer, handing each complete span to the
371
+ // record reader. Permissive by construction: an ambiguous quote keeps
372
+ // it inside the field, so a span never ends before the parser would end
373
+ // a record — see the invariant at the top of this file.
374
+ scan() {
375
+ const buf = this.buf;
376
+ const len = buf.length;
377
+ const delim = this.delimiter;
378
+ const quote = this.quote;
379
+ let pos = this.scanPos;
380
+ let state = this.scanState;
381
+ let start = 0;
382
+ // Lazily-found absolute positions of the next quote / LF / CR.
383
+ // -1 means "none in the rest of the buffer" and stays valid; -2 means
384
+ // not looked yet. Caching them is what keeps a CR-less buffer from
385
+ // being re-scanned for a CR once per record.
386
+ let qi = -2;
387
+ let lf = -2;
388
+ let cr = -2;
389
+
390
+ outer: while (pos < len) {
391
+ // Fast path. The cutter is permissive: it is in a quoted state
392
+ // whenever ANY reading could be inside a quote, so a plain state
393
+ // with no quote character ahead means every newline ahead is a
394
+ // real record end — indexOf can cut records without the
395
+ // per-character machine. A record that does contain a quote is
396
+ // handed to the machine below, one record at a time; one that
397
+ // visibly STARTS with a quote skips the lookups outright.
398
+ if (state === S_PLAIN || (state === S_START && buf.charCodeAt(pos) !== quote)) {
399
+ if (qi !== -1 && qi < pos)
400
+ qi = quote < 0 ? -1 : buf.indexOf(this.quoteChar, pos);
401
+ const qlimit = qi < 0 ? len : qi;
402
+ for (;;) {
403
+ if (lf !== -1 && lf < pos)
404
+ lf = buf.indexOf('\n', pos);
405
+ if (cr !== -1 && cr < pos)
406
+ cr = buf.indexOf('\r', pos);
407
+ let cut;
408
+ if (cr < 0) {
409
+ if (lf < 0)
410
+ cut = -1;
411
+ else
412
+ cut = lf + 1;
413
+ }
414
+ else if (lf >= 0 && lf < cr)
415
+ cut = lf + 1;
416
+ else if (lf === cr + 1) // CRLF
417
+ cut = lf + 1;
418
+ else if (cr + 1 < len || this.ended)
419
+ cut = cr + 1;
420
+ else {
421
+ // A bare CR at the buffer edge is only this record's end if
422
+ // no LF follows it, and that needs one character of
423
+ // lookahead from the next chunk.
424
+ if (qlimit <= cr)
425
+ break; // a quote precedes it: the machine reads this record
426
+ this.scanPos = cr;
427
+ this.scanState = S_PLAIN;
428
+ this.compact(start);
429
+ return;
430
+ }
431
+ if (cut < 0) {
432
+ // No terminator ahead. A quote in the partial tail needs the
433
+ // machine to scan it; otherwise the tail is consumed and its
434
+ // cutter state is decided by its last character.
435
+ if (qlimit < len)
436
+ break;
437
+ this.scanPos = len;
438
+ this.scanState = start === len || buf.charCodeAt(len - 1) === delim
439
+ ? S_START
440
+ : S_PLAIN;
441
+ this.compact(start);
442
+ return;
443
+ }
444
+ if (cut > qlimit)
445
+ break; // this record holds a quote: the machine reads it
446
+ // The terminator belongs to the span, so the parser consumes
447
+ // it and counts the line exactly as it does whole-document.
448
+ this.readSpan(buf, start, cut);
449
+ start = cut;
450
+ pos = cut;
451
+ state = S_START;
452
+ }
453
+ }
454
+
455
+ while (pos < len) {
456
+ const c = buf.charCodeAt(pos);
457
+ switch (state) {
458
+ case S_START:
459
+ if (c === quote) {
460
+ state = S_QUOTED;
461
+ pos++;
462
+ continue;
463
+ }
464
+ state = S_PLAIN;
465
+ continue;
466
+ case S_PLAIN: {
467
+ if (c === delim) {
468
+ state = S_START;
469
+ pos++;
470
+ continue;
471
+ }
472
+ if (c === CC_LF) {
473
+ pos++;
474
+ this.readSpan(buf, start, pos);
475
+ start = pos;
476
+ state = S_START;
477
+ continue outer;
478
+ }
479
+ if (c === CC_CR) {
480
+ // A CR is only this record's end if no LF follows it, and
481
+ // that needs one character of lookahead. Cutting here keeps
482
+ // a CR-only document from buffering to the last byte, and the
483
+ // parser still makes the call: it re-reads the same next
484
+ // character out of the same buffer and heals CSV1006 itself.
485
+ if (pos + 1 >= len) {
486
+ if (this.ended)
487
+ break;
488
+ this.scanPos = pos;
489
+ this.scanState = state;
490
+ this.compact(start);
491
+ return;
492
+ }
493
+ pos++;
494
+ if (buf.charCodeAt(pos) === CC_LF)
495
+ pos++;
496
+ this.readSpan(buf, start, pos);
497
+ start = pos;
498
+ state = S_START;
499
+ continue outer;
500
+ }
501
+ pos++;
502
+ continue;
503
+ }
504
+ case S_QUOTED: {
505
+ // quoted fields can be long, so let the engine find the quote
506
+ const q = buf.indexOf(this.quoteChar, pos);
507
+ if (q < 0) {
508
+ pos = len;
509
+ continue;
510
+ }
511
+ pos = q + 1;
512
+ state = S_QUOTE;
513
+ continue;
514
+ }
515
+ default: { // S_QUOTE - one lookahead decides doubled vs closing
516
+ if (c === quote) { // "" is a literal quote; stay inside
517
+ state = S_QUOTED;
518
+ pos++;
519
+ continue;
520
+ }
521
+ // Anything but a delimiter or terminator keeps the field open:
522
+ // the latest close any reading could pick, which is what makes
523
+ // the cutter permissive.
524
+ state = c === delim || c === CC_LF || c === CC_CR ? S_PLAIN : S_QUOTED;
525
+ continue;
526
+ }
527
+ }
528
+ break;
529
+ }
530
+
531
+ // Out of input. A trailing S_QUOTE is undecided until the next
532
+ // character arrives, so stop one short and re-read it next time.
533
+ if (state === S_QUOTE && !this.ended) {
534
+ this.scanPos = pos - 1;
535
+ this.scanState = S_QUOTED;
536
+ }
537
+ else {
538
+ this.scanPos = pos;
539
+ this.scanState = state === S_QUOTE ? S_PLAIN : state;
540
+ }
541
+ this.compact(start);
542
+ return;
543
+ }
544
+
545
+ this.scanPos = pos;
546
+ this.scanState = state;
547
+ this.compact(start);
548
+ }
549
+
550
+ // Drop the spans already handed off, keeping the partial tail.
551
+ compact(start) {
552
+ if (start === 0)
553
+ return;
554
+ this.buf = this.buf.slice(start);
555
+ this.scanPos -= start;
556
+ // The scanner cursors are absolute in the buffer, so they shift with
557
+ // it; one already consumed has no meaning in the new buffer.
558
+ if (this.nextDelim >= 0)
559
+ this.nextDelim = this.nextDelim >= start ? this.nextDelim - start : -2;
560
+ if (this.nextLf >= 0)
561
+ this.nextLf = this.nextLf >= start ? this.nextLf - start : -2;
562
+ if (this.nextCr >= 0)
563
+ this.nextCr = this.nextCr >= start ? this.nextCr - start : -2;
564
+ }
565
+
566
+ //#endregion
567
+
568
+ //#region record parsing
569
+
570
+ // Read every record in `text[pos, end)`. A cut span always carries its
571
+ // own terminator, so this is the same loop the whole-document form runs
572
+ // and a blank line is a record of one empty field in both.
573
+ readSpan(text, pos, end) {
574
+ const reuse = this.objectRows;
575
+ while (pos < end) {
576
+ // An array row is handed straight to the caller, so it is built in
577
+ // its own array and never copied. An object row is built FROM the
578
+ // cells, so those can go in one scratch buffer that never grows.
579
+ let cells;
580
+ if (reuse) {
581
+ cells = this.cells;
582
+ cells.length = 0;
583
+ }
584
+ else {
585
+ cells = [];
586
+ }
587
+ this.recordOrigin = this.line;
588
+ this.dropRecord = false;
589
+ pos = this.parseRecord(text, pos, end, cells);
590
+ if (!this.dropRecord)
591
+ this.emitRecord(cells);
592
+ }
593
+ return pos;
594
+ }
595
+
596
+ // The single grammar path: fill `cells` with one record's fields and
597
+ // return the offset just past the record, including its terminator.
598
+ parseRecord(text, pos, end, cells) {
599
+ const delim = this.delimiter;
600
+ const quote = this.quote;
601
+ const comment = this.comment;
602
+
603
+ // a comment line is consumed whole and contributes no record
604
+ if (comment >= 0 && pos < end && text.charCodeAt(pos) === comment) {
605
+ this.dropRecord = true;
606
+ while (pos < end) {
607
+ const c = text.charCodeAt(pos);
608
+ if (c === CC_LF) {
609
+ this.line++;
610
+ return pos + 1;
611
+ }
612
+ if (c === CC_CR) {
613
+ this.line++;
614
+ return pos + (text.charCodeAt(pos + 1) === CC_LF ? 2 : 1);
615
+ }
616
+ pos++;
617
+ }
618
+ return pos;
619
+ }
620
+
621
+ for (;;) {
622
+ pos = pos < end && text.charCodeAt(pos) === quote
623
+ ? this.parseQuoted(text, pos, end, cells)
624
+ : this.parsePlain(text, pos, end, cells);
625
+ if (pos >= end)
626
+ return pos;
627
+ const t = text.charCodeAt(pos);
628
+ if (t === delim) {
629
+ pos++;
630
+ continue;
631
+ }
632
+ if (t === CC_LF) {
633
+ this.line++;
634
+ return pos + 1;
635
+ }
636
+ // CC_CR
637
+ if (text.charCodeAt(pos + 1) === CC_LF) {
638
+ this.line++;
639
+ return pos + 2;
640
+ }
641
+ // A bare CR is an old-Mac terminator. Reading it as data instead
642
+ // would silently glue two records together, so repair mode ends the
643
+ // record here and strict mode says why.
644
+ this.heal('CSV1006', this.line, columnOf(text, pos));
645
+ this.line++;
646
+ return pos + 1;
647
+ }
648
+ }
649
+
650
+ // An unquoted field: everything up to the next delimiter or terminator.
651
+ // The cursors make that the minimum of three cached indexOf results, so
652
+ // every character is scanned once per span by the engine's substring
653
+ // search instead of once per character here.
654
+ parsePlain(text, pos, end, cells) {
655
+ let nd = this.nextDelim;
656
+ let nlf = this.nextLf;
657
+ let ncr = this.nextCr;
658
+ if (nd !== -1 && nd < pos)
659
+ nd = this.nextDelim = text.indexOf(this.delimChar, pos);
660
+ if (nlf !== -1 && nlf < pos)
661
+ nlf = this.nextLf = text.indexOf('\n', pos);
662
+ if (ncr !== -1 && ncr < pos)
663
+ ncr = this.nextCr = text.indexOf('\r', pos);
664
+ let stop = end;
665
+ if (nd >= 0 && nd < stop)
666
+ stop = nd;
667
+ if (nlf >= 0 && nlf < stop)
668
+ stop = nlf;
669
+ if (ncr >= 0 && ncr < stop)
670
+ stop = ncr;
671
+ const raw = text.slice(pos, stop);
672
+ cells.push(this.plainCells ? raw : this.finish(raw));
673
+ return stop;
674
+ }
675
+
676
+ // A quoted field: `"` … `"`, with `""` for a literal quote.
677
+ parseQuoted(text, pos, end, cells) {
678
+ const quote = this.quote;
679
+ const delim = this.delimiter;
680
+ pos++; // opening quote
681
+ let start = pos;
682
+ let out = null;
683
+ for (;;) {
684
+ if (pos >= end) {
685
+ // Out of input with the field still open. Closing it here is the
686
+ // only reading that keeps the text.
687
+ this.heal('CSV1001', this.recordOrigin, columnOf(text, pos));
688
+ cells.push(this.finishQuoted(joinCell(out, text, start, pos)));
689
+ return pos;
690
+ }
691
+ const c = text.charCodeAt(pos);
692
+ if (c !== quote) {
693
+ if (c === CC_LF)
694
+ this.line++;
695
+ pos++;
696
+ continue;
697
+ }
698
+ const n = pos + 1 < end ? text.charCodeAt(pos + 1) : -1;
699
+ if (n === quote) { // "" — one literal quote
700
+ out = (out === null ? '' : out) + text.slice(start, pos + 1);
701
+ pos += 2;
702
+ start = pos;
703
+ continue;
704
+ }
705
+ if (n === delim || n === CC_LF || n === CC_CR || n === -1) {
706
+ cells.push(this.finishQuoted(joinCell(out, text, start, pos)));
707
+ return pos + 1; // past the closing quote
708
+ }
709
+
710
+ // A quote followed by ordinary text is neither a close nor an
711
+ // escape, and the two possible readings damage different things.
712
+ // Deciding between them by looking for another quote before the
713
+ // next structural character keeps whichever is intact:
714
+ //
715
+ // "he said "hi" ok" another quote first -> literal quote,
716
+ // so the field text survives
717
+ // "abc"junk,d a delimiter first -> the field closed,
718
+ // so the RECORD's column count survives
719
+ //
720
+ // Column count wins ties, because a consumer indexes by column.
721
+ if (quoteBeforeBreak(text, pos + 1, end, quote, delim)) {
722
+ this.heal('CSV1003', this.line, columnOf(text, pos));
723
+ pos++;
724
+ continue;
725
+ }
726
+ this.heal('CSV1002', this.line, columnOf(text, pos));
727
+ const closed = joinCell(out, text, start, pos);
728
+ pos++; // past the closing quote
729
+ const stray = pos;
730
+ while (pos < end) {
731
+ const s = text.charCodeAt(pos);
732
+ if (s === delim || s === CC_LF || s === CC_CR)
733
+ break;
734
+ pos++;
735
+ }
736
+ cells.push(this.finishQuoted(closed + text.slice(stray, pos)));
737
+ return pos;
738
+ }
739
+ }
740
+
741
+ //#endregion
742
+
743
+ //#region cell and record shaping
744
+
745
+ finish(cell) {
746
+ if (this.trim)
747
+ cell = trimAscii(cell);
748
+ if (cell.length === 0)
749
+ return this.emptyAsNull ? null : cell;
750
+ return this.typed ? coerceCsvValue(cell) : cell;
751
+ }
752
+
753
+ // A quoted cell is never trimmed — the quotes are the author saying the
754
+ // whitespace belongs to the data — and never coerced, for the same
755
+ // reason: `"0123"` is an identifier the writer chose to protect.
756
+ finishQuoted(cell) {
757
+ if (cell.length === 0)
758
+ return this.emptyAsNull ? null : cell;
759
+ return cell;
760
+ }
761
+
762
+ emitRecord(cells) {
763
+ const n = cells.length;
764
+ if (this.skipEmptyLines && n === 1 && (cells[0] === '' || cells[0] === null))
765
+ return;
766
+
767
+ if (this.headerFields === null && this.wantHeader) {
768
+ const names = new Array(n);
769
+ for (let i = 0; i < n; i++)
770
+ names[i] = cells[i] === null ? '' : String(cells[i]);
771
+ this.headerFields = this.#nameHeader(names, this.recordOrigin);
772
+ this.protoSafe = this.headerFields.includes('__proto__');
773
+ if (this.onEvent !== null)
774
+ this.onEvent({ type: 'header', fields: this.headerFields, line: this.recordOrigin });
775
+ return;
776
+ }
777
+
778
+ const index = this.recordIndex++;
779
+ const row = this.objectRows ? this.buildObject(cells) : cells;
780
+ this.outRows.push(row);
781
+ if (this.onEvent !== null) {
782
+ // the scratch buffer is reused, so an event that outlives this call
783
+ // needs its own copy of the cells
784
+ this.onEvent({
785
+ type: 'row',
786
+ index,
787
+ line: this.recordOrigin,
788
+ values: this.objectRows ? cells.slice(0, n) : row,
789
+ record: this.objectRows ? row : null,
790
+ });
791
+ }
792
+ }
793
+
794
+ buildObject(values) {
795
+ const fields = this.headerFields;
796
+ const width = fields.length;
797
+ const n = values.length;
798
+ if (n < width)
799
+ this.heal('CSV1004', this.recordOrigin, 1, `${n} of ${width}`);
800
+ else if (n > width)
801
+ this.heal('CSV1005', this.recordOrigin, 1, `${n} of ${width}`);
802
+
803
+ const row = {};
804
+ const limit = n < width ? n : width;
805
+ if (this.protoSafe) {
806
+ for (let i = 0; i < limit; i++)
807
+ setObjectMember(row, fields[i], values[i]);
808
+ }
809
+ else {
810
+ for (let i = 0; i < limit; i++)
811
+ row[fields[i]] = values[i];
812
+ }
813
+ // A short record leaves the remaining columns absent rather than
814
+ // inventing a value: reading `undefined` says "this record did not
815
+ // carry the column", where `''` would claim it carried an empty one.
816
+ if (n > width) {
817
+ // Widen the header once, so the extra columns have somewhere to go
818
+ // and every later record agrees on the shape.
819
+ for (let i = width; i < n; i++) {
820
+ const name = uniqueName(fields, `column_${i + 1}`);
821
+ fields.push(name);
822
+ setObjectMember(row, name, values[i]);
823
+ }
824
+ this.protoSafe = this.protoSafe || fields.includes('__proto__');
825
+ }
826
+ return row;
827
+ }
828
+
829
+ #nameHeader(names, line) {
830
+ for (let i = 0; i < names.length; i++) {
831
+ let name = names[i];
832
+ if (name.length === 0) {
833
+ this.heal('CSV1008', line, i + 1);
834
+ name = uniqueName(names, `column_${i + 1}`);
835
+ }
836
+ // `lastIndexOf(name, -1)` would search from the END, so the first
837
+ // column would always look like a duplicate of itself
838
+ if (i > 0 && names.lastIndexOf(name, i - 1) >= 0) {
839
+ this.heal('CSV1007', line, i + 1, name);
840
+ name = uniqueName(names, name);
841
+ }
842
+ names[i] = name;
843
+ }
844
+ return names;
845
+ }
846
+
847
+ //#endregion
848
+ }
849
+
850
+ //#endregion
851
+
852
+ //#region helpers
853
+
854
+ // Concatenate a cell whose escaped prefix was already collected. `out`
855
+ // is null for the overwhelmingly common cell with no doubled quote,
856
+ // where the whole cell is one slice and nothing is concatenated.
857
+ function joinCell(out, text, start, pos) {
858
+ const tail = text.slice(start, pos);
859
+ return out === null ? tail : out + tail;
860
+ }
861
+
862
+ // Is there another quote before the next delimiter or terminator?
863
+ function quoteBeforeBreak(text, pos, end, quote, delim) {
864
+ while (pos < end) {
865
+ const c = text.charCodeAt(pos);
866
+ if (c === quote)
867
+ return true;
868
+ if (c === delim || c === CC_LF || c === CC_CR)
869
+ return false;
870
+ pos++;
871
+ }
872
+ return false;
873
+ }
874
+
875
+ function trimAscii(s) {
876
+ let a = 0;
877
+ let b = s.length;
878
+ while (a < b) {
879
+ const c = s.charCodeAt(a);
880
+ if (c !== CC_SPACE && c !== CC_TAB) break;
881
+ a++;
882
+ }
883
+ while (b > a) {
884
+ const c = s.charCodeAt(b - 1);
885
+ if (c !== CC_SPACE && c !== CC_TAB) break;
886
+ b--;
887
+ }
888
+ return a === 0 && b === s.length ? s : s.slice(a, b);
889
+ }
890
+
891
+ // The first free name in the `base`, `base_2`, `base_3`, ... series. A
892
+ // synthesized column keeps its plain name when nothing else claims it;
893
+ // only a genuine collision gets a suffix.
894
+ function uniqueName(taken, base) {
895
+ if (!taken.includes(base))
896
+ return base;
897
+ let n = 2;
898
+ let name = `${base}_${n}`;
899
+ while (taken.includes(name)) {
900
+ n++;
901
+ name = `${base}_${n}`;
902
+ }
903
+ return name;
904
+ }
905
+
906
+ //#endregion
907
+
908
+ //#endregion