@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,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
|