@telorun/ide-support 0.4.1 → 0.4.4
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/README.md +3 -3
- package/dist/completions/build.d.ts.map +1 -1
- package/dist/completions/build.js +141 -12
- package/dist/completions/detect-context.d.ts +71 -15
- package/dist/completions/detect-context.d.ts.map +1 -1
- package/dist/completions/detect-context.js +249 -43
- package/dist/completions/prop-keys.d.ts.map +1 -1
- package/dist/completions/prop-keys.js +33 -5
- package/dist/diagnostics/range-resolver.d.ts +9 -3
- package/dist/diagnostics/range-resolver.d.ts.map +1 -1
- package/dist/diagnostics/range-resolver.js +39 -6
- package/package.json +7 -4
- package/src/completions/build.ts +166 -12
- package/src/completions/detect-context.ts +283 -43
- package/src/completions/prop-keys.ts +43 -7
- package/src/diagnostics/range-resolver.ts +36 -5
package/src/completions/build.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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")
|
|
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
|
-
| {
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
230
|
+
const leaves = peelCombinators(current);
|
|
231
|
+
if (leaves.length === 1) return leaves[0];
|
|
232
|
+
return unionLeaves(current, leaves);
|
|
126
233
|
}
|
|
127
234
|
|
|
128
|
-
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
191
|
-
|
|
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
|
-
?
|
|
431
|
+
? character
|
|
197
432
|
: currentLine.length - currentLine.trimStart().length;
|
|
198
433
|
|
|
199
434
|
if (indent === 0) {
|
|
200
|
-
return {
|
|
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
|
}
|