@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +664 -0
  2. package/package.json +38 -0
  3. package/src/components/AutomationMenuItems.tsx +37 -0
  4. package/src/editor/ActionEditor.tsx +367 -0
  5. package/src/editor/ActionListEditor.tsx +203 -0
  6. package/src/editor/AddActionDialog.tsx +225 -0
  7. package/src/editor/AutomationDefinitionContext.tsx +37 -0
  8. package/src/editor/AutomationDefinitionEditor.tsx +99 -0
  9. package/src/editor/ConditionEditor.tsx +218 -0
  10. package/src/editor/ConditionsEditor.tsx +89 -0
  11. package/src/editor/ItemPicker.tsx +147 -0
  12. package/src/editor/TriggersEditor.tsx +269 -0
  13. package/src/editor/action-composite-cards.tsx +390 -0
  14. package/src/editor/action-helpers.ts +365 -0
  15. package/src/editor/action-leaf-cards.tsx +426 -0
  16. package/src/editor/editor-validation.test.ts +95 -0
  17. package/src/editor/editor-validation.tsx +200 -0
  18. package/src/editor/registry-context.tsx +192 -0
  19. package/src/editor/template-completion.test.ts +412 -0
  20. package/src/editor/template-completion.ts +664 -0
  21. package/src/editor/template-helpers.test.ts +145 -0
  22. package/src/editor/template-helpers.ts +95 -0
  23. package/src/editor/trigger-helpers.test.ts +58 -0
  24. package/src/editor/trigger-helpers.ts +67 -0
  25. package/src/editor/useConnectionOptionResolvers.ts +80 -0
  26. package/src/editor/yaml-markers.ts +116 -0
  27. package/src/index.tsx +95 -0
  28. package/src/pages/AutomationEditPage.tsx +567 -0
  29. package/src/pages/AutomationListPage.tsx +304 -0
  30. package/src/pages/RunDetailPage.tsx +333 -0
  31. package/src/pages/RunsPage.tsx +233 -0
  32. package/src/pages/TemplatePlaygroundPage.tsx +224 -0
  33. package/src/script-context.test.ts +247 -0
  34. package/src/script-context.ts +218 -0
  35. 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
+ }