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