@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/cst.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
//#region JOSL concrete syntax tree
|
|
2
|
+
// A CST keeps the *document*, where the value model keeps only the data.
|
|
3
|
+
// Comments, blank lines, key spelling, quote style, number formatting and
|
|
4
|
+
// alignment all survive a parse/print round trip, so a tool can change one
|
|
5
|
+
// value in a config file and leave every other byte alone — something
|
|
6
|
+
// `parseJosl` + `stringifyJosl` cannot do, because none of that survives
|
|
7
|
+
// into a plain JS value.
|
|
8
|
+
//
|
|
9
|
+
// The tree is a flat list of logical lines, because that is exactly what
|
|
10
|
+
// JOSL's grammar produces: every construct starts and ends on one logical
|
|
11
|
+
// line (a multi-line string or array is still a single logical line). Each
|
|
12
|
+
// node records its source span rather than a copy of the text, so printing
|
|
13
|
+
// an untouched document hands back the original substrings.
|
|
14
|
+
|
|
15
|
+
import { JoslMachine } from './machine.js';
|
|
16
|
+
import { formatValue, formatKeyPath } from './stringify.js';
|
|
17
|
+
import { countCharCode } from '@jarenjs/core/string';
|
|
18
|
+
|
|
19
|
+
const KIND_TRIVIA = 'trivia';
|
|
20
|
+
const KIND_PAIR = 'pair';
|
|
21
|
+
|
|
22
|
+
function samePath(a, b) {
|
|
23
|
+
if (a.length !== b.length)
|
|
24
|
+
return false;
|
|
25
|
+
for (let i = 0; i < a.length; ++i)
|
|
26
|
+
if (a[i] !== b[i])
|
|
27
|
+
return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A parsed JOSL document that remembers its own text.
|
|
33
|
+
*
|
|
34
|
+
* Nodes are the document's logical lines in source order. Editing marks
|
|
35
|
+
* individual nodes dirty; every untouched node still prints as the exact
|
|
36
|
+
* bytes it was parsed from.
|
|
37
|
+
*/
|
|
38
|
+
export class JoslCstDocument {
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} source - The original document text
|
|
41
|
+
* @param {Array<object>} nodes - Logical-line nodes in source order
|
|
42
|
+
* @param {*} data - The parsed value model
|
|
43
|
+
* @param {object} options - Parse options, reused when re-reading edits
|
|
44
|
+
*/
|
|
45
|
+
constructor(source, nodes, data, options, bom = '') {
|
|
46
|
+
this.source = source;
|
|
47
|
+
this.nodes = nodes;
|
|
48
|
+
this.options = options;
|
|
49
|
+
// Node spans index the BOM-less text the parser saw, but rewriting a
|
|
50
|
+
// file must not silently strip its byte-order mark.
|
|
51
|
+
this.bom = bom;
|
|
52
|
+
this._data = data;
|
|
53
|
+
this._text = bom + source;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The document text, byte-identical to the input while unedited.
|
|
58
|
+
* @returns {string} JOSL source
|
|
59
|
+
*/
|
|
60
|
+
toString() {
|
|
61
|
+
if (this._text !== null)
|
|
62
|
+
return this._text;
|
|
63
|
+
let out = this.bom;
|
|
64
|
+
for (const node of this.nodes) {
|
|
65
|
+
if (node.removed)
|
|
66
|
+
continue;
|
|
67
|
+
if (node.text !== null) {
|
|
68
|
+
out += node.text;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (node.valueText === null) {
|
|
72
|
+
out += this.source.slice(node.start, node.end);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
out += this.source.slice(node.start, node.valueStart)
|
|
76
|
+
+ node.valueText
|
|
77
|
+
+ this.source.slice(node.valueEnd, node.end);
|
|
78
|
+
}
|
|
79
|
+
this._text = out;
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The value model for the document as it currently reads. Recomputed
|
|
85
|
+
* from the text after an edit, so it can never drift from the bytes.
|
|
86
|
+
* @returns {*} The root table, or root array for [[]] documents
|
|
87
|
+
*/
|
|
88
|
+
toJSON() {
|
|
89
|
+
if (this._data === null)
|
|
90
|
+
this._data = new JoslMachine(this.options).parseAll(this.toString());
|
|
91
|
+
return this._data;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read the value at a key path.
|
|
96
|
+
* @param {Array<string|number>} path - Absolute path of the pair
|
|
97
|
+
* @returns {*} The value, or undefined when the path holds no pair
|
|
98
|
+
*/
|
|
99
|
+
get(path) {
|
|
100
|
+
const node = this.nodes.find(
|
|
101
|
+
(n) => n.kind === KIND_PAIR && !n.removed && samePath(n.path, path));
|
|
102
|
+
return node === undefined ? undefined : node.value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Replace the value of an existing pair, or append a new pair to the
|
|
107
|
+
* section its path belongs to. Only the value's own bytes change, so the
|
|
108
|
+
* key's spelling, the spacing around `=` and any trailing comment stay
|
|
109
|
+
* exactly as written.
|
|
110
|
+
* @param {Array<string|number>} path - Absolute path of the pair
|
|
111
|
+
* @param {*} value - The new value
|
|
112
|
+
* @returns {this} The document, for chaining
|
|
113
|
+
*/
|
|
114
|
+
set(path, value) {
|
|
115
|
+
const rendered = formatValue(value, this.options);
|
|
116
|
+
const node = this.nodes.find(
|
|
117
|
+
(n) => n.kind === KIND_PAIR && !n.removed && samePath(n.path, path));
|
|
118
|
+
if (node !== undefined) {
|
|
119
|
+
node.valueText = rendered;
|
|
120
|
+
node.value = value;
|
|
121
|
+
return this.invalidate();
|
|
122
|
+
}
|
|
123
|
+
return this.insertPair(path, value, rendered);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Remove a pair, taking its whole line — including a trailing comment on
|
|
128
|
+
* that line — with it. Comments on their own lines are left alone.
|
|
129
|
+
* @param {Array<string|number>} path - Absolute path of the pair
|
|
130
|
+
* @returns {boolean} True when a pair was removed
|
|
131
|
+
*/
|
|
132
|
+
delete(path) {
|
|
133
|
+
const node = this.nodes.find(
|
|
134
|
+
(n) => n.kind === KIND_PAIR && !n.removed && samePath(n.path, path));
|
|
135
|
+
if (node === undefined)
|
|
136
|
+
return false;
|
|
137
|
+
node.removed = true;
|
|
138
|
+
this.invalidate();
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
invalidate() {
|
|
143
|
+
this._text = null;
|
|
144
|
+
this._data = null;
|
|
145
|
+
return this;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Append a pair to the end of the section that owns `path`, so a new key
|
|
149
|
+
// lands under its own [table] header rather than at the end of the file
|
|
150
|
+
// (where it would belong to whichever section happens to be last).
|
|
151
|
+
insertPair(path, value, rendered) {
|
|
152
|
+
const owner = path.slice(0, -1);
|
|
153
|
+
const line = `${formatKeyPath(path.slice(-1))} = ${rendered}\n`;
|
|
154
|
+
// The insertion point is the last line that belongs to the owning
|
|
155
|
+
// section *itself*. Matching sub-sections too would append the key
|
|
156
|
+
// after a nested [a.b] header, where it would read back as `a.b.key`.
|
|
157
|
+
let at = -1;
|
|
158
|
+
for (let i = 0; i < this.nodes.length; ++i) {
|
|
159
|
+
const node = this.nodes[i];
|
|
160
|
+
if (node.removed || node.kind === KIND_TRIVIA)
|
|
161
|
+
continue;
|
|
162
|
+
const scope = node.kind === KIND_PAIR ? node.path.slice(0, -1) : node.path;
|
|
163
|
+
if (samePath(scope, owner))
|
|
164
|
+
at = i;
|
|
165
|
+
}
|
|
166
|
+
if (at === -1 && owner.length !== 0)
|
|
167
|
+
throw new Error(`no section for '${formatKeyPath(owner)}' to hold the new key`);
|
|
168
|
+
const node = {
|
|
169
|
+
kind: KIND_PAIR,
|
|
170
|
+
path,
|
|
171
|
+
value,
|
|
172
|
+
line: 0,
|
|
173
|
+
start: 0,
|
|
174
|
+
end: 0,
|
|
175
|
+
valueStart: 0,
|
|
176
|
+
valueEnd: 0,
|
|
177
|
+
valueText: null,
|
|
178
|
+
text: line,
|
|
179
|
+
removed: false,
|
|
180
|
+
};
|
|
181
|
+
// a root-level key must precede the first header, or it would be read
|
|
182
|
+
// back as a member of that section
|
|
183
|
+
this.nodes.splice(at === -1 ? this.firstHeaderIndex() : at + 1, 0, node);
|
|
184
|
+
return this.invalidate();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
firstHeaderIndex() {
|
|
188
|
+
const at = this.nodes.findIndex(
|
|
189
|
+
(n) => !n.removed && n.kind !== KIND_TRIVIA && n.kind !== KIND_PAIR);
|
|
190
|
+
return at === -1 ? this.nodes.length : at;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Parse a document into a CST that preserves its exact text.
|
|
196
|
+
* @param {string} text - JOSL source text
|
|
197
|
+
* @param {object} [options] - Reader options
|
|
198
|
+
* @param {'josl'|'toml'} [options.mode] - 'toml' rejects JOSL extensions
|
|
199
|
+
* @returns {JoslCstDocument} The document
|
|
200
|
+
* @throws {import('./errors.js').JoslSyntaxError} On invalid input
|
|
201
|
+
*/
|
|
202
|
+
export function parseJoslCst(text, options = undefined) {
|
|
203
|
+
// the document keeps only the grammar-selecting options: re-reading its
|
|
204
|
+
// own text after an edit must not re-fire the caller's event sinks
|
|
205
|
+
const opts = options?.mode === undefined ? {} : { mode: options.mode };
|
|
206
|
+
const userEvent = options?.onEvent ?? null;
|
|
207
|
+
const nodes = [];
|
|
208
|
+
const pending = [];
|
|
209
|
+
let line = 1;
|
|
210
|
+
const machine = new JoslMachine({
|
|
211
|
+
...opts,
|
|
212
|
+
onEvent(event) {
|
|
213
|
+
pending.push(event);
|
|
214
|
+
if (userEvent !== null)
|
|
215
|
+
userEvent(event);
|
|
216
|
+
},
|
|
217
|
+
onLine(start, end, valueStart, valueEnd) {
|
|
218
|
+
const event = pending.length === 1 ? pending[0] : null;
|
|
219
|
+
nodes.push({
|
|
220
|
+
kind: event === null ? KIND_TRIVIA : event.type,
|
|
221
|
+
path: event === null ? [] : event.path,
|
|
222
|
+
value: event !== null && event.type === KIND_PAIR ? event.value : undefined,
|
|
223
|
+
line,
|
|
224
|
+
start,
|
|
225
|
+
end,
|
|
226
|
+
valueStart,
|
|
227
|
+
valueEnd,
|
|
228
|
+
valueText: null,
|
|
229
|
+
text: null,
|
|
230
|
+
removed: false,
|
|
231
|
+
});
|
|
232
|
+
pending.length = 0;
|
|
233
|
+
line += countCharCode(text, 0x0A, start, end);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
const data = machine.parseAll(text);
|
|
237
|
+
// parseAll strips a leading BOM before assigning offsets, so the spans
|
|
238
|
+
// index the same string the nodes must print from
|
|
239
|
+
const hasBom = text.charCodeAt(0) === 0xFEFF;
|
|
240
|
+
const source = hasBom ? text.slice(1) : text;
|
|
241
|
+
return new JoslCstDocument(source, nodes, data, opts, hasBom ? '' : '');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Parse a CST in strict TOML 1.0 mode.
|
|
246
|
+
* @param {string} text - TOML source text
|
|
247
|
+
* @param {object} [options] - Reader options minus `mode`
|
|
248
|
+
* @returns {JoslCstDocument} The document
|
|
249
|
+
*/
|
|
250
|
+
export function parseTomlCst(text, options = undefined) {
|
|
251
|
+
return parseJoslCst(text, { ...options, mode: 'toml' });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export { JoslSyntaxError } from './errors.js';
|
|
255
|
+
|
|
256
|
+
//#endregion
|