@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,5 +1,6 @@
1
- import type { AnalysisRegistry } from "@telorun/analyzer";
1
+ import { parseToAst, type AnalysisRegistry, type AstDocument, type AstMap } from "@telorun/analyzer";
2
2
  import type { CompletionResult, IdeEnvironmentAdapter } from "../types.js";
3
+ import type { ReplaceRange } from "./detect-context.js";
3
4
  import { detectContext, lookupRefConstraint } from "./detect-context.js";
4
5
  import { importSourceCompletions } from "./import-source.js";
5
6
  import { propKeyCompletions } from "./prop-keys.js";
@@ -10,56 +11,29 @@ interface ResourceRecord {
10
11
  name: string;
11
12
  }
12
13
 
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[] {
14
+ /** Read the top-level `kind` and `metadata.name` scalar of each document from
15
+ * the AST. Consumed only for ref-name completion ranking, so a doc missing
16
+ * either is simply skipped; the analyzer remains the source of truth. */
17
+ function extractInFileResources(docs: AstDocument[]): ResourceRecord[] {
20
18
  const out: ResourceRecord[] = [];
21
- const lines = text.split("\n");
22
- let currentKind: string | undefined;
23
- let currentName: string | undefined;
24
- let inMetadata = false;
19
+ const scalar = (node: { kind: string; value?: unknown } | undefined): string | undefined =>
20
+ node?.kind === "scalar" && typeof node.value === "string" ? node.value : undefined;
25
21
 
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;
22
+ for (const doc of docs) {
23
+ if (doc.root?.kind !== "map") continue;
24
+ let kind: string | undefined;
25
+ let name: string | undefined;
26
+ for (const pair of doc.root.entries) {
27
+ const key = scalar(pair.key);
28
+ if (key === "kind") kind = scalar(pair.value);
29
+ else if (key === "metadata" && pair.value?.kind === "map") {
30
+ const meta = pair.value as AstMap;
31
+ const nameEntry = meta.entries.find((e) => scalar(e.key) === "name");
32
+ name = scalar(nameEntry?.value);
59
33
  }
60
34
  }
35
+ if (kind && name) out.push({ kind, name });
61
36
  }
62
- flush();
63
37
  return out;
64
38
  }
65
39
 
@@ -71,13 +45,13 @@ function extractInFileResources(text: string): ResourceRecord[] {
71
45
  * user still sees something rather than nothing when the registry
72
46
  * doesn't recognize the kind yet. */
73
47
  function refNameCompletions(
74
- text: string,
48
+ docs: AstDocument[],
75
49
  refKind: string | undefined,
76
50
  refConstraint: string | undefined,
77
51
  registry: AnalysisRegistry | undefined,
78
- valueStartColumn: number,
52
+ replaceRange: ReplaceRange,
79
53
  ): CompletionResult[] {
80
- const resources = extractInFileResources(text);
54
+ const resources = extractInFileResources(docs);
81
55
  let acceptable: Set<string> | undefined;
82
56
 
83
57
  if (refKind) {
@@ -97,10 +71,9 @@ function refNameCompletions(
97
71
  label: r.name,
98
72
  kind: "value",
99
73
  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,
74
+ // Replace the whole existing value so names with `.`, `-`, or `/` (legal
75
+ // in resource names) overwrite cleanly instead of the trailing word.
76
+ replaceRange,
104
77
  });
105
78
  }
106
79
  return out;
@@ -129,7 +102,7 @@ function kindCompletions(
129
102
  registry: AnalysisRegistry | undefined,
130
103
  docKind: string | undefined,
131
104
  yamlPath: string[] | undefined,
132
- valueStartColumn: number | undefined,
105
+ replaceRange: ReplaceRange,
133
106
  ): CompletionResult[] {
134
107
  let kinds: Iterable<string>;
135
108
  if (registry && docKind && yamlPath && yamlPath.length > 0) {
@@ -145,14 +118,10 @@ function kindCompletions(
145
118
  for (const kind of kinds) {
146
119
  if (seen.has(kind)) continue;
147
120
  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);
121
+ // Replace the whole existing kind scalar so a pick of `Sql.Connection`
122
+ // over `Sql.Co|nnection` leaves no `nnection` suffix and no `Sql.` prefix
123
+ // duplication (VS Code's default word range stops at the last `.`).
124
+ results.push({ label: kind, kind: "class", detail: "Telo resource kind", replaceRange });
156
125
  }
157
126
  return results;
158
127
  }
@@ -171,11 +140,16 @@ export async function buildCompletions(
171
140
  character: number,
172
141
  registry: AnalysisRegistry | undefined,
173
142
  adapter?: IdeEnvironmentAdapter,
143
+ docs?: AstDocument[],
174
144
  ): Promise<CompletionResult[]> {
175
- const ctx = detectContext(text, line, character);
145
+ // Reuse the host's already-parsed AST when it matches the current buffer;
146
+ // otherwise parse once here (Part 1 stands alone). Both `detectContext` and
147
+ // ref-name in-file resource extraction share this single parse.
148
+ const astDocs = docs ?? parseToAst(text);
149
+ const ctx = detectContext(text, line, character, astDocs);
176
150
  if (!ctx) return [];
177
151
  if (ctx.type === "kind") {
178
- return kindCompletions(registry, ctx.docKind, ctx.yamlPath, ctx.valueStartColumn);
152
+ return kindCompletions(registry, ctx.docKind, ctx.yamlPath, ctx.replaceRange);
179
153
  }
180
154
  if (ctx.type === "capability") return capabilityCompletions();
181
155
  if (ctx.type === "ref-name") {
@@ -183,17 +157,11 @@ export async function buildCompletions(
183
157
  const refConstraint = definition?.schema
184
158
  ? lookupRefConstraint(definition.schema as Record<string, any>, ctx.yamlPath)
185
159
  : undefined;
186
- return refNameCompletions(
187
- text,
188
- ctx.refKind,
189
- refConstraint,
190
- registry,
191
- ctx.valueStartColumn,
192
- );
160
+ return refNameCompletions(astDocs, ctx.refKind, refConstraint, registry, ctx.replaceRange);
193
161
  }
194
162
  if (ctx.type === "field-value") {
195
163
  if (ctx.field === "import-source") {
196
- return importSourceCompletions(ctx.prefix, ctx.valueStartColumn, adapter);
164
+ return importSourceCompletions(ctx.prefix, ctx.replaceRange, adapter);
197
165
  }
198
166
  return [];
199
167
  }
@@ -1,3 +1,9 @@
1
+ import { parseToAst, type AstDocument } from "@telorun/analyzer";
2
+ import type { ReplaceRange } from "../types.js";
3
+ import { resolveNodeAtPosition } from "./resolve-node.js";
4
+
5
+ export type { ReplaceRange };
6
+
1
7
  export type CompletionCtx =
2
8
  | {
3
9
  type: "kind";
@@ -7,12 +13,10 @@ export type CompletionCtx =
7
13
  * Absent for top-level `kind:` — there, no constraint applies. */
8
14
  docKind?: string;
9
15
  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
+ /** Full source range of the kind value, so a pick overwrites the whole
17
+ * existing scalar (e.g. `Sql.Co|nnection` + `Sql.Connection` no
18
+ * suffix left behind). */
19
+ replaceRange: ReplaceRange;
16
20
  }
17
21
  | { type: "capability" }
18
22
  | { type: "prop-key"; docKind: string; yamlPath: string[]; existingKeys: Set<string> }
@@ -29,7 +33,7 @@ export type CompletionCtx =
29
33
  /** The kind value of the sibling `kind:` line, if present. */
30
34
  refKind?: string;
31
35
  prefix: string;
32
- valueStartColumn: number;
36
+ replaceRange: ReplaceRange;
33
37
  }
34
38
  | {
35
39
  type: "field-value";
@@ -37,141 +41,10 @@ export type CompletionCtx =
37
41
  field: string;
38
42
  /** Text from the start of the value to the cursor. */
39
43
  prefix: string;
40
- /** 0-based column where the value starts (right after `<field>:` + whitespace). */
41
- valueStartColumn: number;
44
+ /** Full source range of the value being completed. */
45
+ replaceRange: ReplaceRange;
42
46
  };
43
47
 
44
- export function findDocBounds(lines: string[], cursorLine: number): { start: number; end: number } {
45
- let start = 0;
46
- for (let i = cursorLine; i >= 0; i--) {
47
- if (lines[i]?.trimEnd() === "---") {
48
- start = i + 1;
49
- break;
50
- }
51
- }
52
- let end = lines.length;
53
- for (let i = cursorLine + 1; i < lines.length; i++) {
54
- if (lines[i]?.trimEnd() === "---") {
55
- end = i;
56
- break;
57
- }
58
- }
59
- return { start, end };
60
- }
61
-
62
- export function extractKindFromDoc(lines: string[], start: number, end: number): string | undefined {
63
- for (let i = start; i < end; i++) {
64
- const m = lines[i]?.match(/^kind:\s*(\S+)/);
65
- if (m) return m[1];
66
- }
67
- return undefined;
68
- }
69
-
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> {
80
- const keys = new Set<string>();
81
- for (let i = start; i < end; i++) {
82
- if (i === skipLine) continue;
83
- const m = lines[i]?.match(/^([a-zA-Z_][a-zA-Z0-9_]*):/);
84
- if (m) keys.add(m[1]);
85
- }
86
- return keys;
87
- }
88
-
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. */
106
- export function buildYamlPath(
107
- lines: string[],
108
- cursorLine: number,
109
- docStart: number,
110
- cursorIndent: number,
111
- ): string[] {
112
- if (cursorIndent === 0) return [];
113
-
114
- const path: string[] = [];
115
- let targetIndent = cursorIndent;
116
-
117
- for (let i = cursorLine - 1; i >= docStart; i--) {
118
- const line = lines[i] ?? "";
119
- const trimmed = line.trimStart();
120
- if (trimmed === "" || trimmed.startsWith("#")) continue;
121
-
122
- const lineIndent = line.length - trimmed.length;
123
- if (lineIndent < targetIndent) {
124
- // Match a plain object key (not a list item marker)
125
- const m = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*):/);
126
- if (m) {
127
- path.unshift(m[1]);
128
- targetIndent = lineIndent;
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;
140
- } else {
141
- // Hit something we can't parse; stop
142
- break;
143
- }
144
- }
145
- }
146
-
147
- return path;
148
- }
149
-
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. */
153
- export function extractKeysAtIndent(
154
- lines: string[],
155
- start: number,
156
- end: number,
157
- indent: number,
158
- skipLine?: number,
159
- ): Set<string> {
160
- const keys = new Set<string>();
161
- const prefix = " ".repeat(indent);
162
- for (let i = start; i < end; i++) {
163
- if (i === skipLine) continue;
164
- const line = lines[i] ?? "";
165
- if (!line.startsWith(prefix)) continue;
166
- const rest = line.slice(indent);
167
- const m = rest.match(/^([a-zA-Z_][a-zA-Z0-9_]*):/);
168
- if (m && line.length - line.trimStart().length === indent) {
169
- keys.add(m[1]);
170
- }
171
- }
172
- return keys;
173
- }
174
-
175
48
  /** Returns every schema branch reachable from `node` after peeling `anyOf` /
176
49
  * `oneOf` recursively. A branch with no combinators is its own only entry.
177
50
  * Used so an `x-telo-ref` slot like `{anyOf: [{type: string}, {type: object,
@@ -272,36 +145,6 @@ function unionLeaves(
272
145
  * completion to discover what kind of resource the user is targeting in an
273
146
  * object-form ref. Walking stops at the first line with a strictly smaller
274
147
  * 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];
293
- }
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
148
  /** Looks up the `x-telo-ref` string carried by the schema node at `yamlPath`
306
149
  * inside `definitionSchema`. Checks both the property node directly and its
307
150
  * peeled `anyOf` / `oneOf` branches, since some library schemas place the
@@ -320,144 +163,84 @@ export function lookupRefConstraint(
320
163
  return undefined;
321
164
  }
322
165
 
166
+ /** Derive a `CompletionCtx` from the AST-resolved cursor (Approach B). The
167
+ * structural resolution lives in `resolveNodeAtPosition`; this only maps a
168
+ * resolved slot onto the completion the editor should offer. `docs` lets a
169
+ * host thread its already-parsed AST; without it we parse locally so this
170
+ * stands alone. */
323
171
  export function detectContext(
324
172
  text: string,
325
173
  line: number,
326
174
  character: number,
175
+ docs?: AstDocument[],
327
176
  ): CompletionCtx | undefined {
328
- const lines = text.split("\n");
329
- const currentLine = lines[line] ?? "";
330
-
331
- const { start, end } = findDocBounds(lines, line);
332
- const docKind = extractKindFromDoc(lines, start, end);
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
- }
177
+ const resolved = resolveNodeAtPosition(text, docs ?? parseToAst(text), line, character);
178
+ if (!resolved) return undefined;
179
+ const { docKind } = resolved;
180
+
181
+ if (resolved.slot === "value") {
182
+ // Inside a CEL body — structural completion does not apply (a future
183
+ // CEL-completion feature consumes `resolved.cel`).
184
+ if (resolved.cel) return undefined;
185
+ const replaceRange = resolved.replaceRange;
186
+ if (!replaceRange) return undefined;
187
+ const key = resolved.path[resolved.path.length - 1];
188
+ const parentPath = resolved.path.slice(0, -1);
189
+ const prefix = resolved.prefix ?? "";
190
+
191
+ if (key === "kind") {
192
+ if (parentPath.length === 0) return { type: "kind", replaceRange };
193
+ if (docKind) return { type: "kind", docKind, yamlPath: parentPath, replaceRange };
194
+ return undefined;
349
195
  }
350
- // Cursor is on the key portion — fall through to prop-key handling.
351
- }
352
196
 
353
- // Capability value completion: only inside Telo.Definition docs
354
- if (/^capability:\s*\S*$/.test(currentLine) && docKind === "Telo.Definition") {
355
- return { type: "capability" };
356
- }
197
+ if (key === "capability" && docKind === "Telo.Definition") {
198
+ return { type: "capability" };
199
+ }
357
200
 
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);
201
+ // Any `name:` value is a candidate ref target; the sibling `kind:` (when
202
+ // present) narrows the in-file resource list. Harmless on `metadata.name`,
203
+ // where no ref constraint resolves and the list is the fallback.
204
+ if (key === "name" && docKind) {
377
205
  return {
378
206
  type: "ref-name",
379
207
  docKind,
380
- yamlPath,
381
- refKind,
382
- prefix: valuePrefix,
383
- valueStartColumn: valueStart,
208
+ yamlPath: parentPath,
209
+ refKind: resolved.siblingKind,
210
+ prefix,
211
+ replaceRange,
384
212
  };
385
213
  }
386
- }
387
214
 
