@telorun/ide-support 0.23.0 → 0.24.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/dist/completions/build.d.ts.map +1 -1
- package/dist/completions/build.js +7 -0
- package/dist/completions/detect-context.d.ts +33 -0
- package/dist/completions/detect-context.d.ts.map +1 -1
- package/dist/completions/detect-context.js +52 -0
- package/dist/completions/module-file-completions.d.ts +18 -0
- package/dist/completions/module-file-completions.d.ts.map +1 -0
- package/dist/completions/module-file-completions.js +59 -0
- package/dist/completions/resolve-node.d.ts +23 -5
- package/dist/completions/resolve-node.d.ts.map +1 -1
- package/dist/completions/resolve-node.js +54 -14
- package/dist/completions/value-tag-completions.d.ts +23 -0
- package/dist/completions/value-tag-completions.d.ts.map +1 -0
- package/dist/completions/value-tag-completions.js +39 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/types.d.ts +13 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/value-tags/offered-value-tags.d.ts +56 -0
- package/dist/value-tags/offered-value-tags.d.ts.map +1 -0
- package/dist/value-tags/offered-value-tags.js +92 -0
- package/package.json +3 -2
- package/src/completions/build.ts +6 -0
- package/src/completions/detect-context.ts +84 -0
- package/src/completions/module-file-completions.ts +64 -0
- package/src/completions/resolve-node.ts +87 -19
- package/src/completions/value-tag-completions.ts +55 -0
- package/src/index.ts +2 -0
- package/src/types.ts +22 -1
- package/src/value-tags/offered-value-tags.ts +127 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A YAML tag an author may write on a value, and what it means to write one.
|
|
3
|
+
*
|
|
4
|
+
* The split with `@telorun/templating` is deliberate. An engine declares what a
|
|
5
|
+
* tag PRODUCES (`producedType()`) and where its CEL is (`expressionRegions`);
|
|
6
|
+
* this declares how the tag is PRESENTED to an author, which is editor knowledge
|
|
7
|
+
* no engine should carry. Applicability is derived from the engine's own
|
|
8
|
+
* declaration rather than from a list of names here — so the table says how to
|
|
9
|
+
* describe a tag, never which tags fit where.
|
|
10
|
+
*
|
|
11
|
+
* Shared by every editor host (VS Code completion, studio's source view and
|
|
12
|
+
* schema form), so the hosts cannot disagree about which tags a field takes.
|
|
13
|
+
*/
|
|
14
|
+
export interface ValueTag {
|
|
15
|
+
/** Engine name, which is the YAML tag without its `!`. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** How the tag is written. */
|
|
18
|
+
label: string;
|
|
19
|
+
/** One line on what the tag does. */
|
|
20
|
+
hint: string;
|
|
21
|
+
/** Only meaningful where the slot is EVALUATED — the tag decides what
|
|
22
|
+
* evaluation does with the value (`!cel` supplies the expression, `!literal`
|
|
23
|
+
* opts out of interpolation), so outside such a field it says nothing the
|
|
24
|
+
* plain value does not. An embed is the other case: it supplies a value, and
|
|
25
|
+
* evaluation was never involved. */
|
|
26
|
+
requiresEvalSlot?: boolean;
|
|
27
|
+
/** Set when the scalar under the tag is a module-root-relative location of
|
|
28
|
+
* something that ships with the module, saying what it may name. */
|
|
29
|
+
names?: "file" | "file-or-directory";
|
|
30
|
+
}
|
|
31
|
+
/** The authorable tag an engine name denotes, or undefined for a tag no host
|
|
32
|
+
* offers (`!ref`, `!sql`, an unknown one). */
|
|
33
|
+
export declare function valueTag(id: string): ValueTag | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* The tags offerable at one field.
|
|
36
|
+
*
|
|
37
|
+
* Two rules, both read off the engine rather than off its name:
|
|
38
|
+
*
|
|
39
|
+
* - CAN its value satisfy the slot? A tag that declares a produced type is
|
|
40
|
+
* offered only where that type fits. This is what puts `!include-bytes` on a
|
|
41
|
+
* `Telo.Bytes` slot and keeps it off a string one — and what keeps
|
|
42
|
+
* `!literal`, which is always text, off a boolean predicate. Checked with
|
|
43
|
+
* the analyzer's own comparator so the editor and `telo check` agree about
|
|
44
|
+
* what fits. A tag declaring no produced type (`!cel`) produces whatever the
|
|
45
|
+
* slot says and constrains nothing here.
|
|
46
|
+
* - Is it MEANINGFUL here? A tag that decides what evaluation does with the
|
|
47
|
+
* value needs a slot that is evaluated at all: outside one, `!cel` is a
|
|
48
|
+
* value the runtime never evaluates (`CEL_IN_NON_EVAL_FIELD`), and
|
|
49
|
+
* `!literal` suppresses an interpolation that was never going to happen.
|
|
50
|
+
*
|
|
51
|
+
* `prop` undefined is a field with no declared schema, which constrains
|
|
52
|
+
* nothing. `evalMode` undefined means no rule decides whether the field is
|
|
53
|
+
* evaluated, so the second question is not asked.
|
|
54
|
+
*/
|
|
55
|
+
export declare function offeredValueTags(prop: Record<string, unknown> | undefined, evalMode: "compile" | "runtime" | null | undefined): ValueTag[];
|
|
56
|
+
//# sourceMappingURL=offered-value-tags.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"offered-value-tags.d.ts","sourceRoot":"","sources":["../../src/value-tags/offered-value-tags.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,QAAQ;IACvB,0DAA0D;IAC1D,EAAE,EAAE,MAAM,CAAC;IACX,8BAA8B;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,qCAAqC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb;;;;yCAIqC;IACrC,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;yEACqE;IACrE,KAAK,CAAC,EAAE,MAAM,GAAG,mBAAmB,CAAC;CACtC;AA4CD;+CAC+C;AAC/C,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAGzD;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EACzC,QAAQ,EAAE,SAAS,GAAG,SAAS,GAAG,IAAI,GAAG,SAAS,GACjD,QAAQ,EAAE,CAWZ"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { checkSchemaCompatibility } from "@telorun/analyzer";
|
|
2
|
+
import { builtinEngines, producedTypeOf } from "@telorun/templating";
|
|
3
|
+
/**
|
|
4
|
+
* The tags an author may write, by engine name.
|
|
5
|
+
*
|
|
6
|
+
* `!ref` is absent on purpose: it names a RESOURCE rather than producing a
|
|
7
|
+
* value, so it belongs to a reference slot, never to a value one. `!sql` is
|
|
8
|
+
* absent until a host can edit it as SQL — a plain text box would be the wrong
|
|
9
|
+
* widget, and the hosts offer one set. An engine with no entry is simply not
|
|
10
|
+
* offered, which is the safe direction.
|
|
11
|
+
*/
|
|
12
|
+
const AUTHORABLE = {
|
|
13
|
+
cel: {
|
|
14
|
+
label: "!cel",
|
|
15
|
+
hint: "A CEL expression, evaluated against this field's scope.",
|
|
16
|
+
requiresEvalSlot: true,
|
|
17
|
+
},
|
|
18
|
+
interpolate: {
|
|
19
|
+
label: "!interpolate",
|
|
20
|
+
hint: "Text with `${{ }}` holes, each a CEL expression; always a string.",
|
|
21
|
+
requiresEvalSlot: true,
|
|
22
|
+
},
|
|
23
|
+
literal: {
|
|
24
|
+
label: "!literal",
|
|
25
|
+
hint: "Opaque text. `${{ }}` inside it is not interpolated.",
|
|
26
|
+
requiresEvalSlot: true,
|
|
27
|
+
},
|
|
28
|
+
"include-text": {
|
|
29
|
+
label: "!include-text",
|
|
30
|
+
hint: "Contents of a file shipped with this module, as text.",
|
|
31
|
+
names: "file",
|
|
32
|
+
},
|
|
33
|
+
"include-bytes": {
|
|
34
|
+
label: "!include-bytes",
|
|
35
|
+
hint: "Contents of a file shipped with this module, as raw bytes.",
|
|
36
|
+
names: "file",
|
|
37
|
+
},
|
|
38
|
+
"module-path": {
|
|
39
|
+
label: "!module-path",
|
|
40
|
+
hint: "Location of a file or directory shipped with this module.",
|
|
41
|
+
names: "file-or-directory",
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
/** The authorable tag an engine name denotes, or undefined for a tag no host
|
|
45
|
+
* offers (`!ref`, `!sql`, an unknown one). */
|
|
46
|
+
export function valueTag(id) {
|
|
47
|
+
const entry = AUTHORABLE[id];
|
|
48
|
+
return entry ? { id, ...entry } : undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The tags offerable at one field.
|
|
52
|
+
*
|
|
53
|
+
* Two rules, both read off the engine rather than off its name:
|
|
54
|
+
*
|
|
55
|
+
* - CAN its value satisfy the slot? A tag that declares a produced type is
|
|
56
|
+
* offered only where that type fits. This is what puts `!include-bytes` on a
|
|
57
|
+
* `Telo.Bytes` slot and keeps it off a string one — and what keeps
|
|
58
|
+
* `!literal`, which is always text, off a boolean predicate. Checked with
|
|
59
|
+
* the analyzer's own comparator so the editor and `telo check` agree about
|
|
60
|
+
* what fits. A tag declaring no produced type (`!cel`) produces whatever the
|
|
61
|
+
* slot says and constrains nothing here.
|
|
62
|
+
* - Is it MEANINGFUL here? A tag that decides what evaluation does with the
|
|
63
|
+
* value needs a slot that is evaluated at all: outside one, `!cel` is a
|
|
64
|
+
* value the runtime never evaluates (`CEL_IN_NON_EVAL_FIELD`), and
|
|
65
|
+
* `!literal` suppresses an interpolation that was never going to happen.
|
|
66
|
+
*
|
|
67
|
+
* `prop` undefined is a field with no declared schema, which constrains
|
|
68
|
+
* nothing. `evalMode` undefined means no rule decides whether the field is
|
|
69
|
+
* evaluated, so the second question is not asked.
|
|
70
|
+
*/
|
|
71
|
+
export function offeredValueTags(prop, evalMode) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const engine of builtinEngines) {
|
|
74
|
+
const tag = valueTag(engine.name);
|
|
75
|
+
if (!tag)
|
|
76
|
+
continue;
|
|
77
|
+
const produced = producedTypeOf(engine.name);
|
|
78
|
+
const fitsSlot = produced && prop ? producedFits(produced, prop) : true;
|
|
79
|
+
const meaningful = tag.requiresEvalSlot && evalMode !== undefined ? evalMode !== null : true;
|
|
80
|
+
if (fitsSlot && meaningful)
|
|
81
|
+
out.push(tag);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/** Whether a tag's produced type satisfies the slot's declared one. An
|
|
86
|
+
* undeclared slot accepts anything — it constrains nothing, so nothing about
|
|
87
|
+
* the value can contradict it. A union declares through its branches. */
|
|
88
|
+
function producedFits(produced, prop) {
|
|
89
|
+
if (!prop.type && !prop["x-telo-type"] && !prop.anyOf && !prop.oneOf)
|
|
90
|
+
return true;
|
|
91
|
+
return checkSchemaCompatibility(produced, prop).compatible;
|
|
92
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/ide-support",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Editor-host-agnostic IDE support (completions, diagnostic normalization) for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"yaml": "^2.8.3",
|
|
40
|
-
"@telorun/analyzer": "0.
|
|
40
|
+
"@telorun/analyzer": "0.84.0",
|
|
41
|
+
"@telorun/templating": "0.25.0"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@types/node": "^20.0.0",
|
package/src/completions/build.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { celCompletions } from "./cel-completions.js";
|
|
|
12
12
|
import { docIdentity } from "../doc-identity.js";
|
|
13
13
|
import { detectContext, lookupRefConstraints, navigateSchema } from "./detect-context.js";
|
|
14
14
|
import { importSourceCompletions } from "./import-source.js";
|
|
15
|
+
import { moduleFileCompletions } from "./module-file-completions.js";
|
|
16
|
+
import { valueTagCompletions } from "./value-tag-completions.js";
|
|
15
17
|
import { propKeyCompletions } from "./prop-keys.js";
|
|
16
18
|
import { CAPABILITY_VALUES } from "./valid-capabilities.js";
|
|
17
19
|
|
|
@@ -192,6 +194,10 @@ export async function buildCompletions(
|
|
|
192
194
|
return kindCompletions(registry, ctx.docKind, ctx.yamlPath, ctx.replaceRange);
|
|
193
195
|
}
|
|
194
196
|
if (ctx.type === "capability") return capabilityCompletions();
|
|
197
|
+
if (ctx.type === "value-tag") return valueTagCompletions(ctx, registry);
|
|
198
|
+
if (ctx.type === "module-file") {
|
|
199
|
+
return moduleFileCompletions(ctx.names, ctx.prefix, ctx.replaceRange, adapter);
|
|
200
|
+
}
|
|
195
201
|
if (ctx.type === "value-suggestions") {
|
|
196
202
|
return valueSuggestions(registry, ctx.docKind, ctx.yamlPath, ctx.replaceRange);
|
|
197
203
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type CelSegment,
|
|
7
7
|
} from "@telorun/analyzer";
|
|
8
8
|
import type { ReplaceRange } from "../types.js";
|
|
9
|
+
import { valueTag } from "../value-tags/offered-value-tags.js";
|
|
9
10
|
import { resolveNodeAtPosition } from "./resolve-node.js";
|
|
10
11
|
|
|
11
12
|
export type { ReplaceRange };
|
|
@@ -88,6 +89,32 @@ export type CompletionCtx =
|
|
|
88
89
|
segment: CelSegment;
|
|
89
90
|
/** Cursor as a document offset. */
|
|
90
91
|
offset: number;
|
|
92
|
+
}
|
|
93
|
+
| {
|
|
94
|
+
/** Cursor sits on a value's YAML tag (`root: !mo|`). What is offered is
|
|
95
|
+
* every tag the field takes, so its schema is addressed relative to the
|
|
96
|
+
* nearest enclosing resource. */
|
|
97
|
+
type: "value-tag";
|
|
98
|
+
/** Absent at a document declaring no resource kind. */
|
|
99
|
+
kind?: string;
|
|
100
|
+
yamlPath: string[];
|
|
101
|
+
/** Resource-relative, sequence indices kept — what the eval mode is
|
|
102
|
+
* resolved at. */
|
|
103
|
+
concretePath: string;
|
|
104
|
+
/** The value is a sequence item, so the field's schema is its item's. */
|
|
105
|
+
isItem: boolean;
|
|
106
|
+
/** Nothing follows the tag yet, so a pick may lead into the value. */
|
|
107
|
+
bare: boolean;
|
|
108
|
+
replaceRange: ReplaceRange;
|
|
109
|
+
}
|
|
110
|
+
| {
|
|
111
|
+
/** Cursor sits in the value of a tag naming a location inside the module
|
|
112
|
+
* (`!module-path ./pu|`). */
|
|
113
|
+
type: "module-file";
|
|
114
|
+
names: "file" | "file-or-directory";
|
|
115
|
+
/** Text from the start of the value to the cursor. */
|
|
116
|
+
prefix: string;
|
|
117
|
+
replaceRange: ReplaceRange;
|
|
91
118
|
};
|
|
92
119
|
|
|
93
120
|
/** Returns every schema branch reachable from `node` after peeling `anyOf` /
|
|
@@ -250,6 +277,38 @@ export function navigateSchema(
|
|
|
250
277
|
return unionLeaves(current, leaves);
|
|
251
278
|
}
|
|
252
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The schema of the field at `path`, as DECLARED — combinators kept.
|
|
282
|
+
*
|
|
283
|
+
* `navigateSchema` merges a leaf's branches into one object node, which is what
|
|
284
|
+
* key completion wants and exactly what a type comparison must not see: a
|
|
285
|
+
* `Telo.HostPath` branch of an `anyOf` stops being one. `isItem` selects the
|
|
286
|
+
* field's item schema, for a value written as a sequence item.
|
|
287
|
+
*/
|
|
288
|
+
export function fieldSchemaAt(
|
|
289
|
+
schema: Record<string, any>,
|
|
290
|
+
path: string[],
|
|
291
|
+
isItem: boolean,
|
|
292
|
+
schemaFrom?: SchemaFromResolver,
|
|
293
|
+
): Record<string, any> | undefined {
|
|
294
|
+
if (path.length === 0) return undefined;
|
|
295
|
+
const parent = navigateSchema(schema, path.slice(0, -1), schemaFrom);
|
|
296
|
+
const key = path[path.length - 1]!;
|
|
297
|
+
let field = parent?.properties?.[key] ?? parent?.additionalProperties;
|
|
298
|
+
if (!field || typeof field !== "object") return undefined;
|
|
299
|
+
field = resolveLocalRef(field, schema);
|
|
300
|
+
if (isItem) {
|
|
301
|
+
while (field?.type === "array" && field.items) field = resolveLocalRef(field.items, schema);
|
|
302
|
+
}
|
|
303
|
+
return field;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** `concretePath` relative to the resource map at `resourceConcrete`. */
|
|
307
|
+
function relativeConcretePath(concretePath: string, resourceConcrete: string): string {
|
|
308
|
+
if (!resourceConcrete) return concretePath;
|
|
309
|
+
return concretePath.slice(resourceConcrete.length).replace(/^\./, "");
|
|
310
|
+
}
|
|
311
|
+
|
|
253
312
|
/** Merge multiple peeled schema branches into one node for completion purposes.
|
|
254
313
|
* Property maps are unioned (first branch wins on key collision). `required`
|
|
255
314
|
* becomes the intersection so optional-in-any-branch keys still surface.
|
|
@@ -328,6 +387,31 @@ export function detectContext(
|
|
|
328
387
|
const { docKind } = resolved;
|
|
329
388
|
|
|
330
389
|
if (resolved.slot === "value") {
|
|
390
|
+
const tag = resolved.tag;
|
|
391
|
+
if (tag?.prefix !== undefined) {
|
|
392
|
+
const depth = resolved.resourceDepth ?? 0;
|
|
393
|
+
return {
|
|
394
|
+
type: "value-tag",
|
|
395
|
+
kind: resolved.resourceKind ?? docKind,
|
|
396
|
+
yamlPath: resolved.path.slice(depth),
|
|
397
|
+
concretePath: relativeConcretePath(
|
|
398
|
+
resolved.concretePath ?? "",
|
|
399
|
+
resolved.resourceConcretePath ?? "",
|
|
400
|
+
),
|
|
401
|
+
isItem: resolved.container === undefined,
|
|
402
|
+
bare: tag.bare,
|
|
403
|
+
replaceRange: tag.replaceRange,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const names = tag ? valueTag(tag.text.slice(1))?.names : undefined;
|
|
407
|
+
if (names && resolved.replaceRange) {
|
|
408
|
+
return {
|
|
409
|
+
type: "module-file",
|
|
410
|
+
names,
|
|
411
|
+
prefix: resolved.prefix ?? "",
|
|
412
|
+
replaceRange: resolved.replaceRange,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
331
415
|
// Inside a CEL body — structural completion does not apply; what completes
|
|
332
416
|
// are the names the expression may use.
|
|
333
417
|
if (resolved.cel) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { CompletionResult, IdeEnvironmentAdapter, ReplaceRange } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/** Caps one listing; a popover shows far fewer. */
|
|
4
|
+
const ENTRY_LIMIT = 200;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Completions for the value of a tag naming a location inside the module
|
|
8
|
+
* (`!include-text`, `!include-bytes`, `!module-path`).
|
|
9
|
+
*
|
|
10
|
+
* Paths are measured from the MODULE ROOT, never from the declaring file, so the
|
|
11
|
+
* listing is asked of the host against that root. The typed text up to its last
|
|
12
|
+
* `/` is the directory listed, and is kept on every insertion, so `./` stays
|
|
13
|
+
* where the author wrote it. A directory is a value in its own right where the
|
|
14
|
+
* tag may name one; where it names a file, a directory is only a way down, so a
|
|
15
|
+
* pick inserts its `/` and reopens completion. Dot-entries are listed only once
|
|
16
|
+
* the typed name starts with `.`, as a shell completes them.
|
|
17
|
+
*
|
|
18
|
+
* Nothing is offered above the root or for an absolute or URL-shaped path: the
|
|
19
|
+
* tag refuses those, and a listing there would offer what cannot be written.
|
|
20
|
+
*/
|
|
21
|
+
export async function moduleFileCompletions(
|
|
22
|
+
names: "file" | "file-or-directory",
|
|
23
|
+
prefix: string,
|
|
24
|
+
replaceRange: ReplaceRange,
|
|
25
|
+
adapter: IdeEnvironmentAdapter | undefined,
|
|
26
|
+
): Promise<CompletionResult[]> {
|
|
27
|
+
if (!adapter || /^["']/.test(prefix)) return [];
|
|
28
|
+
const lastSlash = prefix.lastIndexOf("/");
|
|
29
|
+
const dirPart = prefix.slice(0, lastSlash + 1);
|
|
30
|
+
const namePart = prefix.slice(lastSlash + 1);
|
|
31
|
+
if (!staysInModule(dirPart)) return [];
|
|
32
|
+
|
|
33
|
+
const entries = await adapter.listModuleEntries(dirPart === "" ? "." : dirPart);
|
|
34
|
+
return entries
|
|
35
|
+
.filter((e) => e.name.startsWith(namePart) && (namePart.startsWith(".") || !e.name.startsWith(".")))
|
|
36
|
+
.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name))
|
|
37
|
+
.slice(0, ENTRY_LIMIT)
|
|
38
|
+
.map((entry) => {
|
|
39
|
+
const navigateOnly = entry.directory && names === "file";
|
|
40
|
+
const path = dirPart + entry.name + (navigateOnly ? "/" : "");
|
|
41
|
+
return {
|
|
42
|
+
label: entry.directory ? `${entry.name}/` : entry.name,
|
|
43
|
+
kind: entry.directory ? "folder" : "file",
|
|
44
|
+
insertText: path,
|
|
45
|
+
filterText: path,
|
|
46
|
+
replaceRange,
|
|
47
|
+
retrigger: navigateOnly,
|
|
48
|
+
sortText: `${entry.directory ? 0 : 1}_${entry.name}`,
|
|
49
|
+
} satisfies CompletionResult;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether a module-root-relative directory stays inside the module. Pure string
|
|
54
|
+
* work, by the rule the tags' own path grammar applies. */
|
|
55
|
+
function staysInModule(dir: string): boolean {
|
|
56
|
+
if (/^([/\\]|[a-z][a-z0-9+.-]*:)/i.test(dir)) return false;
|
|
57
|
+
let depth = 0;
|
|
58
|
+
for (const segment of dir.split(/[/\\]+/)) {
|
|
59
|
+
if (segment === "" || segment === ".") continue;
|
|
60
|
+
depth += segment === ".." ? -1 : 1;
|
|
61
|
+
if (depth < 0) return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
@@ -41,13 +41,18 @@ export interface ResolvedCursor {
|
|
|
41
41
|
/** Key slot: keys already present in the container (the key under the cursor
|
|
42
42
|
* excluded so it still suggests itself). */
|
|
43
43
|
existingKeys?: Set<string>;
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
/** The kind of the nearest enclosing inline resource (or the root resource),
|
|
45
|
+
* whose schema the slot is completed against. Absent at a root declaring no
|
|
46
|
+
* resource kind. */
|
|
47
47
|
resourceKind?: string;
|
|
48
|
-
/**
|
|
49
|
-
*
|
|
48
|
+
/** Number of `path` segments that reach `resourceKind`'s map, so the
|
|
49
|
+
* schema-relative path is `path.slice(resourceDepth)`. */
|
|
50
50
|
resourceDepth?: number;
|
|
51
|
+
/** Value slot: the concrete path of `resourceKind`'s map, so the
|
|
52
|
+
* resource-relative concrete path is what follows it in `concretePath`. */
|
|
53
|
+
resourceConcretePath?: string;
|
|
54
|
+
/** Value slot: the scalar's tag, when written out in front of it. */
|
|
55
|
+
tag?: ResolvedTag;
|
|
51
56
|
/** The path with sequence INDICES kept (`routes[0].handler.url`),
|
|
52
57
|
* as distinct from `path`, through which arrays are transparent. This is the
|
|
53
58
|
* address the analyzer speaks — an `x-telo-context` scope, an error-bearing
|
|
@@ -60,6 +65,17 @@ export interface ResolvedCursor {
|
|
|
60
65
|
cel?: { segment: CelSegment; offset: number };
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
export interface ResolvedTag {
|
|
69
|
+
/** The tag as written, `!` included. */
|
|
70
|
+
text: string;
|
|
71
|
+
replaceRange: { start: Position; end: Position };
|
|
72
|
+
/** Set when the cursor sits on the tag: its text up to the cursor. */
|
|
73
|
+
prefix?: string;
|
|
74
|
+
/** True when nothing follows the tag on its line — the value is still to be
|
|
75
|
+
* written. */
|
|
76
|
+
bare: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
function within(range: [number, number], offset: number): boolean {
|
|
64
80
|
return offset >= range[0] && offset <= range[1];
|
|
65
81
|
}
|
|
@@ -96,15 +112,43 @@ function resourceKindOf(map: AstMap): string | undefined {
|
|
|
96
112
|
|
|
97
113
|
/** The kind + path-depth of the nearest enclosing inline resource (or the root
|
|
98
114
|
* resource). `depth` is the number of `path` segments consumed to reach that
|
|
99
|
-
* map, so a prop-key `yamlPath` relative to it is `path.slice(depth)
|
|
115
|
+
* map, so a prop-key `yamlPath` relative to it is `path.slice(depth)`;
|
|
116
|
+
* `concrete` is that map's own concrete path. */
|
|
100
117
|
interface ResourceScope {
|
|
101
118
|
kind?: string;
|
|
102
119
|
depth: number;
|
|
120
|
+
concrete: string;
|
|
103
121
|
}
|
|
104
122
|
|
|
105
|
-
|
|
123
|
+
const ROOT_SCOPE: ResourceScope = { depth: 0, concrete: "" };
|
|
124
|
+
|
|
125
|
+
function enter(
|
|
126
|
+
scope: ResourceScope,
|
|
127
|
+
map: AstMap,
|
|
128
|
+
ancestorsLen: number,
|
|
129
|
+
concrete: string,
|
|
130
|
+
): ResourceScope {
|
|
106
131
|
const kind = resourceKindOf(map);
|
|
107
|
-
return kind ? { kind, depth: ancestorsLen } : scope;
|
|
132
|
+
return kind ? { kind, depth: ancestorsLen, concrete } : scope;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The span of a scalar's tag, when it is written out in front of the value.
|
|
136
|
+
* The AST's range covers the value alone; the tag precedes it, separated by
|
|
137
|
+
* whitespace — or ends exactly at the range for a tag with no value yet. A tag
|
|
138
|
+
* whose text does not read back verbatim (`!!str`, which the parser expands)
|
|
139
|
+
* has no span. */
|
|
140
|
+
function tagSpan(node: AstNode, text: string): [number, number] | undefined {
|
|
141
|
+
if (node.kind !== "scalar" || !node.tag) return undefined;
|
|
142
|
+
let end = node.range[0];
|
|
143
|
+
while (end > 0 && /\s/.test(text[end - 1]!)) end--;
|
|
144
|
+
const start = end - node.tag.length;
|
|
145
|
+
return start >= 0 && text.slice(start, end) === node.tag ? [start, end] : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A value's span with its tag included, so a cursor on the tag is on the value. */
|
|
149
|
+
function valueSpan(node: AstNode, text: string): [number, number] {
|
|
150
|
+
const tag = tagSpan(node, text);
|
|
151
|
+
return tag ? [tag[0], node.range[1]] : node.range;
|
|
108
152
|
}
|
|
109
153
|
|
|
110
154
|
// ---------------------------------------------------------------------------
|
|
@@ -131,6 +175,7 @@ type Descent =
|
|
|
131
175
|
keyName?: string;
|
|
132
176
|
keyEnd: number;
|
|
133
177
|
valueNode: AstNode;
|
|
178
|
+
scope: ResourceScope;
|
|
134
179
|
}
|
|
135
180
|
| { type: "empty" };
|
|
136
181
|
|
|
@@ -145,9 +190,10 @@ function descend(
|
|
|
145
190
|
concrete: string,
|
|
146
191
|
offset: number,
|
|
147
192
|
scope: ResourceScope,
|
|
193
|
+
text: string,
|
|
148
194
|
): Descent | undefined {
|
|
149
195
|
if (node.kind === "map") {
|
|
150
|
-
const mapScope = enter(scope, node, ancestors.length);
|
|
196
|
+
const mapScope = enter(scope, node, ancestors.length, concrete);
|
|
151
197
|
for (const pair of node.entries) {
|
|
152
198
|
const keyName = scalarString(pair.key);
|
|
153
199
|
if (within(pair.key.range, offset)) {
|
|
@@ -161,11 +207,15 @@ function descend(
|
|
|
161
207
|
scope: mapScope,
|
|
162
208
|
};
|
|
163
209
|
}
|
|
164
|
-
if (pair.value && within(pair.value
|
|
210
|
+
if (pair.value && within(valueSpan(pair.value, text), offset)) {
|
|
165
211
|
const childAncestors = keyName != null ? [...ancestors, keyName] : ancestors;
|
|
166
212
|
const childConcrete = keyName != null ? joinKey(concrete, keyName) : concrete;
|
|
167
213
|
if (pair.value.kind === "map" || pair.value.kind === "seq") {
|
|
168
|
-
return
|
|
214
|
+
return (
|
|
215
|
+
descend(pair.value, childAncestors, childConcrete, offset, mapScope, text) ?? {
|
|
216
|
+
type: "empty",
|
|
217
|
+
}
|
|
218
|
+
);
|
|
169
219
|
}
|
|
170
220
|
return {
|
|
171
221
|
type: "value",
|
|
@@ -175,6 +225,7 @@ function descend(
|
|
|
175
225
|
keyName,
|
|
176
226
|
keyEnd: pair.key.range[1],
|
|
177
227
|
valueNode: pair.value,
|
|
228
|
+
scope: mapScope,
|
|
178
229
|
};
|
|
179
230
|
}
|
|
180
231
|
}
|
|
@@ -186,10 +237,10 @@ function descend(
|
|
|
186
237
|
// `x-telo-context` scope, an error-bearing region and a step's identity are
|
|
187
238
|
// all addressed per item, so a CEL site is unreachable without the index.
|
|
188
239
|
for (const [index, item] of node.items.entries()) {
|
|
189
|
-
if (within(item
|
|
240
|
+
if (within(valueSpan(item, text), offset)) {
|
|
190
241
|
const itemConcrete = `${concrete}[${index}]`;
|
|
191
242
|
if (item.kind === "map" || item.kind === "seq") {
|
|
192
|
-
return descend(item, ancestors, itemConcrete, offset, scope) ?? { type: "empty" };
|
|
243
|
+
return descend(item, ancestors, itemConcrete, offset, scope, text) ?? { type: "empty" };
|
|
193
244
|
}
|
|
194
245
|
// A bare scalar list item (`targets:\n - One`) has no enclosing map of
|
|
195
246
|
// keyed siblings — leave `container` undefined rather than treating the
|
|
@@ -201,6 +252,7 @@ function descend(
|
|
|
201
252
|
concretePath: itemConcrete,
|
|
202
253
|
keyEnd: item.range[0],
|
|
203
254
|
valueNode: item,
|
|
255
|
+
scope,
|
|
204
256
|
};
|
|
205
257
|
}
|
|
206
258
|
}
|
|
@@ -243,7 +295,7 @@ function collectScopes(
|
|
|
243
295
|
pairs: PairScope[],
|
|
244
296
|
): void {
|
|
245
297
|
if (node.kind === "map") {
|
|
246
|
-
const mapScope = enter(scope, node, ancestors.length);
|
|
298
|
+
const mapScope = enter(scope, node, ancestors.length, concrete);
|
|
247
299
|
const keys = new Set<string>();
|
|
248
300
|
let childColumn = -1;
|
|
249
301
|
for (const pair of node.entries) {
|
|
@@ -309,7 +361,7 @@ function columnSearch(
|
|
|
309
361
|
): KeyResolution {
|
|
310
362
|
const maps: MapScope[] = [];
|
|
311
363
|
const pairs: PairScope[] = [];
|
|
312
|
-
collectScopes(root, [], "",
|
|
364
|
+
collectScopes(root, [], "", ROOT_SCOPE, lineOffsets, maps, pairs);
|
|
313
365
|
|
|
314
366
|
// Sibling level: a map whose children already sit at the cursor's column.
|
|
315
367
|
let sibling: MapScope | undefined;
|
|
@@ -349,7 +401,7 @@ function columnSearch(
|
|
|
349
401
|
};
|
|
350
402
|
}
|
|
351
403
|
|
|
352
|
-
return { path: [], concrete: "", existingKeys: new Set(), scope:
|
|
404
|
+
return { path: [], concrete: "", existingKeys: new Set(), scope: ROOT_SCOPE };
|
|
353
405
|
}
|
|
354
406
|
|
|
355
407
|
// ---------------------------------------------------------------------------
|
|
@@ -387,7 +439,7 @@ export function resolveNodeAtPosition(
|
|
|
387
439
|
const doc = docs[docIndex];
|
|
388
440
|
const docKind = docKindOf(doc);
|
|
389
441
|
|
|
390
|
-
const found = doc.root ? descend(doc.root, [], "", offset,
|
|
442
|
+
const found = doc.root ? descend(doc.root, [], "", offset, ROOT_SCOPE, text) : undefined;
|
|
391
443
|
|
|
392
444
|
// Cursor sits on an existing map key → key/prop-key position.
|
|
393
445
|
if (found?.type === "key") {
|
|
@@ -443,6 +495,17 @@ export function resolveNodeAtPosition(
|
|
|
443
495
|
}
|
|
444
496
|
|
|
445
497
|
const clampedEnd = Math.min(offset, value.range[1]);
|
|
498
|
+
const span = tagSpan(value, text);
|
|
499
|
+
let tag: ResolvedTag | undefined;
|
|
500
|
+
if (span) {
|
|
501
|
+
const lineEnd = text.indexOf("\n", span[1]);
|
|
502
|
+
tag = {
|
|
503
|
+
text: value.tag!,
|
|
504
|
+
replaceRange: { start: toPos(span[0]), end: toPos(span[1]) },
|
|
505
|
+
prefix: within(span, offset) ? text.slice(span[0], offset) : undefined,
|
|
506
|
+
bare: text.slice(span[1], lineEnd < 0 ? text.length : lineEnd).trim() === "",
|
|
507
|
+
};
|
|
508
|
+
}
|
|
446
509
|
return {
|
|
447
510
|
docIndex,
|
|
448
511
|
offset,
|
|
@@ -456,7 +519,12 @@ export function resolveNodeAtPosition(
|
|
|
456
519
|
spaceAfterColon: value.range[0] - found.keyEnd >= 2,
|
|
457
520
|
siblingKind: found.container ? siblingKindOf(found.container) : undefined,
|
|
458
521
|
replaceRange: { start: toPos(value.range[0]), end: toPos(value.range[1]) },
|
|
459
|
-
|
|
522
|
+
resourceKind: found.scope.kind,
|
|
523
|
+
resourceDepth: found.scope.depth,
|
|
524
|
+
resourceConcretePath: found.scope.concrete,
|
|
525
|
+
tag,
|
|
526
|
+
// The tag is not the value: a cursor on it is not inside its expression.
|
|
527
|
+
cel: tag?.prefix !== undefined ? undefined : cel,
|
|
460
528
|
};
|
|
461
529
|
}
|
|
462
530
|
|
|
@@ -464,7 +532,7 @@ export function resolveNodeAtPosition(
|
|
|
464
532
|
// resolved by cursor column.
|
|
465
533
|
const resolution: KeyResolution = doc.root
|
|
466
534
|
? columnSearch(doc.root, character, offset, lineOffsets)
|
|
467
|
-
: { path: [], concrete: "", existingKeys: new Set<string>(), scope:
|
|
535
|
+
: { path: [], concrete: "", existingKeys: new Set<string>(), scope: ROOT_SCOPE };
|
|
468
536
|
return {
|
|
469
537
|
docIndex,
|
|
470
538
|
offset,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AnalysisRegistry } from "@telorun/analyzer";
|
|
2
|
+
import type { CompletionResult, ReplaceRange } from "../types.js";
|
|
3
|
+
import { offeredValueTags, type ValueTag } from "../value-tags/offered-value-tags.js";
|
|
4
|
+
import { fieldSchemaAt, lookupRefConstraints } from "./detect-context.js";
|
|
5
|
+
|
|
6
|
+
/** `!ref` names a resource rather than producing a value, so it is offered at a
|
|
7
|
+
* reference slot alone — never beside the value tags. */
|
|
8
|
+
const REF_TAG: ValueTag = { id: "ref", label: "!ref", hint: "A reference to a named resource." };
|
|
9
|
+
|
|
10
|
+
export interface ValueTagSite {
|
|
11
|
+
kind?: string;
|
|
12
|
+
yamlPath: string[];
|
|
13
|
+
concretePath: string;
|
|
14
|
+
isItem: boolean;
|
|
15
|
+
bare: boolean;
|
|
16
|
+
replaceRange: ReplaceRange;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The YAML tags the field under the cursor takes.
|
|
21
|
+
*
|
|
22
|
+
* The same rule studio's schema form applies (`offeredValueTags`), asked of the
|
|
23
|
+
* field's declared schema and of the analyzer's eval mode at the site. Where the
|
|
24
|
+
* field cannot be resolved nothing is known against a tag, so every one is
|
|
25
|
+
* offered rather than none.
|
|
26
|
+
*
|
|
27
|
+
* On a tag with nothing after it, a pick carries a trailing space and, for a tag
|
|
28
|
+
* naming a module location, reopens completion on the path.
|
|
29
|
+
*/
|
|
30
|
+
export function valueTagCompletions(
|
|
31
|
+
site: ValueTagSite,
|
|
32
|
+
registry: AnalysisRegistry | undefined,
|
|
33
|
+
): CompletionResult[] {
|
|
34
|
+
return tagsAt(site, registry).map((tag, index) => ({
|
|
35
|
+
label: tag.label,
|
|
36
|
+
kind: "keyword",
|
|
37
|
+
detail: tag.hint,
|
|
38
|
+
insertText: site.bare ? `${tag.label} ` : tag.label,
|
|
39
|
+
replaceRange: site.replaceRange,
|
|
40
|
+
retrigger: site.bare && tag.names !== undefined,
|
|
41
|
+
sortText: String(index).padStart(2, "0"),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tagsAt(site: ValueTagSite, registry: AnalysisRegistry | undefined): ValueTag[] {
|
|
46
|
+
const { kind } = site;
|
|
47
|
+
const schema = kind ? registry?.resolveDefinition(kind)?.schema : undefined;
|
|
48
|
+
if (!registry || !kind || !schema) return [...offeredValueTags(undefined, undefined), REF_TAG];
|
|
49
|
+
const schemaFrom = (from: string) => registry.resolveSchemaFrom(from, kind);
|
|
50
|
+
if (lookupRefConstraints(schema as Record<string, any>, site.yamlPath, schemaFrom).length > 0) {
|
|
51
|
+
return [REF_TAG];
|
|
52
|
+
}
|
|
53
|
+
const field = fieldSchemaAt(schema as Record<string, any>, site.yamlPath, site.isItem, schemaFrom);
|
|
54
|
+
return offeredValueTags(field, registry.celEvalModeAt(kind, site.concretePath));
|
|
55
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ export * from "./rename/index.js";
|
|
|
8
8
|
export * from "./signature-help/index.js";
|
|
9
9
|
export * from "./import-upgrades/index.js";
|
|
10
10
|
export * from "./workspace/index.js";
|
|
11
|
+
// Which YAML tags a field takes — shared so every host offers the same set.
|
|
12
|
+
export { offeredValueTags, valueTag, type ValueTag } from "./value-tags/offered-value-tags.js";
|
|
11
13
|
// The repo's single CEL-tree walk. Exported because every host that has to
|
|
12
14
|
// answer "where is this name read" needs it and a second copy would be a second
|
|
13
15
|
// answer — the editor asks it before deleting a resource.
|