@telorun/ide-support 0.4.2 → 0.4.5

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.
@@ -1,21 +1,160 @@
1
1
  import type { AnalysisRegistry } from "@telorun/analyzer";
2
2
  import type { CompletionResult, IdeEnvironmentAdapter } from "../types.js";
3
- import { detectContext } from "./detect-context.js";
3
+ import { detectContext, lookupRefConstraint } from "./detect-context.js";
4
4
  import { importSourceCompletions } from "./import-source.js";
5
5
  import { propKeyCompletions } from "./prop-keys.js";
6
6
  import { CAPABILITY_VALUES } from "./valid-capabilities.js";
7
7
 
8
- function kindCompletions(registry: AnalysisRegistry | undefined): CompletionResult[] {
9
- const kinds = new Set<string>(
10
- registry
11
- ? registry.validUserFacingKinds()
12
- : ["Telo.Application", "Telo.Library", "Telo.Import", "Telo.Definition"],
8
+ interface ResourceRecord {
9
+ kind: string;
10
+ name: string;
11
+ }
12
+
13
+ /** Roughly extract `(kind, metadata.name)` pairs from a multi-doc YAML text.
14
+ * This is intentionally lightweight: it scans for top-level `kind:` and the
15
+ * first `name:` under a `metadata:` block per `---`-separated section, with
16
+ * no full YAML parse. The output is consumed only for completion ranking,
17
+ * so misses on edge-case manifests are acceptable; the analyzer remains
18
+ * the source of truth for validation. */
19
+ function extractInFileResources(text: string): ResourceRecord[] {
20
+ const out: ResourceRecord[] = [];
21
+ const lines = text.split("\n");
22
+ let currentKind: string | undefined;
23
+ let currentName: string | undefined;
24
+ let inMetadata = false;
25
+
26
+ const flush = () => {
27
+ if (currentKind && currentName) {
28
+ out.push({ kind: currentKind, name: currentName });
29
+ }
30
+ currentKind = undefined;
31
+ currentName = undefined;
32
+ inMetadata = false;
33
+ };
34
+
35
+ for (const line of lines) {
36
+ if (line.trimEnd() === "---") {
37
+ flush();
38
+ continue;
39
+ }
40
+ const kindMatch = line.match(/^kind:\s*(\S+)/);
41
+ if (kindMatch) {
42
+ currentKind = kindMatch[1];
43
+ continue;
44
+ }
45
+ if (/^metadata:\s*$/.test(line)) {
46
+ inMetadata = true;
47
+ continue;
48
+ }
49
+ if (inMetadata) {
50
+ // Lines inside metadata are indented. Pick the first `name:` we see.
51
+ const nameMatch = line.match(/^\s+name:\s*(\S+)/);
52
+ if (nameMatch && !currentName) {
53
+ currentName = nameMatch[1];
54
+ }
55
+ // Leaving the metadata block — any line that is not indented marks
56
+ // the end of the block.
57
+ if (line.length > 0 && !/^\s/.test(line)) {
58
+ inMetadata = false;
59
+ }
60
+ }
61
+ }
62
+ flush();
63
+ return out;
64
+ }
65
+
66
+ /** Returns the resource records whose kind satisfies the slot. When the
67
+ * slot has a registry-resolvable `x-telo-ref` constraint, results are
68
+ * filtered to that abstract's implementations; otherwise (or when the
69
+ * user already typed a sibling `kind:`) they're filtered by an exact
70
+ * kind match. Falls back to listing every in-file resource so the
71
+ * user still sees something rather than nothing when the registry
72
+ * doesn't recognize the kind yet. */
73
+ function refNameCompletions(
74
+ text: string,
75
+ refKind: string | undefined,
76
+ refConstraint: string | undefined,
77
+ registry: AnalysisRegistry | undefined,
78
+ valueStartColumn: number,
79
+ ): CompletionResult[] {
80
+ const resources = extractInFileResources(text);
81
+ let acceptable: Set<string> | undefined;
82
+
83
+ if (refKind) {
84
+ acceptable = new Set([refKind]);
85
+ } else if (refConstraint && registry) {
86
+ const kinds = registry.userFacingKindsForRef(refConstraint);
87
+ if (kinds) acceptable = new Set(kinds);
88
+ }
89
+
90
+ const seen = new Set<string>();
91
+ const out: CompletionResult[] = [];
92
+ for (const r of resources) {
93
+ if (acceptable && !acceptable.has(r.kind)) continue;
94
+ if (seen.has(r.name)) continue;
95
+ seen.add(r.name);
96
+ out.push({
97
+ label: r.name,
98
+ kind: "value",
99
+ detail: r.kind,
100
+ // Anchor the replace range to the value's start column so names with
101
+ // `.`, `-`, or `/` (legal in resource names) replace the whole typed
102
+ // prefix instead of the trailing word VS Code would pick by default.
103
+ replaceFromColumn: valueStartColumn,
104
+ });
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** Resolve the kinds that satisfy the `x-telo-ref` slot at `parentDocKind` +
110
+ * `parentYamlPath`. Returns `undefined` (caller falls back to the full list)
111
+ * when there's no constraint, the path doesn't resolve, or the ref can't
112
+ * be resolved through the registry. */
113
+ function refConstrainedKinds(
114
+ registry: AnalysisRegistry,
115
+ parentDocKind: string,
116
+ parentYamlPath: string[],
117
+ ): string[] | undefined {
118
+ const definition = registry.resolveDefinition(parentDocKind);
119
+ if (!definition?.schema) return undefined;
120
+ const refString = lookupRefConstraint(
121
+ definition.schema as Record<string, any>,
122
+ parentYamlPath,
13
123
  );
14
- return Array.from(kinds).map((kind) => ({
15
- label: kind,
16
- kind: "class",
17
- detail: "Telo resource kind",
18
- }));
124
+ if (!refString) return undefined;
125
+ return registry.userFacingKindsForRef(refString);
126
+ }
127
+
128
+ function kindCompletions(
129
+ registry: AnalysisRegistry | undefined,
130
+ docKind: string | undefined,
131
+ yamlPath: string[] | undefined,
132
+ valueStartColumn: number | undefined,
133
+ ): CompletionResult[] {
134
+ let kinds: Iterable<string>;
135
+ if (registry && docKind && yamlPath && yamlPath.length > 0) {
136
+ const filtered = refConstrainedKinds(registry, docKind, yamlPath);
137
+ kinds = filtered ?? registry.validUserFacingKinds();
138
+ } else if (registry) {
139
+ kinds = registry.validUserFacingKinds();
140
+ } else {
141
+ kinds = ["Telo.Application", "Telo.Library", "Telo.Import", "Telo.Definition"];
142
+ }
143
+ const seen = new Set<string>();
144
+ const results: CompletionResult[] = [];
145
+ for (const kind of kinds) {
146
+ if (seen.has(kind)) continue;
147
+ seen.add(kind);
148
+ const item: CompletionResult = { label: kind, kind: "class", detail: "Telo resource kind" };
149
+ // Anchor the replace range to the value's start column so kinds with `.`
150
+ // (e.g. `Sql.Connection`) cleanly overwrite the existing prefix. Without
151
+ // this, VS Code's default word boundary stops at the last `.` and a pick
152
+ // of `Sql.Connection` while the buffer reads `Sql.Co|` becomes
153
+ // `Sql.Sql.Connection`.
154
+ if (valueStartColumn !== undefined) item.replaceFromColumn = valueStartColumn;
155
+ results.push(item);
156
+ }
157
+ return results;
19
158
  }
20
159
 
21
160
  function capabilityCompletions(): CompletionResult[] {
@@ -35,8 +174,23 @@ export async function buildCompletions(
35
174
  ): Promise<CompletionResult[]> {
36
175
  const ctx = detectContext(text, line, character);
37
176
  if (!ctx) return [];
38
- if (ctx.type === "kind") return kindCompletions(registry);
177
+ if (ctx.type === "kind") {
178
+ return kindCompletions(registry, ctx.docKind, ctx.yamlPath, ctx.valueStartColumn);
179
+ }
39
180
  if (ctx.type === "capability") return capabilityCompletions();
181
+ if (ctx.type === "ref-name") {
182
+ const definition = registry?.resolveDefinition(ctx.docKind);
183
+ const refConstraint = definition?.schema
184
+ ? lookupRefConstraint(definition.schema as Record<string, any>, ctx.yamlPath)
185
+ : undefined;
186
+ return refNameCompletions(
187
+ text,
188
+ ctx.refKind,
189
+ refConstraint,
190
+ registry,
191
+ ctx.valueStartColumn,
192
+ );
193
+ }
40
194
  if (ctx.type === "field-value") {
41
195
  if (ctx.docKind === "Telo.Import" && ctx.field === "source") {
42
196
  return importSourceCompletions(ctx.prefix, ctx.valueStartColumn, adapter);
@@ -1,7 +1,36 @@
1
1
  export type CompletionCtx =
2
- | { type: "kind" }
2
+ | {
3
+ type: "kind";
4
+ /** Set for indented `kind:` lines. The enclosing docKind + the YAML
5
+ * path to the parent of the `kind:` field (so the value slot's
6
+ * schema node can be looked up to discover `x-telo-ref` constraints).
7
+ * Absent for top-level `kind:` — there, no constraint applies. */
8
+ docKind?: string;
9
+ yamlPath?: string[];
10
+ /** Column where the kind value begins (after `kind:` + whitespace).
11
+ * Editor hosts use this to anchor the replace range so completions
12
+ * cleanly overwrite a kind that contains `.` (e.g. `Sql.Co|` →
13
+ * selecting `Sql.Connection` must replace `Sql.Co`, not just `Co`,
14
+ * which VS Code's default word range would). */
15
+ valueStartColumn: number;
16
+ }
3
17
  | { type: "capability" }
4
18
  | { type: "prop-key"; docKind: string; yamlPath: string[]; existingKeys: Set<string> }
19
+ | {
20
+ /** Cursor sits on the value of an object-form ref's `name:` field
21
+ * (e.g. `connection: { kind: Sql.Connection, name: |}`). Editor hosts
22
+ * use `refKind` (from the sibling `kind:` line) to filter the in-doc
23
+ * resource list to matching candidates. */
24
+ type: "ref-name";
25
+ docKind: string;
26
+ /** YAML path to the parent slot (e.g. `["connection"]`). The schema
27
+ * at this path declares the `x-telo-ref` constraint. */
28
+ yamlPath: string[];
29
+ /** The kind value of the sibling `kind:` line, if present. */
30
+ refKind?: string;
31
+ prefix: string;
32
+ valueStartColumn: number;
33
+ }
5
34
  | {
6
35
  type: "field-value";
7
36
  docKind: string;
@@ -38,16 +67,42 @@ export function extractKindFromDoc(lines: string[], start: number, end: number):
38
67
  return undefined;
39
68
  }
40
69
 
41
- export function extractRootKeys(lines: string[], start: number, end: number): Set<string> {
70
+ /** Collects every top-level key in the doc bounds, skipping `skipLine` so the
71
+ * cursor's own line is treated as "being edited" — its key (if any) stays in
72
+ * the suggestion list. Without this the user can't autocomplete an existing
73
+ * key from its own line (e.g. `ver|sion:`). */
74
+ export function extractRootKeys(
75
+ lines: string[],
76
+ start: number,
77
+ end: number,
78
+ skipLine?: number,
79
+ ): Set<string> {
42
80
  const keys = new Set<string>();
43
81
  for (let i = start; i < end; i++) {
82
+ if (i === skipLine) continue;
44
83
  const m = lines[i]?.match(/^([a-zA-Z_][a-zA-Z0-9_]*):/);
45
84
  if (m) keys.add(m[1]);
46
85
  }
47
86
  return keys;
48
87
  }
49
88
 
50
- /** Walk backward from cursorLine to build the chain of parent YAML keys. */
89
+ /** Walk backward from cursorLine to build the chain of parent YAML keys.
90
+ *
91
+ * List-item handling (` - request:` style): the `-` marker sits at the
92
+ * line's textual indent, but the key after it (`request`) lives at indent
93
+ * `+2`. Whether that post-dash key joins the path depends on the cursor's
94
+ * descent:
95
+ * - When the cursor's current target indent is GREATER than the post-dash
96
+ * key's column, the descent passes through that key (e.g. cursor inside
97
+ * `request.method` at indent 6, key `request` at column 4) → push it.
98
+ * - When the cursor's current target indent EQUALS the post-dash key's
99
+ * column, the post-dash key is a sibling at the list-item level
100
+ * (e.g. cursor on `handler:` at indent 4, key `request:` at column 4) →
101
+ * skip it; descend straight to the array's parent.
102
+ *
103
+ * In both cases the next walk step targets `lineIndent` so the `routes:` /
104
+ * `steps:` parent of the array is captured. The schema walker auto-descends
105
+ * arrays, so no `[]` marker is appended. */
51
106
  export function buildYamlPath(
52
107
  lines: string[],
53
108
  cursorLine: number,
@@ -72,8 +127,18 @@ export function buildYamlPath(
72
127
  path.unshift(m[1]);
73
128
  targetIndent = lineIndent;
74
129
  if (lineIndent === 0) break;
130
+ } else if (trimmed.startsWith("- ")) {
131
+ const postDash = trimmed.slice(2);
132
+ const km = postDash.match(/^([a-zA-Z_][a-zA-Z0-9_]*):/);
133
+ const keyColumn = lineIndent + 2;
134
+ if (km && keyColumn < targetIndent) {
135
+ path.unshift(km[1]);
136
+ }
137
+ targetIndent = lineIndent;
138
+ } else if (trimmed === "-") {
139
+ targetIndent = lineIndent;
75
140
  } else {
76
- // Hit something we can't parse (e.g. a list item `- ...`); stop
141
+ // Hit something we can't parse; stop
77
142
  break;
78
143
  }
79
144
  }
@@ -82,16 +147,20 @@ export function buildYamlPath(
82
147
  return path;
83
148
  }
84
149
 
85
- /** Extract sibling keys already present at `indent` within the doc bounds. */
150
+ /** Extract sibling keys already present at `indent` within the doc bounds.
151
+ * `skipLine` lets the caller exclude the cursor's own line so a key being
152
+ * edited (`ver|sion:`) doesn't filter itself out of the suggestion list. */
86
153
  export function extractKeysAtIndent(
87
154
  lines: string[],
88
155
  start: number,
89
156
  end: number,
90
157
  indent: number,
158
+ skipLine?: number,
91
159
  ): Set<string> {
92
160
  const keys = new Set<string>();
93
161
  const prefix = " ".repeat(indent);
94
162
  for (let i = start; i < end; i++) {
163
+ if (i === skipLine) continue;
95
164
  const line = lines[i] ?? "";
96
165
  if (!line.startsWith(prefix)) continue;
97
166
  const rest = line.slice(indent);
@@ -103,49 +172,152 @@ export function extractKeysAtIndent(
103
172
  return keys;
104
173
  }
105
174
 
106
- /** Navigate a JSON Schema hierarchy following `path`, auto-descending into array items. */
175
+ /** Returns every schema branch reachable from `node` after peeling `anyOf` /
176
+ * `oneOf` recursively. A branch with no combinators is its own only entry.
177
+ * Used so an `x-telo-ref` slot like `{anyOf: [{type: string}, {type: object,
178
+ * properties: …}]}` exposes the object branch's properties to completion. */
179
+ function peelCombinators(node: Record<string, any>): Record<string, any>[] {
180
+ const out: Record<string, any>[] = [];
181
+ const visit = (n: any) => {
182
+ if (!n || typeof n !== "object") return;
183
+ const branches: any[] = [];
184
+ if (Array.isArray(n.anyOf)) branches.push(...n.anyOf);
185
+ if (Array.isArray(n.oneOf)) branches.push(...n.oneOf);
186
+ if (branches.length === 0) {
187
+ out.push(n);
188
+ return;
189
+ }
190
+ for (const b of branches) visit(b);
191
+ };
192
+ visit(node);
193
+ return out;
194
+ }
195
+
196
+ /** Navigate a JSON Schema hierarchy following `path`, auto-descending into
197
+ * array items and peeling `anyOf` / `oneOf` branches. When multiple peeled
198
+ * branches define `properties`, returns a synthetic node whose `properties`
199
+ * is the union (first-wins on key collision) and whose `required` is the
200
+ * intersection — enough for propKeyCompletions to surface every key a value
201
+ * at this slot can legally carry. */
107
202
  export function navigateSchema(
108
203
  schema: Record<string, any>,
109
204
  path: string[],
110
205
  ): Record<string, any> | undefined {
111
- let current = schema;
206
+ let current: Record<string, any> = schema;
112
207
  for (const segment of path) {
113
- // Auto-descend through arrays before looking up the next key
114
- while (current.type === "array" && current.items) {
115
- current = current.items as Record<string, any>;
208
+ const candidates = peelCombinators(current).flatMap((node) => {
209
+ const expanded: Record<string, any>[] = [];
210
+ let cur: Record<string, any> = node;
211
+ while (cur.type === "array" && cur.items) cur = cur.items as Record<string, any>;
212
+ for (const peeled of peelCombinators(cur)) expanded.push(peeled);
213
+ return expanded;
214
+ });
215
+ let next: Record<string, any> | undefined;
216
+ for (const cand of candidates) {
217
+ const sub = (cand.properties as Record<string, any> | undefined)?.[segment];
218
+ if (sub) {
219
+ next = sub as Record<string, any>;
220
+ break;
221
+ }
116
222
  }
117
- const props = current.properties as Record<string, any> | undefined;
118
- if (!props?.[segment]) return undefined;
119
- current = props[segment] as Record<string, any>;
223
+ if (!next) return undefined;
224
+ current = next;
120
225
  }
121
226
  // Auto-descend through a trailing array at the leaf (e.g. cursor inside `mounts:` items)
122
227
  while (current.type === "array" && current.items) {
123
228
  current = current.items as Record<string, any>;
124
229
  }
125
- return current;
230
+ const leaves = peelCombinators(current);
231
+ if (leaves.length === 1) return leaves[0];
232
+ return unionLeaves(current, leaves);
126
233
  }
127
234
 
128
- /**
129
- * For blank lines (and lines with only whitespace), infer the intended indent
130
- * from context rather than relying on the cursor column, which is often 0
131
- * even when the cursor is semantically inside a nested block.
132
- *
133
- * Strategy: look at the previous non-empty line.
134
- * - If it ends with `:` (bare object key, no value) → cursor is one level deeper.
135
- * - Otherwise → cursor is a sibling of that key (same indent).
136
- */
137
- export function inferIndentForBlankLine(lines: string[], cursorLine: number, docStart: number): number {
138
- for (let i = cursorLine - 1; i >= docStart; i--) {
139
- const line = lines[i] ?? "";
140
- if (line.trim() === "" || line.trim().startsWith("#")) continue;
141
- if (line.trimEnd() === "---") break;
142
- const lineIndent = line.length - line.trimStart().length;
143
- if (line.trimEnd().endsWith(":")) {
144
- return lineIndent + 2;
235
+ /** Merge multiple peeled schema branches into one node for completion purposes.
236
+ * Property maps are unioned (first branch wins on key collision). `required`
237
+ * becomes the intersection so optional-in-any-branch keys still surface.
238
+ * `x-telo-ref` from the unpeeled parent is preserved so ref-aware lookups
239
+ * (`lookupRefConstraint`) still see the constraint when navigateSchema is
240
+ * called on a property that places the annotation alongside `anyOf`/`oneOf`. */
241
+ function unionLeaves(
242
+ parent: Record<string, any>,
243
+ leaves: Record<string, any>[],
244
+ ): Record<string, any> {
245
+ const properties: Record<string, any> = {};
246
+ const requiredSets: Set<string>[] = [];
247
+ for (const leaf of leaves) {
248
+ const props = leaf.properties as Record<string, any> | undefined;
249
+ if (props) {
250
+ for (const [k, v] of Object.entries(props)) {
251
+ if (!(k in properties)) properties[k] = v;
252
+ }
253
+ }
254
+ requiredSets.push(
255
+ new Set(Array.isArray(leaf.required) ? (leaf.required as string[]) : []),
256
+ );
257
+ }
258
+ let required: string[] = [];
259
+ if (requiredSets.length > 0) {
260
+ required = [...requiredSets[0]].filter((k) =>
261
+ requiredSets.every((s) => s.has(k)),
262
+ );
263
+ }
264
+ const out: Record<string, any> = { type: "object", properties, required };
265
+ if (typeof parent["x-telo-ref"] === "string") out["x-telo-ref"] = parent["x-telo-ref"];
266
+ return out;
267
+ }
268
+
269
+ /** Walks up and down from `cursorLine` looking for a sibling line at the
270
+ * exact same indent whose key is `kind`. The value of the first such line
271
+ * is returned (alias form, e.g. `"Sql.Connection"`). Used by ref-name
272
+ * completion to discover what kind of resource the user is targeting in an
273
+ * object-form ref. Walking stops at the first line with a strictly smaller
274
+ * indent (that's the parent's structural boundary). */
275
+ export function findSiblingKindValue(
276
+ lines: string[],
277
+ docStart: number,
278
+ docEnd: number,
279
+ cursorLine: number,
280
+ indent: number,
281
+ ): string | undefined {
282
+ const prefix = " ".repeat(indent);
283
+ const scan = (range: number[]): string | undefined => {
284
+ for (const i of range) {
285
+ const line = lines[i] ?? "";
286
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
287
+ if (line.trimEnd() === "---") return undefined;
288
+ const lineIndent = line.length - line.trimStart().length;
289
+ if (lineIndent < indent) return undefined; // parent boundary
290
+ if (lineIndent !== indent || !line.startsWith(prefix)) continue;
291
+ const m = line.slice(indent).match(/^kind:\s*(\S+)/);
292
+ if (m) return m[1];
145
293
  }
146
- return lineIndent;
294
+ return undefined;
295
+ };
296
+ // Forward then backward — order doesn't matter for correctness because
297
+ // any sibling kind value at this indent applies to the same object.
298
+ const after = [];
299
+ for (let i = cursorLine + 1; i < docEnd; i++) after.push(i);
300
+ const before = [];
301
+ for (let i = cursorLine - 1; i >= docStart; i--) before.push(i);
302
+ return scan(after) ?? scan(before);
303
+ }
304
+
305
+ /** Looks up the `x-telo-ref` string carried by the schema node at `yamlPath`
306
+ * inside `definitionSchema`. Checks both the property node directly and its
307
+ * peeled `anyOf` / `oneOf` branches, since some library schemas place the
308
+ * annotation at the property level and others inside a branch. Returns
309
+ * `undefined` when the path doesn't resolve or no ref constraint is declared. */
310
+ export function lookupRefConstraint(
311
+ definitionSchema: Record<string, any>,
312
+ yamlPath: string[],
313
+ ): string | undefined {
314
+ const node = navigateSchema(definitionSchema, yamlPath);
315
+ if (!node) return undefined;
316
+ if (typeof node["x-telo-ref"] === "string") return node["x-telo-ref"];
317
+ for (const branch of peelCombinators(node)) {
318
+ if (typeof branch["x-telo-ref"] === "string") return branch["x-telo-ref"];
147
319
  }
148
- return 0;
320
+ return undefined;
149
321
  }
150
322
 
151
323
  export function detectContext(
@@ -156,19 +328,63 @@ export function detectContext(
156
328
  const lines = text.split("\n");
157
329
  const currentLine = lines[line] ?? "";
158
330
 
159
- // Kind value completion: `kind: ` or `kind: SomePrefix`
160
- if (/^kind:\s*\S*$/.test(currentLine)) {
161
- return { type: "kind" };
162
- }
163
-
164
331
  const { start, end } = findDocBounds(lines, line);
165
332
  const docKind = extractKindFromDoc(lines, start, end);
166
333
 
334
+ // Kind value completion fires ONLY when the cursor sits past the `:` of a
335
+ // `kind:` line. With the cursor on the key portion (start, middle, or right
336
+ // before the colon) we fall through to prop-key completion so `kind` itself
337
+ // can be suggested. Matches both top-level (`kind: …`) and indented forms;
338
+ // indented form also surfaces the enclosing ref slot for filtering.
339
+ const kindLineMatch = currentLine.match(/^(\s*)kind:(\s*)(\S*)$/);
340
+ if (kindLineMatch) {
341
+ const indent = kindLineMatch[1].length;
342
+ const valueStart = indent + "kind:".length + kindLineMatch[2].length;
343
+ if (character >= valueStart) {
344
+ if (indent === 0) return { type: "kind", valueStartColumn: valueStart };
345
+ if (docKind) {
346
+ const yamlPath = buildYamlPath(lines, line, start, indent);
347
+ return { type: "kind", docKind, yamlPath, valueStartColumn: valueStart };
348
+ }
349
+ }
350
+ // Cursor is on the key portion — fall through to prop-key handling.
351
+ }
352
+
167
353
  // Capability value completion: only inside Telo.Definition docs
168
354
  if (/^capability:\s*\S*$/.test(currentLine) && docKind === "Telo.Definition") {
169
355
  return { type: "capability" };
170
356
  }
171
357
 
358
+ // Ref-name value completion: cursor on the value of a `name:` line inside
359
+ // an object-form ref (sibling `kind:` declares which resource kind we're
360
+ // referencing). The enclosing parent slot's schema carries the ref
361
+ // constraint, but we don't need to consult it here — `buildCompletions`
362
+ // will fall back to filtering by `refKind` regardless of the schema. Doing
363
+ // so keeps editor autocomplete working even when the registry hasn't fully
364
+ // resolved the resource's definition.
365
+ const nameLineMatch = currentLine.match(/^(\s+)name:(\s*)(\S*)$/);
366
+ if (nameLineMatch && docKind) {
367
+ const indent = nameLineMatch[1].length;
368
+ const valueStart = indent + "name:".length + nameLineMatch[2].length;
369
+ const valuePrefix = nameLineMatch[3];
370
+ if (character >= valueStart) {
371
+ const yamlPath = buildYamlPath(lines, line, start, indent);
372
+ // The yamlPath built here points at the parent slot — for
373
+ // `connection: { kind: …, name: | }` the path is `["connection"]`.
374
+ // The sibling `kind:` lives at the same indent as our `name:`, so we
375
+ // scan the doc bounds for it.
376
+ const refKind = findSiblingKindValue(lines, start, end, line, indent);
377
+ return {
378
+ type: "ref-name",
379
+ docKind,
380
+ yamlPath,
381
+ refKind,
382
+ prefix: valuePrefix,
383
+ valueStartColumn: valueStart,
384
+ };
385
+ }
386
+ }
387
+
172
388
  if (!docKind) return undefined;
173
389
 
174
390
  // Field-value completion: `source: <prefix>` on a top-level Telo.Import field.
@@ -187,22 +403,46 @@ export function detectContext(
187
403
 
188
404
  const trimmed = currentLine.trim();
189
405
 
190
- // Only trigger when the line looks like a key being typed (or is blank)
191
- const isKeyLine = trimmed === "" || /^[a-zA-Z_][a-zA-Z0-9_]*:?$/.test(trimmed);
406
+ // Trigger when the cursor is on the KEY portion of the line. Three cases:
407
+ // 1. Blank line / whitespace only `existingKeys` skip means the user is
408
+ // starting a fresh key.
409
+ // 2. Line has no `:` yet (e.g. `vers`, `version`) — partial key being typed.
410
+ // 3. Line has `key: value` and the cursor is at or before the colon.
411
+ // The line text is preserved so `version|: 1.0.0` keeps suggesting keys
412
+ // while `version: |1.0.0` falls through to no completion.
413
+ const colonIdx = currentLine.indexOf(":");
414
+ const beforeColon = colonIdx === -1 ? currentLine : currentLine.slice(0, colonIdx);
415
+ const isKeyLine =
416
+ trimmed === "" ||
417
+ (colonIdx === -1 && /^\s*[a-zA-Z_][a-zA-Z0-9_]*$/.test(currentLine)) ||
418
+ (colonIdx !== -1 &&
419
+ character <= colonIdx &&
420
+ /^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$/.test(beforeColon));
192
421
  if (!isKeyLine) return undefined;
193
422
 
423
+ // Indent resolution: for a whitespace-only line the cursor's column tells
424
+ // us exactly where the user is about to type — VS Code parks the cursor at
425
+ // the new auto-indent after Enter, and any deviation (backspace to col 0,
426
+ // type extra spaces) is intentional. Trusting `character` also lets the
427
+ // user reach root level (col 0) on a trailing blank line even when the
428
+ // previous non-blank line was nested.
194
429
  const indent =
195
430
  trimmed === ""
196
- ? inferIndentForBlankLine(lines, line, start)
431
+ ? character
197
432
  : currentLine.length - currentLine.trimStart().length;
198
433
 
199
434
  if (indent === 0) {
200
- return { type: "prop-key", docKind, yamlPath: [], existingKeys: extractRootKeys(lines, start, end) };
435
+ return {
436
+ type: "prop-key",
437
+ docKind,
438
+ yamlPath: [],
439
+ existingKeys: extractRootKeys(lines, start, end, line),
440
+ };
201
441
  }
202
442
 
203
443
  const yamlPath = buildYamlPath(lines, line, start, indent);
204
444
  if (yamlPath.length === 0) return undefined; // couldn't resolve parent — bail
205
445
 
206
- const existingKeys = extractKeysAtIndent(lines, start, end, indent);
446
+ const existingKeys = extractKeysAtIndent(lines, start, end, indent, line);
207
447
  return { type: "prop-key", docKind, yamlPath, existingKeys };
208
448
  }