@jarenjs/josl 0.56.0 → 0.67.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 +42 -5
- package/README.md +99 -10
- package/dist/types/csv-machine.d.ts +9 -0
- package/dist/types/csv-stream.d.ts +27 -3
- package/dist/types/csv.d.ts +24 -3
- package/dist/types/index.d.ts +4 -3
- package/dist/types/jsonx-stream.d.ts +9 -0
- package/dist/types/limits.d.ts +41 -0
- package/dist/types/machine.d.ts +16 -0
- package/dist/types/pull.d.ts +14 -0
- package/dist/types/stream.d.ts +20 -0
- package/dist/types/util.d.ts +12 -0
- package/dist/types/write.d.ts +24 -2
- package/package.json +2 -2
- package/src/csv-machine.js +75 -4
- package/src/csv-stream.js +83 -22
- package/src/csv.js +65 -36
- package/src/index.js +4 -2
- package/src/jsonx-stream.js +70 -6
- package/src/limits.js +96 -0
- package/src/machine.js +112 -13
- package/src/pull.js +40 -0
- package/src/stream.js +58 -0
- package/src/util.js +15 -0
- package/src/write.js +78 -13
package/src/limits.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
//#region hostile-input limits
|
|
2
|
+
// The three readers and the CSV machine take the same shape of guard:
|
|
3
|
+
// a limit that defaults to Infinity — nothing in this package refuses a
|
|
4
|
+
// document by size unless a caller asks — and, once asked, is checked
|
|
5
|
+
// while the offending text is still in cutter, token or container
|
|
6
|
+
// state, before a concatenation or a link could cross it. Every limit
|
|
7
|
+
// counts UTF-8 bytes, never JavaScript code units: a limit stated in
|
|
8
|
+
// bytes is the one an HTTP body limit, a disk quota or a proxy speaks.
|
|
9
|
+
//
|
|
10
|
+
// A crossing is a `JoslLimitError` with a stable code — `CSV2xxx`,
|
|
11
|
+
// `JOSL2xxx`, `JSONX2xxx` — and the limit that was crossed. It is never
|
|
12
|
+
// a repair: repair mode heals damaged syntax, and a document that is
|
|
13
|
+
// too large is not damaged, it is refused.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The CSV limits, by option name.
|
|
17
|
+
* @type {Readonly<Record<'CSV2001' | 'CSV2002' | 'CSV2003' | 'CSV2004', string>>}
|
|
18
|
+
*/
|
|
19
|
+
export const CSV_LIMIT_CODES = Object.freeze({
|
|
20
|
+
CSV2001: 'the document exceeds maxTotalBytes',
|
|
21
|
+
CSV2002: 'a record exceeds maxRecordBytes',
|
|
22
|
+
CSV2003: 'a field exceeds maxFieldBytes',
|
|
23
|
+
CSV2004: 'a record has more than maxColumns fields',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The JOSL/TOML limits, by option name. A "record" is one logical line —
|
|
28
|
+
* the unit the reader buffers before it parses anything.
|
|
29
|
+
* @type {Readonly<Record<'JOSL2001' | 'JOSL2002' | 'JOSL2003' | 'JOSL2004' | 'JOSL2005', string>>}
|
|
30
|
+
*/
|
|
31
|
+
export const JOSL_LIMIT_CODES = Object.freeze({
|
|
32
|
+
JOSL2001: 'the document exceeds maxTotalBytes',
|
|
33
|
+
JOSL2002: 'a logical line exceeds maxRecordBytes',
|
|
34
|
+
JOSL2003: 'a token exceeds maxTokenBytes',
|
|
35
|
+
JOSL2004: 'the document nests deeper than maxDepth',
|
|
36
|
+
JOSL2005: 'the root retains more than maxRetainedValues values',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The JSONX/JSON limits, by option name.
|
|
41
|
+
* @type {Readonly<Record<'JSONX2001' | 'JSONX2002' | 'JSONX2003' | 'JSONX2004', string>>}
|
|
42
|
+
*/
|
|
43
|
+
export const JSONX_LIMIT_CODES = Object.freeze({
|
|
44
|
+
JSONX2001: 'the document exceeds maxTotalBytes',
|
|
45
|
+
JSONX2002: 'a token exceeds maxTokenBytes',
|
|
46
|
+
JSONX2003: 'the document nests deeper than maxDepth',
|
|
47
|
+
JSONX2004: 'the root retains more than maxRetainedValues values',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** The option name each code guards. */
|
|
51
|
+
const OPTION_OF = Object.freeze({
|
|
52
|
+
CSV2001: 'maxTotalBytes', CSV2002: 'maxRecordBytes', CSV2003: 'maxFieldBytes', CSV2004: 'maxColumns',
|
|
53
|
+
JOSL2001: 'maxTotalBytes', JOSL2002: 'maxRecordBytes', JOSL2003: 'maxTokenBytes', JOSL2004: 'maxDepth', JOSL2005: 'maxRetainedValues',
|
|
54
|
+
JSONX2001: 'maxTotalBytes', JSONX2002: 'maxTokenBytes', JSONX2003: 'maxDepth', JSONX2004: 'maxRetainedValues',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Error thrown when a document crosses a limit a caller set. Never a
|
|
59
|
+
* repair, never healed: the text is refused where it stands.
|
|
60
|
+
*/
|
|
61
|
+
export class JoslLimitError extends Error {
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} code - The stable `CSV2xxx` / `JOSL2xxx` / `JSONX2xxx` code
|
|
64
|
+
* @param {string} message - What was crossed
|
|
65
|
+
* @param {number} limit - The limit the option set
|
|
66
|
+
* @param {number} [line] - 1-based physical line where the crossing was met, when known
|
|
67
|
+
*/
|
|
68
|
+
constructor(code, message, limit, line = undefined) {
|
|
69
|
+
super(`${code}: ${message} (${OPTION_OF[code]} ${limit})${line === undefined ? '' : ` at line ${line}`}`);
|
|
70
|
+
this.name = 'JoslLimitError';
|
|
71
|
+
this.code = code;
|
|
72
|
+
this.limit = limit;
|
|
73
|
+
this.line = line;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read one limit option: absent is `Infinity`; anything but a positive
|
|
79
|
+
* integer or `Infinity` is a `TypeError` — a limit of zero refuses every
|
|
80
|
+
* document, and a fraction or a string is a caller's mistake.
|
|
81
|
+
* @param {object} options - The reader/writer options
|
|
82
|
+
* @param {string} name - The option name
|
|
83
|
+
* @returns {number} The limit, `Infinity` when unset
|
|
84
|
+
*/
|
|
85
|
+
export function limitOption(options, name) {
|
|
86
|
+
const value = options[name];
|
|
87
|
+
if (value === undefined || value === null)
|
|
88
|
+
return Infinity;
|
|
89
|
+
if (value === Infinity)
|
|
90
|
+
return Infinity;
|
|
91
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1)
|
|
92
|
+
throw new TypeError(`options.${name} must be a positive integer or Infinity`);
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
//#endregion
|
package/src/machine.js
CHANGED
|
@@ -53,6 +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
57
|
import {
|
|
57
58
|
LocalDate,
|
|
58
59
|
LocalTime,
|
|
@@ -61,10 +62,10 @@ import {
|
|
|
61
62
|
isValidTimeParts,
|
|
62
63
|
} from './values.js';
|
|
63
64
|
import {
|
|
64
|
-
getOwn, columnOf, feedMachine, beginParseAll, stickyExec, RE_DATETIME, RE_TIMEONLY,
|
|
65
|
+
getOwn, columnOf, feedMachine, beginParseAll, stickyExec, RE_DATETIME, RE_TIMEONLY, offsetDateTime,
|
|
65
66
|
} from './util.js';
|
|
66
67
|
import { setObjectMember } from '@jarenjs/core/object';
|
|
67
|
-
import { countCharCode } from '@jarenjs/core/string';
|
|
68
|
+
import { countCharCode, utf8ByteLength } from '@jarenjs/core/string';
|
|
68
69
|
|
|
69
70
|
function isBareKeyCode(c) {
|
|
70
71
|
return isAsciiLetterCode(c)
|
|
@@ -133,6 +134,20 @@ export class JoslMachine {
|
|
|
133
134
|
constructor(options = {}) {
|
|
134
135
|
this.mode = options.mode === 'toml' ? 'toml' : 'josl';
|
|
135
136
|
this.onEvent = options.onEvent ?? null;
|
|
137
|
+
// Root-item detachment: when set, a completed `[[]]` item is handed
|
|
138
|
+
// to it and dropped from the root, so the root never holds more
|
|
139
|
+
// than the item in progress (iterateJoslStream's reason to exist).
|
|
140
|
+
this.detachRoot = options.detachRoot ?? null;
|
|
141
|
+
this.rootCount = 0; // root items opened so far (indices survive detachment)
|
|
142
|
+
// the hostile-input limits: Infinity unless asked for (limits.js)
|
|
143
|
+
this.maxTotalBytes = limitOption(options, 'maxTotalBytes');
|
|
144
|
+
this.maxRecordBytes = limitOption(options, 'maxRecordBytes');
|
|
145
|
+
this.maxTokenBytes = limitOption(options, 'maxTokenBytes');
|
|
146
|
+
this.maxDepth = limitOption(options, 'maxDepth');
|
|
147
|
+
this.maxRetainedValues = limitOption(options, 'maxRetainedValues');
|
|
148
|
+
this.totalBytes = 0;
|
|
149
|
+
this.retained = 0; // values linked into the root since the last detachment
|
|
150
|
+
this.depth = 0; // inline container nesting while a value is parsed
|
|
136
151
|
// Logical-line sink used by the CST layer: reports each line's source
|
|
137
152
|
// span, and the span of a pair's value inside it, so a rewriter can
|
|
138
153
|
// replace a value without disturbing the bytes around it.
|
|
@@ -169,6 +184,18 @@ export class JoslMachine {
|
|
|
169
184
|
* @returns {this} The machine, for chaining
|
|
170
185
|
*/
|
|
171
186
|
feed(chunk) {
|
|
187
|
+
if (this.maxTotalBytes !== Infinity) {
|
|
188
|
+
this.totalBytes += utf8ByteLength(chunk);
|
|
189
|
+
if (this.totalBytes > this.maxTotalBytes)
|
|
190
|
+
throw new JoslLimitError('JOSL2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, this.startLine);
|
|
191
|
+
}
|
|
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
|
+
}
|
|
172
199
|
return feedMachine(this, chunk);
|
|
173
200
|
}
|
|
174
201
|
|
|
@@ -206,6 +233,8 @@ export class JoslMachine {
|
|
|
206
233
|
*/
|
|
207
234
|
parseAll(text) {
|
|
208
235
|
text = beginParseAll(this, text);
|
|
236
|
+
if (this.maxTotalBytes !== Infinity && utf8ByteLength(text) > this.maxTotalBytes)
|
|
237
|
+
throw new JoslLimitError('JOSL2001', 'the document exceeds maxTotalBytes', this.maxTotalBytes, 1);
|
|
209
238
|
// positions are offsets into the whole source, which starts at line 1
|
|
210
239
|
this.lineOrigin = 1;
|
|
211
240
|
const tracking = this.onEvent !== null;
|
|
@@ -215,6 +244,11 @@ export class JoslMachine {
|
|
|
215
244
|
this.lineValueStart = -1;
|
|
216
245
|
this.lineValueEnd = -1;
|
|
217
246
|
const end = this.parseLine(text, pos);
|
|
247
|
+
// the whole document is already in memory here: a logical line is
|
|
248
|
+
// judged by its extent once the parser found it (the streaming
|
|
249
|
+
// path refuses it earlier, while it is still being cut)
|
|
250
|
+
if (this.maxRecordBytes !== Infinity && utf8ByteLength(text, pos, end) > this.maxRecordBytes)
|
|
251
|
+
throw new JoslLimitError('JOSL2002', 'a logical line exceeds maxRecordBytes', this.maxRecordBytes, this.startLine);
|
|
218
252
|
const next = end < len && text.charCodeAt(end) === CC_LF ? end + 1 : end;
|
|
219
253
|
if (this.onLine !== null)
|
|
220
254
|
this.onLine(pos, next, this.lineValueStart, this.lineValueEnd);
|
|
@@ -235,14 +269,47 @@ export class JoslMachine {
|
|
|
235
269
|
/**
|
|
236
270
|
* The (possibly still growing) root value: `{}`-rooted for documents,
|
|
237
271
|
* `[]`-rooted after a `[[]]` header. Undefined content yields `{}`.
|
|
272
|
+
* Under `detachRoot` a finished document hands its last item over
|
|
273
|
+
* here, so the root ends empty.
|
|
238
274
|
* @returns {*} Current root value
|
|
239
275
|
*/
|
|
240
276
|
root() {
|
|
241
277
|
if (this.rootValue === undefined)
|
|
242
278
|
this.rootValue = {};
|
|
279
|
+
if (this.ended && this.detachRoot !== null && this.rootIsArray && this.rootValue.length !== 0)
|
|
280
|
+
this.detachLast();
|
|
243
281
|
return this.rootValue;
|
|
244
282
|
}
|
|
245
283
|
|
|
284
|
+
// Hand the last root item to the detach sink and drop it: the root
|
|
285
|
+
// keeps at most the item in progress, and the retained-value count
|
|
286
|
+
// starts over with it.
|
|
287
|
+
detachLast() {
|
|
288
|
+
const item = this.rootValue.pop();
|
|
289
|
+
this.retained = 0;
|
|
290
|
+
this.detachRoot(item);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// One more value linked into the root: a pair, an array element, an
|
|
294
|
+
// inline-table member, a table or a root item. Judged before the link.
|
|
295
|
+
retain() {
|
|
296
|
+
if (++this.retained > this.maxRetainedValues)
|
|
297
|
+
throw new JoslLimitError('JOSL2005', 'the root retains more than maxRetainedValues values', this.maxRetainedValues, this.startLine);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// One nesting level deeper — an inline array or table, or a header
|
|
301
|
+
// path segment — judged before it opens.
|
|
302
|
+
deeper(depth) {
|
|
303
|
+
if (depth > this.maxDepth)
|
|
304
|
+
throw new JoslLimitError('JOSL2004', 'the document nests deeper than maxDepth', this.maxDepth, this.startLine);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// A token about to be sliced: its byte size, judged first.
|
|
308
|
+
token(line, start, end) {
|
|
309
|
+
if (this.maxTokenBytes !== Infinity && utf8ByteLength(line, start, end) > this.maxTokenBytes)
|
|
310
|
+
throw new JoslLimitError('JOSL2003', 'a token exceeds maxTokenBytes', this.maxTokenBytes, this.startLine);
|
|
311
|
+
}
|
|
312
|
+
|
|
246
313
|
//#endregion
|
|
247
314
|
|
|
248
315
|
//#region chunk cutter
|
|
@@ -402,6 +469,8 @@ export class JoslMachine {
|
|
|
402
469
|
? nlPos - 1
|
|
403
470
|
: nlPos;
|
|
404
471
|
if (end > start) {
|
|
472
|
+
if (this.maxRecordBytes !== Infinity && utf8ByteLength(buf, start, end) > this.maxRecordBytes)
|
|
473
|
+
throw new JoslLimitError('JOSL2002', 'a logical line exceeds maxRecordBytes', this.maxRecordBytes, this.startLine);
|
|
405
474
|
this.lineOrigin = this.startLine;
|
|
406
475
|
this.parseLine(buf.slice(start, end));
|
|
407
476
|
}
|
|
@@ -544,7 +613,7 @@ export class JoslMachine {
|
|
|
544
613
|
|
|
545
614
|
headerBase() {
|
|
546
615
|
if (this.rootIsArray)
|
|
547
|
-
return [this.rootValue[this.rootValue.length - 1], [this.
|
|
616
|
+
return [this.rootValue[this.rootValue.length - 1], [this.rootCount - 1]];
|
|
548
617
|
if (this.rootValue === undefined)
|
|
549
618
|
this.rootValue = {};
|
|
550
619
|
return [this.rootValue, []];
|
|
@@ -554,6 +623,7 @@ export class JoslMachine {
|
|
|
554
623
|
// descending into the last element of arrays-of-tables
|
|
555
624
|
navigate(keys, pos) {
|
|
556
625
|
let [t, path] = this.headerBase();
|
|
626
|
+
this.deeper(keys.length);
|
|
557
627
|
for (let i = 0; i < keys.length - 1; ++i) {
|
|
558
628
|
const k = keys[i];
|
|
559
629
|
const ex = getOwn(t, k);
|
|
@@ -593,6 +663,7 @@ export class JoslMachine {
|
|
|
593
663
|
const k = keys[keys.length - 1];
|
|
594
664
|
const ex = getOwn(t, k);
|
|
595
665
|
if (ex === undefined) {
|
|
666
|
+
this.retain();
|
|
596
667
|
const nt = {};
|
|
597
668
|
this.meta.set(nt, { explicit: true });
|
|
598
669
|
setObjectMember(t, k, nt);
|
|
@@ -622,6 +693,7 @@ export class JoslMachine {
|
|
|
622
693
|
}
|
|
623
694
|
else if (!Array.isArray(arr) || this.meta.get(arr)?.aot !== true)
|
|
624
695
|
this.err(pos, `key '${keys.join('.')}' is not an array of tables`);
|
|
696
|
+
this.retain();
|
|
625
697
|
const el = {};
|
|
626
698
|
arr.push(el);
|
|
627
699
|
this.current = el;
|
|
@@ -641,14 +713,20 @@ export class JoslMachine {
|
|
|
641
713
|
else if (!this.rootIsArray)
|
|
642
714
|
this.err(pos, 'cannot mix a root table and a root array',
|
|
643
715
|
'a document that starts with key-value pairs has an object root');
|
|
716
|
+
// the previous item is complete the moment the next header opens:
|
|
717
|
+
// under detachment it leaves the root here
|
|
718
|
+
if (this.detachRoot !== null && this.rootValue.length !== 0)
|
|
719
|
+
this.detachLast();
|
|
720
|
+
this.retain();
|
|
644
721
|
const el = {};
|
|
645
722
|
this.rootValue.push(el);
|
|
723
|
+
this.rootCount++;
|
|
646
724
|
this.current = el;
|
|
647
|
-
this.currentPath = [this.
|
|
725
|
+
this.currentPath = [this.rootCount - 1];
|
|
648
726
|
this.emit({
|
|
649
727
|
type: 'root-item',
|
|
650
728
|
path: this.currentPath,
|
|
651
|
-
index: this.
|
|
729
|
+
index: this.rootCount - 1,
|
|
652
730
|
line: this.startLine,
|
|
653
731
|
});
|
|
654
732
|
}
|
|
@@ -710,6 +788,7 @@ export class JoslMachine {
|
|
|
710
788
|
const k = keys[keys.length - 1];
|
|
711
789
|
if (Object.hasOwn(t, k))
|
|
712
790
|
this.err(pos, `duplicate key '${k}'`);
|
|
791
|
+
this.retain();
|
|
713
792
|
setObjectMember(t, k, value);
|
|
714
793
|
}
|
|
715
794
|
|
|
@@ -736,6 +815,7 @@ export class JoslMachine {
|
|
|
736
815
|
pos++;
|
|
737
816
|
if (pos === start)
|
|
738
817
|
this.err(pos, 'expected a key');
|
|
818
|
+
this.token(line, start, pos);
|
|
739
819
|
keys.push(line.slice(start, pos));
|
|
740
820
|
}
|
|
741
821
|
pos = this.skipWs(line, pos);
|
|
@@ -875,13 +955,16 @@ export class JoslMachine {
|
|
|
875
955
|
}
|
|
876
956
|
|
|
877
957
|
parseBasicString(line, pos) {
|
|
958
|
+
const opened = pos;
|
|
878
959
|
pos++; // consume '"'
|
|
879
960
|
let out = '';
|
|
880
961
|
let chunk = pos;
|
|
881
962
|
while (pos < line.length) {
|
|
882
963
|
const c = line.charCodeAt(pos);
|
|
883
|
-
if (c === CC_DQUOTE)
|
|
964
|
+
if (c === CC_DQUOTE) {
|
|
965
|
+
this.token(line, opened, pos + 1);
|
|
884
966
|
return [out + line.slice(chunk, pos), pos + 1];
|
|
967
|
+
}
|
|
885
968
|
if (c === CC_BACKSLASH) {
|
|
886
969
|
out += line.slice(chunk, pos);
|
|
887
970
|
const [dec, p] = this.decodeEscape(line, pos);
|
|
@@ -903,8 +986,10 @@ export class JoslMachine {
|
|
|
903
986
|
const start = pos;
|
|
904
987
|
while (pos < line.length) {
|
|
905
988
|
const c = line.charCodeAt(pos);
|
|
906
|
-
if (c === CC_SQUOTE)
|
|
989
|
+
if (c === CC_SQUOTE) {
|
|
990
|
+
this.token(line, start - 1, pos + 1);
|
|
907
991
|
return [line.slice(start, pos), pos + 1];
|
|
992
|
+
}
|
|
908
993
|
if (c === CC_LF)
|
|
909
994
|
break;
|
|
910
995
|
this.checkStringChar(line, pos, false);
|
|
@@ -914,6 +999,7 @@ export class JoslMachine {
|
|
|
914
999
|
}
|
|
915
1000
|
|
|
916
1001
|
parseMlBasicString(line, pos) {
|
|
1002
|
+
const mlStart = pos;
|
|
917
1003
|
pos += 3; // consume '"""'
|
|
918
1004
|
if (line.charCodeAt(pos) === CC_CR && line.charCodeAt(pos + 1) === CC_LF)
|
|
919
1005
|
pos += 2;
|
|
@@ -931,6 +1017,7 @@ export class JoslMachine {
|
|
|
931
1017
|
if (n >= 3) {
|
|
932
1018
|
if (n > 5)
|
|
933
1019
|
this.err(pos, 'too many quotes closing a multi-line string');
|
|
1020
|
+
this.token(line, mlStart, run);
|
|
934
1021
|
return [out + line.slice(chunk, pos) + '"'.repeat(n - 3), run];
|
|
935
1022
|
}
|
|
936
1023
|
pos = run;
|
|
@@ -991,6 +1078,7 @@ export class JoslMachine {
|
|
|
991
1078
|
if (n >= 3) {
|
|
992
1079
|
if (n > 5)
|
|
993
1080
|
this.err(pos, 'too many quotes closing a multi-line string');
|
|
1081
|
+
this.token(line, start - 3, run);
|
|
994
1082
|
return [line.slice(start, pos) + "'".repeat(n - 3), run];
|
|
995
1083
|
}
|
|
996
1084
|
pos = run;
|
|
@@ -1008,15 +1096,19 @@ export class JoslMachine {
|
|
|
1008
1096
|
|
|
1009
1097
|
parseArray(line, pos) {
|
|
1010
1098
|
pos++; // consume '['
|
|
1099
|
+
this.deeper(++this.depth);
|
|
1011
1100
|
const arr = [];
|
|
1012
1101
|
this.meta.set(arr, { aot: false });
|
|
1013
1102
|
for (;;) {
|
|
1014
1103
|
pos = this.skipWsNlComment(line, pos);
|
|
1015
1104
|
if (pos >= line.length)
|
|
1016
1105
|
this.err(pos, 'unterminated array', "close the array with ']'");
|
|
1017
|
-
if (line.charCodeAt(pos) === CC_RBRACKET)
|
|
1106
|
+
if (line.charCodeAt(pos) === CC_RBRACKET) {
|
|
1107
|
+
this.depth--;
|
|
1018
1108
|
return [arr, pos + 1];
|
|
1109
|
+
}
|
|
1019
1110
|
const [v, p] = this.parseValue(line, pos);
|
|
1111
|
+
this.retain();
|
|
1020
1112
|
arr.push(v);
|
|
1021
1113
|
pos = this.skipWsNlComment(line, p);
|
|
1022
1114
|
if (pos >= line.length)
|
|
@@ -1026,19 +1118,24 @@ export class JoslMachine {
|
|
|
1026
1118
|
pos++;
|
|
1027
1119
|
continue;
|
|
1028
1120
|
}
|
|
1029
|
-
if (c === CC_RBRACKET)
|
|
1121
|
+
if (c === CC_RBRACKET) {
|
|
1122
|
+
this.depth--;
|
|
1030
1123
|
return [arr, pos + 1];
|
|
1124
|
+
}
|
|
1031
1125
|
this.err(pos, "expected ',' or ']' in array");
|
|
1032
1126
|
}
|
|
1033
1127
|
}
|
|
1034
1128
|
|
|
1035
1129
|
parseInlineTable(line, pos) {
|
|
1036
1130
|
pos++; // consume '{'
|
|
1131
|
+
this.deeper(++this.depth);
|
|
1037
1132
|
const obj = {};
|
|
1038
1133
|
this.meta.set(obj, { inline: true });
|
|
1039
1134
|
pos = this.skipWs(line, pos);
|
|
1040
|
-
if (pos < line.length && line.charCodeAt(pos) === CC_RBRACE)
|
|
1135
|
+
if (pos < line.length && line.charCodeAt(pos) === CC_RBRACE) {
|
|
1136
|
+
this.depth--;
|
|
1041
1137
|
return [obj, pos + 1];
|
|
1138
|
+
}
|
|
1042
1139
|
for (;;) {
|
|
1043
1140
|
if (pos < line.length && line.charCodeAt(pos) === CC_LF)
|
|
1044
1141
|
this.err(pos, 'newlines are not allowed inside inline tables',
|
|
@@ -1054,8 +1151,10 @@ export class JoslMachine {
|
|
|
1054
1151
|
if (pos >= line.length)
|
|
1055
1152
|
this.err(pos, 'unterminated inline table', "close the table with '}'");
|
|
1056
1153
|
const c = line.charCodeAt(pos);
|
|
1057
|
-
if (c === CC_RBRACE)
|
|
1154
|
+
if (c === CC_RBRACE) {
|
|
1155
|
+
this.depth--;
|
|
1058
1156
|
return [obj, pos + 1];
|
|
1157
|
+
}
|
|
1059
1158
|
if (c === CC_COMMA) {
|
|
1060
1159
|
pos = this.skipWs(line, pos + 1);
|
|
1061
1160
|
continue;
|
|
@@ -1086,6 +1185,7 @@ export class JoslMachine {
|
|
|
1086
1185
|
const k = keys[keys.length - 1];
|
|
1087
1186
|
if (Object.hasOwn(t, k))
|
|
1088
1187
|
this.err(pos, `duplicate key '${k}'`);
|
|
1188
|
+
this.retain();
|
|
1089
1189
|
setObjectMember(t, k, value);
|
|
1090
1190
|
}
|
|
1091
1191
|
|
|
@@ -1154,8 +1254,7 @@ export class JoslMachine {
|
|
|
1154
1254
|
const end = this.checkValueEnd(line, pos + m[0].length);
|
|
1155
1255
|
if (m[8] === undefined)
|
|
1156
1256
|
return [new LocalDateTime(date, time), end];
|
|
1157
|
-
const
|
|
1158
|
-
const instant = new Date(`${date.toString()}T${time.toString()}${offset}`);
|
|
1257
|
+
const instant = offsetDateTime(date, time, m[8]);
|
|
1159
1258
|
if (Number.isNaN(instant.getTime()))
|
|
1160
1259
|
this.err(pos, `invalid date-time '${m[0]}'`);
|
|
1161
1260
|
return [instant, end];
|
package/src/pull.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region pull helpers shared by the async serializers and readers
|
|
2
|
+
// The two facts every pull pipeline in this package relies on, written
|
|
3
|
+
// once: closing an upstream iterator exactly once (a consumer that stops
|
|
4
|
+
// early, an abort, a throw — never a source read to its end, whose
|
|
5
|
+
// `return()` would be a second close), and the rejection an abort
|
|
6
|
+
// carries (the signal's own reason, so a caller's `AbortError` is the
|
|
7
|
+
// one it sees).
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Close an iterator once, swallowing what the close throws: a source
|
|
11
|
+
* that refuses its cancel is already gone.
|
|
12
|
+
* @param {Iterator<any> | AsyncIterator<any>} iterator
|
|
13
|
+
* @returns {Promise<void>}
|
|
14
|
+
*/
|
|
15
|
+
export async function closeIterator(iterator) {
|
|
16
|
+
if (typeof iterator.return !== 'function')
|
|
17
|
+
return;
|
|
18
|
+
try {
|
|
19
|
+
await iterator.return(undefined);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// the upstream refused its close; nothing more can be done for it
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The rejection of an aborted pull: the signal's reason when it has one,
|
|
28
|
+
* else an `AbortError`-named error, as the platform spells it.
|
|
29
|
+
* @param {AbortSignal} signal
|
|
30
|
+
* @returns {unknown}
|
|
31
|
+
*/
|
|
32
|
+
export function abortedError(signal) {
|
|
33
|
+
if (signal.reason !== undefined)
|
|
34
|
+
return signal.reason;
|
|
35
|
+
const error = new Error('The operation was aborted.');
|
|
36
|
+
error.name = 'AbortError';
|
|
37
|
+
return error;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
//#endregion
|
package/src/stream.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// array-of-tables indices), so events are directly JSON-Pointer-able.
|
|
8
8
|
|
|
9
9
|
import { JoslMachine } from './machine.js';
|
|
10
|
+
import { JoslSyntaxError } from './errors.js';
|
|
11
|
+
import { closeIterator, abortedError } from './pull.js';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Create an incremental JOSL/TOML reader.
|
|
@@ -52,6 +54,62 @@ export async function parseJoslStream(chunks, options = undefined) {
|
|
|
52
54
|
return machine.end();
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Yield the `[[]]` root items of a JOSL document as they complete,
|
|
59
|
+
* without ever holding the whole document: each item is DETACHED from
|
|
60
|
+
* the root the moment the next `[[]]` header (or the end) completes it,
|
|
61
|
+
* so `root()`-style retention never grows past one record — the JOSL
|
|
62
|
+
* twin of the JSONX reader's `detach`. A document with a table root is
|
|
63
|
+
* refused by name (`JoslSyntaxError`): a table root is one retained
|
|
64
|
+
* value, not a stream of records. `options.signal` aborts between
|
|
65
|
+
* chunks; an abort, a consumer that stops early or a throw closes the
|
|
66
|
+
* chunk source exactly once.
|
|
67
|
+
* @param {AsyncIterable<string>|Iterable<string>} chunks - Source chunks
|
|
68
|
+
* @param {object} [options] - Reader options; see `createStreamReader`,
|
|
69
|
+
* plus `signal` and the `JOSL2xxx` limits
|
|
70
|
+
* @yields {object} One completed root item at a time
|
|
71
|
+
* @example
|
|
72
|
+
* for await (const record of iterateJoslStream(response.body))
|
|
73
|
+
* await save(record);
|
|
74
|
+
*/
|
|
75
|
+
export async function* iterateJoslStream(chunks, options = undefined) {
|
|
76
|
+
/** @type {object[]} */
|
|
77
|
+
const completed = [];
|
|
78
|
+
const machine = new JoslMachine({
|
|
79
|
+
...(options ?? {}),
|
|
80
|
+
detachRoot: (item) => { completed.push(item); },
|
|
81
|
+
});
|
|
82
|
+
const signal = options?.signal ?? null;
|
|
83
|
+
const iterator = chunks[Symbol.asyncIterator]?.() ?? chunks[Symbol.iterator]();
|
|
84
|
+
let finished = false;
|
|
85
|
+
try {
|
|
86
|
+
for (;;) {
|
|
87
|
+
if (signal !== null && signal.aborted)
|
|
88
|
+
throw abortedError(signal);
|
|
89
|
+
const step = await iterator.next();
|
|
90
|
+
if (step.done) {
|
|
91
|
+
finished = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
machine.feed(step.value);
|
|
95
|
+
if (machine.rootValue !== undefined && !machine.rootIsArray) {
|
|
96
|
+
throw new JoslSyntaxError('iterateJoslStream reads a [[]] root array; this document has a table root',
|
|
97
|
+
machine.lineOrigin, 1, 'stream a document of [[]] records, or read a table root with parseJoslStream');
|
|
98
|
+
}
|
|
99
|
+
while (completed.length !== 0)
|
|
100
|
+
yield /** @type {object} */ (completed.shift());
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
if (!finished)
|
|
105
|
+
await closeIterator(iterator);
|
|
106
|
+
}
|
|
107
|
+
machine.end();
|
|
108
|
+
while (completed.length !== 0)
|
|
109
|
+
yield /** @type {object} */ (completed.shift());
|
|
110
|
+
}
|
|
111
|
+
|
|
55
112
|
export { JoslSyntaxError } from './errors.js';
|
|
113
|
+
export { JoslLimitError, JOSL_LIMIT_CODES } from './limits.js';
|
|
56
114
|
|
|
57
115
|
//#endregion
|
package/src/util.js
CHANGED
|
@@ -17,6 +17,21 @@ export const RE_DATETIME = /(\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2}):(\d{2
|
|
|
17
17
|
/** A bare `HH:MM:SS` with an optional fraction. @type {RegExp} */
|
|
18
18
|
export const RE_TIMEONLY = /(\d{2}):(\d{2}):(\d{2})(\.\d+)?/y;
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Convert a local date and time with an offset to their native instant.
|
|
22
|
+
* ISO parsing preserves years 0000-0099 and truncates sub-millisecond
|
|
23
|
+
* precision consistently for JOSL and typed CSV. An invalid offset (or
|
|
24
|
+
* an instant Date cannot represent) returns an invalid Date for the
|
|
25
|
+
* caller's own refusal or text-preservation policy.
|
|
26
|
+
* @param {import('./values.js').LocalDate} date - Validated calendar date
|
|
27
|
+
* @param {import('./values.js').LocalTime} time - Validated local time
|
|
28
|
+
* @param {string} offset - Z/z or a signed HH:MM offset
|
|
29
|
+
* @returns {Date}
|
|
30
|
+
*/
|
|
31
|
+
export function offsetDateTime(date, time, offset) {
|
|
32
|
+
return new Date(`${date.toString()}T${time.toString()}${offset.toUpperCase()}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/**
|
|
21
36
|
* Run a sticky regex at `pos` and return its match (or null).
|
|
22
37
|
* @param {RegExp} re - A sticky (`y`) pattern
|