@jxsuite/schema 5.6.0 → 6.0.2

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,582 @@
1
+ /**
2
+ * What a `.jxn` file may hold at a position, from the schema it is validated against (`spec.md`
3
+ * §22.6).
4
+ *
5
+ * One reader for every editor, so Studio's Code view and an editor extension offer the same. It is
6
+ * given the text as it stands, which is rarely Jx Notation while it is being typed, so the position
7
+ * is found by `outline.ts` and not by the parser. What a position means is then `spec.md` §22.1 and
8
+ * §22.5 read downwards: each node on the way to it is an element, a JSON block, a `style` block, a
9
+ * `$array` row or the holder of one element, and a JSON block carries the schema of the member it
10
+ * is.
11
+ *
12
+ * Nothing here says a file is wrong. An offer is what the schema lists, and a name it does not list
13
+ * (a custom element's tag, a state entry, an attribute) is as valid as it was.
14
+ *
15
+ * @docs framework/concepts/jxn-files
16
+ */
17
+ import { isJsonObject, isString } from "../guards.ts";
18
+ import type { JsonObject, JsonPrimitive } from "../../types.ts";
19
+ import { classifyJxn } from "./classify.ts";
20
+ import type { JxnSpan } from "./errors.ts";
21
+ import { outlineJxn } from "./outline.ts";
22
+ import type { OutlineCursor, OutlineEntry, OutlineNode } from "./outline.ts";
23
+ import { token } from "./parse.ts";
24
+ import type { ComposedProjectSchemas } from "../project-schemas.ts";
25
+ import { descriptionOf, itemsOf, leavesOf, memberOf, namesOf, valuesOf } from "./schema-walk.ts";
26
+ import type { RootedSchema } from "./schema-walk.ts";
27
+ import { scalar, writeName } from "./serialize.ts";
28
+
29
+ /**
30
+ * The characters that begin a member, an annotation or a value, at which an editor asks without
31
+ * being told to. An editor asks at a word by itself, and none of these is part of one. A space is
32
+ * left out on purpose: Enter accepts an offer, so a list a space opened would eat the next line.
33
+ */
34
+ export const JXN_TRIGGER_CHARACTERS: readonly string[] = [".", "$", "(", "=", '"', "/"];
35
+
36
+ /** What an offer is, for an editor to draw it by. */
37
+ export type JxnCompletionKind = "annotation" | "element" | "member" | "value";
38
+
39
+ /** One thing that may be written at the position. */
40
+ export interface JxnCompletion {
41
+ /** What the schema says it is for, where it says. */
42
+ detail?: string;
43
+ /** The text accepting it writes. */
44
+ insert: string;
45
+ kind: JxnCompletionKind;
46
+ /** What it is listed as. */
47
+ label: string;
48
+ }
49
+
50
+ /** What may be written at a position, and the stretch of the text accepting an offer replaces. */
51
+ export interface JxnCompletions extends JxnSpan {
52
+ /** In the order they are best listed. Empty where nothing is offered. */
53
+ items: JxnCompletion[];
54
+ }
55
+
56
+ /**
57
+ * How `spec.md` §22.1 reads a node: `only` holds one element (`.map`, a value of `.cases`,
58
+ * `.children`), `value` is a JSON block, and `none` is anything nothing is offered in.
59
+ */
60
+ type ReadingKind =
61
+ | "array"
62
+ | "cases"
63
+ | "document"
64
+ | "element"
65
+ | "head"
66
+ | "none"
67
+ | "only"
68
+ | "project"
69
+ | "script"
70
+ | "style"
71
+ | "value";
72
+
73
+ /** One node as it is read, with the schema of what it holds. */
74
+ interface Reading {
75
+ kind: ReadingKind;
76
+ /** Whether this is the document's root element, which holds the document's own members too. */
77
+ root: boolean;
78
+ schema: RootedSchema | null;
79
+ }
80
+
81
+ /** A name a schema declares, with its schema. */
82
+ type Named = [name: string, schema: RootedSchema];
83
+
84
+ /** One entry of a `state` block. */
85
+ interface StateName {
86
+ /** Whether it is written as a function, which is what a handler may point at. */
87
+ fn: boolean;
88
+ name: string;
89
+ }
90
+
91
+ const reading = (kind: ReadingKind, schema: RootedSchema | null = null, root = false): Reading => ({
92
+ kind,
93
+ root,
94
+ schema,
95
+ });
96
+
97
+ /** Read as nothing an offer is made in: code, text, a name the reader gives no schema. */
98
+ const NOTHING = reading("none");
99
+
100
+ /** The annotations whose row holds code. */
101
+ const CODE = new Set(["export", "fn", "server"]);
102
+
103
+ /** The members of an element that are child nodes of their own, or its name. */
104
+ const WRITTEN_PLAINLY = new Set(["attributes", "state", "style", "tagName"]);
105
+
106
+ /** What a string must not hold to be written inside quotes as it is. */
107
+ const NEEDS_ESCAPING = /["\\\n]/u;
108
+
109
+ /** The annotations of `spec.md` §22.1. */
110
+ type Annotation = "array" | "export" | "fn" | "object" | "on" | "ref" | "server";
111
+
112
+ /** What each annotation says, in the words of `spec.md` §22.1. */
113
+ const ANNOTATIONS = {
114
+ array: "A JSON block that is a list, where its contents would not say so.",
115
+ export: "A function the document's client module exports.",
116
+ fn: "A function. Its last string is the body.",
117
+ object: "A JSON block that is an object, where its contents would not say so.",
118
+ on: "An event handler written as a JSON block.",
119
+ ref: "A pointer to another value, such as #/state/name.",
120
+ server: "A function of the document's server module.",
121
+ } satisfies Record<Annotation, string>;
122
+
123
+ /** The annotations a node may carry, by how the block it is in is read. */
124
+ const NODE_ANNOTATIONS: Partial<Record<ReadingKind, Annotation[]>> = {
125
+ document: ["object", "array"],
126
+ element: ["fn", "on", "object", "array"],
127
+ project: ["object", "array"],
128
+ style: ["array"],
129
+ value: ["fn", "export", "server", "object", "array"],
130
+ };
131
+
132
+ /** A value as the writer spells it. A primitive always fits one token. */
133
+ const spelled = (value: JsonPrimitive): string => scalar(value, "", "") ?? "";
134
+
135
+ /** An offer, with what its schema says it is for. */
136
+ function offer(
137
+ kind: JxnCompletionKind,
138
+ label: string,
139
+ insert: string,
140
+ schema: RootedSchema | null = null,
141
+ ): JxnCompletion {
142
+ const detail = schema ? descriptionOf(schema) : null;
143
+ return detail === null ? { insert, kind, label } : { detail, insert, kind, label };
144
+ }
145
+
146
+ /** A node's name. */
147
+ const nodeOffer = ([name, schema]: Named): JxnCompletion =>
148
+ offer("member", name, writeName(name, ""), schema);
149
+
150
+ /** A name on a node's line, which its `=` follows. */
151
+ const keyOffer = ([name, schema]: Named): JxnCompletion =>
152
+ offer("member", `${name}=`, `${writeName(name, "")}=`, schema);
153
+
154
+ /** Values, as offers: inside quotes the text alone, and otherwise as the writer spells each. */
155
+ function valueOffers(
156
+ values: readonly JsonPrimitive[],
157
+ quoted: boolean,
158
+ kind: JxnCompletionKind = "value",
159
+ ): JxnCompletion[] {
160
+ return values.flatMap((value) => {
161
+ if (!quoted) {
162
+ return [offer(kind, spelled(value), spelled(value))];
163
+ }
164
+ return isString(value) && !NEEDS_ESCAPING.test(value) ? [offer(kind, value, value)] : [];
165
+ });
166
+ }
167
+
168
+ /** The annotations named, as offers. */
169
+ const annotationOffers = (names: readonly Annotation[]): JxnCompletion[] =>
170
+ names.map((name) => ({
171
+ detail: ANNOTATIONS[name],
172
+ insert: name,
173
+ kind: "annotation",
174
+ label: name,
175
+ }));
176
+
177
+ /** The first value written alone on a node's line, or null. */
178
+ const firstAlone = (node: OutlineNode): OutlineEntry | null =>
179
+ node.entries.find((entry) => entry.key === null) ?? null;
180
+
181
+ /** One file, read against its schemas. */
182
+ class Completer {
183
+ /** The schema of a `$array` row. */
184
+ readonly array: RootedSchema | null;
185
+ readonly document: RootedSchema;
186
+ /** The schema of an element. */
187
+ readonly element: RootedSchema | null;
188
+ readonly nodes: OutlineNode[];
189
+ /** The project file's schema, where the file is one. */
190
+ readonly project: RootedSchema | null;
191
+ /** The schema of the file's top level: the project file's, or the document's. */
192
+ readonly top: RootedSchema;
193
+
194
+ constructor(nodes: OutlineNode[], schemas: ComposedProjectSchemas, project: boolean) {
195
+ this.nodes = nodes;
196
+ this.document = { root: schemas.document, schema: schemas.document };
197
+ this.project = project ? { root: schemas.project, schema: schemas.project } : null;
198
+ this.top = this.project ?? this.document;
199
+ // Found by what they are and not by a definition's name, which a composed schema nests.
200
+ const children = memberOf(this.document, "children");
201
+ const items = children && itemsOf(children);
202
+ const made = [...(children ? leavesOf(children) : []), ...(items ? leavesOf(items) : [])];
203
+ const declaring = (
204
+ name: string,
205
+ test: (schema: JsonObject) => boolean,
206
+ ): RootedSchema | null => {
207
+ const found = made.find((leaf) => {
208
+ const declared = isJsonObject(leaf.properties) ? leaf.properties[name] : undefined;
209
+ return isJsonObject(declared) && test(declared);
210
+ });
211
+ return found ? { root: schemas.document, schema: found } : null;
212
+ };
213
+ this.element = declaring("tagName", () => true);
214
+ this.array = declaring("$prototype", (schema) => schema.const === "Array");
215
+ }
216
+
217
+ /** The tag names the schema lists. */
218
+ tags(): JsonPrimitive[] {
219
+ const tagName = memberOf(this.document, "tagName");
220
+ return tagName ? valuesOf(tagName) : [];
221
+ }
222
+
223
+ /** The schema of a member of an element, which for the root may be the document's own. */
224
+ elementMember(root: boolean, key: string): RootedSchema | null {
225
+ const own = root ? namesOf(this.document).get(key) : undefined;
226
+ return own ?? (this.element ? memberOf(this.element, key) : null);
227
+ }
228
+
229
+ /** The members an element is declared to have: its own and, for the root, the document's. */
230
+ elementNames(root: boolean): Named[] {
231
+ const own = this.element ? [...namesOf(this.element)] : [];
232
+ const documents = root
233
+ ? [...namesOf(this.document)].filter(([name]) => !name.startsWith("$"))
234
+ : [];
235
+ return [...new Map([...own, ...documents])];
236
+ }
237
+
238
+ /** An element's `$` members, as a node or a line may name them. */
239
+ jxMembers(root: boolean): Named[] {
240
+ return this.elementNames(root).filter(([name]) => name.startsWith("$") && !name.includes("/"));
241
+ }
242
+
243
+ /** An element's handlers. */
244
+ handlers(root: boolean): Named[] {
245
+ return this.elementNames(root).filter(([name]) => name.startsWith("on"));
246
+ }
247
+
248
+ /** An element's other members, each under the `.name` it is written as. */
249
+ dotted(root: boolean): Named[] {
250
+ return this.elementNames(root).flatMap(([name, schema]): Named[] =>
251
+ name.startsWith("$") || name.startsWith("on") || WRITTEN_PLAINLY.has(name)
252
+ ? []
253
+ : [[`.${name}`, schema]],
254
+ );
255
+ }
256
+
257
+ /** How a top-level node is read. */
258
+ atTop(node: OutlineNode): Reading {
259
+ const { name } = node;
260
+ if (name === "$head") {
261
+ return reading("head");
262
+ }
263
+ if (this.project) {
264
+ if (name === "project") {
265
+ return NOTHING;
266
+ }
267
+ const kind = name === "style" || name === "$media" ? "style" : "value";
268
+ return reading(kind, memberOf(this.project, name));
269
+ }
270
+ if (name === "$array") {
271
+ return reading("array", this.array);
272
+ }
273
+ if (name.startsWith("$") && name !== "$element") {
274
+ return reading("value", memberOf(this.document, name));
275
+ }
276
+ return reading("element", this.element, true);
277
+ }
278
+
279
+ /** How a child node of an element is read. */
280
+ inElement(parent: Reading, node: OutlineNode): Reading {
281
+ const { annotation, name } = node;
282
+ if (annotation === "fn" || annotation === "on" || name === "-") {
283
+ return NOTHING;
284
+ }
285
+ if (name === "script") {
286
+ return parent.root ? reading("script") : NOTHING;
287
+ }
288
+ if (name === "style") {
289
+ return reading("style", this.elementMember(parent.root, name));
290
+ }
291
+ if (name === "state" || name === "attributes") {
292
+ return reading("value", this.elementMember(true, name));
293
+ }
294
+ if (name === "$array") {
295
+ return reading("array", this.array);
296
+ }
297
+ if (name === ".map" || (name === ".children" && annotation !== "array" && node.children)) {
298
+ return reading("only");
299
+ }
300
+ if (name === ".cases") {
301
+ return reading("cases");
302
+ }
303
+ if (name.startsWith(".")) {
304
+ return reading("value", this.elementMember(parent.root, name.slice(1)));
305
+ }
306
+ if (name.startsWith("$") && name !== "$element") {
307
+ return reading("value", this.elementMember(parent.root, name));
308
+ }
309
+ return reading("element", this.element);
310
+ }
311
+
312
+ /** How a node is read, given how the block it is in is. */
313
+ readingOf(parent: Reading, node: OutlineNode): Reading {
314
+ const { schema } = parent;
315
+ switch (parent.kind) {
316
+ case "document":
317
+ case "project": {
318
+ return this.atTop(node);
319
+ }
320
+ case "element": {
321
+ return this.inElement(parent, node);
322
+ }
323
+ case "only": {
324
+ return node.name === "$array"
325
+ ? reading("array", this.array)
326
+ : reading("element", this.element);
327
+ }
328
+ case "cases": {
329
+ return reading("only");
330
+ }
331
+ case "array": {
332
+ return reading("element", this.element);
333
+ }
334
+ case "style": {
335
+ return reading("style", schema && memberOf(schema, node.name));
336
+ }
337
+ case "value": {
338
+ if (CODE.has(node.annotation ?? "")) {
339
+ return NOTHING;
340
+ }
341
+ const held = (of: RootedSchema): RootedSchema | null =>
342
+ node.name === "-" ? itemsOf(of) : memberOf(of, node.name);
343
+ return reading("value", schema && held(schema));
344
+ }
345
+ default: {
346
+ return NOTHING;
347
+ }
348
+ }
349
+ }
350
+
351
+ /** The entries of the file's own `state`: the root element's in a document. */
352
+ state(): StateName[] {
353
+ const root = this.nodes.find((node) => !node.name.startsWith("$") || node.name === "$element");
354
+ const holders = this.project ? this.nodes : (root?.children ?? []);
355
+ const block = holders.find((node) => node.name === "state");
356
+ return (block?.children ?? []).map((node) => ({
357
+ fn: CODE.has(node.annotation ?? ""),
358
+ name: node.name,
359
+ }));
360
+ }
361
+
362
+ /** Pointers into the file's `state`, as offers. */
363
+ pointers(functions: boolean, quoted: boolean, annotated: boolean): JxnCompletion[] {
364
+ return this.state().flatMap(({ fn, name }) => {
365
+ if (name === "" || (functions && !fn)) {
366
+ return [];
367
+ }
368
+ const pointer = `#/state/${token(name)}`;
369
+ if (quoted) {
370
+ return valueOffers([pointer], true);
371
+ }
372
+ return [offer("value", pointer, `${annotated ? "" : "(ref)"}${spelled(pointer)}`)];
373
+ });
374
+ }
375
+
376
+ /** The names a node may have in a block read as `container`. */
377
+ names(cursor: OutlineCursor, container: Reading): JxnCompletion[] {
378
+ const siblings = (cursor.path.at(-1)?.children ?? this.nodes).filter(
379
+ (node) => node !== cursor.node,
380
+ );
381
+ const taken = new Set(siblings.map((node) => node.name));
382
+ const free = (names: Iterable<Named>): JxnCompletion[] =>
383
+ [...names].flatMap((named) => (taken.has(named[0]) ? [] : [nodeOffer(named)]));
384
+ const plain = (...names: string[]): JxnCompletion[] =>
385
+ names.flatMap((name) => (taken.has(name) ? [] : [offer("member", name, name)]));
386
+ const tags = (): JxnCompletion[] => valueOffers(this.tags(), false, "element");
387
+ const { schema } = container;
388
+ switch (container.kind) {
389
+ case "document": {
390
+ const members = free([...namesOf(this.document)].filter(([name]) => name.startsWith("$")));
391
+ const rooted = siblings.some(
392
+ (node) =>
393
+ !node.name.startsWith("$") || node.name === "$element" || node.name === "$array",
394
+ );
395
+ return rooted ? members : [...tags(), ...members];
396
+ }
397
+ case "project": {
398
+ // The project's name is the value of the `project` node, and has no node of its own.
399
+ const members = [...namesOf(this.top)].filter(([name]) => name !== "name");
400
+ return [...plain("project"), ...free(members)];
401
+ }
402
+ case "element": {
403
+ const { root } = container;
404
+ if (cursor.node?.annotation === "fn" || cursor.node?.annotation === "on") {
405
+ return free(this.handlers(root));
406
+ }
407
+ return [
408
+ ...plain("state", "style", "attributes"),
409
+ ...(root ? plain("script") : []),
410
+ ...tags(),
411
+ offer("member", "-", "-"),
412
+ offer("member", "$array", "$array"),
413
+ offer("member", "$element", "$element"),
414
+ ...free(this.jxMembers(root)),
415
+ ...free(this.dotted(root)),
416
+ ];
417
+ }
418
+ case "only": {
419
+ return [
420
+ ...tags(),
421
+ offer("member", "$array", "$array"),
422
+ offer("member", "$element", "$element"),
423
+ ];
424
+ }
425
+ case "array": {
426
+ return [...tags(), offer("member", "$element", "$element")];
427
+ }
428
+ case "style":
429
+ case "value": {
430
+ if (!schema) {
431
+ return [];
432
+ }
433
+ // A block is a list or an object, and says which by its annotation or by what it holds.
434
+ const holder = cursor.path.at(-1)?.annotation ?? null;
435
+ const dashed = siblings.some((node) => node.name === "-");
436
+ const list = holder === "array" || (holder !== "object" && dashed);
437
+ const object = holder === "object" || (!list && siblings.length > 0);
438
+ // `$ref` is a member the notation spells `(ref)`.
439
+ const members = list ? [] : free([...namesOf(schema)].filter(([name]) => name !== "$ref"));
440
+ return itemsOf(schema) && !object ? [offer("member", "-", "-"), ...members] : members;
441
+ }
442
+ default: {
443
+ return [];
444
+ }
445
+ }
446
+ }
447
+
448
+ /** What may be written on a node's line, where no name has its `=` yet. */
449
+ entries(cursor: OutlineCursor, node: OutlineNode, line: Reading): JxnCompletion[] {
450
+ const taken = new Set(
451
+ node.entries.flatMap((entry) =>
452
+ entry === cursor.entry || entry.key === null ? [] : [entry.key],
453
+ ),
454
+ );
455
+ const free = (names: Iterable<Named>): JxnCompletion[] =>
456
+ [...names].flatMap((named) => (taken.has(named[0]) ? [] : [keyOffer(named)]));
457
+ const { schema } = line;
458
+ switch (line.kind) {
459
+ case "element": {
460
+ const tag =
461
+ node.name === "$element" && firstAlone(node) === cursor.entry
462
+ ? valueOffers(this.tags(), false, "element")
463
+ : [];
464
+ return [
465
+ ...tag,
466
+ ...free(this.dotted(line.root)),
467
+ ...free(this.jxMembers(line.root)),
468
+ ...free(this.handlers(line.root)),
469
+ ];
470
+ }
471
+ case "array": {
472
+ const keys = schema ? [...namesOf(schema)] : [];
473
+ return free(keys.filter(([name]) => name !== "$prototype" && name !== "map"));
474
+ }
475
+ case "script": {
476
+ return taken.has("timing") ? [] : [offer("member", "timing=server", "timing=server")];
477
+ }
478
+ case "style":
479
+ case "value": {
480
+ if (!schema) {
481
+ return [];
482
+ }
483
+ const items = itemsOf(schema);
484
+ return [
485
+ ...free([...namesOf(schema)].filter(([name]) => name !== "$ref")),
486
+ ...valueOffers([...valuesOf(schema), ...(items ? valuesOf(items) : [])], false),
487
+ ];
488
+ }
489
+ default: {
490
+ return [];
491
+ }
492
+ }
493
+ }
494
+
495
+ /** The values the schema names for one value of a node's line. */
496
+ valuesAt(node: OutlineNode, line: Reading, entry: OutlineEntry | null): JsonPrimitive[] {
497
+ const key = entry?.key ?? null;
498
+ const { schema } = line;
499
+ if (line.kind === "element") {
500
+ if (key === null) {
501
+ return node.name === "$element" && firstAlone(node) === entry ? this.tags() : [];
502
+ }
503
+ // Any other name is an attribute's, which is free.
504
+ const member = key.startsWith(".") ? key.slice(1) : key;
505
+ const named = key.startsWith(".") || key.startsWith("$");
506
+ const declared = named ? this.elementMember(line.root, member) : null;
507
+ return declared ? valuesOf(declared) : [];
508
+ }
509
+ if (line.kind === "script") {
510
+ return key === "timing" ? ["server"] : [];
511
+ }
512
+ if ((line.kind !== "value" && line.kind !== "style") || !schema) {
513
+ return [];
514
+ }
515
+ if (key !== null) {
516
+ const member = memberOf(schema, key);
517
+ return member ? valuesOf(member) : [];
518
+ }
519
+ const items = itemsOf(schema);
520
+ return [...valuesOf(schema), ...(items ? valuesOf(items) : [])];
521
+ }
522
+
523
+ /** What one value of a node's line may be. */
524
+ values(cursor: OutlineCursor, node: OutlineNode, line: Reading): JxnCompletion[] {
525
+ const { entry, quoted } = cursor;
526
+ const annotation = entry?.annotation ?? null;
527
+ const key = entry?.key ?? null;
528
+ if (annotation !== null && annotation !== "ref") {
529
+ return [];
530
+ }
531
+ const handler = line.kind === "element" && key?.startsWith("on") === true;
532
+ const row = line.kind === "array" && key !== null && key !== "key";
533
+ if (handler || row || annotation === "ref") {
534
+ return this.pointers(handler, quoted, annotation === "ref");
535
+ }
536
+ return valueOffers(this.valuesAt(node, line, entry), quoted);
537
+ }
538
+
539
+ /** What may be written at the position. */
540
+ at(cursor: OutlineCursor): JxnCompletion[] {
541
+ const top = reading(this.project ? "project" : "document");
542
+ let container = top;
543
+ for (const node of cursor.path) {
544
+ container = this.readingOf(container, node);
545
+ }
546
+ const { node } = cursor;
547
+ if (!node || cursor.slot === "name") {
548
+ return this.names(cursor, container);
549
+ }
550
+ if (cursor.slot === "annotation") {
551
+ return annotationOffers(cursor.entry ? ["ref"] : (NODE_ANNOTATIONS[container.kind] ?? []));
552
+ }
553
+ const line = this.readingOf(container, node);
554
+ return cursor.slot === "entry"
555
+ ? this.entries(cursor, node, line)
556
+ : this.values(cursor, node, line);
557
+ }
558
+ }
559
+
560
+ /**
561
+ * What may be written at a position in a `.jxn` file.
562
+ *
563
+ * @param text The file as it stands. It need not parse.
564
+ * @param offset The position, as an offset in `text`.
565
+ * @param schemas The schemas a document and a project file are validated against. Each `$ref` in
566
+ * one is a pointer from its own root, which is how `composeProjectSchemas` returns the pair and
567
+ * how `flattenSchema` leaves any other.
568
+ * @returns The offers, and the stretch of `text` accepting one replaces. Where nothing is offered
569
+ * the list is empty and the stretch is the position itself.
570
+ */
571
+ export function completeJxn(
572
+ text: string,
573
+ offset: number,
574
+ schemas: ComposedProjectSchemas,
575
+ ): JxnCompletions {
576
+ const { cursor, nodes } = outlineJxn(text, offset);
577
+ if (!cursor) {
578
+ return { end: offset, items: [], start: offset };
579
+ }
580
+ const completer = new Completer(nodes, schemas, classifyJxn(text) === "project");
581
+ return { end: cursor.end, items: completer.at(cursor), start: cursor.start };
582
+ }