@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.
Files changed (53) hide show
  1. package/dist/completions/build.d.ts +2 -2
  2. package/dist/completions/build.d.ts.map +1 -1
  3. package/dist/completions/build.js +39 -64
  4. package/dist/completions/detect-context.d.ts +16 -43
  5. package/dist/completions/detect-context.d.ts.map +1 -1
  6. package/dist/completions/detect-context.js +63 -262
  7. package/dist/completions/import-source.d.ts +12 -8
  8. package/dist/completions/import-source.d.ts.map +1 -1
  9. package/dist/completions/import-source.js +67 -49
  10. package/dist/completions/resolve-node.d.ts +50 -0
  11. package/dist/completions/resolve-node.d.ts.map +1 -0
  12. package/dist/completions/resolve-node.js +269 -0
  13. package/dist/completions/valid-capabilities.d.ts +3 -0
  14. package/dist/completions/valid-capabilities.d.ts.map +1 -1
  15. package/dist/completions/valid-capabilities.js +10 -0
  16. package/dist/definition/build-definition.d.ts +13 -0
  17. package/dist/definition/build-definition.d.ts.map +1 -0
  18. package/dist/definition/build-definition.js +98 -0
  19. package/dist/definition/index.d.ts +2 -0
  20. package/dist/definition/index.d.ts.map +1 -0
  21. package/dist/definition/index.js +1 -0
  22. package/dist/hover/build-hover.d.ts +4 -0
  23. package/dist/hover/build-hover.d.ts.map +1 -0
  24. package/dist/hover/build-hover.js +125 -0
  25. package/dist/hover/index.d.ts +2 -0
  26. package/dist/hover/index.d.ts.map +1 -0
  27. package/dist/hover/index.js +1 -0
  28. package/dist/index.d.ts +3 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +3 -0
  31. package/dist/semantic-tokens/build-semantic-tokens.d.ts +12 -0
  32. package/dist/semantic-tokens/build-semantic-tokens.d.ts.map +1 -0
  33. package/dist/semantic-tokens/build-semantic-tokens.js +55 -0
  34. package/dist/semantic-tokens/index.d.ts +2 -0
  35. package/dist/semantic-tokens/index.d.ts.map +1 -0
  36. package/dist/semantic-tokens/index.js +1 -0
  37. package/dist/types.d.ts +66 -18
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js +3 -0
  40. package/package.json +2 -2
  41. package/src/completions/build.ts +40 -72
  42. package/src/completions/detect-context.ts +74 -291
  43. package/src/completions/import-source.ts +71 -53
  44. package/src/completions/resolve-node.ts +405 -0
  45. package/src/completions/valid-capabilities.ts +11 -0
  46. package/src/definition/build-definition.ts +123 -0
  47. package/src/definition/index.ts +1 -0
  48. package/src/hover/build-hover.ts +149 -0
  49. package/src/hover/index.ts +1 -0
  50. package/src/index.ts +3 -0
  51. package/src/semantic-tokens/build-semantic-tokens.ts +64 -0
  52. package/src/semantic-tokens/index.ts +1 -0
  53. 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 registry hits to surface in a single completion request.
4
- * Keeps the popover scannable when a broad `q=` query matches the catalog. */
5
- const REGISTRY_LIMIT = 50;
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>" registry search by free-text.
21
- * "<ns>/<name>@<partial>" → version list for that module.
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
- * `valueStartColumn` is forwarded onto every result so the host can replace
25
- * the whole typed value, not just the trailing word (Monaco / VSCode word
26
- * boundaries don't cross `/` or `@`).
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
- valueStartColumn: number,
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, valueStartColumn, adapter);
51
+ return relativePathCompletions(prefix, replaceRange, adapter);
47
52
  }
48
53
 
49
- const atIdx = prefix.indexOf("@");
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, valueStartColumn, adapter);
58
+ return versionCompletions(prefix, atIdx, replaceRange, adapter);
52
59
  }
53
60
 
54
- return registrySearchCompletions(prefix, valueStartColumn, adapter);
61
+ return refSearchCompletions(prefix, replaceRange, adapter);
55
62
  }
56
63
 
57
64
  async function relativePathCompletions(
58
65
  prefix: string,
59
- valueStartColumn: number,
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
- replaceFromColumn: valueStartColumn,
79
+ replaceRange,
73
80
  },
74
81
  {
75
82
  label: "../",
76
83
  kind: "folder",
77
84
  insertText: "../",
78
85
  sortText: "0_../",
79
- replaceFromColumn: valueStartColumn,
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
- replaceFromColumn: valueStartColumn,
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 registrySearchCompletions(
130
+ async function refSearchCompletions(
124
131
  prefix: string,
125
- valueStartColumn: number,
132
+ replaceRange: ReplaceRange,
126
133
  adapter: IdeEnvironmentAdapter,
127
134
  ): Promise<CompletionResult[]> {
128
- // The registry's `q` filter ILIKEs against name / namespace / description
129
- // it doesn't know about the `<namespace>/<name>` shape. Once the user has
130
- // typed a `/`, sending the literal `std/htt` as `q` matches nothing because
131
- // the slash is not in any of those columns. Split here so `q` carries just
132
- // the bit that looks like a name, and apply the namespace constraint
133
- // client-side.
134
- const slashIdx = prefix.indexOf("/");
135
- const namespacePart = slashIdx >= 0 ? prefix.slice(0, slashIdx) : "";
136
- const namePart = slashIdx >= 0 ? prefix.slice(slashIdx + 1) : prefix;
137
-
138
- const hits = await adapter.searchRegistry(namePart);
139
- const filtered = namespacePart
140
- ? hits.filter((h) => h.namespace.startsWith(namespacePart))
141
- : hits;
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: id,
151
+ label: m.latestVersion ? `${name}@${m.latestVersion}` : name,
147
152
  kind: "module",
148
- detail: m.description ?? "registry module",
153
+ detail: m.description ?? m.ref,
154
+ documentation: m.description ? m.ref : undefined,
149
155
  insertText: id,
150
156
  filterText: id,
151
- replaceFromColumn: valueStartColumn,
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
- valueStartColumn: number,
176
+ replaceRange: ReplaceRange,
160
177
  adapter: IdeEnvironmentAdapter,
161
178
  ): Promise<CompletionResult[]> {
162
- const beforeAt = prefix.slice(0, atIdx);
179
+ const ref = prefix.slice(0, atIdx);
163
180
  const partialVersion = prefix.slice(atIdx + 1);
164
- const slashIdx = beforeAt.indexOf("/");
165
- if (slashIdx <= 0 || slashIdx === beforeAt.length - 1) return [];
181
+ if (ref === "") return [];
166
182
 
167
- const namespace = beforeAt.slice(0, slashIdx);
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 = `${namespace}/${name}@${version}`;
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: id,
193
+ label: version,
176
194
  kind: "value",
177
- detail: idx === 0 ? "latest" : `v${version}`,
195
+ detail: idx === 0 ? "latest" : undefined,
178
196
  insertText: id,
179
197
  filterText: id,
180
- replaceFromColumn: valueStartColumn,
181
- // Preserve registry's ordering (newest first) so the latest version is
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
+ };