@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/src/jsonx.js ADDED
@@ -0,0 +1,342 @@
1
+ //#region JSONX
2
+ // JSONX is JSON extended with the JOSL first-class citizens:
3
+ //
4
+ // null (JSON already has it)
5
+ // bigint 123n / -9007199254740993n (unsafe ints auto-promote)
6
+ // regexp /pattern/flags
7
+ // datetimes bare RFC 3339 tokens: 1979-05-27T07:32:00Z (Date),
8
+ // 1979-05-27T07:32:00 (LocalDateTime), 1979-05-27
9
+ // (LocalDate), 07:32:00 (LocalTime)
10
+ // non-finite inf, -inf, nan (also Infinity/NaN, the JS spellings)
11
+ // separators 1_000_000, and a leading + sign
12
+ //
13
+ // `mode: 'json'` is bit-compatible strict JSON: parsing matches
14
+ // `JSON.parse` (minus the reviver) and stringifying delegates to
15
+ // `JSON.stringify`. In place of the reviver — which visits leaves
16
+ // bottom-up, after the fact, without telling you where you are — the
17
+ // parser reports document-order events with absolute paths.
18
+ //
19
+ // Scalar decoding lives in jsonx-scalar.js, shared with the incremental
20
+ // reader (jsonx-stream.js), so both parse every value identically.
21
+
22
+ import { JsonxSyntaxError } from './errors.js';
23
+ import { LocalDate, LocalTime, LocalDateTime } from './values.js';
24
+ import {
25
+ CC_TAB,
26
+ CC_LF,
27
+ CC_CR,
28
+ CC_SPACE,
29
+ CC_DQUOTE,
30
+ CC_PLUS,
31
+ CC_COMMA,
32
+ CC_MINUS,
33
+ CC_SLASH,
34
+ CC_COLON,
35
+ CC_LBRACKET,
36
+ CC_RBRACKET,
37
+ CC_LBRACE,
38
+ CC_RBRACE,
39
+ isDigitCode,
40
+ isAsciiLetterCode,
41
+ } from '@jarenjs/core/scan';
42
+ import { columnOf } from './util.js';
43
+ import { setObjectMember } from '@jarenjs/core/object';
44
+ import { countCharCode } from '@jarenjs/core/string';
45
+ import {
46
+ isValueEndCode,
47
+ decodeString,
48
+ matchDateTime,
49
+ matchNumber,
50
+ matchWord,
51
+ matchRegExp,
52
+ } from './jsonx-scalar.js';
53
+
54
+ class JsonxParser {
55
+ constructor(text, options = {}) {
56
+ this.text = text;
57
+ this.pos = 0;
58
+ this.mode = options.mode === 'json' ? 'json' : 'jsonx';
59
+ this.onEvent = options.onEvent ?? null;
60
+ this.path = [];
61
+ this.errCb = (pos, message, hint) => this.err(message, hint, pos);
62
+ this.endCb = (end) => {
63
+ this.pos = end;
64
+ this.checkValueEnd();
65
+ };
66
+ }
67
+
68
+ err(message, hint, pos = this.pos) {
69
+ throw new JsonxSyntaxError(
70
+ message,
71
+ countCharCode(this.text, 0x0A, 0, pos) + 1,
72
+ columnOf(this.text, pos),
73
+ hint);
74
+ }
75
+
76
+ extension(what, hint) {
77
+ if (this.mode === 'json')
78
+ this.err(`${what} is a JSONX extension`, hint);
79
+ }
80
+
81
+ emit(type, value) {
82
+ if (this.onEvent !== null)
83
+ this.onEvent(type === 'open'
84
+ ? { type, path: this.path.slice(), kind: value }
85
+ : { type, path: this.path.slice(), value });
86
+ }
87
+
88
+ skipWs() {
89
+ const text = this.text;
90
+ while (this.pos < text.length) {
91
+ const c = text.charCodeAt(this.pos);
92
+ if (c !== CC_SPACE && c !== CC_TAB && c !== CC_LF && c !== CC_CR)
93
+ break;
94
+ this.pos++;
95
+ }
96
+ }
97
+
98
+ parse() {
99
+ this.skipWs();
100
+ const value = this.parseValue();
101
+ this.skipWs();
102
+ if (this.pos < this.text.length)
103
+ this.err('unexpected trailing characters');
104
+ return value;
105
+ }
106
+
107
+ parseValue() {
108
+ if (this.pos >= this.text.length)
109
+ this.err('unexpected end of input');
110
+ const c = this.text.charCodeAt(this.pos);
111
+ if (c === CC_LBRACE)
112
+ return this.parseObject();
113
+ if (c === CC_LBRACKET)
114
+ return this.parseArray();
115
+ if (c === CC_DQUOTE)
116
+ return this.scalar(this.parseString());
117
+ if (c === CC_SLASH) {
118
+ this.extension('a regexp literal', 'quote the pattern as a string');
119
+ const [re, end] = matchRegExp(this.text, this.pos, this.errCb);
120
+ this.pos = end;
121
+ this.checkValueEnd();
122
+ return this.scalar(re);
123
+ }
124
+ if (c === CC_MINUS || c === CC_PLUS) {
125
+ if (c === CC_PLUS)
126
+ this.extension("a leading '+' sign", 'remove the + sign');
127
+ const d = this.pos + 1 < this.text.length ? this.text.charCodeAt(this.pos + 1) : -1;
128
+ if (isAsciiLetterCode(d))
129
+ return this.scalar(this.parseWord());
130
+ return this.scalar(this.parseNumber());
131
+ }
132
+ if (isDigitCode(c)) {
133
+ if (this.mode === 'jsonx') {
134
+ const dt = matchDateTime(this.text, this.pos, this.errCb, this.endCb);
135
+ if (dt !== null)
136
+ return this.scalar(dt[0]);
137
+ }
138
+ return this.scalar(this.parseNumber());
139
+ }
140
+ if (isAsciiLetterCode(c))
141
+ return this.scalar(this.parseWord());
142
+ this.err('invalid value');
143
+ }
144
+
145
+ scalar(value) {
146
+ this.emit('value', value);
147
+ return value;
148
+ }
149
+
150
+ checkValueEnd() {
151
+ if (this.pos >= this.text.length)
152
+ return;
153
+ if (!isValueEndCode(this.text.charCodeAt(this.pos)))
154
+ this.err('unexpected character after value');
155
+ }
156
+
157
+ parseObject() {
158
+ this.pos++; // consume '{'
159
+ this.emit('open', 'object');
160
+ const obj = {};
161
+ this.skipWs();
162
+ if (this.pos < this.text.length && this.text.charCodeAt(this.pos) === CC_RBRACE) {
163
+ this.pos++;
164
+ this.emit('close', obj);
165
+ return obj;
166
+ }
167
+ for (;;) {
168
+ this.skipWs();
169
+ if (this.pos >= this.text.length || this.text.charCodeAt(this.pos) !== CC_DQUOTE)
170
+ this.err('expected a string key');
171
+ const key = this.parseString();
172
+ this.skipWs();
173
+ if (this.pos >= this.text.length || this.text.charCodeAt(this.pos) !== CC_COLON)
174
+ this.err("expected ':' after key");
175
+ this.pos++;
176
+ this.skipWs();
177
+ this.path.push(key);
178
+ const value = this.parseValue();
179
+ this.path.pop();
180
+ setObjectMember(obj, key, value);
181
+ this.skipWs();
182
+ if (this.pos >= this.text.length)
183
+ this.err('unterminated object', "close the object with '}'");
184
+ const c = this.text.charCodeAt(this.pos);
185
+ if (c === CC_RBRACE) {
186
+ this.pos++;
187
+ this.emit('close', obj);
188
+ return obj;
189
+ }
190
+ if (c === CC_COMMA) {
191
+ this.pos++;
192
+ continue;
193
+ }
194
+ this.err("expected ',' or '}' in object");
195
+ }
196
+ }
197
+
198
+ parseArray() {
199
+ this.pos++; // consume '['
200
+ this.emit('open', 'array');
201
+ const arr = [];
202
+ this.skipWs();
203
+ if (this.pos < this.text.length && this.text.charCodeAt(this.pos) === CC_RBRACKET) {
204
+ this.pos++;
205
+ this.emit('close', arr);
206
+ return arr;
207
+ }
208
+ for (;;) {
209
+ this.skipWs();
210
+ this.path.push(arr.length);
211
+ const value = this.parseValue();
212
+ this.path.pop();
213
+ arr.push(value);
214
+ this.skipWs();
215
+ if (this.pos >= this.text.length)
216
+ this.err('unterminated array', "close the array with ']'");
217
+ const c = this.text.charCodeAt(this.pos);
218
+ if (c === CC_RBRACKET) {
219
+ this.pos++;
220
+ this.emit('close', arr);
221
+ return arr;
222
+ }
223
+ if (c === CC_COMMA) {
224
+ this.pos++;
225
+ continue;
226
+ }
227
+ this.err("expected ',' or ']' in array");
228
+ }
229
+ }
230
+
231
+ parseString() {
232
+ const [value, end] = decodeString(this.text, this.pos, this.errCb);
233
+ this.pos = end;
234
+ return value;
235
+ }
236
+
237
+ parseWord() {
238
+ const [value, end] = matchWord(this.text, this.pos, this.mode, this.errCb);
239
+ this.pos = end;
240
+ this.checkValueEnd();
241
+ return value;
242
+ }
243
+
244
+ parseNumber() {
245
+ return matchNumber(this.text, this.pos, this.mode, this.errCb, this.endCb)[0];
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Parse JSONX (or, with `mode: 'json'`, strict JSON) text.
251
+ * @param {string} text - Source text
252
+ * @param {object} [options] - Parser options
253
+ * @param {'jsonx'|'json'} [options.mode] - 'json' matches JSON.parse
254
+ * @param {(event: object) => void} [options.onEvent] - Document-order
255
+ * event sink: {type:'open', path, kind}, {type:'value', path, value},
256
+ * {type:'close', path, value} — paths are absolute (JSON-Pointer-able)
257
+ * @returns {*} The parsed value
258
+ * @throws {JsonxSyntaxError} On invalid input
259
+ */
260
+ export function parseJsonx(text, options = undefined) {
261
+ return new JsonxParser(String(text), options ?? {}).parse();
262
+ }
263
+
264
+ function stringifyValue(value, seen, gap, depth) {
265
+ switch (typeof value) {
266
+ case 'string':
267
+ return JSON.stringify(value);
268
+ case 'number':
269
+ if (Number.isNaN(value))
270
+ return 'nan';
271
+ if (value === Infinity)
272
+ return 'inf';
273
+ if (value === -Infinity)
274
+ return '-inf';
275
+ return String(value);
276
+ case 'boolean':
277
+ return value ? 'true' : 'false';
278
+ case 'bigint':
279
+ return `${value}n`;
280
+ case 'object':
281
+ break;
282
+ default:
283
+ return undefined; // functions, symbols, undefined
284
+ }
285
+ if (value === null)
286
+ return 'null';
287
+ if (value instanceof Date)
288
+ return value.toISOString();
289
+ if (value instanceof LocalDate || value instanceof LocalTime || value instanceof LocalDateTime)
290
+ return value.toString();
291
+ if (value instanceof RegExp)
292
+ return `/${value.source}/${value.flags}`;
293
+ if (seen.has(value))
294
+ throw new TypeError('Converting circular structure to JSONX');
295
+ seen.add(value);
296
+ const inner = gap === '' ? '' : '\n' + gap.repeat(depth + 1);
297
+ const outer = gap === '' ? '' : '\n' + gap.repeat(depth);
298
+ const sep = gap === '' ? ',' : ',' + inner;
299
+ let out;
300
+ if (Array.isArray(value)) {
301
+ const parts = [];
302
+ for (let i = 0; i < value.length; ++i)
303
+ parts.push(stringifyValue(value[i], seen, gap, depth + 1) ?? 'null');
304
+ out = parts.length === 0 ? '[]' : `[${inner}${parts.join(sep)}${outer}]`;
305
+ }
306
+ else {
307
+ const parts = [];
308
+ for (const [k, v] of Object.entries(value)) {
309
+ const sv = stringifyValue(v, seen, gap, depth + 1);
310
+ if (sv !== undefined)
311
+ parts.push(`${JSON.stringify(k)}:${gap === '' ? '' : ' '}${sv}`);
312
+ }
313
+ out = parts.length === 0 ? '{}' : `{${inner}${parts.join(sep)}${outer}}`;
314
+ }
315
+ seen.delete(value);
316
+ return out;
317
+ }
318
+
319
+ /**
320
+ * Serialize a value to JSONX text. With `mode: 'json'` this delegates to
321
+ * `JSON.stringify` for exact backward compatibility (bigints throw, dates
322
+ * become quoted strings, non-finite numbers become null, ...).
323
+ * @param {*} value - The value to serialize
324
+ * @param {object} [options] - Writer options
325
+ * @param {'jsonx'|'json'} [options.mode] - Output dialect
326
+ * @param {number|string} [options.indent] - Pretty-print indentation
327
+ * @returns {string|undefined} The text, or undefined for undefined input
328
+ */
329
+ export function stringifyJsonx(value, options = {}) {
330
+ if (options.mode === 'json')
331
+ return JSON.stringify(value, null, options.indent);
332
+ const indent = options.indent ?? 0;
333
+ const gap = typeof indent === 'string'
334
+ ? indent.slice(0, 10)
335
+ : ' '.repeat(Math.min(10, Math.max(0, Math.trunc(indent))));
336
+ return stringifyValue(value, new Set(), gap, 0);
337
+ }
338
+
339
+ export { JsonxSyntaxError } from './errors.js';
340
+ export { LocalDate, LocalTime, LocalDateTime } from './values.js';
341
+
342
+ //#endregion