@jarenjs/josl 0.72.0 → 0.72.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 CHANGED
@@ -147,7 +147,10 @@ for await (const record of iterateJoslStream(response.body, { signal }))
147
147
  Every reader refuses a document by size only when asked: the limits
148
148
  default to `Infinity`, count **UTF-8 bytes** (never code units), and are
149
149
  checked while the offending text is still in cutter, token or container
150
- state before a concatenation or a link could cross them. A crossing
150
+ state. Limited streams inspect bounded slices and reject an oversized pending
151
+ span before accepting another slice; complete spans are checked before decoding
152
+ or linking values. Total bytes include a leading BOM, and splitting a surrogate
153
+ pair across chunks does not change the byte count. A crossing
151
154
  throws `JoslLimitError` with a stable code and the limit; it is never a
152
155
  repair, because a document that is too large is not damaged.
153
156
 
@@ -159,7 +162,7 @@ repair, because a document that is too large is not damaged.
159
162
  | CSV | `maxColumns` | `CSV2004` |
160
163
  | JOSL / TOML | `maxTotalBytes` | `JOSL2001` |
161
164
  | JOSL / TOML | `maxRecordBytes` (one logical line) | `JOSL2002` |
162
- | JOSL / TOML | `maxTokenBytes` (a string or key as written, quotes included) | `JOSL2003` |
165
+ | JOSL / TOML | `maxTokenBytes` (a key or scalar token as written, quotes and regexp flags included) | `JOSL2003` |
163
166
  | JOSL / TOML | `maxDepth` (inline nesting, header path depth) | `JOSL2004` |
164
167
  | JOSL / TOML | `maxRetainedValues` (values the root holds; starts over per detached `[[]]` item) | `JOSL2005` |
165
168
  | JSONX / JSON stream | `maxTotalBytes` | `JSONX2001` |
@@ -172,6 +175,11 @@ the same machines; the JSONX limits are the stream reader's, which is
172
175
  where a document arrives a chunk at a time. `CSV_LIMIT_CODES`,
173
176
  `JOSL_LIMIT_CODES` and `JSONX_LIMIT_CODES` are the tables as data.
174
177
 
178
+ Field and token limits bound those spans, not an entire record containing many
179
+ small spans. Use `maxRecordBytes` or `maxTotalBytes` as well when the buffered
180
+ source itself needs a hard ceiling. Open strings, bare keys and scalar tokens
181
+ are checked as they arrive, including in the first chunk.
182
+
175
183
  Measured on a synthetic OpenStreetMap-shaped extract (`node --expose-gc
176
184
  benchmark/jsonx-stream.js`), reading 20 000 features of 40 vertices each:
177
185
 
@@ -457,12 +465,12 @@ acceptance corpus, and on a scorecard of damaged documents.
457
465
  <!--fact:csv.table-->
458
466
  | engine | csv-spectrum | 10k×6 plain | 10k×3 quoted | 1k×50 wide |
459
467
  | --- | --- | --- | --- | --- |
460
- | **jaren** | **11/11** | 2.3 ms | 3.4 ms | 1.4 ms |
461
- | udsv | 11/11 | **1.9 ms** | **3.1 ms** | **1.0 ms** |
462
- | papaparse | 11/11 | 6.8 ms | 8.0 ms | 2.1 ms |
463
- | csv-parse | 11/11 | 18.8 ms | 12.1 ms | 11.0 ms |
464
- | d3-dsv | 11/11 | 2.8 ms | 4.3 ms | 1.7 ms |
465
- | @vanillaes/csv | n/a | 7.9 ms | 7.9 ms | 4.8 ms |
468
+ | **jaren** | **11/11** | 2.2 ms | 3.0 ms | 1.3 ms |
469
+ | udsv | 11/11 | **1.8 ms** | **2.9 ms** | **1.0 ms** |
470
+ | papaparse | 11/11 | 5.8 ms | 7.5 ms | 2.1 ms |
471
+ | csv-parse | 11/11 | 17.7 ms | 11.9 ms | 11.0 ms |
472
+ | d3-dsv | 11/11 | 2.9 ms | 3.9 ms | 1.7 ms |
473
+ | @vanillaes/csv | n/a | 5.9 ms | 7.3 ms | 4.6 ms |
466
474
  <!--/fact-->
467
475
 