388
- if (!docKind) return undefined;
389
-
390
- // Import-source value completion: entries in the `imports:` map on a module
391
- // doc. Two shapes are completed against filesystem paths / registry ids:
392
- // scalar shorthand ` Alias: <src>` → the entry value IS the source
393
- // object form ` source: <src>` → the `source:` under `imports.<Alias>`
394
- // Gated on the enclosing path resolving to the `imports:` map so unrelated
395
- // `source:` fields (e.g. `Assert.Manifest.source`) never trigger it.
396
- if (docKind === "Telo.Application" || docKind === "Telo.Library") {
397
- // Require a space after the colon (`key: …`) so a bare object-form header —
398
- // ` Tiny:` about to carry a nested `source:`/`variables:` — is treated as a
399
- // key position, not an import-source value. A flow-map (`Alias: { … }`) never
400
- // matches: `\S*` can't span the spaces inside the braces.
401
- const entryMatch = currentLine.match(/^(\s+)([A-Za-z_][\w-]*):(\s+)(\S*)$/);
402
- if (entryMatch) {
403
- const indent = entryMatch[1].length;
404
- const key = entryMatch[2];
405
- const valueStartColumn = indent + key.length + 1 + entryMatch[3].length;
406
- if (character >= valueStartColumn) {
407
- const parentPath = buildYamlPath(lines, line, start, indent);
408
- const isScalarEntry = parentPath.length === 1 && parentPath[0] === "imports";
409
- const isObjectSource =
410
- key === "source" && parentPath.length === 2 && parentPath[0] === "imports";
411
- if (isScalarEntry || isObjectSource) {
412
- const prefix = currentLine.slice(valueStartColumn, character);
413
- return { type: "field-value", docKind, field: "import-source", prefix, valueStartColumn };
414
- }
215
+ // Import-source: the scalar shorthand `imports.<Alias>` or the object-form
216
+ // `imports.<Alias>.source`. `spaceAfterColon` distinguishes `Console: ` (a
217
+ // value) from a bare `Tiny:` header about to carry a nested `source:`.
218
+ if (
219
+ (docKind === "Telo.Application" || docKind === "Telo.Library") &&
220
+ resolved.spaceAfterColon
221
+ ) {
222
+ const isScalarEntry = parentPath.length === 1 && parentPath[0] === "imports";
223
+ const isObjectSource =
224
+ key === "source" && parentPath.length === 2 && parentPath[0] === "imports";
225
+ if (isScalarEntry || isObjectSource) {
226
+ return { type: "field-value", docKind, field: "import-source", prefix, replaceRange };
415
227
  }
416
228
  }
417
- }
418
-
419
- const trimmed = currentLine.trim();
420
229
 
421
- // Trigger when the cursor is on the KEY portion of the line. Three cases:
422
- // 1. Blank line / whitespace only — `existingKeys` skip means the user is
423
- // starting a fresh key.
424
- // 2. Line has no `:` yet (e.g. `vers`, `version`) — partial key being typed.
425
- // 3. Line has `key: value` and the cursor is at or before the colon.
426
- // The line text is preserved so `version|: 1.0.0` keeps suggesting keys
427
- // while `version: |1.0.0` falls through to no completion.
428
- const colonIdx = currentLine.indexOf(":");
429
- const beforeColon = colonIdx === -1 ? currentLine : currentLine.slice(0, colonIdx);
430
- const isKeyLine =
431
- trimmed === "" ||
432
- (colonIdx === -1 && /^\s*[a-zA-Z_][a-zA-Z0-9_]*$/.test(currentLine)) ||
433
- (colonIdx !== -1 &&
434
- character <= colonIdx &&
435
- /^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$/.test(beforeColon));
436
- if (!isKeyLine) return undefined;
437
-
438
- // Indent resolution: for a whitespace-only line the cursor's column tells
439
- // us exactly where the user is about to type — VS Code parks the cursor at
440
- // the new auto-indent after Enter, and any deviation (backspace to col 0,
441
- // type extra spaces) is intentional. Trusting `character` also lets the
442
- // user reach root level (col 0) on a trailing blank line even when the
443
- // previous non-blank line was nested.
444
- const indent =
445
- trimmed === ""
446
- ? character
447
- : currentLine.length - currentLine.trimStart().length;
448
-
449
- if (indent === 0) {
450
- return {
451
- type: "prop-key",
452
- docKind,
453
- yamlPath: [],
454
- existingKeys: extractRootKeys(lines, start, end, line),
455
- };
230
+ return undefined;
456
231
  }
457
232
 
458
- const yamlPath = buildYamlPath(lines, line, start, indent);
459
- if (yamlPath.length === 0) return undefined; // couldn't resolve parent bail
460
-
461
- const existingKeys = extractKeysAtIndent(lines, start, end, indent, line);
462
- return { type: "prop-key", docKind, yamlPath, existingKeys };
233
+ // Key position (existing key, blank line, or trailing indent). Complete
234
+ // against the nearest enclosing inline resource's schema (or the root
235
+ // resource), with the path made relative to it — so a prop key inside
236
+ // `mount: { kind: Crud.Resource, }` offers Crud.Resource's fields, not the
237
+ // outer ref slot's.
238
+ const scopeKind = resolved.resourceKind ?? docKind;
239
+ if (!scopeKind) return undefined;
240
+ return {
241
+ type: "prop-key",
242
+ docKind: scopeKind,
243
+ yamlPath: resolved.path.slice(resolved.resourceDepth ?? 0),
244
+ existingKeys: resolved.existingKeys ?? new Set<string>(),
245
+ };
463
246
  }