@checkstack/automation-frontend 0.2.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.
- package/CHANGELOG.md +664 -0
- package/package.json +38 -0
- package/src/components/AutomationMenuItems.tsx +37 -0
- package/src/editor/ActionEditor.tsx +367 -0
- package/src/editor/ActionListEditor.tsx +203 -0
- package/src/editor/AddActionDialog.tsx +225 -0
- package/src/editor/AutomationDefinitionContext.tsx +37 -0
- package/src/editor/AutomationDefinitionEditor.tsx +99 -0
- package/src/editor/ConditionEditor.tsx +218 -0
- package/src/editor/ConditionsEditor.tsx +89 -0
- package/src/editor/ItemPicker.tsx +147 -0
- package/src/editor/TriggersEditor.tsx +269 -0
- package/src/editor/action-composite-cards.tsx +390 -0
- package/src/editor/action-helpers.ts +365 -0
- package/src/editor/action-leaf-cards.tsx +426 -0
- package/src/editor/editor-validation.test.ts +95 -0
- package/src/editor/editor-validation.tsx +200 -0
- package/src/editor/registry-context.tsx +192 -0
- package/src/editor/template-completion.test.ts +412 -0
- package/src/editor/template-completion.ts +664 -0
- package/src/editor/template-helpers.test.ts +145 -0
- package/src/editor/template-helpers.ts +95 -0
- package/src/editor/trigger-helpers.test.ts +58 -0
- package/src/editor/trigger-helpers.ts +67 -0
- package/src/editor/useConnectionOptionResolvers.ts +80 -0
- package/src/editor/yaml-markers.ts +116 -0
- package/src/index.tsx +95 -0
- package/src/pages/AutomationEditPage.tsx +567 -0
- package/src/pages/AutomationListPage.tsx +304 -0
- package/src/pages/RunDetailPage.tsx +333 -0
- package/src/pages/RunsPage.tsx +233 -0
- package/src/pages/TemplatePlaygroundPage.tsx +224 -0
- package/src/script-context.test.ts +247 -0
- package/src/script-context.ts +218 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged, context-aware autocomplete for template / condition
|
|
3
|
+
* expressions.
|
|
4
|
+
*
|
|
5
|
+
* Rather than pre-computing snippets, the provider parses the
|
|
6
|
+
* in-progress expression up to the cursor and decides what the operator
|
|
7
|
+
* most plausibly wants next:
|
|
8
|
+
*
|
|
9
|
+
* 1. **field** — typing an identifier → offer in-scope paths.
|
|
10
|
+
* 2. **operator** — just finished a field (or any operand) → offer
|
|
11
|
+
* comparators (`==`, `!=`, …), logical connectors (`&&`, `||`) and
|
|
12
|
+
* the filter pipe (`|`).
|
|
13
|
+
* 3. **value** — just typed a comparator → offer that field's known
|
|
14
|
+
* values (enum members, or `true` / `false` for booleans).
|
|
15
|
+
* 4. **filter** — just typed `|` → offer the built-in filters.
|
|
16
|
+
*
|
|
17
|
+
* The analyzer (`analyzeExpression`) is a pure function over the
|
|
18
|
+
* expression substring; `createTemplateCompletionProvider` wraps it with
|
|
19
|
+
* the field / filter catalogue and maps offsets back into the full
|
|
20
|
+
* field value. Both are unit-tested.
|
|
21
|
+
*/
|
|
22
|
+
import type {
|
|
23
|
+
TemplateCompletionItem,
|
|
24
|
+
TemplateCompletionProvider,
|
|
25
|
+
TemplateCompletionResult,
|
|
26
|
+
} from "@checkstack/ui";
|
|
27
|
+
|
|
28
|
+
export interface CompletionField {
|
|
29
|
+
/**
|
|
30
|
+
* Canonical dot path, e.g. `trigger.payload.severity` or
|
|
31
|
+
* `artifact.integration-jira.issue.issueKey`. Used ONLY for shell-env
|
|
32
|
+
* derivation (path-based, must match the backend) — never inserted
|
|
33
|
+
* into `{{ }}` (it isn't always runtime-parseable).
|
|
34
|
+
*/
|
|
35
|
+
path: string;
|
|
36
|
+
/**
|
|
37
|
+
* Runtime-parseable insertion text — what actually gets written into
|
|
38
|
+
* the `{{ }}` editor. Plural top-level namespace + bracket notation
|
|
39
|
+
* for non-identifier segments, e.g.
|
|
40
|
+
* `artifacts["integration-jira.issue"].issueKey`. Drives the label,
|
|
41
|
+
* filter/match, insertText, and the value-stage field lookup.
|
|
42
|
+
*/
|
|
43
|
+
templateRef: string;
|
|
44
|
+
/** Type label shown on the right. */
|
|
45
|
+
type: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
/** Known discrete values (enum / boolean) — drives the value stage. */
|
|
48
|
+
enumValues?: Array<string | number | boolean>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CompletionFilter {
|
|
52
|
+
name: string;
|
|
53
|
+
/** Human-readable signature, e.g. `value, fallback`. */
|
|
54
|
+
signature?: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
/** True when the filter takes arguments — drives `name()` + caret-inside insertion. */
|
|
57
|
+
hasArgs?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Comparators offered in the operator stage, longest-first for the tokenizer. */
|
|
61
|
+
const COMPARATORS = ["==", "!=", "<=", ">=", "<", ">"] as const;
|
|
62
|
+
const LOGICAL = ["&&", "||"] as const;
|
|
63
|
+
|
|
64
|
+
// ─── Tokenizer ─────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
type TokenType =
|
|
67
|
+
| "path"
|
|
68
|
+
| "number"
|
|
69
|
+
| "string"
|
|
70
|
+
| "op"
|
|
71
|
+
| "pipe"
|
|
72
|
+
| "lparen"
|
|
73
|
+
| "rparen"
|
|
74
|
+
| "lbracket"
|
|
75
|
+
| "rbracket"
|
|
76
|
+
| "comma"
|
|
77
|
+
| "ws";
|
|
78
|
+
|
|
79
|
+
interface Token {
|
|
80
|
+
type: TokenType;
|
|
81
|
+
value: string;
|
|
82
|
+
start: number;
|
|
83
|
+
end: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const OPERATORS_BY_LENGTH = ["==", "!=", "<=", ">=", "&&", "||", "<", ">", "!"];
|
|
87
|
+
|
|
88
|
+
function tokenize(expr: string): Token[] {
|
|
89
|
+
const tokens: Token[] = [];
|
|
90
|
+
let i = 0;
|
|
91
|
+
while (i < expr.length) {
|
|
92
|
+
const ch = expr[i]!;
|
|
93
|
+
|
|
94
|
+
// Whitespace
|
|
95
|
+
if (/\s/.test(ch)) {
|
|
96
|
+
let j = i + 1;
|
|
97
|
+
while (j < expr.length && /\s/.test(expr[j]!)) j++;
|
|
98
|
+
tokens.push({ type: "ws", value: expr.slice(i, j), start: i, end: j });
|
|
99
|
+
i = j;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Strings (single or double quoted). Tolerate an unterminated
|
|
104
|
+
// closing quote — the operator is still typing.
|
|
105
|
+
if (ch === '"' || ch === "'") {
|
|
106
|
+
let j = i + 1;
|
|
107
|
+
while (j < expr.length && expr[j] !== ch) {
|
|
108
|
+
if (expr[j] === "\\") j++;
|
|
109
|
+
j++;
|
|
110
|
+
}
|
|
111
|
+
// Include the closing quote when present.
|
|
112
|
+
const end = j < expr.length ? j + 1 : j;
|
|
113
|
+
tokens.push({ type: "string", value: expr.slice(i, end), start: i, end });
|
|
114
|
+
i = end;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Pipe (must check before logical `||`)
|
|
119
|
+
if (ch === "|" && expr[i + 1] !== "|") {
|
|
120
|
+
tokens.push({ type: "pipe", value: "|", start: i, end: i + 1 });
|
|
121
|
+
i += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Multi/!single-char operators
|
|
126
|
+
const op = OPERATORS_BY_LENGTH.find((o) => expr.startsWith(o, i));
|
|
127
|
+
if (op) {
|
|
128
|
+
tokens.push({ type: "op", value: op, start: i, end: i + op.length });
|
|
129
|
+
i += op.length;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (ch === "(") {
|
|
134
|
+
tokens.push({ type: "lparen", value: ch, start: i, end: i + 1 });
|
|
135
|
+
i += 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (ch === ")") {
|
|
139
|
+
tokens.push({ type: "rparen", value: ch, start: i, end: i + 1 });
|
|
140
|
+
i += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (ch === ",") {
|
|
144
|
+
tokens.push({ type: "comma", value: ch, start: i, end: i + 1 });
|
|
145
|
+
i += 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// Bracket member access — real tokens so `reconstructChain` can rebuild a
|
|
149
|
+
// `foo["bar"].baz` access chain (it used to swallow these as ws).
|
|
150
|
+
if (ch === "[") {
|
|
151
|
+
tokens.push({ type: "lbracket", value: ch, start: i, end: i + 1 });
|
|
152
|
+
i += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (ch === "]") {
|
|
156
|
+
tokens.push({ type: "rbracket", value: ch, start: i, end: i + 1 });
|
|
157
|
+
i += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Number
|
|
162
|
+
if (/\d/.test(ch)) {
|
|
163
|
+
let j = i + 1;
|
|
164
|
+
while (j < expr.length && /[\d.]/.test(expr[j]!)) j++;
|
|
165
|
+
tokens.push({ type: "number", value: expr.slice(i, j), start: i, end: j });
|
|
166
|
+
i = j;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Path / identifier (allow dots + trailing dot while typing)
|
|
171
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
172
|
+
let j = i + 1;
|
|
173
|
+
while (j < expr.length && /[A-Za-z0-9_.]/.test(expr[j]!)) j++;
|
|
174
|
+
tokens.push({ type: "path", value: expr.slice(i, j), start: i, end: j });
|
|
175
|
+
i = j;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Unknown char — skip it as a 1-char ws-like token so we don't loop.
|
|
180
|
+
tokens.push({ type: "ws", value: ch, start: i, end: i + 1 });
|
|
181
|
+
i += 1;
|
|
182
|
+
}
|
|
183
|
+
return tokens;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Analyzer ────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export type ExprStage =
|
|
189
|
+
| { kind: "field"; tokenStart: number; query: string }
|
|
190
|
+
| { kind: "operator"; tokenStart: number }
|
|
191
|
+
| {
|
|
192
|
+
kind: "value";
|
|
193
|
+
tokenStart: number;
|
|
194
|
+
fieldPath: string;
|
|
195
|
+
query: string;
|
|
196
|
+
quoted: boolean;
|
|
197
|
+
}
|
|
198
|
+
| { kind: "filter"; tokenStart: number; query: string };
|
|
199
|
+
|
|
200
|
+
const isComparator = (token: Token | undefined): boolean =>
|
|
201
|
+
token?.type === "op" && (COMPARATORS as readonly string[]).includes(token.value);
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Classify the completion stage for an expression substring whose end
|
|
205
|
+
* is the cursor. `tokenStart` is the offset (within `expr`) where the
|
|
206
|
+
* text to be replaced begins; the replace-end is always `expr.length`.
|
|
207
|
+
*/
|
|
208
|
+
export function analyzeExpression(expr: string): ExprStage {
|
|
209
|
+
const tokens = tokenize(expr);
|
|
210
|
+
const cursor = expr.length;
|
|
211
|
+
const nonWs = tokens.filter((t) => t.type !== "ws");
|
|
212
|
+
const trailingWs = expr.length > 0 && /\s$/.test(expr);
|
|
213
|
+
const last = nonWs.at(-1);
|
|
214
|
+
// The partial token actively being typed: the final non-ws token when
|
|
215
|
+
// there's no whitespace between it and the cursor.
|
|
216
|
+
const partial = !trailingWs && last && last.end === cursor ? last : undefined;
|
|
217
|
+
const beforePartial = partial
|
|
218
|
+
? nonWs[nonWs.indexOf(partial) - 1]
|
|
219
|
+
: last;
|
|
220
|
+
|
|
221
|
+
// 1. Filter stage — after a pipe.
|
|
222
|
+
if (partial && partial.type === "path" && beforePartial?.type === "pipe") {
|
|
223
|
+
return { kind: "filter", tokenStart: partial.start, query: partial.value };
|
|
224
|
+
}
|
|
225
|
+
if (!partial && last?.type === "pipe") {
|
|
226
|
+
return { kind: "filter", tokenStart: cursor, query: "" };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 2. Value stage — after a comparator.
|
|
230
|
+
if (!partial && isComparator(last)) {
|
|
231
|
+
// The comparator is the last token; the operand chain ends at the
|
|
232
|
+
// token just before it.
|
|
233
|
+
return {
|
|
234
|
+
kind: "value",
|
|
235
|
+
tokenStart: cursor,
|
|
236
|
+
fieldPath: reconstructChain(nonWs, nonWs.length - 2).ref,
|
|
237
|
+
query: "",
|
|
238
|
+
quoted: false,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (partial && isComparator(beforePartial)) {
|
|
242
|
+
const quoted = partial.type === "string";
|
|
243
|
+
// The partial value is at indexOf(partial); the comparator is the
|
|
244
|
+
// token before it, so the operand chain ends two tokens back.
|
|
245
|
+
return {
|
|
246
|
+
kind: "value",
|
|
247
|
+
tokenStart: partial.start,
|
|
248
|
+
fieldPath: reconstructChain(nonWs, nonWs.indexOf(partial) - 2).ref,
|
|
249
|
+
query: quoted ? stripQuotes(partial.value) : partial.value,
|
|
250
|
+
quoted,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 3. Operator stage — a completed operand followed by whitespace. A
|
|
255
|
+
// `rbracket` closes a bracket member access (`artifacts["x"] `), so it
|
|
256
|
+
// also counts as a completed operand.
|
|
257
|
+
if (
|
|
258
|
+
trailingWs &&
|
|
259
|
+
last &&
|
|
260
|
+
(last.type === "path" ||
|
|
261
|
+
last.type === "string" ||
|
|
262
|
+
last.type === "number" ||
|
|
263
|
+
last.type === "rparen" ||
|
|
264
|
+
last.type === "rbracket")
|
|
265
|
+
) {
|
|
266
|
+
return { kind: "operator", tokenStart: cursor };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 4. Field stage (default). Reconstruct the full access chain being
|
|
270
|
+
// typed so a partial like `artifacts["integration-jira.issue"].iss`
|
|
271
|
+
// filters against the field's templateRef (not just the `.iss` tail).
|
|
272
|
+
if (partial && (partial.type === "path" || partial.type === "rbracket")) {
|
|
273
|
+
const partialIndex = nonWs.indexOf(partial);
|
|
274
|
+
const { ref, startIndex } = reconstructChain(nonWs, partialIndex);
|
|
275
|
+
return {
|
|
276
|
+
kind: "field",
|
|
277
|
+
tokenStart: nonWs[startIndex]!.start,
|
|
278
|
+
query: ref,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return { kind: "field", tokenStart: cursor, query: "" };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Reconstruct the full member-access chain that ends at `nonWs[index]`,
|
|
286
|
+
* rebuilding it in EXACTLY the same string form as
|
|
287
|
+
* `CompletionField.templateRef` so the value-stage lookup
|
|
288
|
+
* (`f.templateRef === fieldPath`) matches, AND returning the start index of
|
|
289
|
+
* the chain so the field stage can map back to a replace offset.
|
|
290
|
+
*
|
|
291
|
+
* The chain is a leading identifier followed by any sequence of:
|
|
292
|
+
* - `.ident` dotted members (`...issueKey`),
|
|
293
|
+
* - `["literal"]` bracket key access (rebuilt double-quoted to match
|
|
294
|
+
* `appendTemplateSegment` — see the string branch below), and
|
|
295
|
+
* - `[number]` bracket array-index access (rebuilt as a bare number, no
|
|
296
|
+
* quotes, to match `appendArrayIndex`).
|
|
297
|
+
*
|
|
298
|
+
* e.g. `artifacts["integration-jira.issue"].comments[0].author`. Plain
|
|
299
|
+
* dotted paths such as `trigger.payload.severity` reconstruct unchanged.
|
|
300
|
+
*
|
|
301
|
+
* We scan backward from `index`, collecting a contiguous run of chain
|
|
302
|
+
* tokens, then join them left-to-right.
|
|
303
|
+
*/
|
|
304
|
+
function reconstructChain(
|
|
305
|
+
nonWs: Token[],
|
|
306
|
+
index: number,
|
|
307
|
+
): { ref: string; startIndex: number } {
|
|
308
|
+
// Collect the contiguous chain ending at `index`, scanning backward.
|
|
309
|
+
// `path`/`number` tokens (paths may embed leading/trailing dots), plus
|
|
310
|
+
// matched `[ string ]` and `[ number ]` triples, all belong to the chain.
|
|
311
|
+
// Anything else ends it.
|
|
312
|
+
const chain: Token[] = [];
|
|
313
|
+
let i = index;
|
|
314
|
+
let startIndex = index;
|
|
315
|
+
while (i >= 0) {
|
|
316
|
+
const tok = nonWs[i]!;
|
|
317
|
+
if (tok.type === "path" || tok.type === "number") {
|
|
318
|
+
chain.unshift(tok);
|
|
319
|
+
startIndex = i;
|
|
320
|
+
i -= 1;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
// A bracket index: `] (string|number) [` reading backward.
|
|
324
|
+
if (
|
|
325
|
+
tok.type === "rbracket" &&
|
|
326
|
+
(nonWs[i - 1]?.type === "string" || nonWs[i - 1]?.type === "number") &&
|
|
327
|
+
nonWs[i - 2]?.type === "lbracket"
|
|
328
|
+
) {
|
|
329
|
+
// unshift in left-to-right order: `[ literal ]`.
|
|
330
|
+
chain.unshift(nonWs[i - 2]!, nonWs[i - 1]!, nonWs[i]!);
|
|
331
|
+
startIndex = i - 2;
|
|
332
|
+
i -= 3;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
if (chain.length === 0) return { ref: "", startIndex: index };
|
|
338
|
+
|
|
339
|
+
// Rebuild left-to-right. A `path` token may carry leading/trailing dots
|
|
340
|
+
// (the tokenizer eats `.` greedily), which we preserve verbatim so plain
|
|
341
|
+
// dotted paths round-trip. String bracket triples become `["value"]`;
|
|
342
|
+
// number bracket triples become a bare `[0]`. A lone member-access dot
|
|
343
|
+
// between `]` and an identifier (e.g. `].issueKey`) is dropped by the
|
|
344
|
+
// tokenizer's whitespace fallback, so re-insert a `.` when a `path`
|
|
345
|
+
// segment directly follows a bracket close — but NOT before another `[`.
|
|
346
|
+
let out = "";
|
|
347
|
+
let prevWasBracketClose = false;
|
|
348
|
+
for (let k = 0; k < chain.length; k++) {
|
|
349
|
+
const tok = chain[k]!;
|
|
350
|
+
if (tok.type === "lbracket") {
|
|
351
|
+
const literal = chain[k + 1];
|
|
352
|
+
if (literal?.type === "string") {
|
|
353
|
+
out += `[${JSON.stringify(parseStringLiteral(literal.value))}]`;
|
|
354
|
+
} else if (literal?.type === "number") {
|
|
355
|
+
out += `[${literal.value}]`;
|
|
356
|
+
}
|
|
357
|
+
k += 2; // skip literal + rbracket
|
|
358
|
+
prevWasBracketClose = true;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (prevWasBracketClose && !tok.value.startsWith(".")) {
|
|
362
|
+
out += ".";
|
|
363
|
+
}
|
|
364
|
+
out += tok.value;
|
|
365
|
+
prevWasBracketClose = false;
|
|
366
|
+
}
|
|
367
|
+
return { ref: out, startIndex };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Parse a (possibly single-quoted or unterminated) string literal token to
|
|
372
|
+
* its true runtime value, so the caller can re-`JSON.stringify` it into the
|
|
373
|
+
* canonical double-quoted form WITHOUT double-escaping.
|
|
374
|
+
*
|
|
375
|
+
* A naive `JSON.stringify(stripQuotes(value))` double-escapes keys that
|
|
376
|
+
* contain `"` or `\` (the stored templateRef would re-escape an already
|
|
377
|
+
* escaped sequence). Instead: for a well-formed double-quoted literal use
|
|
378
|
+
* `JSON.parse` directly; for single-quoted or unterminated input, strip the
|
|
379
|
+
* outer quotes and interpret the standard JSON escape sequences minimally so
|
|
380
|
+
* a key like `a"b` round-trips.
|
|
381
|
+
*/
|
|
382
|
+
function parseStringLiteral(value: string): string {
|
|
383
|
+
const quote = value[0];
|
|
384
|
+
if (quote === '"') {
|
|
385
|
+
// Well-formed `"…"` → JSON.parse gives the true value directly.
|
|
386
|
+
try {
|
|
387
|
+
const parsed: unknown = JSON.parse(value);
|
|
388
|
+
if (typeof parsed === "string") return parsed;
|
|
389
|
+
} catch {
|
|
390
|
+
// Unterminated or otherwise invalid — fall through to manual unescape.
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return unescapeQuoted(value);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Strip the outer quote(s) and interpret standard escape sequences from a
|
|
398
|
+
* single-quoted or unterminated literal. Mirrors the JSON escape set the
|
|
399
|
+
* template engine tokenizer accepts.
|
|
400
|
+
*/
|
|
401
|
+
function unescapeQuoted(value: string): string {
|
|
402
|
+
const quote = value[0];
|
|
403
|
+
let inner = value;
|
|
404
|
+
if (quote === '"' || quote === "'") {
|
|
405
|
+
inner = value.slice(1);
|
|
406
|
+
if (inner.endsWith(quote)) inner = inner.slice(0, -1);
|
|
407
|
+
}
|
|
408
|
+
// Mirrors the JSON escape set the template engine tokenizer accepts; any
|
|
409
|
+
// other escaped char (incl. `"` `'` `\` `/`) collapses to the char itself.
|
|
410
|
+
const escapes: Record<string, string> = { n: "\n", t: "\t", r: "\r" };
|
|
411
|
+
let out = "";
|
|
412
|
+
for (let i = 0; i < inner.length; i++) {
|
|
413
|
+
const ch = inner[i]!;
|
|
414
|
+
if (ch === "\\" && i + 1 < inner.length) {
|
|
415
|
+
const next = inner[i + 1]!;
|
|
416
|
+
out += escapes[next] ?? next;
|
|
417
|
+
i += 1;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
out += ch;
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function stripQuotes(value: string): string {
|
|
426
|
+
const quote = value[0];
|
|
427
|
+
if (quote !== '"' && quote !== "'") return value;
|
|
428
|
+
let inner = value.slice(1);
|
|
429
|
+
if (inner.endsWith(quote)) inner = inner.slice(0, -1);
|
|
430
|
+
return inner;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ─── Provider factory ──────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
export interface CreateCompletionProviderArgs {
|
|
436
|
+
fields: CompletionField[];
|
|
437
|
+
filters: CompletionFilter[];
|
|
438
|
+
/**
|
|
439
|
+
* `template` — the value is text with `{{ … }}` blocks; completion
|
|
440
|
+
* only fires inside an (un)closed block. `expression` — the whole
|
|
441
|
+
* value is a single bare expression (used by condition editors).
|
|
442
|
+
*/
|
|
443
|
+
mode: "template" | "expression";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Locate the expression window that the cursor sits in.
|
|
448
|
+
*
|
|
449
|
+
* - expression mode: the whole value, starting at 0.
|
|
450
|
+
* - template mode: the text inside the `{{` that most recently opened
|
|
451
|
+
* before the cursor and hasn't been closed by a `}}` before the
|
|
452
|
+
* cursor. Returns null when the cursor isn't inside a `{{ … }}`.
|
|
453
|
+
*
|
|
454
|
+
* `closed` reports whether a `}}` already exists after the cursor for
|
|
455
|
+
* this block (so field insertion can append one when missing).
|
|
456
|
+
*/
|
|
457
|
+
function locateExpression(
|
|
458
|
+
value: string,
|
|
459
|
+
cursor: number,
|
|
460
|
+
mode: "template" | "expression",
|
|
461
|
+
): { exprStart: number; closed: boolean } | null {
|
|
462
|
+
if (mode === "expression") {
|
|
463
|
+
return { exprStart: 0, closed: true };
|
|
464
|
+
}
|
|
465
|
+
const before = value.slice(0, cursor);
|
|
466
|
+
const open = before.lastIndexOf("{{");
|
|
467
|
+
if (open === -1) return null;
|
|
468
|
+
const closeBefore = before.lastIndexOf("}}");
|
|
469
|
+
if (closeBefore > open) return null; // the latest `{{` was already closed
|
|
470
|
+
const exprStart = open + 2;
|
|
471
|
+
const closed = value.includes("}}", cursor);
|
|
472
|
+
return { exprStart, closed };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function createTemplateCompletionProvider(
|
|
476
|
+
args: CreateCompletionProviderArgs,
|
|
477
|
+
): TemplateCompletionProvider {
|
|
478
|
+
const { fields, filters, mode } = args;
|
|
479
|
+
|
|
480
|
+
return ({ value, cursor }): TemplateCompletionResult | null => {
|
|
481
|
+
const window = locateExpression(value, cursor, mode);
|
|
482
|
+
if (!window) return null;
|
|
483
|
+
|
|
484
|
+
const expr = value.slice(window.exprStart, cursor);
|
|
485
|
+
const stage = analyzeExpression(expr);
|
|
486
|
+
const replaceStart = window.exprStart + stage.tokenStart;
|
|
487
|
+
const replaceEnd = cursor;
|
|
488
|
+
|
|
489
|
+
switch (stage.kind) {
|
|
490
|
+
case "field": {
|
|
491
|
+
return fieldResult({
|
|
492
|
+
stage,
|
|
493
|
+
fields,
|
|
494
|
+
replaceStart,
|
|
495
|
+
replaceEnd,
|
|
496
|
+
appendClose: mode === "template" && !window.closed,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
case "operator": {
|
|
500
|
+
return operatorResult({ replaceStart, replaceEnd });
|
|
501
|
+
}
|
|
502
|
+
case "value": {
|
|
503
|
+
return valueResult({ stage, fields, replaceStart, replaceEnd });
|
|
504
|
+
}
|
|
505
|
+
case "filter": {
|
|
506
|
+
return filterResult({ stage, filters, replaceStart, replaceEnd });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function fieldResult(args: {
|
|
513
|
+
stage: Extract<ExprStage, { kind: "field" }>;
|
|
514
|
+
fields: CompletionField[];
|
|
515
|
+
replaceStart: number;
|
|
516
|
+
replaceEnd: number;
|
|
517
|
+
appendClose: boolean;
|
|
518
|
+
}): TemplateCompletionResult | null {
|
|
519
|
+
const { stage, fields, replaceStart, replaceEnd, appendClose } = args;
|
|
520
|
+
const q = stage.query.toLowerCase();
|
|
521
|
+
// Match + insert in templateRef space (what gets written into `{{ }}`).
|
|
522
|
+
// Also match against the canonical `path` so typing the dotted form
|
|
523
|
+
// (or shell-flavoured fragments) still surfaces the field.
|
|
524
|
+
const matches = fields.filter(
|
|
525
|
+
(f) =>
|
|
526
|
+
q === "" ||
|
|
527
|
+
f.templateRef.toLowerCase().includes(q) ||
|
|
528
|
+
f.path.toLowerCase().includes(q),
|
|
529
|
+
);
|
|
530
|
+
if (matches.length === 0) return null;
|
|
531
|
+
return {
|
|
532
|
+
heading: "Fields",
|
|
533
|
+
replaceStart,
|
|
534
|
+
replaceEnd,
|
|
535
|
+
items: matches.map((f) => {
|
|
536
|
+
// Always append a trailing space so the caret lands in
|
|
537
|
+
// whitespace after the field — that re-opens completion in the
|
|
538
|
+
// operator stage (comparators / filters) automatically, the same
|
|
539
|
+
// way picking a comparator advances to the value stage.
|
|
540
|
+
if (appendClose) {
|
|
541
|
+
// Unclosed `{{` — also append the closing braces, and land the
|
|
542
|
+
// caret after the space but before `}}` (offset -2 into ` }}`).
|
|
543
|
+
return {
|
|
544
|
+
label: f.templateRef,
|
|
545
|
+
detail: f.type,
|
|
546
|
+
description: f.description,
|
|
547
|
+
insertText: `${f.templateRef} }}`,
|
|
548
|
+
caretOffset: -2,
|
|
549
|
+
} satisfies TemplateCompletionItem;
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
label: f.templateRef,
|
|
553
|
+
detail: f.type,
|
|
554
|
+
description: f.description,
|
|
555
|
+
insertText: `${f.templateRef} `,
|
|
556
|
+
caretOffset: 0,
|
|
557
|
+
} satisfies TemplateCompletionItem;
|
|
558
|
+
}),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function operatorResult(args: {
|
|
563
|
+
replaceStart: number;
|
|
564
|
+
replaceEnd: number;
|
|
565
|
+
}): TemplateCompletionResult {
|
|
566
|
+
const { replaceStart, replaceEnd } = args;
|
|
567
|
+
const items: TemplateCompletionItem[] = [
|
|
568
|
+
...COMPARATORS.map((op) => ({
|
|
569
|
+
label: op,
|
|
570
|
+
detail: "comparator",
|
|
571
|
+
insertText: `${op} `,
|
|
572
|
+
})),
|
|
573
|
+
...LOGICAL.map((op) => ({
|
|
574
|
+
label: op,
|
|
575
|
+
detail: "logical",
|
|
576
|
+
insertText: `${op} `,
|
|
577
|
+
})),
|
|
578
|
+
{
|
|
579
|
+
label: "|",
|
|
580
|
+
detail: "filter pipe",
|
|
581
|
+
description: "Apply a filter to the value.",
|
|
582
|
+
insertText: "| ",
|
|
583
|
+
},
|
|
584
|
+
];
|
|
585
|
+
return { heading: "Operators", replaceStart, replaceEnd, items };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function valueResult(args: {
|
|
589
|
+
stage: Extract<ExprStage, { kind: "value" }>;
|
|
590
|
+
fields: CompletionField[];
|
|
591
|
+
replaceStart: number;
|
|
592
|
+
replaceEnd: number;
|
|
593
|
+
}): TemplateCompletionResult | null {
|
|
594
|
+
const { stage, fields, replaceStart, replaceEnd } = args;
|
|
595
|
+
// `stage.fieldPath` is reconstructed by `reconstructChain` in templateRef
|
|
596
|
+
// form, so match against `templateRef` (not the canonical `path`).
|
|
597
|
+
const field = fields.find((f) => f.templateRef === stage.fieldPath);
|
|
598
|
+
const values = possibleValues(field);
|
|
599
|
+
if (!values || values.length === 0) return null;
|
|
600
|
+
|
|
601
|
+
const q = stage.query.toLowerCase();
|
|
602
|
+
const matches = values.filter(
|
|
603
|
+
(v) => q === "" || String(v.raw).toLowerCase().includes(q),
|
|
604
|
+
);
|
|
605
|
+
if (matches.length === 0) return null;
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
heading: field ? `Values for ${field.templateRef}` : "Values",
|
|
609
|
+
replaceStart,
|
|
610
|
+
replaceEnd,
|
|
611
|
+
items: matches.map((v) => ({
|
|
612
|
+
label: v.display,
|
|
613
|
+
detail: typeof v.raw,
|
|
614
|
+
insertText: v.insert,
|
|
615
|
+
})),
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function possibleValues(
|
|
620
|
+
field: CompletionField | undefined,
|
|
621
|
+
): Array<{ raw: string | number | boolean; display: string; insert: string }> | null {
|
|
622
|
+
if (!field) return null;
|
|
623
|
+
if (field.enumValues && field.enumValues.length > 0) {
|
|
624
|
+
return field.enumValues.map((raw) => ({
|
|
625
|
+
raw,
|
|
626
|
+
display: typeof raw === "string" ? `"${raw}"` : String(raw),
|
|
627
|
+
insert: typeof raw === "string" ? JSON.stringify(raw) : String(raw),
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
// Boolean fields have a known closed value set even without an enum.
|
|
631
|
+
if (field.type === "boolean") {
|
|
632
|
+
return [
|
|
633
|
+
{ raw: true, display: "true", insert: "true" },
|
|
634
|
+
{ raw: false, display: "false", insert: "false" },
|
|
635
|
+
];
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function filterResult(args: {
|
|
641
|
+
stage: Extract<ExprStage, { kind: "filter" }>;
|
|
642
|
+
filters: CompletionFilter[];
|
|
643
|
+
replaceStart: number;
|
|
644
|
+
replaceEnd: number;
|
|
645
|
+
}): TemplateCompletionResult | null {
|
|
646
|
+
const { stage, filters, replaceStart, replaceEnd } = args;
|
|
647
|
+
const q = stage.query.toLowerCase();
|
|
648
|
+
const matches = filters.filter(
|
|
649
|
+
(f) => q === "" || f.name.toLowerCase().includes(q),
|
|
650
|
+
);
|
|
651
|
+
if (matches.length === 0) return null;
|
|
652
|
+
return {
|
|
653
|
+
heading: "Filters",
|
|
654
|
+
replaceStart,
|
|
655
|
+
replaceEnd,
|
|
656
|
+
items: matches.map((f) => ({
|
|
657
|
+
label: f.signature ? `${f.name}(${f.signature})` : f.name,
|
|
658
|
+
detail: "filter",
|
|
659
|
+
description: f.description,
|
|
660
|
+
insertText: f.hasArgs ? `${f.name}()` : f.name,
|
|
661
|
+
caretOffset: f.hasArgs ? -1 : 0,
|
|
662
|
+
})),
|
|
663
|
+
};
|
|
664
|
+
}
|