@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
package/src/parse.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region JOSL parse
|
|
2
|
+
// Whole-document parsing and streaming share one grammar implementation
|
|
3
|
+
// (machine.js) and differ only in how a logical line's end is found: with
|
|
4
|
+
// the whole text in hand the parser discovers it as it goes, while a chunk
|
|
5
|
+
// stream needs the cutter's side-effect-free pre-pass first.
|
|
6
|
+
|
|
7
|
+
import { JoslMachine } from './machine.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse a complete JOSL document.
|
|
11
|
+
* @param {string} text - JOSL source text
|
|
12
|
+
* @param {object} [options] - Reader options
|
|
13
|
+
* @param {'josl'|'toml'} [options.mode] - 'toml' rejects JOSL extensions
|
|
14
|
+
* (null, bigint, regexp literals, root arrays) for strict TOML 1.0 input
|
|
15
|
+
* @param {(event: object) => void} [options.onEvent] - Document-order
|
|
16
|
+
* event sink; see `createStreamReader`
|
|
17
|
+
* @returns {object|Array} The root table, or root array for [[]] documents
|
|
18
|
+
* @throws {import('./errors.js').JoslSyntaxError} On invalid input
|
|
19
|
+
*/
|
|
20
|
+
export function parseJosl(text, options = undefined) {
|
|
21
|
+
return new JoslMachine(options).parseAll(text);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a complete document in strict TOML 1.0 mode.
|
|
26
|
+
* @param {string} text - TOML source text
|
|
27
|
+
* @param {object} [options] - Reader options minus `mode`
|
|
28
|
+
* @returns {object} The root table
|
|
29
|
+
*/
|
|
30
|
+
export function parseToml(text, options = undefined) {
|
|
31
|
+
return new JoslMachine({ ...options, mode: 'toml' }).parseAll(text);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { JoslSyntaxError } from './errors.js';
|
|
35
|
+
export { LocalDate, LocalTime, LocalDateTime } from './values.js';
|
|
36
|
+
|
|
37
|
+
//#endregion
|
package/src/stream.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region JOSL streaming reader
|
|
2
|
+
// Chunk-feedable reader for incremental (e.g. LLM token) output. Events
|
|
3
|
+
// fire in document order the moment each construct completes — the
|
|
4
|
+
// opposite of `JSON.parse(text, reviver)`, which visits leaves bottom-up
|
|
5
|
+
// after the whole text has arrived and never tells you where you are.
|
|
6
|
+
// Every event carries an absolute `path` (strings for keys, numbers for
|
|
7
|
+
// array-of-tables indices), so events are directly JSON-Pointer-able.
|
|
8
|
+
|
|
9
|
+
import { JoslMachine } from './machine.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create an incremental JOSL/TOML reader.
|
|
13
|
+
*
|
|
14
|
+
* Events, in document order:
|
|
15
|
+
* {type:'table', path, line} - a [header] opened
|
|
16
|
+
* {type:'table-array', path, line} - a [[header]] appended
|
|
17
|
+
* {type:'root-item', path, index, line} - a [[]] element started
|
|
18
|
+
* {type:'pair', path, key, value, line} - a key-value completed
|
|
19
|
+
*
|
|
20
|
+
* @param {object} [options] - Reader options
|
|
21
|
+
* @param {'josl'|'toml'} [options.mode] - 'toml' rejects JOSL extensions
|
|
22
|
+
* @param {(event: object) => void} [options.onEvent] - Event sink
|
|
23
|
+
* @returns {{feed(chunk: string): void, end(): *, root(): *}} The reader:
|
|
24
|
+
* `feed` accepts chunks that may split any token, `end` flushes and
|
|
25
|
+
* returns the completed root, `root` peeks at the partial result.
|
|
26
|
+
*/
|
|
27
|
+
export function createStreamReader(options = undefined) {
|
|
28
|
+
const machine = new JoslMachine(options);
|
|
29
|
+
return {
|
|
30
|
+
feed(chunk) {
|
|
31
|
+
machine.feed(chunk);
|
|
32
|
+
},
|
|
33
|
+
end() {
|
|
34
|
+
return machine.end();
|
|
35
|
+
},
|
|
36
|
+
root() {
|
|
37
|
+
return machine.root();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse an async iterable of string chunks (e.g. an LLM output stream).
|
|
44
|
+
* @param {AsyncIterable<string>|Iterable<string>} chunks - Source chunks
|
|
45
|
+
* @param {object} [options] - Reader options; see `createStreamReader`
|
|
46
|
+
* @returns {Promise<*>} The completed root value
|
|
47
|
+
*/
|
|
48
|
+
export async function parseJoslStream(chunks, options = undefined) {
|
|
49
|
+
const machine = new JoslMachine(options);
|
|
50
|
+
for await (const chunk of chunks)
|
|
51
|
+
machine.feed(chunk);
|
|
52
|
+
return machine.end();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { JoslSyntaxError } from './errors.js';
|
|
56
|
+
|
|
57
|
+
//#endregion
|
package/src/stringify.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
//#region JOSL writer
|
|
2
|
+
// Serializes a JS value to JOSL text, or to strict TOML 1.0 with
|
|
3
|
+
// `mode: 'toml'` — in which case the JOSL-only types must be downleveled:
|
|
4
|
+
// null is omitted or an error (`onNull`), regexps become strings or an
|
|
5
|
+
// error (`onRegExp`), and bigints are emitted plain while they fit TOML's
|
|
6
|
+
// 64-bit integer range. Round-trips are faithful for data, not for
|
|
7
|
+
// formatting: key order is preserved, comments do not exist in the value
|
|
8
|
+
// model, and any array whose elements are all plain objects is emitted in
|
|
9
|
+
// array-of-tables form.
|
|
10
|
+
|
|
11
|
+
import { JoslStringifyError } from './errors.js';
|
|
12
|
+
import { LocalDate, LocalTime, LocalDateTime } from './values.js';
|
|
13
|
+
|
|
14
|
+
const RE_BARE_KEY = /^[A-Za-z0-9_-]+$/;
|
|
15
|
+
const TOML_INT_MIN = -(2n ** 63n);
|
|
16
|
+
const TOML_INT_MAX = 2n ** 63n - 1n;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether a value serializes as a table (a plain object).
|
|
20
|
+
* @param {*} v - The value
|
|
21
|
+
* @returns {boolean} True for plain objects
|
|
22
|
+
*/
|
|
23
|
+
export function isPlainTable(v) {
|
|
24
|
+
return isPlainObject(v);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isPlainObject(v) {
|
|
28
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v))
|
|
29
|
+
return false;
|
|
30
|
+
const proto = Object.getPrototypeOf(v);
|
|
31
|
+
return proto === Object.prototype || proto === null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// eslint-disable-next-line no-control-regex
|
|
35
|
+
const RE_NEEDS_ESCAPE = /["\\\u0000-\u001F\u007F]/;
|
|
36
|
+
|
|
37
|
+
function escapeChar(c) {
|
|
38
|
+
if (c === 0x22)
|
|
39
|
+
return '\\"';
|
|
40
|
+
if (c === 0x5C)
|
|
41
|
+
return '\\\\';
|
|
42
|
+
if (c === 0x08)
|
|
43
|
+
return '\\b';
|
|
44
|
+
if (c === 0x09)
|
|
45
|
+
return '\\t';
|
|
46
|
+
if (c === 0x0A)
|
|
47
|
+
return '\\n';
|
|
48
|
+
if (c === 0x0C)
|
|
49
|
+
return '\\f';
|
|
50
|
+
if (c === 0x0D)
|
|
51
|
+
return '\\r';
|
|
52
|
+
return `\\u${c.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function quoteString(s) {
|
|
56
|
+
if (!RE_NEEDS_ESCAPE.test(s))
|
|
57
|
+
return `"${s}"`;
|
|
58
|
+
// Copy unescaped runs whole; only escape characters break the run.
|
|
59
|
+
let out = '"';
|
|
60
|
+
let start = 0;
|
|
61
|
+
for (let i = 0; i < s.length; ++i) {
|
|
62
|
+
const c = s.charCodeAt(i);
|
|
63
|
+
if (c === 0x22 || c === 0x5C || c < 0x20 || c === 0x7F) {
|
|
64
|
+
if (start < i)
|
|
65
|
+
out += s.slice(start, i);
|
|
66
|
+
out += escapeChar(c);
|
|
67
|
+
start = i + 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (start < s.length)
|
|
71
|
+
out += s.slice(start);
|
|
72
|
+
return out + '"';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fmtKey(k) {
|
|
76
|
+
return RE_BARE_KEY.test(k) ? k : quoteString(k);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fmtPath(path) {
|
|
80
|
+
let out = '';
|
|
81
|
+
for (let i = 0; i < path.length; ++i)
|
|
82
|
+
out += (i === 0 ? '' : '.') + fmtKey(path[i]);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fmtNumber(v) {
|
|
87
|
+
if (Number.isFinite(v))
|
|
88
|
+
return Object.is(v, -0) ? '-0.0' : String(v);
|
|
89
|
+
if (Number.isNaN(v))
|
|
90
|
+
return 'nan';
|
|
91
|
+
return v === Infinity ? 'inf' : '-inf';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The error path lives on `ctx.path` as a mutable stack (pushed/popped
|
|
95
|
+
// around each descent) so the happy path allocates no per-key arrays; it
|
|
96
|
+
// is sliced only at a throw site.
|
|
97
|
+
function fmtValue(ctx, v) {
|
|
98
|
+
switch (typeof v) {
|
|
99
|
+
case 'string':
|
|
100
|
+
return quoteString(v);
|
|
101
|
+
case 'number':
|
|
102
|
+
return fmtNumber(v);
|
|
103
|
+
case 'boolean':
|
|
104
|
+
return v ? 'true' : 'false';
|
|
105
|
+
case 'bigint':
|
|
106
|
+
if (ctx.mode === 'toml') {
|
|
107
|
+
if (v < TOML_INT_MIN || v > TOML_INT_MAX)
|
|
108
|
+
throw new JoslStringifyError('bigint exceeds the TOML 64-bit integer range', ctx.path.slice());
|
|
109
|
+
return String(v);
|
|
110
|
+
}
|
|
111
|
+
return `${v}n`;
|
|
112
|
+
case 'object':
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
throw new JoslStringifyError(`cannot represent a ${typeof v} value`, ctx.path.slice());
|
|
116
|
+
}
|
|
117
|
+
if (v === null) {
|
|
118
|
+
if (ctx.mode === 'toml')
|
|
119
|
+
throw new JoslStringifyError('TOML cannot represent null', ctx.path.slice());
|
|
120
|
+
return 'null';
|
|
121
|
+
}
|
|
122
|
+
if (v instanceof Date)
|
|
123
|
+
return v.toISOString();
|
|
124
|
+
if (v instanceof LocalDate || v instanceof LocalTime || v instanceof LocalDateTime)
|
|
125
|
+
return v.toString();
|
|
126
|
+
if (v instanceof RegExp) {
|
|
127
|
+
if (ctx.mode === 'toml') {
|
|
128
|
+
if (ctx.onRegExp === 'string')
|
|
129
|
+
return quoteString(`/${v.source}/${v.flags}`);
|
|
130
|
+
throw new JoslStringifyError('TOML cannot represent a RegExp', ctx.path.slice());
|
|
131
|
+
}
|
|
132
|
+
return `/${v.source}/${v.flags}`;
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(v)) {
|
|
135
|
+
if (ctx.seen.has(v))
|
|
136
|
+
throw new JoslStringifyError('circular reference', ctx.path.slice());
|
|
137
|
+
ctx.seen.add(v);
|
|
138
|
+
let out = '[';
|
|
139
|
+
for (let i = 0; i < v.length; ++i) {
|
|
140
|
+
ctx.path.push(i);
|
|
141
|
+
out += (i === 0 ? ' ' : ', ') + fmtValue(ctx, v[i]);
|
|
142
|
+
ctx.path.pop();
|
|
143
|
+
}
|
|
144
|
+
ctx.seen.delete(v);
|
|
145
|
+
return v.length === 0 ? '[]' : out + ' ]';
|
|
146
|
+
}
|
|
147
|
+
if (isPlainObject(v)) {
|
|
148
|
+
if (ctx.seen.has(v))
|
|
149
|
+
throw new JoslStringifyError('circular reference', ctx.path.slice());
|
|
150
|
+
ctx.seen.add(v);
|
|
151
|
+
let out = '{';
|
|
152
|
+
let first = true;
|
|
153
|
+
const keys = Object.keys(v);
|
|
154
|
+
for (let i = 0; i < keys.length; ++i) {
|
|
155
|
+
const k = keys[i];
|
|
156
|
+
const e = v[k];
|
|
157
|
+
if (e === null && ctx.mode === 'toml' && ctx.onNull === 'omit')
|
|
158
|
+
continue;
|
|
159
|
+
ctx.path.push(k);
|
|
160
|
+
out += (first ? ' ' : ', ') + `${fmtKey(k)} = ${fmtValue(ctx, e)}`;
|
|
161
|
+
ctx.path.pop();
|
|
162
|
+
first = false;
|
|
163
|
+
}
|
|
164
|
+
ctx.seen.delete(v);
|
|
165
|
+
return first ? '{}' : out + ' }';
|
|
166
|
+
}
|
|
167
|
+
throw new JoslStringifyError('cannot represent this object type', ctx.path.slice());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// `header` is the already-formatted dotted header prefix ('' at the root),
|
|
171
|
+
// so a nested section formats only its own key instead of re-walking every
|
|
172
|
+
// ancestor. Values are read exactly once (a getter must not fire twice):
|
|
173
|
+
// deferred tables/arrays-of-tables are kept as flat [key, value, ...] runs.
|
|
174
|
+
function emitTable(ctx, header, obj) {
|
|
175
|
+
if (ctx.seen.has(obj))
|
|
176
|
+
throw new JoslStringifyError('circular reference', ctx.path.slice());
|
|
177
|
+
ctx.seen.add(obj);
|
|
178
|
+
const keys = Object.keys(obj);
|
|
179
|
+
let tables = null;
|
|
180
|
+
let aots = null;
|
|
181
|
+
for (let i = 0; i < keys.length; ++i) {
|
|
182
|
+
const k = keys[i];
|
|
183
|
+
const v = obj[k];
|
|
184
|
+
if (v === undefined)
|
|
185
|
+
continue;
|
|
186
|
+
if (v === null && ctx.mode === 'toml' && ctx.onNull === 'omit')
|
|
187
|
+
continue;
|
|
188
|
+
if (isPlainObject(v))
|
|
189
|
+
(tables ??= []).push(k, v);
|
|
190
|
+
else if (Array.isArray(v) && v.length !== 0 && v.every(isPlainObject))
|
|
191
|
+
(aots ??= []).push(k, v);
|
|
192
|
+
else {
|
|
193
|
+
ctx.path.push(k);
|
|
194
|
+
ctx.out.push(`${fmtKey(k)} = ${fmtValue(ctx, v)}`);
|
|
195
|
+
ctx.path.pop();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (tables !== null) {
|
|
199
|
+
for (let i = 0; i < tables.length; i += 2) {
|
|
200
|
+
const k = tables[i];
|
|
201
|
+
const h = header === '' ? fmtKey(k) : `${header}.${fmtKey(k)}`;
|
|
202
|
+
if (ctx.out.length !== 0)
|
|
203
|
+
ctx.out.push('');
|
|
204
|
+
ctx.out.push(`[${h}]`);
|
|
205
|
+
ctx.path.push(k);
|
|
206
|
+
emitTable(ctx, h, tables[i + 1]);
|
|
207
|
+
ctx.path.pop();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (aots !== null) {
|
|
211
|
+
for (let i = 0; i < aots.length; i += 2) {
|
|
212
|
+
const k = aots[i];
|
|
213
|
+
const arr = aots[i + 1];
|
|
214
|
+
const h = header === '' ? fmtKey(k) : `${header}.${fmtKey(k)}`;
|
|
215
|
+
const line = `[[${h}]]`;
|
|
216
|
+
ctx.path.push(k);
|
|
217
|
+
for (let j = 0; j < arr.length; ++j) {
|
|
218
|
+
if (ctx.out.length !== 0)
|
|
219
|
+
ctx.out.push('');
|
|
220
|
+
ctx.out.push(line);
|
|
221
|
+
ctx.path.push(j);
|
|
222
|
+
emitTable(ctx, h, arr[j]);
|
|
223
|
+
ctx.path.pop();
|
|
224
|
+
}
|
|
225
|
+
ctx.path.pop();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
ctx.seen.delete(obj);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function makeCtx(options) {
|
|
232
|
+
return {
|
|
233
|
+
mode: options.mode === 'toml' ? 'toml' : 'josl',
|
|
234
|
+
onNull: options.onNull === 'omit' ? 'omit' : 'error',
|
|
235
|
+
onRegExp: options.onRegExp === 'string' ? 'string' : 'error',
|
|
236
|
+
out: [],
|
|
237
|
+
seen: new Set(),
|
|
238
|
+
path: [],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Format a single key (bare when possible, quoted otherwise).
|
|
244
|
+
* @param {string} key - The key
|
|
245
|
+
* @returns {string} JOSL/TOML key text
|
|
246
|
+
*/
|
|
247
|
+
export function formatKey(key) {
|
|
248
|
+
return fmtKey(key);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Format a dotted key path.
|
|
253
|
+
* @param {string[]} path - Key path segments
|
|
254
|
+
* @returns {string} Dotted key path text
|
|
255
|
+
*/
|
|
256
|
+
export function formatKeyPath(path) {
|
|
257
|
+
return fmtPath(path);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Format a single value (scalars, inline arrays, inline tables).
|
|
262
|
+
* @param {*} value - The value
|
|
263
|
+
* @param {object} [options] - Writer options; see `stringifyJosl`
|
|
264
|
+
* @param {(string|number)[]} [path] - Error-reporting path
|
|
265
|
+
* @returns {string} JOSL/TOML value text
|
|
266
|
+
* @throws {JoslStringifyError} When the value cannot be represented
|
|
267
|
+
*/
|
|
268
|
+
export function formatValue(value, options = {}, path = []) {
|
|
269
|
+
const ctx = makeCtx(options);
|
|
270
|
+
ctx.path = path.slice();
|
|
271
|
+
return fmtValue(ctx, value);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Format a table body: its pairs followed by nested `[header]` /
|
|
276
|
+
* `[[header]]` sections, with headers made relative to `headerPath`.
|
|
277
|
+
* @param {object} obj - A plain object table
|
|
278
|
+
* @param {object} [options] - Writer options; see `stringifyJosl`
|
|
279
|
+
* @param {string[]} [headerPath] - Prefix for nested section headers
|
|
280
|
+
* @returns {string} Section text (newline terminated, may be empty)
|
|
281
|
+
* @throws {JoslStringifyError} When a value cannot be represented
|
|
282
|
+
*/
|
|
283
|
+
export function formatSection(obj, options = {}, headerPath = []) {
|
|
284
|
+
const ctx = makeCtx(options);
|
|
285
|
+
emitTable(ctx, fmtPath(headerPath), obj);
|
|
286
|
+
return ctx.out.length === 0 ? '' : ctx.out.join('\n') + '\n';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Serialize a value to JOSL (or strict TOML) text.
|
|
291
|
+
* @param {object|Array} value - A plain object root, or (JOSL mode only)
|
|
292
|
+
* an array of plain objects for a [[]] root-array document
|
|
293
|
+
* @param {object} [options] - Writer options
|
|
294
|
+
* @param {'josl'|'toml'} [options.mode] - 'toml' emits strict TOML 1.0
|
|
295
|
+
* @param {'error'|'omit'} [options.onNull] - TOML mode: what to do with
|
|
296
|
+
* null table values (array elements always error)
|
|
297
|
+
* @param {'error'|'string'} [options.onRegExp] - TOML mode: represent
|
|
298
|
+
* regexps as strings, or error
|
|
299
|
+
* @returns {string} The serialized document, newline terminated
|
|
300
|
+
* @throws {JoslStringifyError} When the value cannot be represented
|
|
301
|
+
*/
|
|
302
|
+
export function stringifyJosl(value, options = {}) {
|
|
303
|
+
const ctx = makeCtx(options);
|
|
304
|
+
if (Array.isArray(value)) {
|
|
305
|
+
if (ctx.mode === 'toml')
|
|
306
|
+
throw new JoslStringifyError('a TOML root must be a table; root arrays are a JOSL extension');
|
|
307
|
+
for (let i = 0; i < value.length; ++i) {
|
|
308
|
+
if (!isPlainObject(value[i]))
|
|
309
|
+
throw new JoslStringifyError('root array elements must be tables', [i]);
|
|
310
|
+
if (ctx.out.length !== 0)
|
|
311
|
+
ctx.out.push('');
|
|
312
|
+
ctx.out.push('[[]]');
|
|
313
|
+
ctx.path.push(i);
|
|
314
|
+
emitTable(ctx, '', value[i]);
|
|
315
|
+
ctx.path.pop();
|
|
316
|
+
}
|
|
317
|
+
if (value.length === 0)
|
|
318
|
+
return '';
|
|
319
|
+
}
|
|
320
|
+
else if (isPlainObject(value))
|
|
321
|
+
emitTable(ctx, '', value);
|
|
322
|
+
else
|
|
323
|
+
throw new JoslStringifyError(ctx.mode === 'toml'
|
|
324
|
+
? 'a TOML root must be a table'
|
|
325
|
+
: 'a JOSL root must be a table or an array of tables');
|
|
326
|
+
return ctx.out.length === 0 ? '' : ctx.out.join('\n') + '\n';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Serialize a value to strict TOML 1.0 text.
|
|
331
|
+
* @param {object} value - A plain object root
|
|
332
|
+
* @param {object} [options] - Writer options minus `mode`
|
|
333
|
+
* @returns {string} The serialized document
|
|
334
|
+
*/
|
|
335
|
+
export function stringifyToml(value, options = {}) {
|
|
336
|
+
return stringifyJosl(value, { ...options, mode: 'toml' });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export { JoslStringifyError } from './errors.js';
|
|
340
|
+
|
|
341
|
+
//#endregion
|
package/src/util.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
//#region internal helpers shared by the JOSL and JSONX readers
|
|
2
|
+
|
|
3
|
+
// The date-time token patterns of both readers. Sticky (`y`) so they
|
|
4
|
+
// match in place at the current position without slicing the logical
|
|
5
|
+
// line. They live here because the JOSL machine and the JSONX scalar
|
|
6
|
+
// reader lex the same tokens: two copies drifting apart would give one
|
|
7
|
+
// syntax two date grammars.
|
|
8
|
+
//
|
|
9
|
+
// The capture groups are the readers' contract: 1-3 date, 4-6 clock,
|
|
10
|
+
// 7 the fraction INCLUDING its leading dot, 8 the offset. The fraction
|
|
11
|
+
// is captured as text on purpose - JOSL round-trips a document, so
|
|
12
|
+
// `00.100` must not come back `00.1`.
|
|
13
|
+
|
|
14
|
+
/** `YYYY-MM-DD` with an optional time half. @type {RegExp} */
|
|
15
|
+
export const RE_DATETIME = /(\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?)?/y;
|
|
16
|
+
|
|
17
|
+
/** A bare `HH:MM:SS` with an optional fraction. @type {RegExp} */
|
|
18
|
+
export const RE_TIMEONLY = /(\d{2}):(\d{2}):(\d{2})(\.\d+)?/y;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run a sticky regex at `pos` and return its match (or null).
|
|
22
|
+
* @param {RegExp} re - A sticky (`y`) pattern
|
|
23
|
+
* @param {string} text - The text to match against
|
|
24
|
+
* @param {number} pos - Position the match must start at
|
|
25
|
+
* @returns {RegExpExecArray | null} The match, anchored at `pos`
|
|
26
|
+
*/
|
|
27
|
+
export function stickyExec(re, text, pos) {
|
|
28
|
+
re.lastIndex = pos;
|
|
29
|
+
return re.exec(text);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Shared `feed(chunk)` body for the buffering stream machines (JOSL and
|
|
34
|
+
* CSV): guard against feeding after `end()`, strip a leading BOM on the
|
|
35
|
+
* first non-empty chunk, then buffer and scan. The JSONX stream reader
|
|
36
|
+
* has its own `feed` on purpose - it pumps a token loop and models BOM
|
|
37
|
+
* handling differently.
|
|
38
|
+
* @template {{ ended: boolean, started: boolean, buf: string, scan: () => void }} T
|
|
39
|
+
* @param {T} machine - The stream machine (`this` of its `feed`)
|
|
40
|
+
* @param {string} chunk - Next piece of the document
|
|
41
|
+
* @returns {T} The machine, for chaining
|
|
42
|
+
*/
|
|
43
|
+
export function feedMachine(machine, chunk) {
|
|
44
|
+
if (machine.ended)
|
|
45
|
+
throw new Error('cannot feed after end()');
|
|
46
|
+
if (!machine.started && chunk.length !== 0) {
|
|
47
|
+
machine.started = true;
|
|
48
|
+
if (chunk.charCodeAt(0) === 0xFEFF)
|
|
49
|
+
chunk = chunk.slice(1); // strip a leading BOM
|
|
50
|
+
}
|
|
51
|
+
if (chunk.length !== 0) {
|
|
52
|
+
machine.buf += chunk;
|
|
53
|
+
machine.scan();
|
|
54
|
+
}
|
|
55
|
+
return machine;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Shared `parseAll(text)` prelude for the buffering stream machines:
|
|
60
|
+
* reject mixing with `feed()`/`end()`, mark the machine started and
|
|
61
|
+
* ended, and strip a leading BOM.
|
|
62
|
+
* @param {{ ended: boolean, started: boolean }} machine - The stream machine
|
|
63
|
+
* @param {string} text - The entire document
|
|
64
|
+
* @returns {string} The text with any leading BOM removed
|
|
65
|
+
*/
|
|
66
|
+
export function beginParseAll(machine, text) {
|
|
67
|
+
if (machine.started || machine.ended)
|
|
68
|
+
throw new Error('parseAll cannot be mixed with feed()/end()');
|
|
69
|
+
machine.started = true;
|
|
70
|
+
machine.ended = true;
|
|
71
|
+
return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read an own property, ignoring the prototype chain.
|
|
76
|
+
* @param {object} obj - Source object
|
|
77
|
+
* @param {string} key - Member name
|
|
78
|
+
* @returns {*} The own value or undefined
|
|
79
|
+
*/
|
|
80
|
+
export function getOwn(obj, key) {
|
|
81
|
+
return Object.hasOwn(obj, key) ? obj[key] : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compute the 1-based column of `pos` inside `str` (columns restart after
|
|
86
|
+
* every newline; multi-line logical lines report positions within them).
|
|
87
|
+
* @param {string} str - Input text
|
|
88
|
+
* @param {number} pos - Offset into the text
|
|
89
|
+
* @returns {number} 1-based column number
|
|
90
|
+
*/
|
|
91
|
+
export function columnOf(str, pos) {
|
|
92
|
+
const nl = str.lastIndexOf('\n', pos - 1);
|
|
93
|
+
return pos - nl;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
//#endregion
|
package/src/values.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//#region JOSL first-class value types
|
|
2
|
+
// TOML distinguishes four date-time flavours; JavaScript's `Date` can only
|
|
3
|
+
// faithfully hold the offset one. JOSL keeps the local flavours in small
|
|
4
|
+
// immutable value classes that round-trip through `toString()` and behave
|
|
5
|
+
// under `JSON.stringify` via `toJSON()`. An offset date-time parses to a
|
|
6
|
+
// native `Date` (the original offset is normalized to the instant).
|
|
7
|
+
|
|
8
|
+
function pad(n, w) {
|
|
9
|
+
return String(n).padStart(w, '0');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A TOML/JOSL local date (no time, no offset), e.g. `1979-05-27`.
|
|
14
|
+
*/
|
|
15
|
+
export class LocalDate {
|
|
16
|
+
/**
|
|
17
|
+
* @param {number} year - Full year
|
|
18
|
+
* @param {number} month - 1-based month
|
|
19
|
+
* @param {number} day - 1-based day of month
|
|
20
|
+
*/
|
|
21
|
+
constructor(year, month, day) {
|
|
22
|
+
this.year = year;
|
|
23
|
+
this.month = month;
|
|
24
|
+
this.day = day;
|
|
25
|
+
Object.freeze(this);
|
|
26
|
+
}
|
|
27
|
+
toString() {
|
|
28
|
+
return `${pad(this.year, 4)}-${pad(this.month, 2)}-${pad(this.day, 2)}`;
|
|
29
|
+
}
|
|
30
|
+
toJSON() {
|
|
31
|
+
return this.toString();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A TOML/JOSL local time (no date, no offset), e.g. `07:32:00.999`.
|
|
37
|
+
* The sub-second part is kept as the literal fraction string (including
|
|
38
|
+
* the leading dot, or `''`) so precision round-trips exactly.
|
|
39
|
+
*/
|
|
40
|
+
export class LocalTime {
|
|
41
|
+
/**
|
|
42
|
+
* @param {number} hour - 0-23
|
|
43
|
+
* @param {number} minute - 0-59
|
|
44
|
+
* @param {number} second - 0-60 (60 allows leap seconds)
|
|
45
|
+
* @param {string} [fraction] - Literal fraction incl. leading dot, or ''
|
|
46
|
+
*/
|
|
47
|
+
constructor(hour, minute, second, fraction = '') {
|
|
48
|
+
this.hour = hour;
|
|
49
|
+
this.minute = minute;
|
|
50
|
+
this.second = second;
|
|
51
|
+
this.fraction = fraction;
|
|
52
|
+
Object.freeze(this);
|
|
53
|
+
}
|
|
54
|
+
toString() {
|
|
55
|
+
return `${pad(this.hour, 2)}:${pad(this.minute, 2)}:${pad(this.second, 2)}${this.fraction}`;
|
|
56
|
+
}
|
|
57
|
+
toJSON() {
|
|
58
|
+
return this.toString();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A TOML/JOSL local date-time (no offset), e.g. `1979-05-27T07:32:00`.
|
|
64
|
+
*/
|
|
65
|
+
export class LocalDateTime {
|
|
66
|
+
/**
|
|
67
|
+
* @param {LocalDate} date - The date part
|
|
68
|
+
* @param {LocalTime} time - The time part
|
|
69
|
+
*/
|
|
70
|
+
constructor(date, time) {
|
|
71
|
+
this.date = date;
|
|
72
|
+
this.time = time;
|
|
73
|
+
Object.freeze(this);
|
|
74
|
+
}
|
|
75
|
+
toString() {
|
|
76
|
+
return `${this.date.toString()}T${this.time.toString()}`;
|
|
77
|
+
}
|
|
78
|
+
toJSON() {
|
|
79
|
+
return this.toString();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Whether year/month/day form an existing calendar date: core's
|
|
84
|
+
// `isDateOnlyInRange`, re-exported under this package's established
|
|
85
|
+
// name (the pair of `isValidTimeParts` below). Using the shared
|
|
86
|
+
// calendar kernel means TOML's four date flavours and the rest of the
|
|
87
|
+
// suite cannot disagree about whether a date exists.
|
|
88
|
+
export { isDateOnlyInRange as isValidDateParts } from '@jarenjs/core/dates';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether hour/minute/second form a valid time-of-day (second 60 is
|
|
92
|
+
* accepted for leap seconds).
|
|
93
|
+
* @param {number} hour - Hours
|
|
94
|
+
* @param {number} minute - Minutes
|
|
95
|
+
* @param {number} second - Seconds
|
|
96
|
+
* @returns {boolean} True when in range
|
|
97
|
+
*/
|
|
98
|
+
export function isValidTimeParts(hour, minute, second) {
|
|
99
|
+
return hour >= 0 && hour <= 23
|
|
100
|
+
&& minute >= 0 && minute <= 59
|
|
101
|
+
&& second >= 0 && second <= 60;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
//#endregion
|