468
476
  (The suite's twelfth fixture, `location_coordinates`, is excluded: its
@@ -45,6 +45,8 @@ export declare class CsvMachine {
45
45
  maxColumns: number;
46
46
  limited: boolean;
47
47
  totalBytes: number;
48
+ byteTail: any;
49
+ scanFieldStart: number;
48
50
  wantHeader: boolean;
49
51
  headerFields: any;
50
52
  objectRows: boolean;
@@ -76,7 +78,6 @@ export declare class CsvMachine {
76
78
  */
77
79
  feed(chunk: string): this;
78
80
  count(text: any): void;
79
- endsRecordWithin(text: any): boolean;
80
81
  /**
81
82
  * Finish the document, flushing any pending record.
82
83
  * @returns {Array} The completed rows
@@ -101,6 +102,8 @@ export declare class CsvMachine {
101
102
  heal(code: any, line: any, column: any, detail?: undefined): void;
102
103
  scan(): void;
103
104
  compact(start: any): void;
105
+ /** Check a field span while its record is still incomplete. */
106
+ checkFieldBytes(text: any, start: any, end: any, recordStart?: number): void;
104
107
  readSpan(text: any, pos: any, end: any): any;
105
108
  admitCell(cells: any, text: any, start: any, stop: any): void;
106
109
  parseRecord(text: any, pos: any, end: any, cells: any): any;
@@ -8,6 +8,7 @@ export declare class JsonxMachine {
8
8
  maxDepth: number;
9
9
  maxRetainedValues: number;
10
10
  totalBytes: number;
11
+ byteTail: any;
11
12
  retained: number;
12
13
  partialFrom: number;
13
14
  partialHold: string;
@@ -21,6 +22,7 @@ export declare class JsonxMachine {
21
22
  curLine: number;
22
23
  lineStart: number;
23
24
  scanPos: number;
25
+ partialBoundary: boolean;
24
26
  scanInFlags: boolean;
25
27
  scanInClass: boolean;
26
28
  scanDtSpace: boolean;
@@ -1,3 +1,11 @@
1
+ /** Count a string stream without charging split surrogate pairs as two replacements.
2
+ * @param {{ byteTail?: number }} state @param {string} text @returns {number} */
3
+ export declare function chunkByteLength(state: {
4
+ byteTail?: number;
5
+ }, text: string): number;
6
+ /** Let a cutter inspect bounded additions, including the first incomplete token.
7
+ * @param {string} chunk @param {number} limit @param {(part: string) => void} consume */
8
+ export declare function feedBounded(chunk: string, limit: number, consume: (part: string) => void): void;
1
9
  /**
2
10
  * The CSV limits, by option name.
3
11
  * @type {Readonly<Record<'CSV2001' | 'CSV2002' | 'CSV2003' | 'CSV2004', string>>}
@@ -9,6 +9,12 @@ export declare class JoslMachine {
9
9
  maxDepth: number;
10
10
  maxRetainedValues: number;
11
11
  totalBytes: number;
12
+ byteTail: any;
13
+ scanTokenStart: number;
14
+ scanBareStart: number;
15
+ scanKeyMode: boolean;
16
+ scanContainers: any[];
17
+ scanRegExpClass: boolean;
12
18
  retained: number;
13
19
  depth: number;
14
20
  onLine: any;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/josl",
3
3
  "private": false,
4
- "version": "0.72.0",
4
+ "version": "0.72.2",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -99,6 +99,6 @@
99
99
  "./package.json": "./package.json"
100
100
  },
101
101
  "dependencies": {
102
- "@jarenjs/core": "^0.72.0"
102
+ "@jarenjs/core": "^0.72.2"
103
103
  }
104
104
  }
@@ -48,7 +48,7 @@ import {
48
48
  } from '@jarenjs/core/scan';
49
49
 
50
50
  import { CsvSyntaxError } from './errors.js';
51
- import { JoslLimitError, limitOption } from './limits.js';
51
+ import { JoslLimitError, limitOption, chunkByteLength, feedBounded } from './limits.js';
52
52
  import { columnOf, feedMachine, beginParseAll, offsetDateTime } from './util.js';
53
53
  import { setObjectMember } from '@jarenjs/core/object';
54
54
  import { utf8ByteLength } from '@jarenjs/core/string';
@@ -253,6 +253,8 @@ export class CsvMachine {
253
253
  this.limited = this.maxTotalBytes !== Infinity || this.maxRecordBytes !== Infinity
254
254
  || this.maxFieldBytes !== Infinity || this.maxColumns !== Infinity;
255
255
  this.totalBytes = 0;
256
+ this.byteTail = undefined;
257
+ this.scanFieldStart = 0;
256
258
 
257
259
  const headers = options.headers;
258
260
  this.wantHeader = headers === true;
@@ -299,44 +301,31 @@ export class CsvMachine {
299
301
  * @returns {this} The machine, for chaining
300
302
  */
301
303
  feed(chunk) {
302
- // Growth is append-only, so a found cursor position stays valid; but
303
- // a cursor that had run off the end must look again in the new data.
304
- if (this.nextDelim === -1)
305
- this.nextDelim = -2;
306
- if (this.nextLf === -1)
307
- this.nextLf = -2;
308
- if (this.nextCr === -1)
309
- this.nextCr = -2;
310
304
  if (this.limited)
311
305
  this.count(chunk);
312
- return feedMachine(this, chunk);
306
+ feedBounded(chunk, Math.min(this.maxRecordBytes, this.maxFieldBytes), (part) => {
307
+ // Every bounded addition can extend a delimiter search that ran
308
+ // off the previous buffer, just as a caller's next chunk can.
309
+ if (this.nextDelim === -1) this.nextDelim = -2;
310
+ if (this.nextLf === -1) this.nextLf = -2;
311
+ if (this.nextCr === -1) this.nextCr = -2;
312
+ feedMachine(this, part);
313
+ if (this.maxRecordBytes !== Infinity && utf8ByteLength(this.buf) > this.maxRecordBytes)
314
+ throw new JoslLimitError('CSV2002', 'a record exceeds maxRecordBytes', this.maxRecordBytes, this.recordOrigin);
315
+ const end = this.scanState !== S_QUOTED && this.buf.endsWith('\r') ? this.buf.length - 1 : this.buf.length;
316
+ this.checkFieldBytes(this.buf, this.scanFieldStart, end);
317
+ });
318
+ return this;
313
319
  }
314
320
 
315
- // The byte limits, checked BEFORE the chunk is buffered: a chunk that
316
- // takes the document past maxTotalBytes is refused whole, and a record
317
- // still being cut — the unconsumed tail plus this chunk — that would
318
- // pass maxRecordBytes is refused before the concatenation that would
319
- // hold it. `parseAll` counts its one text the same way.
321
+ // Whole-input accounting precedes buffering. Pending field and record
322
+ // spans are checked between bounded additions by feed().
320
323
  count(text) {
321
324
  if (this.maxTotalBytes !== Infinity) {
322
- this.totalBytes += utf8ByteLength(text);
325
+ this.totalBytes += chunkByteLength(this, text);
323
326
  if (this.totalBytes > this.maxTotalBytes)
324
327
  throw new JoslLimitError('CSV2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.line);
325
328
  }
326
- if (this.maxRecordBytes !== Infinity) {
327
- // the pending record is what the cutter has not handed off yet;
328
- // a fresh chunk extends it
329
- const pending = utf8ByteLength(this.buf) + utf8ByteLength(text);
330
- if (pending > this.maxRecordBytes && !this.endsRecordWithin(text))
331
- throw new JoslLimitError('CSV2002', 'a record exceeds maxRecordBytes', this.maxRecordBytes, this.recordOrigin);
332
- }
333
- }
334
-
335
- // Whether a chunk can end the pending record within the bound: cheap
336
- // and permissive — a terminator anywhere in the chunk means the cutter
337
- // gets its chance; the span check in readSpan is the exact judge.
338
- endsRecordWithin(text) {
339
- return text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0;
340
329
  }
341
330
 
342
331
  /**
@@ -367,9 +356,9 @@ export class CsvMachine {
367
356
  * @returns {Array} The completed rows
368
357
  */
369
358
  parseAll(text) {
370
- text = beginParseAll(this, text);
371
359
  if (this.maxTotalBytes !== Infinity && utf8ByteLength(text) > this.maxTotalBytes)
372
360
  throw new JoslLimitError('CSV2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, 1);
361
+ text = beginParseAll(this, text);
373
362
  this.readSpan(text, 0, text.length);
374
363
  return this.outRows;
375
364
  }
@@ -439,7 +428,7 @@ export class CsvMachine {
439
428
  // per-character machine. A record that does contain a quote is
440
429
  // handed to the machine below, one record at a time; one that
441
430
  // visibly STARTS with a quote skips the lookups outright.
442
- if (state === S_PLAIN || (state === S_START && buf.charCodeAt(pos) !== quote)) {
431
+ if (this.maxFieldBytes === Infinity && (state === S_PLAIN || (state === S_START && buf.charCodeAt(pos) !== quote))) {
443
432
  if (qi !== -1 && qi < pos)
444
433
  qi = quote < 0 ? -1 : buf.indexOf(this.quoteChar, pos);
445
434
  const qlimit = qi < 0 ? len : qi;
@@ -509,6 +498,8 @@ export class CsvMachine {
509
498
  continue;
510
499
  case S_PLAIN: {
511
500
  if (c === delim) {
501
+ this.checkFieldBytes(buf, this.scanFieldStart, pos, start);
502
+ this.scanFieldStart = pos + 1;
512
503
  state = S_START;
513
504
  pos++;
514
505
  continue;
@@ -517,6 +508,7 @@ export class CsvMachine {
517
508
  pos++;
518
509
  this.readSpan(buf, start, pos);
519
510
  start = pos;
511
+ this.scanFieldStart = pos;
520
512
  state = S_START;
521
513
  continue outer;
522
514
  }
@@ -539,6 +531,7 @@ export class CsvMachine {
539
531
  pos++;
540
532
  this.readSpan(buf, start, pos);
541
533
  start = pos;
534
+ this.scanFieldStart = pos;
542
535
  state = S_START;
543
536
  continue outer;
544
537
  }
@@ -597,6 +590,7 @@ export class CsvMachine {
597
590
  return;
598
591
  this.buf = this.buf.slice(start);
599
592
  this.scanPos -= start;
593
+ this.scanFieldStart = Math.max(0, this.scanFieldStart - start);
600
594
  // The scanner cursors are absolute in the buffer, so they shift with
601
595
  // it; one already consumed has no meaning in the new buffer.
602
596
  if (this.nextDelim >= 0)
@@ -611,6 +605,13 @@ export class CsvMachine {
611
605
 
612
606
  //#region record parsing
613
607
 
608
+ /** Check a field span while its record is still incomplete. */
609
+ checkFieldBytes(text, start, end, recordStart = 0) {
610
+ if (this.comment >= 0 && text.charCodeAt(recordStart) === this.comment) return;
611
+ if (this.maxFieldBytes !== Infinity && utf8ByteLength(text, start, end) > this.maxFieldBytes)
612
+ throw new JoslLimitError('CSV2003', 'a field exceeds maxFieldBytes', this.maxFieldBytes, this.line);
613
+ }
614
+
614
615
  // Read every record in `text[pos, end)`. A cut span always carries its
615
616
  // own terminator, so this is the same loop the whole-document form runs
616
617
  // and a blank line is a record of one empty field in both.
package/src/csv.js CHANGED
@@ -166,7 +166,8 @@ export function sniffCsvDialect(text, options = undefined) {
166
166
  let bestWidth = 0;
167
167
  let bestRows = null;
168
168
 
169
- for (const candidate of CANDIDATES) {
169
+ const candidates = opts.delimiter && opts.delimiter !== 'auto' ? [opts.delimiter] : CANDIDATES;
170
+ for (const candidate of candidates) {
170
171
  const probe = probeDelimiter(sample, candidate, opts);
171
172
  if (probe.score > bestScore || (probe.score === bestScore && probe.width > bestWidth)) {
172
173
  bestScore = probe.score;
@@ -79,7 +79,7 @@ import {
79
79
  } from '@jarenjs/core/scan';
80
80
 
81
81
  import { JsonxSyntaxError } from './errors.js';
82
- import { JoslLimitError, limitOption } from './limits.js';
82
+ import { JoslLimitError, limitOption, chunkByteLength, feedBounded } from './limits.js';
83
83
  import { setObjectMember } from '@jarenjs/core/object';
84
84
  import { utf8ByteLength } from '@jarenjs/core/string';
85
85
  import {
@@ -162,6 +162,7 @@ export class JsonxMachine {
162
162
  this.maxDepth = limitOption(options, 'maxDepth');
163
163
  this.maxRetainedValues = limitOption(options, 'maxRetainedValues');
164
164
  this.totalBytes = 0;
165
+ this.byteTail = undefined;
165
166
  this.retained = 0; // values linked into the tree
166
167
  this.partialFrom = -1; // body offset the next text-partial delta starts at
167
168
  this.partialHold = ''; // lone high surrogate held back for the next delta
@@ -179,6 +180,7 @@ export class JsonxMachine {
179
180
  this.lineStart = 0; // buf offset of the current line start (may go negative after compaction)
180
181
  // resumable token-scan state
181
182
  this.scanPos = -1; // where the pending token's scan left off
183
+ this.partialBoundary = true;
182
184
  this.scanInFlags = false; // regexp scan: past the closing '/'
183
185
  this.scanInClass = false; // regexp scan: inside [...]
184
186
  this.scanDtSpace = false; // scalar scan: crossed a date-time space separator
@@ -198,19 +200,21 @@ export class JsonxMachine {
198
200
  throw new Error('cannot feed after end()');
199
201
  if (chunk.length !== 0) {
200
202
  if (this.maxTotalBytes !== Infinity) {
201
- this.totalBytes += utf8ByteLength(chunk);
203
+ this.totalBytes += chunkByteLength(this, chunk);
202
204
  if (this.totalBytes > this.maxTotalBytes)
203
205
  throw new JoslLimitError('JSONX2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.curLine);
204
206
  }
205
- if (this.maxTokenBytes !== Infinity && this.scanPos >= 0) {
206
- // a token still being scanned grows by this chunk: refused
207
- // before the concatenation that would hold it
208
- const pending = utf8ByteLength(this.buf, this.pos, this.buf.length) + utf8ByteLength(chunk);
209
- if (pending > this.maxTokenBytes)
210
- throw new JoslLimitError('JSONX2002', 'a token exceeds maxTokenBytes', this.maxTokenBytes, this.curLine);
207
+ let remaining = chunk.length;
208
+ try {
209
+ feedBounded(chunk, this.maxTokenBytes, (part) => {
210
+ remaining -= part.length;
211
+ this.partialBoundary = remaining === 0;
212
+ this.buf += part;
213
+ this.pump();
214
+ if (this.scanPos >= 0) this.token(this.buf, this.pos, this.buf.length);
215
+ });
211
216
  }
212
- this.buf += chunk;
213
- this.pump();
217
+ finally { this.partialBoundary = true; }
214
218
  }
215
219
  return this;
216
220
  }
@@ -426,7 +430,8 @@ export class JsonxMachine {
426
430
  if (c === CC_DQUOTE) {
427
431
  const end = this.scanString(buf, pos);
428
432
  if (end < 0) {
429
- if (this.partialText)
433
+ this.token(buf, pos, buf.length);
434
+ if (this.partialText && this.partialBoundary)
430
435
  this.emitPartialText(buf, pos, buf.length, true);
431
436
  return -1;
432
437
  }
package/src/limits.js CHANGED
@@ -1,9 +1,32 @@
1
+ //@ts-check
2
+ import { utf8ByteLength } from '@jarenjs/core/string';
3
+
4
+ /** Count a string stream without charging split surrogate pairs as two replacements.
5
+ * @param {{ byteTail?: number }} state @param {string} text @returns {number} */
6
+ export function chunkByteLength(state, text) {
7
+ if (text.length === 0) return 0;
8
+ const first = text.charCodeAt(0);
9
+ const joined = state.byteTail >= 0xD800 && state.byteTail <= 0xDBFF
10
+ && first >= 0xDC00 && first <= 0xDFFF;
11
+ state.byteTail = text.charCodeAt(text.length - 1);
12
+ return utf8ByteLength(text) - (joined ? 2 : 0);
13
+ }
14
+
15
+ /** Let a cutter inspect bounded additions, including the first incomplete token.
16
+ * @param {string} chunk @param {number} limit @param {(part: string) => void} consume */
17
+ export function feedBounded(chunk, limit, consume) {
18
+ if (limit === Infinity || chunk.length === 0) { consume(chunk); return; }
19
+ const size = Math.max(1, Math.min(16384, limit));
20
+ for (let offset = 0; offset < chunk.length; offset += size) consume(chunk.slice(offset, offset + size));
21
+ }
22
+
1
23
  //#region hostile-input limits
2
24
  // The three readers and the CSV machine take the same shape of guard:
3
25
  // a limit that defaults to Infinity — nothing in this package refuses a
4
26
  // document by size unless a caller asks — and, once asked, is checked
5
27
  // while the offending text is still in cutter, token or container
6
- // state, before a concatenation or a link could cross it. Every limit
28
+ // state. Limited streams inspect bounded additions before accepting another;
29
+ // complete spans are checked before decoding or linking values. Every limit
7
30
  // counts UTF-8 bytes, never JavaScript code units: a limit stated in
8
31
  // bytes is the one an HTTP body limit, a disk quota or a proxy speaks.
9
32
  //
package/src/machine.js CHANGED
@@ -53,7 +53,7 @@ import {
53
53
  } from '@jarenjs/core/scan';
54
54
 
55
55
  import { JoslSyntaxError } from './errors.js';
56
- import { JoslLimitError, limitOption } from './limits.js';
56
+ import { JoslLimitError, limitOption, chunkByteLength, feedBounded } from './limits.js';
57
57
  import {
58
58
  LocalDate,
59
59
  LocalTime,
@@ -81,6 +81,8 @@ const S_LITERAL = 2; // '...' (single line)
81
81
  const S_ML_BASIC = 3; // """..."""
82
82
  const S_ML_LITERAL = 4; // '''...'''
83
83
  const S_COMMENT = 5;
84
+ const S_REGEXP = 6;
85
+ const S_REGEXP_FLAGS = 7;
84
86
 
85
87
  // Runs of characters that cannot end a logical line or change the cutter's
86
88
  // state. The cutter skips them with the regex engine rather than stepping
@@ -89,7 +91,7 @@ const S_COMMENT = 5;
89
91
  // logical line spans without a separate walk over it.
90
92
  // The basic/literal classes serve both the single- and multi-line states:
91
93
  // a `'` is inert inside a basic string and a `"` inside a literal one.
92
- const RUN_NONE = /[^\n"'#[\]]*/y;
94
+ const RUN_NONE = /[^\n"'#[\]/]*/y;
93
95
  const RUN_BASIC = /[^\n"\\]*/y;
94
96
  const RUN_LITERAL = /[^\n']*/y;
95
97
 
@@ -146,6 +148,12 @@ export class JoslMachine {
146
148
  this.maxDepth = limitOption(options, 'maxDepth');
147
149
  this.maxRetainedValues = limitOption(options, 'maxRetainedValues');
148
150
  this.totalBytes = 0;
151
+ this.byteTail = undefined;
152
+ this.scanTokenStart = -1;
153
+ this.scanBareStart = -1;
154
+ this.scanKeyMode = true;
155
+ this.scanContainers = [];
156
+ this.scanRegExpClass = false;
149
157
  this.retained = 0; // values linked into the root since the last detachment
150
158
  this.depth = 0; // inline container nesting while a value is parsed
151
159
  // Logical-line sink used by the CST layer: reports each line's source
@@ -185,18 +193,20 @@ export class JoslMachine {
185
193
  */
186
194
  feed(chunk) {
187
195
  if (this.maxTotalBytes !== Infinity) {
188
- this.totalBytes += utf8ByteLength(chunk);
196
+ this.totalBytes += chunkByteLength(this, chunk);
189
197
  if (this.totalBytes > this.maxTotalBytes)
190
198
  throw new JoslLimitError('JOSL2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.startLine);
191
199
  }
192
- if (this.maxRecordBytes !== Infinity && chunk.indexOf('\n') < 0
193
- && utf8ByteLength(this.buf) + utf8ByteLength(chunk) > this.maxRecordBytes) {
194
- // the logical line still being cut, plus this chunk, would pass the
195
- // bound before any newline could end it: refused before the
196
- // concatenation that would hold it
197
- throw new JoslLimitError('JOSL2002', 'a logical line exceeds maxRecordBytes', this.maxRecordBytes, this.startLine);
198
- }
199
- return feedMachine(this, chunk);
200
+ feedBounded(chunk, Math.min(this.maxRecordBytes, this.maxTokenBytes), (part) => {
201
+ feedMachine(this, part);
202
+ const end = this.scanDepth === 0 && (this.scanState === S_NONE || this.scanState === S_COMMENT)
203
+ && this.buf.endsWith('\r') ? this.buf.length - 1 : this.buf.length;
204
+ if (this.maxRecordBytes !== Infinity && utf8ByteLength(this.buf, 0, end) > this.maxRecordBytes)
205
+ throw new JoslLimitError('JOSL2002', 'a logical line exceeds maxRecordBytes', this.maxRecordBytes, this.startLine);
206
+ if (this.scanTokenStart >= 0) this.token(this.buf, this.scanTokenStart, this.buf.length);
207
+ if (this.scanBareStart >= 0) this.token(this.buf, this.scanBareStart, this.buf.length);
208
+ });
209
+ return this;
200
210
  }
201
211
 
202
212
  /**
@@ -232,9 +242,9 @@ export class JoslMachine {
232
242
  * @returns {*} The completed root value
233
243
  */
234
244
  parseAll(text) {
235
- text = beginParseAll(this, text);
236
245
  if (this.maxTotalBytes !== Infinity && utf8ByteLength(text) > this.maxTotalBytes)
237
246
  throw new JoslLimitError('JOSL2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, 1);
247
+ text = beginParseAll(this, text);
238
248
  // positions are offsets into the whole source, which starts at line 1
239
249
  this.lineOrigin = 1;
240
250
  const tracking = this.onEvent !== null;
@@ -327,20 +337,54 @@ export class JoslMachine {
327
337
  let state = this.scanState;
328
338
  let depth = this.scanDepth;
329
339
  let nl = this.scanNl;
340
+ let tokenStart = this.scanTokenStart;
341
+ let bareStart = this.scanBareStart;
342
+ let keyMode = this.scanKeyMode;
343
+ const containers = this.scanContainers;
344
+ const limited = this.maxTokenBytes !== Infinity;
330
345
  const ended = this.ended;
331
346
  outer:
332
347
  while (pos < buf.length) {
333
348
  switch (state) {
334
349
  case S_NONE: {
335
- pos = skipRun(RUN_NONE, buf, pos);
350
+ if (!limited) pos = skipRun(RUN_NONE, buf, pos);
336
351
  if (pos >= buf.length)
337
352
  break outer;
338
353
  const c = buf.charCodeAt(pos);
354
+ if (limited) {
355
+ const boundary = c === CC_SPACE || c === CC_TAB || c === CC_CR || c === CC_LF
356
+ || c === CC_EQ || c === CC_COMMA || c === CC_LBRACE || c === CC_RBRACE
357
+ || c === CC_LBRACKET || c === CC_RBRACKET || c === CC_HASH
358
+ || c === CC_DQUOTE || c === CC_SQUOTE || (keyMode && c === CC_DOT)
359
+ || (!keyMode && c === CC_SLASH);
360
+ if (boundary) {
361
+ if (bareStart >= 0) this.token(buf, bareStart, pos);
362
+ bareStart = -1;
363
+ }
364
+ else if (bareStart < 0) bareStart = pos;
365
+ if (c === CC_EQ) keyMode = false;
366
+ else if (c === CC_LBRACE) { containers.push('table'); keyMode = true; }
367
+ else if (c === CC_LBRACKET) {
368
+ const header = keyMode && (containers.length === 0 || containers.at(-1) === 'header');
369
+ containers.push(header ? 'header' : 'array');
370
+ keyMode = header;
371
+ }
372
+ else if (c === CC_RBRACKET || c === CC_RBRACE) { containers.pop(); keyMode = false; }
373
+ else if (c === CC_COMMA) keyMode = containers.at(-1) === 'table';
374
+ }
375
+ if (c === CC_SLASH) {
376
+ tokenStart = pos++;
377
+ this.scanRegExpClass = false;
378
+ state = S_REGEXP;
379
+ break;
380
+ }
339
381
  if (c === CC_LF) {
340
382
  if (depth === 0) {
341
383
  this.cutLine(buf, lineStart, pos, nl);
342
384
  lineStart = pos + 1;
343
385
  nl = 0;
386
+ keyMode = true;
387
+ containers.length = 0;
344
388
  }
345
389
  else
346
390
  nl++;
@@ -348,6 +392,7 @@ export class JoslMachine {
348
392
  break;
349
393
  }
350
394
  if (c === CC_DQUOTE || c === CC_SQUOTE) {
395
+ tokenStart = pos;
351
396
  if (pos + 2 >= buf.length && !ended)
352
397
  break outer; // may be a triple delimiter split across chunks
353
398
  if (buf.charCodeAt(pos + 1) === c && buf.charCodeAt(pos + 2) === c) {
@@ -383,6 +428,8 @@ export class JoslMachine {
383
428
  this.cutLine(buf, lineStart, at, nl);
384
429
  lineStart = at + 1;
385
430
  nl = 0;
431
+ keyMode = true;
432
+ containers.length = 0;
386
433
  }
387
434
  else
388
435
  nl++;
@@ -390,6 +437,28 @@ export class JoslMachine {
390
437
  pos = at + 1;
391
438
  break;
392
439
  }
440
+ case S_REGEXP: {
441
+ const c = buf.charCodeAt(pos);
442
+ if (c === CC_BACKSLASH) {
443
+ if (pos + 1 >= buf.length && !ended) break outer;
444
+ pos += 2;
445
+ break;
446
+ }
447
+ if (c === CC_LF) this.cutLine(buf, lineStart, pos, nl);
448
+ if (c === CC_LBRACKET) this.scanRegExpClass = true;
449
+ else if (c === CC_RBRACKET) this.scanRegExpClass = false;
450
+ else if (c === CC_SLASH && !this.scanRegExpClass) state = S_REGEXP_FLAGS;
451
+ pos++;
452
+ break;
453
+ }
454
+ case S_REGEXP_FLAGS:
455
+ if (isAsciiLetterCode(buf.charCodeAt(pos))) pos++;
456
+ else {
457
+ this.token(buf, tokenStart, pos);
458
+ tokenStart = -1;
459
+ state = S_NONE;
460
+ }
461
+ break;
393
462
  case S_BASIC:
394
463
  case S_LITERAL: {
395
464
  const basic = state === S_BASIC;
@@ -418,6 +487,8 @@ export class JoslMachine {
418
487
  }
419
488
  state = S_NONE; // the run only stops on the closing quote
420
489
  pos++;
490
+ this.token(buf, tokenStart, pos);
491
+ tokenStart = -1;
421
492
  break;
422
493
  }
423
494
  case S_ML_BASIC:
@@ -444,8 +515,11 @@ export class JoslMachine {
444
515
  run++;
445
516
  if (run === buf.length && run - pos < 3 && !ended)
446
517
  break outer; // quote run may continue in the next chunk
447
- if (run - pos >= 3)
518
+ if (run - pos >= 3) {
448
519
  state = S_NONE;
520
+ this.token(buf, tokenStart, run);
521
+ tokenStart = -1;
522
+ }
449
523
  pos = run;
450
524
  break;
451
525
  }
@@ -460,6 +534,9 @@ export class JoslMachine {
460
534
  this.scanState = state;
461
535
  this.scanDepth = depth;
462
536
  this.scanNl = nl;
537
+ this.scanTokenStart = tokenStart < 0 ? -1 : tokenStart - lineStart;
538
+ this.scanBareStart = bareStart < 0 ? -1 : bareStart - lineStart;
539
+ this.scanKeyMode = keyMode;
463
540
  }
464
541
 
465
542
  // `innerNl` is how many newlines the cutter already counted inside this
@@ -876,6 +953,7 @@ export class JoslMachine {
876
953
  const start = pos;
877
954
  while (pos < line.length && isAsciiLetterCode(line.charCodeAt(pos)))
878
955
  pos++;
956
+ this.token(line, start, pos);
879
957
  const word = line.slice(start, pos);
880
958
  switch (word) {
881
959
  case 'true': return [true, this.checkValueEnd(line, pos)];
@@ -899,6 +977,7 @@ export class JoslMachine {
899
977
  const start = p;
900
978
  while (p < line.length && isAsciiLetterCode(line.charCodeAt(p)))
901
979
  p++;
980
+ this.token(line, pos, p);
902
981
  const word = line.slice(start, p);
903
982
  if (word === 'inf')
904
983
  return [neg ? -Infinity : Infinity, this.checkValueEnd(line, p)];
@@ -1216,11 +1295,13 @@ export class JoslMachine {
1216
1295
  break;
1217
1296
  pos++;
1218
1297
  }
1298
+ this.token(line, start, pos + 1);
1219
1299
  const body = line.slice(start + 1, pos);
1220
1300
  pos++; // consume '/'
1221
1301
  const flagStart = pos;
1222
1302
  while (pos < line.length && isAsciiLetterCode(line.charCodeAt(pos)))
1223
1303
  pos++;
1304
+ this.token(line, start, pos);
1224
1305
  const flags = line.slice(flagStart, pos);
1225
1306
  try {
1226
1307
  return [new RegExp(body, flags), this.checkValueEnd(line, pos)];
@@ -1237,6 +1318,7 @@ export class JoslMachine {
1237
1318
  return this.parseNumber(line, pos);
1238
1319
  let m = stickyExec(RE_DATETIME, line, pos);
1239
1320
  if (m !== null) {
1321
+ this.token(line, pos, pos + m[0].length);
1240
1322
  const year = Number(m[1]);
1241
1323
  const month = Number(m[2]);
1242
1324
  const day = Number(m[3]);
@@ -1261,6 +1343,7 @@ export class JoslMachine {
1261
1343
  }
1262
1344
  m = stickyExec(RE_TIMEONLY, line, pos);
1263
1345
  if (m !== null) {
1346
+ this.token(line, pos, pos + m[0].length);
1264
1347
  const hour = Number(m[1]);
1265
1348
  const minute = Number(m[2]);
1266
1349
  const second = Number(m[3]);
@@ -1309,6 +1392,7 @@ export class JoslMachine {
1309
1392
  while (p < line.length && isDigitCode(line.charCodeAt(p)))
1310
1393
  p++;
1311
1394
  if ((p - pos === 1 || c0 !== CC_0) && atValueEnd(line, p)) {
1395
+ this.token(line, pos, p);
1312
1396
  const source = line.slice(pos, p);
1313
1397
  return [this.intValue(pos, source, false, source), p];
1314
1398
  }
@@ -1325,6 +1409,7 @@ export class JoslMachine {
1325
1409
  m = stickyExec(RE_BIN, line, pos);
1326
1410
  }
1327
1411
  if (m !== null) {
1412
+ this.token(line, pos, pos + m[0].length);
1328
1413
  const big = this.bigIntCheck(pos, m[1]);
1329
1414
  const stripped = (big ? m[0].slice(0, -1) : m[0]).replace(/_/g, '');
1330
1415
  const end = this.checkValueEnd(line, pos + m[0].length);
@@ -1333,6 +1418,7 @@ export class JoslMachine {
1333
1418
  m = stickyExec(RE_NUM, line, pos);
1334
1419
  if (m === null)
1335
1420
  this.err(pos, 'invalid number');
1421
+ this.token(line, pos, pos + m[0].length);
1336
1422
  const big = this.bigIntCheck(pos, m[1]);
1337
1423
  const token = big ? m[0].slice(0, -1) : m[0];
1338
1424
  const isFloat = /[.eE]/.test(token);