@telorun/ide-support 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cel/cursor-chain.d.ts +30 -0
- package/dist/cel/cursor-chain.d.ts.map +1 -0
- package/dist/cel/cursor-chain.js +30 -0
- package/dist/cel/symbols.d.ts +82 -0
- package/dist/cel/symbols.d.ts.map +1 -0
- package/dist/cel/symbols.js +147 -0
- package/dist/cel/tokens.d.ts +32 -0
- package/dist/cel/tokens.d.ts.map +1 -0
- package/dist/cel/tokens.js +162 -0
- package/dist/completions/build.d.ts +6 -2
- package/dist/completions/build.d.ts.map +1 -1
- package/dist/completions/build.js +52 -24
- package/dist/completions/call-inputs.d.ts +25 -0
- package/dist/completions/call-inputs.d.ts.map +1 -0
- package/dist/completions/call-inputs.js +78 -0
- package/dist/completions/cel-completions.d.ts +26 -0
- package/dist/completions/cel-completions.d.ts.map +1 -0
- package/dist/completions/cel-completions.js +78 -0
- package/dist/completions/detect-context.d.ts +47 -8
- package/dist/completions/detect-context.d.ts.map +1 -1
- package/dist/completions/detect-context.js +51 -15
- package/dist/completions/prop-keys.d.ts +5 -1
- package/dist/completions/prop-keys.d.ts.map +1 -1
- package/dist/completions/prop-keys.js +51 -3
- package/dist/completions/resolve-node.d.ts +9 -2
- package/dist/completions/resolve-node.d.ts.map +1 -1
- package/dist/completions/resolve-node.js +63 -21
- package/dist/completions/valid-capabilities.js +1 -1
- package/dist/definition/build-definition.d.ts +6 -2
- package/dist/definition/build-definition.d.ts.map +1 -1
- package/dist/definition/build-definition.js +16 -3
- package/dist/definition/locate-context-binding.d.ts +15 -0
- package/dist/definition/locate-context-binding.d.ts.map +1 -0
- package/dist/definition/locate-context-binding.js +35 -0
- package/dist/definition/locate-step.d.ts +13 -0
- package/dist/definition/locate-step.d.ts.map +1 -0
- package/dist/definition/locate-step.js +33 -0
- package/dist/definition/resolve-cel-target.d.ts +11 -1
- package/dist/definition/resolve-cel-target.d.ts.map +1 -1
- package/dist/definition/resolve-cel-target.js +14 -14
- package/dist/doc-identity.d.ts +17 -0
- package/dist/doc-identity.d.ts.map +1 -0
- package/dist/doc-identity.js +19 -0
- package/dist/hover/build-hover.d.ts +6 -2
- package/dist/hover/build-hover.d.ts.map +1 -1
- package/dist/hover/build-hover.js +64 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/semantic-tokens/build-semantic-tokens.d.ts +13 -8
- package/dist/semantic-tokens/build-semantic-tokens.d.ts.map +1 -1
- package/dist/semantic-tokens/build-semantic-tokens.js +81 -37
- package/dist/types.d.ts +25 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +16 -2
- package/package.json +2 -2
- package/src/cel/cursor-chain.ts +58 -0
- package/src/cel/symbols.ts +189 -0
- package/src/cel/tokens.ts +169 -0
- package/src/completions/build.ts +85 -22
- package/src/completions/call-inputs.ts +92 -0
- package/src/completions/cel-completions.ts +108 -0
- package/src/completions/detect-context.ts +107 -13
- package/src/completions/prop-keys.ts +59 -2
- package/src/completions/resolve-node.ts +82 -17
- package/src/completions/valid-capabilities.ts +1 -1
- package/src/definition/build-definition.ts +30 -2
- package/src/definition/locate-context-binding.ts +53 -0
- package/src/definition/locate-step.ts +50 -0
- package/src/definition/resolve-cel-target.ts +25 -0
- package/src/doc-identity.ts +31 -0
- package/src/hover/build-hover.ts +67 -1
- package/src/index.ts +4 -0
- package/src/semantic-tokens/build-semantic-tokens.ts +84 -30
- package/src/types.ts +47 -6
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic tokens for the inside of a CEL body.
|
|
3
|
+
*
|
|
4
|
+
* A `!cel "..."` scalar is not a string, and under a stock YAML grammar it is
|
|
5
|
+
* painted as one. Colouring it through the SEMANTIC layer rather than a grammar
|
|
6
|
+
* is what makes one implementation serve both hosts — VS Code and the editor's
|
|
7
|
+
* Monaco already share `buildSemanticTokens`, while a Monarch tokenizer beside
|
|
8
|
+
* the TextMate one would be a second CEL lexer to keep in agreement.
|
|
9
|
+
*
|
|
10
|
+
* It is also the only layer that can be right about NAMES. A grammar knows the
|
|
11
|
+
* seven kernel roots someone hardcoded into it, which is why `request` and
|
|
12
|
+
* `steps` go uncoloured today; the scope query knows what is actually in scope
|
|
13
|
+
* at this exact site. So a name the scope confirms is coloured and a name it
|
|
14
|
+
* cannot is left alone — the quiet signal an unresolved `kind:` already gives.
|
|
15
|
+
*/
|
|
16
|
+
import { CelParseError, type CelNode, type CelScope, type CelSegment } from "@telorun/analyzer";
|
|
17
|
+
import type { SemanticTokenType } from "../types.js";
|
|
18
|
+
import { flattenChain } from "../cel-chain.js";
|
|
19
|
+
import { celRootSymbols, celSymbolAt } from "./symbols.js";
|
|
20
|
+
|
|
21
|
+
/** A token before it is placed on a line — document offsets, resolved by the
|
|
22
|
+
* caller which owns the line table. */
|
|
23
|
+
export interface CelTokenSpan {
|
|
24
|
+
range: [number, number];
|
|
25
|
+
type: SemanticTokenType;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** How a name is coloured when the scope has no opinion. With a scope, an
|
|
29
|
+
* unconfirmed name is deliberately left uncoloured; with none — a host that
|
|
30
|
+
* passed no query, or a buffer analysis has not reached — every identifier is
|
|
31
|
+
* coloured syntactically, so a CEL body still never reads as a plain string. */
|
|
32
|
+
type NameMode = "scoped" | "syntactic";
|
|
33
|
+
|
|
34
|
+
const LITERAL_TYPE = (value: unknown): SemanticTokenType | undefined => {
|
|
35
|
+
if (typeof value === "number" || typeof value === "bigint") return "number";
|
|
36
|
+
if (typeof value === "string") return "string";
|
|
37
|
+
if (typeof value === "boolean" || value === null) return "keyword";
|
|
38
|
+
return undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Tokens for one CEL segment.
|
|
43
|
+
*
|
|
44
|
+
* A body that does not parse yields nothing: mid-typing is the normal case
|
|
45
|
+
* here, and the analyzer reports the syntax error itself. Only that failure is
|
|
46
|
+
* tolerated — a defect in the CEL wrapper propagates.
|
|
47
|
+
*/
|
|
48
|
+
export function celSegmentTokens(
|
|
49
|
+
text: string,
|
|
50
|
+
segment: CelSegment,
|
|
51
|
+
scope: CelScope | undefined,
|
|
52
|
+
): CelTokenSpan[] {
|
|
53
|
+
let ast: CelNode;
|
|
54
|
+
try {
|
|
55
|
+
ast = segment.ast();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!(error instanceof CelParseError)) throw error;
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const out: CelTokenSpan[] = [];
|
|
61
|
+
const mode: NameMode = scope ? "scoped" : "syntactic";
|
|
62
|
+
// Resolved once per SEGMENT, not once per name: the root set is a property of
|
|
63
|
+
// the scope, and this runs over the whole document on every token request.
|
|
64
|
+
const inScope = scope ? new Set(celRootSymbols(scope).map((s) => s.name)) : undefined;
|
|
65
|
+
|
|
66
|
+
/** True when the scope confirms the chain `parts`. Callers walk a chain
|
|
67
|
+
* left-to-right and stop at the first unconfirmed hop, so each name is
|
|
68
|
+
* resolved once rather than the chain being re-walked from its root per hop. */
|
|
69
|
+
const confirms = (parts: string[]): boolean => {
|
|
70
|
+
if (mode === "syntactic") return true;
|
|
71
|
+
if (parts.length === 1) return inScope!.has(parts[0]);
|
|
72
|
+
return celSymbolAt(scope!, parts) !== undefined;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The span of `name` between two offsets, or undefined when it is not there
|
|
76
|
+
* (a receiver-style call written across an unexpected layout). Nothing is
|
|
77
|
+
* invented: a token placed on a guessed span would paint the wrong text. */
|
|
78
|
+
const spanOf = (name: string, from: number, to: number): [number, number] | undefined => {
|
|
79
|
+
const at = text.indexOf(name, from);
|
|
80
|
+
return at >= 0 && at + name.length <= to ? [at, at + name.length] : undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const visit = (node: CelNode): void => {
|
|
84
|
+
switch (node.kind) {
|
|
85
|
+
case "literal": {
|
|
86
|
+
const type = LITERAL_TYPE(node.value);
|
|
87
|
+
if (type) out.push({ range: node.range, type });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
case "ident":
|
|
91
|
+
// A root-position name is a scope the runtime injects, not data the
|
|
92
|
+
// author declared — see `SemanticTokenType`.
|
|
93
|
+
if (confirms([node.name])) out.push({ range: node.range, type: "namespace" });
|
|
94
|
+
return;
|
|
95
|
+
case "member": {
|
|
96
|
+
// A plain chain is resolved as a whole, so each hop is judged against
|
|
97
|
+
// what the one before it declared — `resources.db.url` colours `db` only
|
|
98
|
+
// if `resources` really carries it. A chain rooted in something computed
|
|
99
|
+
// (`f().x`) flattens to nothing, and the scope has no opinion about it.
|
|
100
|
+
const parts = flattenChain(node);
|
|
101
|
+
if (parts) {
|
|
102
|
+
// A hop the scope cannot confirm makes every hop past it unresolvable
|
|
103
|
+
// too, so the walk STOPS there rather than re-resolving the rest — the
|
|
104
|
+
// chain is resolved once, not once per hop.
|
|
105
|
+
for (let i = 0; i < parts.length; i++) {
|
|
106
|
+
if (!confirms(parts.slice(0, i + 1).map((p) => p.name))) break;
|
|
107
|
+
out.push({ range: parts[i].range, type: i === 0 ? "namespace" : "property" });
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
visit(node.target);
|
|
112
|
+
if (mode === "syntactic") out.push({ range: node.propertyRange, type: "property" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
case "index":
|
|
116
|
+
visit(node.target);
|
|
117
|
+
visit(node.index);
|
|
118
|
+
return;
|
|
119
|
+
case "call": {
|
|
120
|
+
// `foo(...)` starts with its own name, so the head of the node IS the
|
|
121
|
+
// callee's span.
|
|
122
|
+
out.push({ range: [node.range[0], node.range[0] + node.name.length], type: "function" });
|
|
123
|
+
for (const arg of node.args) visit(arg);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
case "methodCall": {
|
|
127
|
+
visit(node.receiver);
|
|
128
|
+
const span = spanOf(node.name, node.receiver.range[1], node.range[1]);
|
|
129
|
+
if (span) out.push({ range: span, type: "function" });
|
|
130
|
+
for (const arg of node.args) visit(arg);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
case "list":
|
|
134
|
+
for (const item of node.items) visit(item);
|
|
135
|
+
return;
|
|
136
|
+
case "map":
|
|
137
|
+
for (const entry of node.entries) {
|
|
138
|
+
visit(entry.key);
|
|
139
|
+
visit(entry.value);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
case "ternary":
|
|
143
|
+
visit(node.cond);
|
|
144
|
+
visit(node.then);
|
|
145
|
+
visit(node.else);
|
|
146
|
+
return;
|
|
147
|
+
case "unary": {
|
|
148
|
+
const span = spanOf(node.op, node.range[0], node.operand.range[0]);
|
|
149
|
+
if (span) out.push({ range: span, type: "operator" });
|
|
150
|
+
visit(node.operand);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
case "binary": {
|
|
154
|
+
visit(node.left);
|
|
155
|
+
const span = spanOf(node.op, node.left.range[1], node.right.range[0]);
|
|
156
|
+
if (span) out.push({ range: span, type: "operator" });
|
|
157
|
+
visit(node.right);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Exhaustive by construction: a new `CelNode` variant fails the build here
|
|
162
|
+
// rather than going silently uncoloured.
|
|
163
|
+
const unhandled: never = node;
|
|
164
|
+
throw new Error(`Unhandled CEL node: ${JSON.stringify(unhandled)}`);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
visit(ast);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
package/src/completions/build.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
parseToAst,
|
|
3
|
+
type AnalysisRegistry,
|
|
4
|
+
type AstDocument,
|
|
5
|
+
type AstMap,
|
|
6
|
+
type ManifestAnalysis,
|
|
7
|
+
} from "@telorun/analyzer";
|
|
2
8
|
import type { CompletionResult, IdeEnvironmentAdapter } from "../types.js";
|
|
3
9
|
import type { ReplaceRange } from "./detect-context.js";
|
|
4
|
-
import {
|
|
10
|
+
import { callInputsAt } from "./call-inputs.js";
|
|
11
|
+
import { celCompletions } from "./cel-completions.js";
|
|
12
|
+
import { docIdentity } from "../doc-identity.js";
|
|
13
|
+
import { detectContext, lookupRefConstraints, navigateSchema } from "./detect-context.js";
|
|
5
14
|
import { importSourceCompletions } from "./import-source.js";
|
|
6
15
|
import { propKeyCompletions } from "./prop-keys.js";
|
|
7
16
|
import { CAPABILITY_VALUES } from "./valid-capabilities.js";
|
|
@@ -16,23 +25,9 @@ interface ResourceRecord {
|
|
|
16
25
|
* either is simply skipped; the analyzer remains the source of truth. */
|
|
17
26
|
function extractInFileResources(docs: AstDocument[]): ResourceRecord[] {
|
|
18
27
|
const out: ResourceRecord[] = [];
|
|
19
|
-
const scalar = (node: { kind: string; value?: unknown } | undefined): string | undefined =>
|
|
20
|
-
node?.kind === "scalar" && typeof node.value === "string" ? node.value : undefined;
|
|
21
|
-
|
|
22
28
|
for (const doc of docs) {
|
|
23
|
-
|
|
24
|
-
|
|
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);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
if (kind && name) out.push({ kind, name });
|
|
29
|
+
const identity = docIdentity(doc);
|
|
30
|
+
if (identity.kind && identity.name) out.push({ kind: identity.kind, name: identity.name });
|
|
36
31
|
}
|
|
37
32
|
return out;
|
|
38
33
|
}
|
|
@@ -98,6 +93,7 @@ function refConstrainedKinds(
|
|
|
98
93
|
const constraints = lookupRefConstraints(
|
|
99
94
|
definition.schema as Record<string, any>,
|
|
100
95
|
parentYamlPath,
|
|
96
|
+
(from) => registry.resolveSchemaFrom(from, parentDocKind),
|
|
101
97
|
);
|
|
102
98
|
if (constraints.length === 0) return undefined;
|
|
103
99
|
const resolved = constraints.map((c) => registry.userFacingKindsForRef(c));
|
|
@@ -133,6 +129,39 @@ function kindCompletions(
|
|
|
133
129
|
return results;
|
|
134
130
|
}
|
|
135
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The values a field's schema says it may take.
|
|
134
|
+
*
|
|
135
|
+
* `enum` is closed and `examples` open — the same distinction `propertyNames`
|
|
136
|
+
* carries for a map's keys, one level down. Nothing is offered when the schema
|
|
137
|
+
* declares neither, which is most slots.
|
|
138
|
+
*/
|
|
139
|
+
function valueSuggestions(
|
|
140
|
+
registry: AnalysisRegistry | undefined,
|
|
141
|
+
docKind: string,
|
|
142
|
+
yamlPath: string[],
|
|
143
|
+
replaceRange: ReplaceRange,
|
|
144
|
+
): CompletionResult[] {
|
|
145
|
+
const definition = registry?.resolveDefinition(docKind);
|
|
146
|
+
if (!registry || !definition?.schema || yamlPath.length === 0) return [];
|
|
147
|
+
const field = navigateSchema(definition.schema as Record<string, any>, yamlPath, (from) =>
|
|
148
|
+
registry.resolveSchemaFrom(from, docKind),
|
|
149
|
+
);
|
|
150
|
+
if (!field) return [];
|
|
151
|
+
const closed = Array.isArray(field.enum) ? (field.enum as unknown[]) : undefined;
|
|
152
|
+
const values = closed ?? (Array.isArray(field.examples) ? (field.examples as unknown[]) : []);
|
|
153
|
+
return values
|
|
154
|
+
.filter((v) => v !== null && typeof v !== "object")
|
|
155
|
+
.map((value) => ({
|
|
156
|
+
label: String(value),
|
|
157
|
+
kind: "enumMember" as const,
|
|
158
|
+
detail: closed ? "allowed value" : "known value",
|
|
159
|
+
// Whole-value replacement, so picking over a partially typed value leaves
|
|
160
|
+
// no suffix — the rule every other value completion here follows.
|
|
161
|
+
replaceRange,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
|
|
136
165
|
function capabilityCompletions(): CompletionResult[] {
|
|
137
166
|
return CAPABILITY_VALUES.map((cap) => ({
|
|
138
167
|
label: cap,
|
|
@@ -148,6 +177,10 @@ export async function buildCompletions(
|
|
|
148
177
|
registry: AnalysisRegistry | undefined,
|
|
149
178
|
adapter?: IdeEnvironmentAdapter,
|
|
150
179
|
docs?: AstDocument[],
|
|
180
|
+
/** The host's analysis of the manifests it loaded. Required for anything
|
|
181
|
+
* that has to resolve against the manifest SET — CEL completion, and a
|
|
182
|
+
* target's declared inputs. */
|
|
183
|
+
analysis?: ManifestAnalysis,
|
|
151
184
|
): Promise<CompletionResult[]> {
|
|
152
185
|
// Reuse the host's already-parsed AST when it matches the current buffer;
|
|
153
186
|
// otherwise parse once here (Part 1 stands alone). Both `detectContext` and
|
|
@@ -159,11 +192,27 @@ export async function buildCompletions(
|
|
|
159
192
|
return kindCompletions(registry, ctx.docKind, ctx.yamlPath, ctx.replaceRange);
|
|
160
193
|
}
|
|
161
194
|
if (ctx.type === "capability") return capabilityCompletions();
|
|
195
|
+
if (ctx.type === "value-suggestions") {
|
|
196
|
+
return valueSuggestions(registry, ctx.docKind, ctx.yamlPath, ctx.replaceRange);
|
|
197
|
+
}
|
|
198
|
+
if (ctx.type === "cel") {
|
|
199
|
+
return celCompletions(
|
|
200
|
+
text,
|
|
201
|
+
ctx.segment,
|
|
202
|
+
ctx.offset,
|
|
203
|
+
ctx.concretePath,
|
|
204
|
+
docIdentity(astDocs[ctx.docIndex]),
|
|
205
|
+
analysis?.celScope,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
162
208
|
if (ctx.type === "ref-name") {
|
|
163
209
|
const definition = registry?.resolveDefinition(ctx.docKind);
|
|
164
|
-
const refConstraints =
|
|
165
|
-
|
|
166
|
-
|
|
210
|
+
const refConstraints =
|
|
211
|
+
registry && definition?.schema
|
|
212
|
+
? lookupRefConstraints(definition.schema as Record<string, any>, ctx.yamlPath, (from) =>
|
|
213
|
+
registry.resolveSchemaFrom(from, ctx.docKind),
|
|
214
|
+
)
|
|
215
|
+
: [];
|
|
167
216
|
return refNameCompletions(astDocs, ctx.refKind, refConstraints, registry, ctx.replaceRange);
|
|
168
217
|
}
|
|
169
218
|
if (ctx.type === "field-value") {
|
|
@@ -172,5 +221,19 @@ export async function buildCompletions(
|
|
|
172
221
|
}
|
|
173
222
|
return [];
|
|
174
223
|
}
|
|
175
|
-
|
|
224
|
+
// A slot that IS an enclosing call's argument map completes from the target's
|
|
225
|
+
// declared inputs rather than from its own (open) schema.
|
|
226
|
+
return propKeyCompletions(
|
|
227
|
+
ctx.docKind,
|
|
228
|
+
ctx.yamlPath,
|
|
229
|
+
ctx.existingKeys,
|
|
230
|
+
registry,
|
|
231
|
+
callInputsAt(
|
|
232
|
+
registry,
|
|
233
|
+
analysis,
|
|
234
|
+
docIdentity(astDocs[ctx.docIndex]).kind ?? ctx.docKind,
|
|
235
|
+
docIdentity(astDocs[ctx.docIndex]).name,
|
|
236
|
+
ctx.concretePath,
|
|
237
|
+
),
|
|
238
|
+
);
|
|
176
239
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arguments a call site declares.
|
|
3
|
+
*
|
|
4
|
+
* A slot that transfers control names its argument slot on its own `x-telo-ref`
|
|
5
|
+
* — `inputs: /inputs`, a JSON Pointer relative to the object enclosing the slot.
|
|
6
|
+
* That is the only thing tying an `inputs:` map to the resource it is arguments
|
|
7
|
+
* FOR: the map itself is an open object, and the reference sits in a sibling
|
|
8
|
+
* field whose name no walker may assume.
|
|
9
|
+
*
|
|
10
|
+
* Reading the pointer here means completion offers exactly the keys the invoked
|
|
11
|
+
* target declares — resolved through the shared contract resolver, so they are
|
|
12
|
+
* the keys `telo check` validates that call against and the kernel binds at
|
|
13
|
+
* dispatch, instance declaration first.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
navigateConcretePath,
|
|
17
|
+
readRefSlot,
|
|
18
|
+
type AnalysisRegistry,
|
|
19
|
+
type ManifestAnalysis,
|
|
20
|
+
type ManifestRef,
|
|
21
|
+
} from "@telorun/analyzer";
|
|
22
|
+
import { navigateSchema } from "./detect-context.js";
|
|
23
|
+
|
|
24
|
+
/** Resolve a JSON Pointer that is a plain property path (`/inputs`) into path
|
|
25
|
+
* segments. Pointers here address a sibling FIELD, never an array element, so
|
|
26
|
+
* anything else is left alone rather than guessed at. */
|
|
27
|
+
function pointerSegments(pointer: string): string[] | undefined {
|
|
28
|
+
if (!pointer.startsWith("/")) return undefined;
|
|
29
|
+
const segments = pointer
|
|
30
|
+
.slice(1)
|
|
31
|
+
.split("/")
|
|
32
|
+
.map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
33
|
+
return segments.every((s) => s.length > 0 && !/^\d+$/.test(s)) ? segments : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The declared input contract of the call whose argument slot is at
|
|
38
|
+
* `concretePath`, or undefined when this path is not one.
|
|
39
|
+
*
|
|
40
|
+
* Both halves have to line up: the enclosing object's schema must declare a ref
|
|
41
|
+
* slot whose `inputs` pointer names this field, and the manifest must fill that
|
|
42
|
+
* ref. Either missing means there is no call here to take arguments for.
|
|
43
|
+
*/
|
|
44
|
+
export function callInputsAt(
|
|
45
|
+
registry: AnalysisRegistry | undefined,
|
|
46
|
+
analysis: ManifestAnalysis | undefined,
|
|
47
|
+
docKind: string,
|
|
48
|
+
resourceName: string | undefined,
|
|
49
|
+
concretePath: string,
|
|
50
|
+
): Record<string, any> | undefined {
|
|
51
|
+
if (!registry || !analysis || !concretePath) return undefined;
|
|
52
|
+
const resource = analysis.resourceFor(docKind, resourceName);
|
|
53
|
+
if (!resource) return undefined;
|
|
54
|
+
const definition = registry.resolveDefinition(docKind);
|
|
55
|
+
if (!definition?.schema) return undefined;
|
|
56
|
+
|
|
57
|
+
const segments = concretePath.split(".").map((seg) => seg.replace(/\[\d+\]$/, ""));
|
|
58
|
+
|
|
59
|
+
// The pointer is relative to the object ENCLOSING the annotated slot, and it
|
|
60
|
+
// may name a nested field (`/handler/inputs`), so every prefix of this path is
|
|
61
|
+
// a candidate enclosing object — not just the immediate parent. Trying them
|
|
62
|
+
// longest-first keeps the nearest enclosing declaration winning.
|
|
63
|
+
const concreteSegments = concretePath.split(".");
|
|
64
|
+
for (let depth = segments.length - 1; depth >= 0; depth--) {
|
|
65
|
+
const enclosing = segments.slice(0, depth);
|
|
66
|
+
const tail = segments.slice(depth);
|
|
67
|
+
const enclosingSchema = navigateSchema(
|
|
68
|
+
definition.schema as Record<string, any>,
|
|
69
|
+
// Schema navigation is index-free; the concrete path is not.
|
|
70
|
+
enclosing.filter(Boolean),
|
|
71
|
+
(from) => registry.resolveSchemaFrom(from, docKind),
|
|
72
|
+
);
|
|
73
|
+
const properties = enclosingSchema?.properties as Record<string, any> | undefined;
|
|
74
|
+
if (!properties) continue;
|
|
75
|
+
|
|
76
|
+
for (const [siblingName, siblingSchema] of Object.entries(properties)) {
|
|
77
|
+
const slot = readRefSlot(siblingSchema);
|
|
78
|
+
if (!slot?.inputs) continue;
|
|
79
|
+
const pointed = pointerSegments(slot.inputs);
|
|
80
|
+
if (!pointed || pointed.length !== tail.length) continue;
|
|
81
|
+
if (!pointed.every((seg, i) => seg === tail[i])) continue;
|
|
82
|
+
|
|
83
|
+
const refPath = [...concreteSegments.slice(0, depth), siblingName].filter(Boolean).join(".");
|
|
84
|
+
const ref = navigateConcretePath(resource as Record<string, any>, refPath) as
|
|
85
|
+
| ManifestRef
|
|
86
|
+
| undefined;
|
|
87
|
+
if (!ref || typeof ref !== "object" || !ref.name) continue;
|
|
88
|
+
return analysis.contractFor(ref, "inputType");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Completion inside a CEL body.
|
|
3
|
+
*
|
|
4
|
+
* Every candidate comes from the scope the analyzer resolved for this exact
|
|
5
|
+
* site, so the list is a claim that what it offers will type-check — not a
|
|
6
|
+
* separate model of what CEL sees. Where the scope declares nothing (an open
|
|
7
|
+
* node, a permissive contract, a live value) nothing is offered, which is the
|
|
8
|
+
* honest answer rather than a guess.
|
|
9
|
+
*/
|
|
10
|
+
import type { CelScopeQuery, CelSegment } from "@telorun/analyzer";
|
|
11
|
+
import type { CompletionResult, ReplaceRange } from "../types.js";
|
|
12
|
+
import { celCursorChain } from "../cel/cursor-chain.js";
|
|
13
|
+
import { celFunctions, celMemberSymbols, celRootSymbols, type CelSymbol } from "../cel/symbols.js";
|
|
14
|
+
|
|
15
|
+
/** The resource a cursor's document addresses. */
|
|
16
|
+
export interface CelCompletionTarget {
|
|
17
|
+
kind?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Markdown listing every overload, with the function's own description above
|
|
22
|
+
* it. A single-signature function needs no list — its one signature is already
|
|
23
|
+
* on the detail line. */
|
|
24
|
+
function describeOverloads(signatures: string[], description?: string): string | undefined {
|
|
25
|
+
if (signatures.length <= 1) return description;
|
|
26
|
+
const list = signatures.map((s) => `- \`${s}\``).join("\n");
|
|
27
|
+
return description ? `${description}\n\n${list}` : list;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toResult(symbol: CelSymbol, replaceRange?: ReplaceRange): CompletionResult {
|
|
31
|
+
return {
|
|
32
|
+
label: symbol.name,
|
|
33
|
+
kind: "property",
|
|
34
|
+
detail: symbol.type,
|
|
35
|
+
documentation: symbol.description,
|
|
36
|
+
replaceRange,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Candidates for the cursor inside `segment`.
|
|
42
|
+
*
|
|
43
|
+
* A member position (`req.|`) offers only what the prefix declares — no
|
|
44
|
+
* functions, since a receiver-style call is rare next to a field access and
|
|
45
|
+
* mixing them buries the fields. A root position offers the scope's names
|
|
46
|
+
* first, then the global functions the environment declares.
|
|
47
|
+
*/
|
|
48
|
+
export function celCompletions(
|
|
49
|
+
text: string,
|
|
50
|
+
segment: CelSegment,
|
|
51
|
+
offset: number,
|
|
52
|
+
concretePath: string,
|
|
53
|
+
target: CelCompletionTarget,
|
|
54
|
+
query: CelScopeQuery | undefined,
|
|
55
|
+
): CompletionResult[] {
|
|
56
|
+
if (!query) return [];
|
|
57
|
+
const resource = query.resourceFor(target.kind, target.name);
|
|
58
|
+
if (!resource) return [];
|
|
59
|
+
const scope = query.scopeAt(resource, concretePath);
|
|
60
|
+
|
|
61
|
+
const chain = celCursorChain(text, segment, offset);
|
|
62
|
+
const prefix = chain?.prefix ?? [];
|
|
63
|
+
|
|
64
|
+
if (chain?.member) {
|
|
65
|
+
return celMemberSymbols(scope, prefix).map((s) => toResult(s));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const roots = celRootSymbols(scope);
|
|
69
|
+
const results = roots.map((s) => toResult(s));
|
|
70
|
+
// A CEL type name (`double`, `int`, `string`) is registered as a variable of
|
|
71
|
+
// type `type` AND as the conversion function of the same name. Both are real,
|
|
72
|
+
// but two identical labels are two things an author cannot choose between, so
|
|
73
|
+
// the callable form wins the slot and says it also names the type — that is
|
|
74
|
+
// the form written at a root position.
|
|
75
|
+
const typeNames = new Set(roots.filter((s) => s.type === "type").map((s) => s.name));
|
|
76
|
+
|
|
77
|
+
// Global functions only: a receiver-style one (`string.startsWith`) is
|
|
78
|
+
// reachable through a member position, where the receiver's type is known.
|
|
79
|
+
for (const fn of celFunctions(scope)) {
|
|
80
|
+
if (fn.receiverType) continue;
|
|
81
|
+
const alsoAType = typeNames.has(fn.name);
|
|
82
|
+
if (alsoAType) {
|
|
83
|
+
const at = results.findIndex((r) => r.label === fn.name);
|
|
84
|
+
if (at >= 0) results.splice(at, 1);
|
|
85
|
+
}
|
|
86
|
+
results.push({
|
|
87
|
+
label: fn.name,
|
|
88
|
+
kind: "value",
|
|
89
|
+
// One candidate per function, so the extra overloads are reported IN it
|
|
90
|
+
// rather than as repeated labels: the count on the detail line, the
|
|
91
|
+
// signatures themselves in the documentation.
|
|
92
|
+
detail:
|
|
93
|
+
fn.signatures.length > 1
|
|
94
|
+
? `${fn.signatures[0]} (+${fn.signatures.length - 1} overloads)`
|
|
95
|
+
: fn.signatures[0],
|
|
96
|
+
documentation: describeOverloads(
|
|
97
|
+
fn.signatures,
|
|
98
|
+
alsoAType
|
|
99
|
+
? [fn.description, `Also names the CEL type \`${fn.name}\`.`].filter(Boolean).join("\n\n")
|
|
100
|
+
: fn.description,
|
|
101
|
+
),
|
|
102
|
+
// Sorted after the scope's own names: a variable is what an author is
|
|
103
|
+
// reaching for at a root position far more often than a built-in.
|
|
104
|
+
sortText: `z${fn.name}`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return results;
|
|
108
|
+
}
|