@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.
- package/FORMAT.md +235 -0
- package/README.md +494 -0
- package/dist/types/cst.d.ts +78 -0
- package/dist/types/csv-machine.d.ts +104 -0
- package/dist/types/csv-stream.d.ts +102 -0
- package/dist/types/csv.d.ts +141 -0
- package/dist/types/errors.d.ts +79 -0
- package/dist/types/gbnf.d.ts +20 -0
- package/dist/types/index.d.ts +12 -0
- package/dist/types/jsonx-scalar.d.ts +92 -0
- package/dist/types/jsonx-stream.d.ts +163 -0
- package/dist/types/jsonx.d.ts +31 -0
- package/dist/types/machine.d.ts +97 -0
- package/dist/types/parse.d.ts +24 -0
- package/dist/types/stream.d.ts +32 -0
- package/dist/types/stringify.d.ts +63 -0
- package/dist/types/util.d.ts +56 -0
- package/dist/types/values.d.ts +60 -0
- package/dist/types/write.d.ts +92 -0
- package/package.json +104 -0
- package/schemas/jaren-josl-data.schema.json +21 -0
- package/src/cst.js +256 -0
- package/src/csv-machine.js +908 -0
- package/src/csv-stream.js +196 -0
- package/src/csv.js +363 -0
- package/src/errors.js +103 -0
- package/src/gbnf.js +179 -0
- package/src/index.js +50 -0
- package/src/jsonx-scalar.js +326 -0
- package/src/jsonx-stream.js +806 -0
- package/src/jsonx.js +342 -0
- package/src/machine.js +1252 -0
- package/src/parse.js +37 -0
- package/src/stream.js +57 -0
- package/src/stringify.js +341 -0
- package/src/util.js +96 -0
- package/src/values.js +104 -0
- package/src/write.js +226 -0
|
@@ -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
|