@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,806 @@
1
+ //#region JSONX streaming reader
2
+ // Chunk-feedable incremental reader for JSONX and strict JSON, mirroring
3
+ // the JOSL reader's API shape (feed/end/root). Chunks may split ANY token
4
+ // — escapes mid-`\uXXXX`, numbers, literals (`tru` + `e`), surrogate
5
+ // pairs — which is what makes token-by-token LLM output feedable.
6
+ //
7
+ // Events, in document order (paths are absolute and JSON-Pointer-able:
8
+ // strings for object keys, numbers for array indices):
9
+ //
10
+ // {type:'object-start', path, line} - '{' opened
11
+ // {type:'array-start', path, line} - '[' opened
12
+ // {type:'pair', path, key, value, line} - scalar object member,
13
+ // fired on completion
14
+ // {type:'item', path, index, value, line} - scalar array element,
15
+ // fired on completion
16
+ // {type:'object-end', path, value, line} - container completed
17
+ // {type:'array-end', path, value, line}
18
+ //
19
+ // Scalars fire once, on completion (a number is complete only at its
20
+ // delimiter; a string at its closing quote). A container value does NOT
21
+ // additionally fire `pair`/`item` — its `*-start`/`*-end` events carry
22
+ // that. This is deliberately asymmetric with the JOSL reader, where
23
+ // inline tables arrive as completed `pair` values and containers have no
24
+ // end events: JSON nests, JOSL's line-oriented grammar does not. The
25
+ // `pair` event itself is shared verbatim between both readers, so a
26
+ // consumer keyed on `pair` paths never branches on syntax.
27
+ //
28
+ // A document whose root is a single scalar emits no events; the value is
29
+ // available from `end()` (and `root()` once complete).
30
+ //
31
+ // With `partialText: true` a string value additionally emits
32
+ //
33
+ // {type:'text-partial', path, text, line} - more of a string arrived
34
+ //
35
+ // each time a chunk leaves it unterminated, plus a closing one when it
36
+ // completes. `text` is the *delta* since the previous event, already
37
+ // unescaped, so a progressive display appends it directly and a value's
38
+ // deltas always add up to exactly its string — there is no tail left over
39
+ // in the `pair`/`item` event, which still carries the whole value as the
40
+ // completion signal. Deltas never split a surrogate pair or an escape.
41
+ // Object keys emit none: a key has no path until it is complete, and half
42
+ // a key is not something to display.
43
+ //
44
+ // With `detach: [...path pattern...]` a value whose absolute path matches
45
+ // is **never linked into the tree**. Its completion event still fires and
46
+ // still carries the whole value, so a consumer sees every record exactly
47
+ // once; what changes is that letting go of the event lets go of the
48
+ // record. Without it, feeding a document in chunks bounds the *parse*
49
+ // but not the *result* — the reader still ends up holding everything it
50
+ // has read, which is the wrong answer for a continent-sized
51
+ // FeatureCollection or a log with a million lines.
52
+ //
53
+ // createJsonxStreamReader({ detach: ['features', '*'], onEvent })
54
+ //
55
+ // leaves `root()` holding the document's frame — its header members and
56
+ // an empty `features` array — however many features went past. The
57
+ // pattern matches an exact path, not a prefix, so a feature's own rings
58
+ // are not separately detached: they belong to their feature and are
59
+ // freed with it.
60
+
61
+ import {
62
+ CC_TAB,
63
+ CC_LF,
64
+ CC_CR,
65
+ CC_SPACE,
66
+ CC_DQUOTE,
67
+ CC_PLUS,
68
+ CC_COMMA,
69
+ CC_MINUS,
70
+ CC_SLASH,
71
+ CC_COLON,
72
+ CC_LBRACKET,
73
+ CC_BACKSLASH,
74
+ CC_RBRACKET,
75
+ CC_LBRACE,
76
+ CC_RBRACE,
77
+ isDigitCode,
78
+ isAsciiLetterCode,
79
+ } from '@jarenjs/core/scan';
80
+
81
+ import { JsonxSyntaxError } from './errors.js';
82
+ import { setObjectMember } from '@jarenjs/core/object';
83
+ import {
84
+ isValueEndCode,
85
+ decodeString,
86
+ decodeStringSpan,
87
+ matchDateTime,
88
+ matchNumber,
89
+ matchWord,
90
+ matchRegExp,
91
+ } from './jsonx-scalar.js';
92
+
93
+ // Machine states: what the next non-whitespace character must be.
94
+ const ST_VALUE = 0; // a value (root, after ':', or after ',' in an array)
95
+ const ST_ELEM_FIRST = 1; // inside a fresh '[': a value or ']'
96
+ const ST_KEY_FIRST = 2; // inside a fresh '{': a key or '}'
97
+ const ST_KEY = 3; // after ',' in an object: a key
98
+ const ST_COLON = 4; // after a key: ':'
99
+ const ST_OBJ_NEXT = 5; // after a member value: ',' or '}'
100
+ const ST_ARR_NEXT = 6; // after an element value: ',' or ']'
101
+ const ST_DONE = 7; // after the root value: whitespace only
102
+
103
+ const RE_BARE_DATE = /^\d{4}-\d{2}-\d{2}$/;
104
+
105
+ /** The one-segment wildcard in a detach pattern. */
106
+ const WILDCARD = '*';
107
+
108
+ /**
109
+ * Validate a detach path pattern: a non-empty array of segments, each a
110
+ * string key, a non-negative integer index, or `'*'` matching any one
111
+ * segment. `['features', '*']` names every member of the root's
112
+ * `features` array; `['*']` names every element of an array root.
113
+ *
114
+ * A pattern matches a value's own **absolute path**, exactly — it is not
115
+ * a prefix. That is what makes it safe on nested data: `['features',
116
+ * '*']` detaches each feature and says nothing about the rings inside
117
+ * it, which travel with their feature and are freed with it.
118
+ *
119
+ * Note the ambiguity `'*'` carries, the same one JSONPath has: a literal
120
+ * object key spelled `*` cannot be named. Nothing here can fix that
121
+ * without a second syntax, and the trade is worth it.
122
+ *
123
+ * @param {any} spec
124
+ * @returns {(string|number)[]}
125
+ * @throws {TypeError} On a malformed pattern
126
+ */
127
+ function detachPattern(spec) {
128
+ if (!Array.isArray(spec) || spec.length === 0)
129
+ throw new TypeError('detach must be a non-empty array of path segments');
130
+ for (const segment of spec) {
131
+ const ok = typeof segment === 'string'
132
+ || (typeof segment === 'number' && Number.isInteger(segment) && segment >= 0);
133
+ if (!ok) {
134
+ throw new TypeError(
135
+ `detach segment ${JSON.stringify(segment)} must be a string key, a non-negative integer index, or '*'`);
136
+ }
137
+ }
138
+ return spec.slice();
139
+ }
140
+
141
+ export class JsonxMachine {
142
+ /**
143
+ * @param {object} [options] - Reader options
144
+ * @param {'jsonx'|'json'} [options.mode] - 'json' rejects every JSONX
145
+ * extension (bigint, regexp, datetime, non-finite, separators, +)
146
+ * @param {(event: JsonxStreamEvent) => void} [options.onEvent] - Event sink
147
+ * @param {boolean} [options.partialText] - Also emit `text-partial`
148
+ * deltas while a string value is still arriving
149
+ * @param {(string|number)[]} [options.detach] - Path pattern whose
150
+ * matching values are NOT retained in the root (see `detachPattern`)
151
+ */
152
+ constructor(options = {}) {
153
+ this.mode = options.mode === 'json' ? 'json' : 'jsonx';
154
+ this.onEvent = options.onEvent ?? null;
155
+ this.partialText = options.partialText === true;
156
+ this.detach = options.detach === undefined ? null : detachPattern(options.detach);
157
+ this.partialFrom = -1; // body offset the next text-partial delta starts at
158
+ this.partialHold = ''; // lone high surrogate held back for the next delta
159
+ this.buf = '';
160
+ this.pos = 0; // consumed up to here; stalls at the pending token start
161
+ this.state = ST_VALUE;
162
+ this.stack = []; // open container frames
163
+ this.path = []; // absolute path of the innermost open container
164
+ this.rootValue = undefined;
165
+ this.ended = false;
166
+ // line/column bookkeeping; newlines only ever occur as whitespace in
167
+ // this grammar (strings and regexps must escape them), so tracking
168
+ // them in the whitespace skipper alone is exact
169
+ this.curLine = 1;
170
+ this.lineStart = 0; // buf offset of the current line start (may go negative after compaction)
171
+ // resumable token-scan state
172
+ this.scanPos = -1; // where the pending token's scan left off
173
+ this.scanInFlags = false; // regexp scan: past the closing '/'
174
+ this.scanInClass = false; // regexp scan: inside [...]
175
+ this.scanDtSpace = false; // scalar scan: crossed a date-time space separator
176
+ this.errCb = (pos, message, hint) => this.errAt(pos, message, hint);
177
+ this.endCb = (pos) => this.checkValueEnd(this.buf, pos);
178
+ }
179
+
180
+ //#region public surface
181
+
182
+ /**
183
+ * Feed the next chunk of source text; chunks may split any token.
184
+ * @param {string} chunk - Next piece of the document
185
+ * @returns {this} The machine, for chaining
186
+ */
187
+ feed(chunk) {
188
+ if (this.ended)
189
+ throw new Error('cannot feed after end()');
190
+ if (chunk.length !== 0) {
191
+ this.buf += chunk;
192
+ this.pump();
193
+ }
194
+ return this;
195
+ }
196
+
197
+ /**
198
+ * Finish the document, flushing any pending token.
199
+ * @returns {*} The completed root value
200
+ * @throws {JsonxSyntaxError} When the document is incomplete or invalid
201
+ */
202
+ end() {
203
+ if (this.ended)
204
+ return this.rootValue;
205
+ this.ended = true;
206
+ this.pump();
207
+ if (this.state !== ST_DONE)
208
+ this.endError();
209
+ return this.rootValue;
210
+ }
211
+
212
+ /**
213
+ * The (possibly still growing) root value. Undefined until the root
214
+ * value has started; container members appear as they complete.
215
+ *
216
+ * Under `detach`, matching values were never linked in — the root is
217
+ * the document's *frame* (its header members, and an empty array where
218
+ * the detached records would have been), which is the whole point.
219
+ * @returns {*} Current root value
220
+ */
221
+ root() {
222
+ return this.rootValue;
223
+ }
224
+
225
+ //#endregion
226
+
227
+ //#region errors & events
228
+
229
+ errAt(pos, message, hint) {
230
+ throw new JsonxSyntaxError(message, this.curLine, pos - this.lineStart + 1, hint);
231
+ }
232
+
233
+ endError() {
234
+ const pos = this.buf.length;
235
+ switch (this.state) {
236
+ case ST_VALUE:
237
+ case ST_ELEM_FIRST:
238
+ this.errAt(pos, 'unexpected end of input');
239
+ break;
240
+ case ST_KEY_FIRST:
241
+ case ST_KEY:
242
+ this.errAt(pos, 'expected a string key');
243
+ break;
244
+ case ST_COLON:
245
+ this.errAt(pos, "expected ':' after key");
246
+ break;
247
+ case ST_OBJ_NEXT:
248
+ this.errAt(pos, 'unterminated object', "close the object with '}'");
249
+ break;
250
+ case ST_ARR_NEXT:
251
+ this.errAt(pos, 'unterminated array', "close the array with ']'");
252
+ break;
253
+ }
254
+ }
255
+
256
+ //#endregion
257
+
258
+ //#region pump
259
+
260
+ // Consume as much of the buffer as possible. Exits when the buffer is
261
+ // exhausted or the pending token needs input that has not arrived yet
262
+ // (this.pos then rests on the token start; the scan* fields remember
263
+ // how far scanning got so nothing is rescanned on the next feed).
264
+ pump() {
265
+ const buf = this.buf;
266
+ let pos = this.pos;
267
+ pumping:
268
+ for (;;) {
269
+ // skip whitespace, tracking physical lines
270
+ while (pos < buf.length) {
271
+ const c = buf.charCodeAt(pos);
272
+ if (c === CC_LF) {
273
+ this.curLine++;
274
+ this.lineStart = pos + 1;
275
+ pos++;
276
+ continue;
277
+ }
278
+ if (c === CC_SPACE || c === CC_TAB || c === CC_CR) {
279
+ pos++;
280
+ continue;
281
+ }
282
+ break;
283
+ }
284
+ this.pos = pos;
285
+ if (pos >= buf.length)
286
+ break;
287
+ const c = buf.charCodeAt(pos);
288
+ switch (this.state) {
289
+ case ST_DONE:
290
+ this.errAt(pos, 'unexpected trailing characters');
291
+ break;
292
+ case ST_KEY_FIRST:
293
+ case ST_KEY: {
294
+ if (this.state === ST_KEY_FIRST && c === CC_RBRACE) {
295
+ this.closeContainer();
296
+ pos++;
297
+ continue;
298
+ }
299
+ if (c !== CC_DQUOTE)
300
+ this.errAt(pos, 'expected a string key');
301
+ const end = this.scanString(buf, pos);
302
+ if (end < 0)
303
+ break pumping;
304
+ this.stack[this.stack.length - 1].key = decodeString(buf, pos, this.errCb)[0];
305
+ this.state = ST_COLON;
306
+ pos = end;
307
+ continue;
308
+ }
309
+ case ST_COLON:
310
+ if (c !== CC_COLON)
311
+ this.errAt(pos, "expected ':' after key");
312
+ this.state = ST_VALUE;
313
+ pos++;
314
+ continue;
315
+ case ST_OBJ_NEXT:
316
+ if (c === CC_RBRACE) {
317
+ this.closeContainer();
318
+ pos++;
319
+ continue;
320
+ }
321
+ if (c === CC_COMMA) {
322
+ this.state = ST_KEY;
323
+ pos++;
324
+ continue;
325
+ }
326
+ this.errAt(pos, "expected ',' or '}' in object");
327
+ break;
328
+ case ST_ARR_NEXT:
329
+ if (c === CC_RBRACKET) {
330
+ this.closeContainer();
331
+ pos++;
332
+ continue;
333
+ }
334
+ if (c === CC_COMMA) {
335
+ this.state = ST_VALUE;
336
+ pos++;
337
+ continue;
338
+ }
339
+ this.errAt(pos, "expected ',' or ']' in array");
340
+ break;
341
+ case ST_ELEM_FIRST:
342
+ case ST_VALUE: {
343
+ if (this.state === ST_ELEM_FIRST && c === CC_RBRACKET) {
344
+ this.closeContainer();
345
+ pos++;
346
+ continue;
347
+ }
348
+ const end = this.parseValueAt(buf, pos, c);
349
+ if (end < 0)
350
+ break pumping;
351
+ pos = end;
352
+ continue;
353
+ }
354
+ }
355
+ }
356
+ this.pos = pos;
357
+ this.compact();
358
+ }
359
+
360
+ // Drop the consumed prefix once per pump so the buffer holds only the
361
+ // pending token and unscanned tail.
362
+ compact() {
363
+ const pos = this.pos;
364
+ if (pos === 0)
365
+ return;
366
+ this.buf = pos === this.buf.length ? '' : this.buf.slice(pos);
367
+ this.pos = 0;
368
+ this.lineStart -= pos;
369
+ if (this.scanPos >= 0)
370
+ this.scanPos -= pos;
371
+ if (this.partialFrom >= 0)
372
+ this.partialFrom -= pos;
373
+ }
374
+
375
+ //#endregion
376
+
377
+ //#region values
378
+
379
+ // Parse the value starting at pos; returns the position after it, or
380
+ // -1 when the token is incomplete and more input is needed.
381
+ parseValueAt(buf, pos, c) {
382
+ if (c === CC_LBRACE) {
383
+ this.openContainer(false);
384
+ return pos + 1;
385
+ }
386
+ if (c === CC_LBRACKET) {
387
+ this.openContainer(true);
388
+ return pos + 1;
389
+ }
390
+ if (c === CC_DQUOTE) {
391
+ const end = this.scanString(buf, pos);
392
+ if (end < 0) {
393
+ if (this.partialText)
394
+ this.emitPartialText(buf, pos, buf.length, true);
395
+ return -1;
396
+ }
397
+ const value = decodeString(buf, pos, this.errCb)[0];
398
+ if (this.partialText) {
399
+ // the closing delta completes the run, so the deltas for a string
400
+ // always add up to its value — with no tail left in the pair event
401
+ this.emitPartialText(buf, pos, end - 1, false);
402
+ this.partialFrom = -1;
403
+ this.partialHold = '';
404
+ }
405
+ this.completeScalar(value);
406
+ return end;
407
+ }
408
+ if (c === CC_SLASH) {
409
+ if (this.mode === 'json')
410
+ this.errAt(pos, 'a regexp literal is a JSONX extension', 'quote the pattern as a string');
411
+ if (this.scanRegExp(buf, pos) < 0)
412
+ return -1;
413
+ const [re, end] = matchRegExp(buf, pos, this.errCb);
414
+ this.checkValueEnd(buf, end);
415
+ this.completeScalar(re);
416
+ return end;
417
+ }
418
+ if (this.mode === 'json' && c === CC_PLUS)
419
+ this.errAt(pos, "a leading '+' sign is a JSONX extension", 'remove the + sign');
420
+ const end = this.scanScalar(buf, pos);
421
+ if (end < 0)
422
+ return -1;
423
+ return this.decodeScalarToken(buf, pos, c);
424
+ }
425
+
426
+ // Decode a delimiter-terminated scalar token (number, word, datetime),
427
+ // dispatching exactly like the full-text parser's parseValue.
428
+ decodeScalarToken(buf, pos, c) {
429
+ let m;
430
+ if (c === CC_MINUS || c === CC_PLUS) {
431
+ const d = pos + 1 < buf.length ? buf.charCodeAt(pos + 1) : -1;
432
+ if (isAsciiLetterCode(d)) {
433
+ m = matchWord(buf, pos, this.mode, this.errCb);
434
+ this.checkValueEnd(buf, m[1]);
435
+ }
436
+ else
437
+ m = matchNumber(buf, pos, this.mode, this.errCb, this.endCb);
438
+ }
439
+ else if (isDigitCode(c)) {
440
+ if (this.mode === 'jsonx')
441
+ m = matchDateTime(buf, pos, this.errCb, this.endCb);
442
+ if (m == null)
443
+ m = matchNumber(buf, pos, this.mode, this.errCb, this.endCb);
444
+ }
445
+ else if (isAsciiLetterCode(c)) {
446
+ m = matchWord(buf, pos, this.mode, this.errCb);
447
+ this.checkValueEnd(buf, m[1]);
448
+ }
449
+ else
450
+ this.errAt(pos, 'invalid value');
451
+ this.completeScalar(m[0]);
452
+ return m[1];
453
+ }
454
+
455
+ checkValueEnd(buf, pos) {
456
+ if (pos >= buf.length)
457
+ return;
458
+ if (!isValueEndCode(buf.charCodeAt(pos)))
459
+ this.errAt(pos, 'unexpected character after value');
460
+ }
461
+
462
+ // Emit the string text that arrived since the last delta. `stop` is the
463
+ // end of what may be decoded — the buffer's end while the string is
464
+ // still open, or its closing quote once it has arrived.
465
+ emitPartialText(buf, pos, stop, open) {
466
+ if (this.onEvent === null)
467
+ return;
468
+ if (this.partialFrom < 0)
469
+ this.partialFrom = pos + 1;
470
+ const [decoded, reached] = decodeStringSpan(
471
+ buf, this.partialFrom, stop, this.errCb, open);
472
+ this.partialFrom = reached;
473
+ let text = this.partialHold + decoded;
474
+ this.partialHold = '';
475
+ // never split a surrogate pair across two deltas: a display appending
476
+ // them one at a time would render a replacement character
477
+ const last = text.charCodeAt(text.length - 1);
478
+ if (open && last >= 0xD800 && last <= 0xDBFF) {
479
+ this.partialHold = text.slice(-1);
480
+ text = text.slice(0, -1);
481
+ }
482
+ if (text.length !== 0)
483
+ this.onEvent({ type: 'text-partial', path: this.valuePath(), text, line: this.curLine });
484
+ }
485
+
486
+ // Absolute path the value being read will land at.
487
+ valuePath() {
488
+ const stack = this.stack;
489
+ if (stack.length === 0)
490
+ return [];
491
+ const frame = stack[stack.length - 1];
492
+ return this.path.concat(frame.array ? frame.count : frame.key);
493
+ }
494
+
495
+ /**
496
+ * Whether `this.path` plus one more segment matches the detach
497
+ * pattern. Spelled out rather than built on `valuePath()` so the
498
+ * common case — a document read with no `detach` at all — costs one
499
+ * null check, and the matching case costs no array allocation.
500
+ */
501
+ detaches(last) {
502
+ const pattern = this.detach;
503
+ if (pattern === null)
504
+ return false;
505
+ const path = this.path;
506
+ if (pattern.length !== path.length + 1)
507
+ return false;
508
+ for (let i = 0; i < path.length; i++) {
509
+ if (pattern[i] !== WILDCARD && pattern[i] !== path[i])
510
+ return false;
511
+ }
512
+ const tail = pattern[path.length];
513
+ return tail === WILDCARD || tail === last;
514
+ }
515
+
516
+ completeScalar(value) {
517
+ const stack = this.stack;
518
+ if (stack.length === 0) {
519
+ this.rootValue = value;
520
+ this.state = ST_DONE;
521
+ return;
522
+ }
523
+ const frame = stack[stack.length - 1];
524
+ if (frame.array) {
525
+ const index = frame.count++;
526
+ // A detached scalar is not stored: the `item` event below already
527
+ // carries it, so linking it in would retain exactly what the
528
+ // caller asked not to retain.
529
+ if (!this.detaches(index))
530
+ frame.value[index] = value;
531
+ if (this.onEvent !== null)
532
+ this.onEvent({
533
+ type: 'item',
534
+ path: this.path.concat(index),
535
+ index,
536
+ value,
537
+ line: this.curLine,
538
+ });
539
+ this.state = ST_ARR_NEXT;
540
+ }
541
+ else {
542
+ if (!this.detaches(frame.key))
543
+ setObjectMember(frame.value, frame.key, value);
544
+ if (this.onEvent !== null)
545
+ this.onEvent({
546
+ type: 'pair',
547
+ path: this.path.concat(frame.key),
548
+ key: frame.key,
549
+ value,
550
+ line: this.curLine,
551
+ });
552
+ frame.key = undefined;
553
+ this.state = ST_OBJ_NEXT;
554
+ }
555
+ }
556
+
557
+ //#endregion
558
+
559
+ //#region containers
560
+
561
+ openContainer(isArray) {
562
+ const container = isArray ? [] : {};
563
+ const stack = this.stack;
564
+ const parent = stack.length !== 0 ? stack[stack.length - 1] : null;
565
+ if (parent === null)
566
+ this.rootValue = container;
567
+ else if (parent.array) {
568
+ const index = parent.count++;
569
+ // The decision is made HERE, at open, not at close: a subtree that
570
+ // is never linked to its parent is never reachable from the root,
571
+ // so the memory is not freed later — it is not held in the first
572
+ // place, and a document with a million records never builds a
573
+ // million-slot array either.
574
+ if (!this.detaches(index))
575
+ parent.value[index] = container;
576
+ this.path.push(index);
577
+ }
578
+ else {
579
+ if (!this.detaches(parent.key))
580
+ setObjectMember(parent.value, parent.key, container);
581
+ this.path.push(parent.key);
582
+ }
583
+ stack.push({
584
+ array: isArray,
585
+ value: container,
586
+ key: undefined,
587
+ count: 0,
588
+ pathed: parent !== null,
589
+ });
590
+ if (this.onEvent !== null)
591
+ this.onEvent({
592
+ type: isArray ? 'array-start' : 'object-start',
593
+ path: this.path.slice(),
594
+ line: this.curLine,
595
+ });
596
+ this.state = isArray ? ST_ELEM_FIRST : ST_KEY_FIRST;
597
+ }
598
+
599
+ closeContainer() {
600
+ const frame = this.stack.pop();
601
+ if (this.onEvent !== null)
602
+ this.onEvent({
603
+ type: frame.array ? 'array-end' : 'object-end',
604
+ path: this.path.slice(),
605
+ value: frame.value,
606
+ line: this.curLine,
607
+ });
608
+ if (frame.pathed)
609
+ this.path.pop();
610
+ const stack = this.stack;
611
+ if (stack.length === 0)
612
+ this.state = ST_DONE;
613
+ else if (stack[stack.length - 1].array)
614
+ this.state = ST_ARR_NEXT;
615
+ else {
616
+ stack[stack.length - 1].key = undefined;
617
+ this.state = ST_OBJ_NEXT;
618
+ }
619
+ }
620
+
621
+ //#endregion
622
+
623
+ //#region resumable token scanners
624
+
625
+ // Find the position after a string's closing quote, or -1 when the
626
+ // close has not arrived yet. Content is validated by decodeString once
627
+ // the extent is known; at end-of-input the decoder surfaces the
628
+ // unterminated-string/escape error with its exact position.
629
+ scanString(buf, pos) {
630
+ let p = this.scanPos >= 0 ? this.scanPos : pos + 1;
631
+ while (p < buf.length) {
632
+ const c = buf.charCodeAt(p);
633
+ if (c === CC_DQUOTE) {
634
+ this.scanPos = -1;
635
+ return p + 1;
636
+ }
637
+ if (c === CC_BACKSLASH) {
638
+ if (p + 1 >= buf.length)
639
+ break; // the escaped character is in the next chunk
640
+ p += 2;
641
+ continue;
642
+ }
643
+ p++;
644
+ }
645
+ if (this.ended) {
646
+ this.scanPos = -1;
647
+ decodeString(buf, pos, this.errCb); // always throws here
648
+ }
649
+ this.scanPos = p;
650
+ return -1;
651
+ }
652
+
653
+ // Find the end of a regexp literal (body, then flags), or -1 when more
654
+ // input is needed. matchRegExp re-validates and reports errors.
655
+ scanRegExp(buf, pos) {
656
+ let p = this.scanPos >= 0 ? this.scanPos : pos + 1;
657
+ if (!this.scanInFlags) {
658
+ body:
659
+ while (p < buf.length) {
660
+ const c = buf.charCodeAt(p);
661
+ if (c === CC_LF)
662
+ break; // matchRegExp reports the unterminated literal
663
+ if (c === CC_BACKSLASH) {
664
+ if (p + 1 >= buf.length)
665
+ break body;
666
+ p += 2;
667
+ continue;
668
+ }
669
+ if (c === CC_LBRACKET)
670
+ this.scanInClass = true;
671
+ else if (c === CC_RBRACKET)
672
+ this.scanInClass = false;
673
+ else if (c === CC_SLASH && !this.scanInClass) {
674
+ this.scanInFlags = true;
675
+ p++;
676
+ break body;
677
+ }
678
+ p++;
679
+ }
680
+ if (!this.scanInFlags) {
681
+ if (p < buf.length && buf.charCodeAt(p) === CC_LF) {
682
+ this.scanPos = -1;
683
+ this.scanInClass = false;
684
+ return p + 1;
685
+ }
686
+ if (this.ended) {
687
+ this.scanPos = -1;
688
+ this.scanInClass = false;
689
+ matchRegExp(buf, pos, this.errCb); // always throws here
690
+ }
691
+ this.scanPos = p;
692
+ return -1;
693
+ }
694
+ }
695
+ while (p < buf.length && isAsciiLetterCode(buf.charCodeAt(p)))
696
+ p++;
697
+ if (p >= buf.length && !this.ended) {
698
+ this.scanPos = p; // a flag letter may still follow
699
+ return -1;
700
+ }
701
+ this.scanPos = -1;
702
+ this.scanInFlags = false;
703
+ this.scanInClass = false;
704
+ return p;
705
+ }
706
+
707
+ // Find the delimiter that terminates a number/word/datetime token, or
708
+ // -1 when the token may still grow. A bare date followed by a single
709
+ // space may yet become a space-separated RFC 3339 date-time, so the
710
+ // scan crosses that one space and the run after it before deciding —
711
+ // the datetime matcher then consumes exactly as much as is valid.
712
+ scanScalar(buf, pos) {
713
+ let p = this.scanPos >= 0 ? this.scanPos : pos;
714
+ for (;;) {
715
+ while (p < buf.length && !isValueEndCode(buf.charCodeAt(p)))
716
+ p++;
717
+ if (p >= buf.length && !this.ended) {
718
+ this.scanPos = p;
719
+ return -1;
720
+ }
721
+ if (this.mode === 'jsonx'
722
+ && !this.scanDtSpace
723
+ && p < buf.length
724
+ && buf.charCodeAt(p) === CC_SPACE
725
+ && p - pos === 10
726
+ && RE_BARE_DATE.test(buf.slice(pos, p))) {
727
+ this.scanDtSpace = true;
728
+ p++;
729
+ continue;
730
+ }
731
+ this.scanPos = -1;
732
+ this.scanDtSpace = false;
733
+ return p;
734
+ }
735
+ }
736
+
737
+ //#endregion
738
+ }
739
+
740
+ /**
741
+ * Absolute document path: strings for object keys, numbers for array
742
+ * indices (JSON-Pointer-able).
743
+ * @typedef {(string|number)[]} JsonxStreamPath
744
+ */
745
+
746
+ /**
747
+ * Document-order reader event; see the module doc comment for semantics.
748
+ * @typedef {(
749
+ * {type: 'object-start'|'array-start', path: JsonxStreamPath, line: number}
750
+ * | {type: 'object-end'|'array-end', path: JsonxStreamPath, value: *, line: number}
751
+ * | {type: 'pair', path: JsonxStreamPath, key: string, value: *, line: number}
752
+ * | {type: 'item', path: JsonxStreamPath, index: number, value: *, line: number}
753
+ * | {type: 'text-partial', path: JsonxStreamPath, text: string, line: number}
754
+ * )} JsonxStreamEvent
755
+ */
756
+
757
+ /**
758
+ * Create an incremental JSONX / strict-JSON reader.
759
+ * @param {object} [options] - Reader options
760
+ * @param {'jsonx'|'json'} [options.mode] - 'json' rejects every JSONX
761
+ * extension and matches `JSON.parse` for accepted documents
762
+ * @param {(event: JsonxStreamEvent) => void} [options.onEvent] - Event sink
763
+ * @param {boolean} [options.partialText] - Also emit `text-partial` deltas
764
+ * while a string value is still arriving, for progressive display
765
+ * @param {(string|number)[]} [options.detach] - Path pattern (segments,
766
+ * or `'*'` for any one segment) whose matching values are never linked
767
+ * into the tree. Their completion events still carry them, so the
768
+ * consumer sees every record and the reader retains none — this is what
769
+ * makes a document larger than memory readable.
770
+ * @returns {{feed(chunk: string): void, end(): *, root(): *}} The reader:
771
+ * `feed` accepts chunks that may split any token, `end` flushes,
772
+ * validates completeness and returns the root, `root` peeks at the
773
+ * partial result.
774
+ * @throws {TypeError} On a malformed `detach` pattern
775
+ */
776
+ export function createJsonxStreamReader(options = undefined) {
777
+ const machine = new JsonxMachine(options ?? {});
778
+ return {
779
+ feed(chunk) {
780
+ machine.feed(chunk);
781
+ },
782
+ end() {
783
+ return machine.end();
784
+ },
785
+ root() {
786
+ return machine.root();
787
+ },
788
+ };
789
+ }
790
+
791
+ /**
792
+ * Parse an async iterable of string chunks (e.g. an LLM output stream).
793
+ * @param {AsyncIterable<string>|Iterable<string>} chunks - Source chunks
794
+ * @param {object} [options] - Reader options; see `createJsonxStreamReader`
795
+ * @returns {Promise<*>} The completed root value
796
+ */
797
+ export async function parseJsonxStream(chunks, options = undefined) {
798
+ const machine = new JsonxMachine(options ?? {});
799
+ for await (const chunk of chunks)
800
+ machine.feed(chunk);
801
+ return machine.end();
802
+ }
803
+
804
+ export { JsonxSyntaxError } from './errors.js';
805
+
806
+ //#endregion