@telorun/ide-support 0.5.0 → 0.7.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 +2 -2
- package/dist/completions/build.d.ts.map +1 -1
- package/dist/completions/build.js +39 -64
- package/dist/completions/detect-context.d.ts +16 -43
- package/dist/completions/detect-context.d.ts.map +1 -1
- package/dist/completions/detect-context.js +63 -262
- package/dist/completions/import-source.d.ts +12 -8
- package/dist/completions/import-source.d.ts.map +1 -1
- package/dist/completions/import-source.js +67 -49
- package/dist/completions/resolve-node.d.ts +50 -0
- package/dist/completions/resolve-node.d.ts.map +1 -0
- package/dist/completions/resolve-node.js +269 -0
- package/dist/completions/valid-capabilities.d.ts +3 -0
- package/dist/completions/valid-capabilities.d.ts.map +1 -1
- package/dist/completions/valid-capabilities.js +10 -0
- package/dist/definition/build-definition.d.ts +13 -0
- package/dist/definition/build-definition.d.ts.map +1 -0
- package/dist/definition/build-definition.js +98 -0
- package/dist/definition/index.d.ts +2 -0
- package/dist/definition/index.d.ts.map +1 -0
- package/dist/definition/index.js +1 -0
- package/dist/hover/build-hover.d.ts +4 -0
- package/dist/hover/build-hover.d.ts.map +1 -0
- package/dist/hover/build-hover.js +125 -0
- package/dist/hover/index.d.ts +2 -0
- package/dist/hover/index.d.ts.map +1 -0
- package/dist/hover/index.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/semantic-tokens/build-semantic-tokens.d.ts +12 -0
- package/dist/semantic-tokens/build-semantic-tokens.d.ts.map +1 -0
- package/dist/semantic-tokens/build-semantic-tokens.js +55 -0
- package/dist/semantic-tokens/index.d.ts +2 -0
- package/dist/semantic-tokens/index.d.ts.map +1 -0
- package/dist/semantic-tokens/index.js +1 -0
- package/dist/types.d.ts +66 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -0
- package/package.json +2 -2
- package/src/completions/build.ts +40 -72
- package/src/completions/detect-context.ts +74 -291
- package/src/completions/import-source.ts +71 -53
- package/src/completions/resolve-node.ts +405 -0
- package/src/completions/valid-capabilities.ts +11 -0
- package/src/definition/build-definition.ts +123 -0
- package/src/definition/index.ts +1 -0
- package/src/hover/build-hover.ts +149 -0
- package/src/hover/index.ts +1 -0
- package/src/index.ts +3 -0
- package/src/semantic-tokens/build-semantic-tokens.ts +64 -0
- package/src/semantic-tokens/index.ts +1 -0
- package/src/types.ts +78 -18
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { CompletionResult, IdeEnvironmentAdapter } from "../types.js";
|
|
1
|
+
import type { CompletionResult, IdeEnvironmentAdapter, ReplaceRange } from "../types.js";
|
|
2
2
|
|
|
3
|
-
/** Maximum
|
|
4
|
-
*
|
|
5
|
-
|
|
3
|
+
/** Maximum ref hits to surface in a single completion request. Keeps the
|
|
4
|
+
* popover scannable when a broad `q=` query matches many registered refs
|
|
5
|
+
* (the hub already caps `/refs` server-side; this is a client backstop). */
|
|
6
|
+
const REF_LIMIT = 50;
|
|
6
7
|
|
|
7
8
|
/** Caps the number of directory entries we probe with `hasManifest` per
|
|
8
9
|
* request. Each probe is a host-side filesystem stat; the popover would not
|
|
@@ -17,17 +18,21 @@ const PATH_PROBE_LIMIT = 50;
|
|
|
17
18
|
* Branches by prefix shape:
|
|
18
19
|
* "" → relative dirs under the manifest dir, plus `./` / `../` seeds.
|
|
19
20
|
* "./..", "../", "/..." → subdirs of the typed path (any subdir; existing manifest gets a hint).
|
|
20
|
-
* "<word>"
|
|
21
|
-
* "<
|
|
22
|
-
* "http(s)://", "file://" → no suggestions (opaque URLs).
|
|
21
|
+
* "<word>", "oci://…" → hub ref autocomplete (fuzzy substring over registered refs).
|
|
22
|
+
* "<ref>@<partial>" → version list for that ref.
|
|
23
|
+
* "http(s)://", "file://" → no suggestions (opaque URLs the author types verbatim).
|
|
23
24
|
*
|
|
24
|
-
* `
|
|
25
|
-
* the whole typed
|
|
26
|
-
*
|
|
25
|
+
* `oci://` is deliberately NOT opaque: without an `@` it routes to the ref
|
|
26
|
+
* search below, whose query is the whole typed prefix — so the hub fuzzy-matches
|
|
27
|
+
* `oci://ghcr.io/aws/telo-s3` as readily as a bare `s3`.
|
|
28
|
+
*
|
|
29
|
+
* `replaceRange` is forwarded onto every result so the host replaces the whole
|
|
30
|
+
* value, not just the trailing word (Monaco / VSCode word boundaries don't
|
|
31
|
+
* cross `/` or `@`).
|
|
27
32
|
*/
|
|
28
33
|
export async function importSourceCompletions(
|
|
29
34
|
prefix: string,
|
|
30
|
-
|
|
35
|
+
replaceRange: ReplaceRange,
|
|
31
36
|
adapter: IdeEnvironmentAdapter | undefined,
|
|
32
37
|
): Promise<CompletionResult[]> {
|
|
33
38
|
if (!adapter) return [];
|
|
@@ -43,20 +48,22 @@ export async function importSourceCompletions(
|
|
|
43
48
|
const isRelativeShape =
|
|
44
49
|
prefix === "" || prefix.startsWith(".") || prefix.startsWith("/");
|
|
45
50
|
if (isRelativeShape) {
|
|
46
|
-
return relativePathCompletions(prefix,
|
|
51
|
+
return relativePathCompletions(prefix, replaceRange, adapter);
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
|
|
54
|
+
// The version (or `@sha256:` digest) is the trailing `@`-segment, so split on
|
|
55
|
+
// the LAST `@` — a digest-pinned ref keeps everything before it as the ref.
|
|
56
|
+
const atIdx = prefix.lastIndexOf("@");
|
|
50
57
|
if (atIdx > 0) {
|
|
51
|
-
return versionCompletions(prefix, atIdx,
|
|
58
|
+
return versionCompletions(prefix, atIdx, replaceRange, adapter);
|
|
52
59
|
}
|
|
53
60
|
|
|
54
|
-
return
|
|
61
|
+
return refSearchCompletions(prefix, replaceRange, adapter);
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
async function relativePathCompletions(
|
|
58
65
|
prefix: string,
|
|
59
|
-
|
|
66
|
+
replaceRange: ReplaceRange,
|
|
60
67
|
adapter: IdeEnvironmentAdapter,
|
|
61
68
|
): Promise<CompletionResult[]> {
|
|
62
69
|
// Empty prefix → seed `./` and `../` so the user gets traction; otherwise
|
|
@@ -69,14 +76,14 @@ async function relativePathCompletions(
|
|
|
69
76
|
kind: "folder",
|
|
70
77
|
insertText: "./",
|
|
71
78
|
sortText: "0_./",
|
|
72
|
-
|
|
79
|
+
replaceRange,
|
|
73
80
|
},
|
|
74
81
|
{
|
|
75
82
|
label: "../",
|
|
76
83
|
kind: "folder",
|
|
77
84
|
insertText: "../",
|
|
78
85
|
sortText: "0_../",
|
|
79
|
-
|
|
86
|
+
replaceRange,
|
|
80
87
|
},
|
|
81
88
|
];
|
|
82
89
|
}
|
|
@@ -111,7 +118,7 @@ async function relativePathCompletions(
|
|
|
111
118
|
detail: isModule ? "telo module" : "folder",
|
|
112
119
|
insertText: fullPath,
|
|
113
120
|
filterText: fullPath,
|
|
114
|
-
|
|
121
|
+
replaceRange,
|
|
115
122
|
// Modules sort above plain folders so they surface first when the user
|
|
116
123
|
// is browsing a `modules/` tree mixed with non-Telo siblings.
|
|
117
124
|
sortText: isModule ? `0_${name}` : `1_${name}`,
|
|
@@ -120,65 +127,76 @@ async function relativePathCompletions(
|
|
|
120
127
|
);
|
|
121
128
|
}
|
|
122
129
|
|
|
123
|
-
async function
|
|
130
|
+
async function refSearchCompletions(
|
|
124
131
|
prefix: string,
|
|
125
|
-
|
|
132
|
+
replaceRange: ReplaceRange,
|
|
126
133
|
adapter: IdeEnvironmentAdapter,
|
|
127
134
|
): Promise<CompletionResult[]> {
|
|
128
|
-
// The
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return filtered.slice(0, REGISTRY_LIMIT).map((m) => {
|
|
144
|
-
const id = `${m.namespace}/${m.name}@${m.version}`;
|
|
135
|
+
// The whole typed prefix is the fuzzy query — the hub matches it as a
|
|
136
|
+
// substring over each registered ref, so no client-side splitting is needed
|
|
137
|
+
// (and `oci://ghcr.io/aws/telo-s3` matches without mangling the `//`).
|
|
138
|
+
const hits = await adapter.searchRefs(prefix);
|
|
139
|
+
|
|
140
|
+
return hits.slice(0, REF_LIMIT).map((m) => {
|
|
141
|
+
// Seed the pinned `ref@latestVersion` so the completion is directly usable;
|
|
142
|
+
// the author can still narrow the version afterwards (the `@` re-triggers
|
|
143
|
+
// version completion).
|
|
144
|
+
const id = m.latestVersion ? `${m.ref}@${m.latestVersion}` : m.ref;
|
|
145
|
+
const name = refDisplayName(m.ref);
|
|
146
|
+
// Lead the label with the module name so the interesting part isn't cut off
|
|
147
|
+
// behind the transport/host boilerplate (`oci://ghcr.io/telorun/…`). The
|
|
148
|
+
// full ref moves to `detail`, and `insertText`/`filterText` stay the ref so
|
|
149
|
+
// acceptance still inserts it and a fully-typed ref still filters.
|
|
145
150
|
return {
|
|
146
|
-
label:
|
|
151
|
+
label: m.latestVersion ? `${name}@${m.latestVersion}` : name,
|
|
147
152
|
kind: "module",
|
|
148
|
-
detail: m.description ??
|
|
153
|
+
detail: m.description ?? m.ref,
|
|
154
|
+
documentation: m.description ? m.ref : undefined,
|
|
149
155
|
insertText: id,
|
|
150
156
|
filterText: id,
|
|
151
|
-
|
|
157
|
+
replaceRange,
|
|
152
158
|
};
|
|
153
159
|
});
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
/** The `org/name` tail of a location ref: its last two path segments, with the
|
|
163
|
+
* transport scheme (`oci://`, `https://`, …) and registry host dropped.
|
|
164
|
+
* `oci://ghcr.io/telorun/telo-console` → `telorun/telo-console`; `std/console`
|
|
165
|
+
* → `std/console`. Falls back to fewer segments (or the whole ref) when there
|
|
166
|
+
* aren't two. */
|
|
167
|
+
function refDisplayName(ref: string): string {
|
|
168
|
+
const withoutScheme = ref.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
|
|
169
|
+
const segments = withoutScheme.split("/").filter(Boolean);
|
|
170
|
+
return segments.slice(-2).join("/") || ref;
|
|
171
|
+
}
|
|
172
|
+
|
|
156
173
|
async function versionCompletions(
|
|
157
174
|
prefix: string,
|
|
158
175
|
atIdx: number,
|
|
159
|
-
|
|
176
|
+
replaceRange: ReplaceRange,
|
|
160
177
|
adapter: IdeEnvironmentAdapter,
|
|
161
178
|
): Promise<CompletionResult[]> {
|
|
162
|
-
const
|
|
179
|
+
const ref = prefix.slice(0, atIdx);
|
|
163
180
|
const partialVersion = prefix.slice(atIdx + 1);
|
|
164
|
-
|
|
165
|
-
if (slashIdx <= 0 || slashIdx === beforeAt.length - 1) return [];
|
|
181
|
+
if (ref === "") return [];
|
|
166
182
|
|
|
167
|
-
const
|
|
168
|
-
const name = beforeAt.slice(slashIdx + 1);
|
|
169
|
-
const versions = await adapter.listRegistryVersions(namespace, name);
|
|
183
|
+
const versions = await adapter.listVersionsForRef(ref);
|
|
170
184
|
|
|
171
185
|
const matches = versions.filter((v) => v.startsWith(partialVersion));
|
|
172
186
|
return matches.map((version, idx) => {
|
|
173
|
-
const id = `${
|
|
187
|
+
const id = `${ref}@${version}`;
|
|
188
|
+
// The ref is already typed and visible on the line, so the label is just the
|
|
189
|
+
// version — no point repeating the full ref on every row. `insertText` /
|
|
190
|
+
// `filterText` stay the full id so acceptance replaces the whole value and
|
|
191
|
+
// the already-typed ref prefix keeps the item in the filtered set.
|
|
174
192
|
return {
|
|
175
|
-
label:
|
|
193
|
+
label: version,
|
|
176
194
|
kind: "value",
|
|
177
|
-
detail: idx === 0 ? "latest" :
|
|
195
|
+
detail: idx === 0 ? "latest" : undefined,
|
|
178
196
|
insertText: id,
|
|
179
197
|
filterText: id,
|
|
180
|
-
|
|
181
|
-
// Preserve
|
|
198
|
+
replaceRange,
|
|
199
|
+
// Preserve the hub's ordering (newest first) so the latest version is
|
|
182
200
|
// suggested at the top regardless of lexical comparison.
|
|
183
201
|
sortText: String(idx).padStart(4, "0"),
|
|
184
202
|
};
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildLineOffsets,
|
|
3
|
+
offsetToPosition,
|
|
4
|
+
type AstDocument,
|
|
5
|
+
type AstMap,
|
|
6
|
+
type AstNode,
|
|
7
|
+
type AstScalar,
|
|
8
|
+
type CelSegment,
|
|
9
|
+
type Position,
|
|
10
|
+
} from "@telorun/analyzer";
|
|
11
|
+
|
|
12
|
+
/** The cursor's resolved position against the read-only AST. `locate` produces
|
|
13
|
+
* this; `detect-context` maps it onto a `CompletionCtx`. Structure comes from
|
|
14
|
+
* the AST; the cursor *column* is used only to resolve empty-space (blank /
|
|
15
|
+
* trailing-indent) key positions, where indentation is the sole signal for
|
|
16
|
+
* "which container am I typing into". */
|
|
17
|
+
export interface ResolvedCursor {
|
|
18
|
+
docIndex: number;
|
|
19
|
+
/** Top-level `kind:` value of the cursor's document, when present. */
|
|
20
|
+
docKind?: string;
|
|
21
|
+
slot: "key" | "value";
|
|
22
|
+
/** Value slot: ancestor keys + the field key (last element is the field).
|
|
23
|
+
* Key slot: the container map's ancestor key chain. */
|
|
24
|
+
path: string[];
|
|
25
|
+
node?: AstNode;
|
|
26
|
+
container?: AstMap;
|
|
27
|
+
replaceRange?: { start: Position; end: Position };
|
|
28
|
+
/** Value slot: text from the value start up to the cursor. */
|
|
29
|
+
prefix?: string;
|
|
30
|
+
/** Value slot: true when whitespace separates the key's colon from the value
|
|
31
|
+
* (distinguishes `Console: ` — a value — from `Tiny:` — a bare header). */
|
|
32
|
+
spaceAfterColon?: boolean;
|
|
33
|
+
/** Value slot: the value of a sibling `kind:` in the same map (object-form
|
|
34
|
+
* ref name completion). */
|
|
35
|
+
siblingKind?: string;
|
|
36
|
+
/** Key slot: keys already present in the container (the key under the cursor
|
|
37
|
+
* excluded so it still suggests itself). */
|
|
38
|
+
existingKeys?: Set<string>;
|
|
39
|
+
/** Key slot: the kind of the nearest enclosing inline resource (or the root
|
|
40
|
+
* resource), whose schema the prop keys are completed against. Falls back to
|
|
41
|
+
* the document kind at the root. */
|
|
42
|
+
resourceKind?: string;
|
|
43
|
+
/** Key slot: number of `path` segments that reach `resourceKind`'s map, so
|
|
44
|
+
* the schema-relative path is `path.slice(resourceDepth)`. */
|
|
45
|
+
resourceDepth?: number;
|
|
46
|
+
/** Set when the cursor sits inside a CEL body (closed or open). Populated for
|
|
47
|
+
* a future CEL-completion feature; this refactor does not consume it. */
|
|
48
|
+
cel?: { segment: CelSegment; offset: number };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function within(range: [number, number], offset: number): boolean {
|
|
52
|
+
return offset >= range[0] && offset <= range[1];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function scalarString(node: AstNode | undefined): string | undefined {
|
|
56
|
+
if (node?.kind === "scalar" && typeof node.value === "string") return node.value;
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The `kind:` value of a document's root map, if any. */
|
|
61
|
+
function docKindOf(doc: AstDocument): string | undefined {
|
|
62
|
+
if (doc.root?.kind !== "map") return undefined;
|
|
63
|
+
for (const pair of doc.root.entries) {
|
|
64
|
+
if (scalarString(pair.key) === "kind") return scalarString(pair.value);
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Value of a sibling `kind:` entry in `map`, for object-form ref detection. */
|
|
70
|
+
function siblingKindOf(map: AstMap): string | undefined {
|
|
71
|
+
for (const pair of map.entries) {
|
|
72
|
+
if (scalarString(pair.key) === "kind") return scalarString(pair.value);
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The map's `kind:` value when it names a resource kind (`Alias.Kind`), i.e.
|
|
78
|
+
* the map is an inline resource. A prop-key position inside such a map is
|
|
79
|
+
* completed against *this* kind's schema, not the outer ref slot's. */
|
|
80
|
+
function resourceKindOf(map: AstMap): string | undefined {
|
|
81
|
+
const kind = siblingKindOf(map);
|
|
82
|
+
return kind && /^\w+\.\w+/.test(kind) ? kind : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The kind + path-depth of the nearest enclosing inline resource (or the root
|
|
86
|
+
* resource). `depth` is the number of `path` segments consumed to reach that
|
|
87
|
+
* map, so a prop-key `yamlPath` relative to it is `path.slice(depth)`. */
|
|
88
|
+
interface ResourceScope {
|
|
89
|
+
kind?: string;
|
|
90
|
+
depth: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function enter(scope: ResourceScope, map: AstMap, ancestorsLen: number): ResourceScope {
|
|
94
|
+
const kind = resourceKindOf(map);
|
|
95
|
+
return kind ? { kind, depth: ancestorsLen } : scope;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Containment descent — used for the cursor sitting ON a real node.
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
type Descent =
|
|
103
|
+
| {
|
|
104
|
+
type: "key";
|
|
105
|
+
container: AstMap;
|
|
106
|
+
path: string[];
|
|
107
|
+
keyNode: AstScalar;
|
|
108
|
+
keyName?: string;
|
|
109
|
+
scope: ResourceScope;
|
|
110
|
+
}
|
|
111
|
+
| {
|
|
112
|
+
type: "value";
|
|
113
|
+
/** The enclosing map, or `undefined` for a bare scalar sequence item
|
|
114
|
+
* (which has no keyed siblings). */
|
|
115
|
+
container: AstMap | undefined;
|
|
116
|
+
path: string[];
|
|
117
|
+
keyName?: string;
|
|
118
|
+
keyEnd: number;
|
|
119
|
+
valueNode: AstNode;
|
|
120
|
+
}
|
|
121
|
+
| { type: "empty" };
|
|
122
|
+
|
|
123
|
+
function descend(
|
|
124
|
+
node: AstNode,
|
|
125
|
+
ancestors: string[],
|
|
126
|
+
offset: number,
|
|
127
|
+
scope: ResourceScope,
|
|
128
|
+
): Descent | undefined {
|
|
129
|
+
if (node.kind === "map") {
|
|
130
|
+
const mapScope = enter(scope, node, ancestors.length);
|
|
131
|
+
for (const pair of node.entries) {
|
|
132
|
+
const keyName = scalarString(pair.key);
|
|
133
|
+
if (within(pair.key.range, offset)) {
|
|
134
|
+
return {
|
|
135
|
+
type: "key",
|
|
136
|
+
container: node,
|
|
137
|
+
path: ancestors,
|
|
138
|
+
keyNode: pair.key as AstScalar,
|
|
139
|
+
keyName,
|
|
140
|
+
scope: mapScope,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (pair.value && within(pair.value.range, offset)) {
|
|
144
|
+
const childAncestors = keyName != null ? [...ancestors, keyName] : ancestors;
|
|
145
|
+
if (pair.value.kind === "map" || pair.value.kind === "seq") {
|
|
146
|
+
return descend(pair.value, childAncestors, offset, mapScope) ?? { type: "empty" };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
type: "value",
|
|
150
|
+
container: node,
|
|
151
|
+
path: ancestors,
|
|
152
|
+
keyName,
|
|
153
|
+
keyEnd: pair.key.range[1],
|
|
154
|
+
valueNode: pair.value,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
if (node.kind === "seq") {
|
|
161
|
+
// Sequence items are transparent to the key path (mirrors the schema
|
|
162
|
+
// walker, which auto-descends arrays).
|
|
163
|
+
for (const item of node.items) {
|
|
164
|
+
if (within(item.range, offset)) {
|
|
165
|
+
if (item.kind === "map" || item.kind === "seq") {
|
|
166
|
+
return descend(item, ancestors, offset, scope) ?? { type: "empty" };
|
|
167
|
+
}
|
|
168
|
+
// A bare scalar list item (`targets:\n - One`) has no enclosing map of
|
|
169
|
+
// keyed siblings — leave `container` undefined rather than treating the
|
|
170
|
+
// seq as a map.
|
|
171
|
+
return { type: "value", container: undefined, path: ancestors, keyEnd: item.range[0], valueNode: item };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Column search — used for empty-space (blank / trailing-indent) key positions.
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
interface MapScope {
|
|
184
|
+
path: string[];
|
|
185
|
+
childColumn: number;
|
|
186
|
+
keys: Set<string>;
|
|
187
|
+
rangeStart: number;
|
|
188
|
+
scope: ResourceScope;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface PairScope {
|
|
192
|
+
path: string[]; // full key path to this pair
|
|
193
|
+
keyColumn: number;
|
|
194
|
+
keyOffset: number;
|
|
195
|
+
childKeys: Set<string>;
|
|
196
|
+
scope: ResourceScope;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function collectScopes(
|
|
200
|
+
node: AstNode,
|
|
201
|
+
ancestors: string[],
|
|
202
|
+
scope: ResourceScope,
|
|
203
|
+
lineOffsets: number[],
|
|
204
|
+
maps: MapScope[],
|
|
205
|
+
pairs: PairScope[],
|
|
206
|
+
): void {
|
|
207
|
+
if (node.kind === "map") {
|
|
208
|
+
const mapScope = enter(scope, node, ancestors.length);
|
|
209
|
+
const keys = new Set<string>();
|
|
210
|
+
let childColumn = -1;
|
|
211
|
+
for (const pair of node.entries) {
|
|
212
|
+
const keyName = scalarString(pair.key);
|
|
213
|
+
if (keyName != null) keys.add(keyName);
|
|
214
|
+
if (childColumn < 0) childColumn = offsetToPosition(pair.key.range[0], lineOffsets).character;
|
|
215
|
+
}
|
|
216
|
+
if (childColumn >= 0) {
|
|
217
|
+
maps.push({ path: ancestors, childColumn, keys, rangeStart: node.range[0], scope: mapScope });
|
|
218
|
+
}
|
|
219
|
+
for (const pair of node.entries) {
|
|
220
|
+
const keyName = scalarString(pair.key);
|
|
221
|
+
const fullPath = keyName != null ? [...ancestors, keyName] : ancestors;
|
|
222
|
+
const childKeys = new Set<string>();
|
|
223
|
+
if (pair.value?.kind === "map") {
|
|
224
|
+
for (const p of pair.value.entries) {
|
|
225
|
+
const k = scalarString(p.key);
|
|
226
|
+
if (k != null) childKeys.add(k);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
pairs.push({
|
|
230
|
+
path: fullPath,
|
|
231
|
+
keyColumn: offsetToPosition(pair.key.range[0], lineOffsets).character,
|
|
232
|
+
keyOffset: pair.key.range[0],
|
|
233
|
+
childKeys,
|
|
234
|
+
scope: mapScope,
|
|
235
|
+
});
|
|
236
|
+
if (pair.value) collectScopes(pair.value, fullPath, mapScope, lineOffsets, maps, pairs);
|
|
237
|
+
}
|
|
238
|
+
} else if (node.kind === "seq") {
|
|
239
|
+
for (const item of node.items) collectScopes(item, ancestors, scope, lineOffsets, maps, pairs);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
interface KeyResolution {
|
|
244
|
+
path: string[];
|
|
245
|
+
existingKeys: Set<string>;
|
|
246
|
+
scope: ResourceScope;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Resolve the container a new key at `cursorColumn` belongs to. Prefers an
|
|
250
|
+
* existing sibling level (a map whose child keys sit at exactly `cursorColumn`);
|
|
251
|
+
* otherwise nests under the nearest-preceding shallower key. */
|
|
252
|
+
function columnSearch(
|
|
253
|
+
root: AstNode,
|
|
254
|
+
cursorColumn: number,
|
|
255
|
+
cursorOffset: number,
|
|
256
|
+
lineOffsets: number[],
|
|
257
|
+
): KeyResolution {
|
|
258
|
+
const maps: MapScope[] = [];
|
|
259
|
+
const pairs: PairScope[] = [];
|
|
260
|
+
collectScopes(root, [], { depth: 0 }, lineOffsets, maps, pairs);
|
|
261
|
+
|
|
262
|
+
// Sibling level: a map whose children already sit at the cursor's column.
|
|
263
|
+
let sibling: MapScope | undefined;
|
|
264
|
+
for (const m of maps) {
|
|
265
|
+
if (m.childColumn === cursorColumn && m.rangeStart < cursorOffset) {
|
|
266
|
+
if (!sibling || m.rangeStart > sibling.rangeStart) sibling = m;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (sibling) return { path: sibling.path, existingKeys: sibling.keys, scope: sibling.scope };
|
|
270
|
+
|
|
271
|
+
// Nest under the nearest-preceding key shallower than the cursor.
|
|
272
|
+
let nest: PairScope | undefined;
|
|
273
|
+
for (const p of pairs) {
|
|
274
|
+
if (p.keyColumn < cursorColumn && p.keyOffset < cursorOffset) {
|
|
275
|
+
if (
|
|
276
|
+
!nest ||
|
|
277
|
+
p.keyColumn > nest.keyColumn ||
|
|
278
|
+
(p.keyColumn === nest.keyColumn && p.keyOffset > nest.keyOffset)
|
|
279
|
+
) {
|
|
280
|
+
nest = p;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (nest) return { path: nest.path, existingKeys: nest.childKeys, scope: nest.scope };
|
|
285
|
+
|
|
286
|
+
return { path: [], existingKeys: new Set(), scope: { depth: 0 } };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
function selectDoc(docs: AstDocument[], offset: number): number {
|
|
292
|
+
let best = -1;
|
|
293
|
+
for (let i = 0; i < docs.length; i++) {
|
|
294
|
+
if (docs[i].range[0] <= offset) best = i;
|
|
295
|
+
}
|
|
296
|
+
return best < 0 ? (docs.length > 0 ? 0 : -1) : best;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function celAt(node: AstScalar, offset: number): ResolvedCursor["cel"] {
|
|
300
|
+
for (const segment of node.celSegments()) {
|
|
301
|
+
if (offset >= segment.range[0] && offset <= segment.range[1]) return { segment, offset };
|
|
302
|
+
}
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Resolve `(line, character)` against the AST (Approach B: AST for structure,
|
|
307
|
+
* cursor column only to place empty-space key positions). */
|
|
308
|
+
export function resolveNodeAtPosition(
|
|
309
|
+
text: string,
|
|
310
|
+
docs: AstDocument[],
|
|
311
|
+
line: number,
|
|
312
|
+
character: number,
|
|
313
|
+
): ResolvedCursor | undefined {
|
|
314
|
+
if (docs.length === 0) return undefined;
|
|
315
|
+
const lineOffsets = buildLineOffsets(text);
|
|
316
|
+
const offset = (lineOffsets[line] ?? 0) + character;
|
|
317
|
+
const toPos = (o: number): Position => offsetToPosition(o, lineOffsets);
|
|
318
|
+
|
|
319
|
+
const docIndex = selectDoc(docs, offset);
|
|
320
|
+
if (docIndex < 0) return undefined;
|
|
321
|
+
const doc = docs[docIndex];
|
|
322
|
+
const docKind = docKindOf(doc);
|
|
323
|
+
|
|
324
|
+
const found = doc.root ? descend(doc.root, [], offset, { depth: 0 }) : undefined;
|
|
325
|
+
|
|
326
|
+
// Cursor sits on an existing map key → key/prop-key position.
|
|
327
|
+
if (found?.type === "key") {
|
|
328
|
+
const existingKeys = new Set<string>();
|
|
329
|
+
for (const pair of found.container.entries) {
|
|
330
|
+
const k = scalarString(pair.key);
|
|
331
|
+
if (k != null && k !== found.keyName) existingKeys.add(k);
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
docIndex,
|
|
335
|
+
docKind,
|
|
336
|
+
slot: "key",
|
|
337
|
+
path: found.path,
|
|
338
|
+
node: found.keyNode,
|
|
339
|
+
replaceRange: { start: toPos(found.keyNode.range[0]), end: toPos(found.keyNode.range[1]) },
|
|
340
|
+
container: found.container,
|
|
341
|
+
existingKeys,
|
|
342
|
+
resourceKind: found.scope.kind,
|
|
343
|
+
resourceDepth: found.scope.depth,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Cursor sits on a scalar value.
|
|
348
|
+
if (found?.type === "value" && found.valueNode.kind === "scalar") {
|
|
349
|
+
const value = found.valueNode;
|
|
350
|
+
const cel = celAt(value, offset);
|
|
351
|
+
// A bare identifier on its own line with no colon is a partial *key* being
|
|
352
|
+
// typed as a first child (yaml parses it as the parent's value). Route to a
|
|
353
|
+
// key position via column search — the documented cursor-line carve-out.
|
|
354
|
+
const lineText = text.slice(lineOffsets[line] ?? 0, lineOffsets[line + 1] ?? text.length);
|
|
355
|
+
const isPartialKey =
|
|
356
|
+
typeof value.value === "string" &&
|
|
357
|
+
!lineText.includes(":") &&
|
|
358
|
+
/^\s*[A-Za-z_][\w-]*\s*$/.test(lineText) &&
|
|
359
|
+
toPos(value.range[0]).line !== toPos(found.keyEnd).line;
|
|
360
|
+
if (isPartialKey && doc.root) {
|
|
361
|
+
const col = toPos(value.range[0]).character;
|
|
362
|
+
const { path, existingKeys, scope } = columnSearch(doc.root, col, offset, lineOffsets);
|
|
363
|
+
return {
|
|
364
|
+
docIndex,
|
|
365
|
+
docKind,
|
|
366
|
+
slot: "key",
|
|
367
|
+
path,
|
|
368
|
+
container: found.container,
|
|
369
|
+
existingKeys,
|
|
370
|
+
resourceKind: scope.kind,
|
|
371
|
+
resourceDepth: scope.depth,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const clampedEnd = Math.min(offset, value.range[1]);
|
|
376
|
+
return {
|
|
377
|
+
docIndex,
|
|
378
|
+
docKind,
|
|
379
|
+
slot: "value",
|
|
380
|
+
path: found.keyName != null ? [...found.path, found.keyName] : found.path,
|
|
381
|
+
node: value,
|
|
382
|
+
container: found.container,
|
|
383
|
+
prefix: text.slice(value.range[0], clampedEnd),
|
|
384
|
+
spaceAfterColon: value.range[0] - found.keyEnd >= 2,
|
|
385
|
+
siblingKind: found.container ? siblingKindOf(found.container) : undefined,
|
|
386
|
+
replaceRange: { start: toPos(value.range[0]), end: toPos(value.range[1]) },
|
|
387
|
+
cel,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Empty space (blank line, trailing indent, empty document) → key position,
|
|
392
|
+
// resolved by cursor column.
|
|
393
|
+
const resolution: KeyResolution = doc.root
|
|
394
|
+
? columnSearch(doc.root, character, offset, lineOffsets)
|
|
395
|
+
: { path: [], existingKeys: new Set<string>(), scope: { depth: 0 } };
|
|
396
|
+
return {
|
|
397
|
+
docIndex,
|
|
398
|
+
docKind,
|
|
399
|
+
slot: "key",
|
|
400
|
+
path: resolution.path,
|
|
401
|
+
existingKeys: resolution.existingKeys,
|
|
402
|
+
resourceKind: resolution.scope.kind,
|
|
403
|
+
resourceDepth: resolution.scope.depth,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
@@ -6,3 +6,14 @@ export const CAPABILITY_VALUES = [
|
|
|
6
6
|
"Telo.Mount",
|
|
7
7
|
"Telo.Type",
|
|
8
8
|
] as const;
|
|
9
|
+
|
|
10
|
+
/** One-line role summary per capability, surfaced on hover. Kept in sync with
|
|
11
|
+
* the capability list in `CLAUDE.md` / the kernel builtins. */
|
|
12
|
+
export const CAPABILITY_DOCS: Record<string, string> = {
|
|
13
|
+
"Telo.Service": "Long-lived resource: `init()` + optional `teardown()` (servers, pools).",
|
|
14
|
+
"Telo.Runnable": "One-shot task: `run()` (pipelines, boot steps).",
|
|
15
|
+
"Telo.Invocable": "Request handler: `invoke(inputs)` (scripts, endpoints).",
|
|
16
|
+
"Telo.Provider": "Value-flow source: `init()` + optional `provide()` (config, secrets).",
|
|
17
|
+
"Telo.Mount": "Mounted into a Service (HTTP APIs, middleware).",
|
|
18
|
+
"Telo.Type": "Pure schema definition — no runtime instance.",
|
|
19
|
+
};
|