@jxsuite/schema 5.5.0 → 6.0.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.
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Where a position is in a `.jxn` file that may not parse (`spec.md` §22.6).
3
+ *
4
+ * A completion is asked for while a file is being typed, and `button onclick=` is not KDL, so the
5
+ * parser has nothing to say about the one moment that matters. This is a reading of its own: it
6
+ * keeps to the notation's lexical rules (strings, raw strings, comments, blocks) and gives up at no
7
+ * error. A string left open ends at its line, a block left open ends with the file, and a character
8
+ * that belongs nowhere is stepped over.
9
+ *
10
+ * It answers two things. The nodes of the file, each with its name, its annotation, the values of
11
+ * its line and its block, which is enough to say how `spec.md` §22.1 reads each one. And the
12
+ * position: which node's line or block it is in, and whether a name, a value or an annotation is
13
+ * what would be typed there.
14
+ */
15
+ import type { JxnSpan } from "./errors.ts";
16
+
17
+ /** One value of a node's line, named or alone. */
18
+ export interface OutlineEntry {
19
+ /** The annotation written before the value, without its parentheses, or null. */
20
+ annotation: string | null;
21
+ /** The name before `=`, or null for a value written alone. */
22
+ key: string | null;
23
+ }
24
+
25
+ /** One node, as far as it is written. */
26
+ export interface OutlineNode {
27
+ /** The annotation written before the name, without its parentheses, or null. */
28
+ annotation: string | null;
29
+ /** The nodes of its block, or null where it has none. */
30
+ children: OutlineNode[] | null;
31
+ entries: OutlineEntry[];
32
+ /** The name as written, without the quotes of a quoted one. Empty while none is written. */
33
+ name: string;
34
+ }
35
+
36
+ /** What would be typed at a position. */
37
+ export type OutlineSlot = "annotation" | "entry" | "name" | "value";
38
+
39
+ /**
40
+ * A position something can be offered at. Its span is the text an offer replaces: the word the
41
+ * position is in, the inside of the string it is in, or nothing.
42
+ */
43
+ export interface OutlineCursor extends JxnSpan {
44
+ /**
45
+ * The value the position is in or annotates, or the name it is in. Null for the name of a node,
46
+ * and for a place on a node's line where nothing is written yet.
47
+ */
48
+ entry: OutlineEntry | null;
49
+ /** The node whose line the position is on. Null in a block, where no node is begun. */
50
+ node: OutlineNode | null;
51
+ /** The nodes whose blocks hold the position, outermost first. */
52
+ path: OutlineNode[];
53
+ /** Whether the position is inside a quoted string, so an offer is the text of one. */
54
+ quoted: boolean;
55
+ /**
56
+ * `name` is a node's name, `entry` a name or a lone value on a node's line, `value` what follows
57
+ * `name=` or the inside of a string, and `annotation` the word between parentheses.
58
+ */
59
+ slot: OutlineSlot;
60
+ }
61
+
62
+ /** A file as far as it can be read, and the position asked about. */
63
+ export interface Outline {
64
+ /** Null where nothing is offered: in a comment, in code, or after something already closed. */
65
+ cursor: OutlineCursor | null;
66
+ /** The file's top-level nodes. */
67
+ nodes: OutlineNode[];
68
+ }
69
+
70
+ /** What a name is made of: anything the notation gives no other meaning. */
71
+ const NAME_CHAR = /[^\s\\/(){};[\]"#=]/u;
72
+
73
+ /** Space that does not end a line. */
74
+ const INLINE_SPACE = /[^\S\n]/u;
75
+
76
+ /** A string as it was read. */
77
+ interface StringToken extends JxnSpan {
78
+ /** One past the last character of its text: where its closing quote is, or would be. */
79
+ inside: number;
80
+ /** Whether it is one quoted line, which is the only kind an offer is made in. */
81
+ plain: boolean;
82
+ }
83
+
84
+ /** One reading of one file. */
85
+ class Outliner {
86
+ cursor: OutlineCursor | null = null;
87
+ index: number;
88
+ /** How many slashdashes are being read, each of which comments out what it is read into. */
89
+ muted = 0;
90
+ readonly nodes: OutlineNode[] = [];
91
+ readonly offset: number;
92
+ /** Whether the position has been placed, which happens once. */
93
+ settled = false;
94
+ readonly stack: OutlineNode[] = [];
95
+ readonly text: string;
96
+
97
+ constructor(text: string, offset: number) {
98
+ this.text = text;
99
+ this.offset = offset;
100
+ this.index = text.codePointAt(0) === 0xfe_ff ? 1 : 0;
101
+ }
102
+
103
+ get char(): string {
104
+ return this.text[this.index] ?? "";
105
+ }
106
+
107
+ get done(): boolean {
108
+ return this.index >= this.text.length;
109
+ }
110
+
111
+ startsWith(what: string): boolean {
112
+ return this.text.startsWith(what, this.index);
113
+ }
114
+
115
+ /** Whether the position is from `start` to `end`, ends included, and not yet placed. */
116
+ holds(start: number, end: number): boolean {
117
+ return !this.settled && this.offset >= start && this.offset <= end;
118
+ }
119
+
120
+ /** Place the position. */
121
+ claim(
122
+ slot: OutlineSlot,
123
+ span: JxnSpan,
124
+ node: OutlineNode | null,
125
+ entry: OutlineEntry | null,
126
+ quoted = false,
127
+ ): void {
128
+ // What a slashdash comments out is a comment, and holds the position as one does.
129
+ this.cursor =
130
+ this.muted > 0 ? null : { ...span, entry, node, path: [...this.stack], quoted, slot };
131
+ this.settled = true;
132
+ }
133
+
134
+ /** Place the position where nothing is offered, if it is strictly inside `start` to `end`. */
135
+ refuse(start: number, end: number): void {
136
+ if (!this.settled && this.offset > start && this.offset < end) {
137
+ this.settled = true;
138
+ }
139
+ }
140
+
141
+ /** Past a `//` comment, which holds the position up to the end of its line. */
142
+ skipLine(): void {
143
+ const start = this.index;
144
+ const end = this.text.indexOf("\n", start);
145
+ this.index = end === -1 ? this.text.length : end;
146
+ this.refuse(start, this.index + 1);
147
+ }
148
+
149
+ /** Past a block comment, which nests. */
150
+ skipComment(): void {
151
+ const start = this.index;
152
+ let depth = 1;
153
+ this.index += 2;
154
+ while (!this.done && depth > 0) {
155
+ if (this.startsWith("/*")) {
156
+ depth += 1;
157
+ this.index += 2;
158
+ } else if (this.startsWith("*/")) {
159
+ depth -= 1;
160
+ this.index += 2;
161
+ } else {
162
+ this.index += 1;
163
+ }
164
+ }
165
+ // One left open holds everything after it.
166
+ this.refuse(start, depth > 0 ? this.index + 1 : this.index);
167
+ }
168
+
169
+ /** Past what may stand between two values of one line. Answers whether it moved. */
170
+ skipInline(): boolean {
171
+ const start = this.index;
172
+ for (;;) {
173
+ if (INLINE_SPACE.test(this.char)) {
174
+ this.index += 1;
175
+ } else if (this.startsWith("/*")) {
176
+ this.skipComment();
177
+ } else if (this.char === "\\") {
178
+ // A line carried on: the rest of this one is space, and so is its end.
179
+ this.index += 1;
180
+ while (INLINE_SPACE.test(this.char)) {
181
+ this.index += 1;
182
+ }
183
+ if (this.startsWith("//")) {
184
+ this.skipLine();
185
+ }
186
+ this.index += this.startsWith("\n") ? 1 : 0;
187
+ } else {
188
+ return this.index > start;
189
+ }
190
+ }
191
+ }
192
+
193
+ /** Past what may stand between two nodes. */
194
+ skipBetween(): void {
195
+ for (;;) {
196
+ if (this.startsWith("//")) {
197
+ this.skipLine();
198
+ } else if (this.char === "\n" || this.char === ";" || this.char === "\u{FEFF}") {
199
+ this.index += 1;
200
+ } else if (!this.skipInline()) {
201
+ return;
202
+ }
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Read what the slashdash here comments out: the node, the value or the block after it, which is
208
+ * in no list of the outline and is offered nothing.
209
+ */
210
+ slashdash(read: () => void): void {
211
+ const start = this.index;
212
+ this.index += 2;
213
+ this.muted += 1;
214
+ this.skipBetween();
215
+ read();
216
+ this.muted -= 1;
217
+ this.refuse(start, this.index);
218
+ }
219
+
220
+ /** Whether a string begins here: a quote, or the `#`s of a raw one. */
221
+ atString(): boolean {
222
+ return this.char === '"' || /^#+"/u.test(this.text.slice(this.index, this.index + 16));
223
+ }
224
+
225
+ /** Read the string that begins here. */
226
+ string(): StringToken {
227
+ const start = this.index;
228
+ while (this.char === "#") {
229
+ this.index += 1;
230
+ }
231
+ const hashes = "#".repeat(this.index - start);
232
+ const lines = this.startsWith('"""');
233
+ const quote = lines ? '"""' : '"';
234
+ this.index += quote.length;
235
+ const close = `${quote}${hashes}`;
236
+ let inside = this.index;
237
+ for (;;) {
238
+ if (this.done || (!lines && this.char === "\n")) {
239
+ inside = this.index;
240
+ break;
241
+ }
242
+ if (this.startsWith(close)) {
243
+ inside = this.index;
244
+ this.index += close.length;
245
+ break;
246
+ }
247
+ // An escape, which a raw string has none of.
248
+ this.index += hashes === "" && this.char === "\\" && !this.startsWith("\\\n") ? 2 : 1;
249
+ }
250
+ this.index = Math.min(this.index, this.text.length);
251
+ return { end: this.index, inside, plain: !lines && hashes === "", start };
252
+ }
253
+
254
+ /** Read a string that is a value, and place the position if it is in it. */
255
+ stringValue(node: OutlineNode, entry: OutlineEntry): void {
256
+ const token = this.string();
257
+ if (token.plain && this.holds(token.start + 1, token.inside)) {
258
+ this.claim("value", { end: token.inside, start: token.start + 1 }, node, entry, true);
259
+ }
260
+ // Code, or the far side of a closing quote, where a word would be no part of anything.
261
+ this.refuse(token.start, token.end + 1);
262
+ }
263
+
264
+ /** Read the word that begins here, which may be none. */
265
+ word(): JxnSpan {
266
+ const start = this.index;
267
+ while (this.char === "#") {
268
+ this.index += 1;
269
+ }
270
+ while (!this.done && NAME_CHAR.test(this.char)) {
271
+ this.index += 1;
272
+ }
273
+ return { end: this.index, start };
274
+ }
275
+
276
+ /** Read the annotation that begins here, and place the position if it is in its word. */
277
+ annotation(node: OutlineNode, entry: OutlineEntry | null): string {
278
+ this.index += 1;
279
+ this.skipInline();
280
+ const word = this.word();
281
+ if (this.holds(word.start, word.end)) {
282
+ this.claim("annotation", word, node, entry);
283
+ }
284
+ this.skipInline();
285
+ this.index += this.char === ")" ? 1 : 0;
286
+ return this.text.slice(word.start, word.end);
287
+ }
288
+
289
+ /** Read what follows `name=`, or an annotation, on a node's line. */
290
+ value(node: OutlineNode, entry: OutlineEntry): void {
291
+ if (this.atString()) {
292
+ this.stringValue(node, entry);
293
+ return;
294
+ }
295
+ const word = this.word();
296
+ if (this.holds(word.start, word.end)) {
297
+ this.claim("value", word, node, entry);
298
+ }
299
+ }
300
+
301
+ /** Read one value of a node's line. Answers false where what is here is no part of one. */
302
+ entry(node: OutlineNode): boolean {
303
+ const entry: OutlineEntry = { annotation: null, key: null };
304
+ if (this.char === "(") {
305
+ node.entries.push(entry);
306
+ entry.annotation = this.annotation(node, entry);
307
+ this.value(node, entry);
308
+ return true;
309
+ }
310
+ if (this.atString()) {
311
+ node.entries.push(entry);
312
+ this.stringValue(node, entry);
313
+ return true;
314
+ }
315
+ const word = this.word();
316
+ if (word.end === word.start) {
317
+ return false;
318
+ }
319
+ node.entries.push(entry);
320
+ if (this.char !== "=") {
321
+ if (this.holds(word.start, word.end)) {
322
+ this.claim("entry", word, node, entry);
323
+ }
324
+ return true;
325
+ }
326
+ entry.key = this.text.slice(word.start, word.end);
327
+ this.index += 1;
328
+ // A name that already has its `=` is replaced with it, so an offer does not write a second.
329
+ if (this.holds(word.start, word.end)) {
330
+ this.claim("entry", { end: this.index, start: word.start }, node, entry);
331
+ }
332
+ // Asked of the text: the compiler still holds `char` to be the `=` it was before the step.
333
+ if (this.startsWith("(")) {
334
+ entry.annotation = this.annotation(node, entry);
335
+ }
336
+ this.value(node, entry);
337
+ return true;
338
+ }
339
+
340
+ /** Read the name of a node, and place the position if it is in it. */
341
+ name(node: OutlineNode): void {
342
+ if (this.atString()) {
343
+ const token = this.string();
344
+ node.name = this.text.slice(
345
+ token.start + (token.inside === token.end ? 0 : token.end - token.inside),
346
+ token.inside,
347
+ );
348
+ this.refuse(token.start, token.end + 1);
349
+ return;
350
+ }
351
+ const word = this.word();
352
+ node.name = this.text.slice(word.start, word.end);
353
+ if (this.holds(word.start, word.end)) {
354
+ this.claim("name", word, node, null);
355
+ }
356
+ }
357
+
358
+ /** Read the node that begins here, into the list of its block. */
359
+ node(into: OutlineNode[]): void {
360
+ const node: OutlineNode = { annotation: null, children: null, entries: [], name: "" };
361
+ into.push(node);
362
+ if (this.char === "(") {
363
+ node.annotation = this.annotation(node, null);
364
+ this.skipInline();
365
+ }
366
+ this.name(node);
367
+ for (;;) {
368
+ const before = this.index;
369
+ if (this.skipInline() && this.holds(before + 1, this.index)) {
370
+ this.claim("entry", { end: this.offset, start: this.offset }, node, null);
371
+ }
372
+ if (this.done || this.char === "\n" || this.char === ";" || this.char === "}") {
373
+ return;
374
+ }
375
+ if (this.startsWith("//")) {
376
+ return;
377
+ }
378
+ if (this.char === "{") {
379
+ this.index += 1;
380
+ node.children = [];
381
+ this.stack.push(node);
382
+ this.block(node.children);
383
+ this.stack.pop();
384
+ return;
385
+ }
386
+ if (this.startsWith("/-")) {
387
+ this.slashdash(() => {
388
+ this.commented();
389
+ });
390
+ } else if (!this.entry(node)) {
391
+ this.index += 1;
392
+ }
393
+ }
394
+ }
395
+
396
+ /** Read a value or a block that is commented out, into nothing. */
397
+ commented(): void {
398
+ const nowhere: OutlineNode = { annotation: null, children: null, entries: [], name: "" };
399
+ if (this.char === "{") {
400
+ this.index += 1;
401
+ this.stack.push(nowhere);
402
+ this.block([]);
403
+ this.stack.pop();
404
+ } else {
405
+ this.entry(nowhere);
406
+ }
407
+ }
408
+
409
+ /** Read nodes into `into` until the block they are in closes, or the file ends. */
410
+ block(into: OutlineNode[]): void {
411
+ for (;;) {
412
+ const before = this.index;
413
+ this.skipBetween();
414
+ if (this.holds(before, this.index)) {
415
+ this.claim("name", { end: this.offset, start: this.offset }, null, null);
416
+ }
417
+ if (this.done) {
418
+ return;
419
+ }
420
+ if (this.char === "}") {
421
+ this.index += 1;
422
+ if (this.stack.length > 0) {
423
+ return;
424
+ }
425
+ } else if (this.startsWith("/-")) {
426
+ this.slashdash(() => {
427
+ this.node([]);
428
+ });
429
+ } else if (this.char === "(" || this.char === "#" || this.atString()) {
430
+ this.node(into);
431
+ } else if (NAME_CHAR.test(this.char)) {
432
+ this.node(into);
433
+ } else {
434
+ // Something that begins nothing: `)`, `=`, a lone `/`.
435
+ this.index += 1;
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Read a `.jxn` file as far as it can be read, and say where a position in it is.
443
+ *
444
+ * @param text The file, which need not parse.
445
+ * @param offset The position, as an offset in `text`.
446
+ * @returns The file's nodes, and the position: null where nothing can be offered at it.
447
+ */
448
+ export function outlineJxn(text: string, offset: number): Outline {
449
+ const outliner = new Outliner(text, offset);
450
+ outliner.block(outliner.nodes);
451
+ return { cursor: outliner.cursor, nodes: outliner.nodes };
452
+ }