diff --git a/ui/src/components/MustacheEditor/mustache-completion.test.ts b/ui/src/components/MustacheEditor/mustache-completion.test.ts new file mode 100644 index 00000000..13ae845c --- /dev/null +++ b/ui/src/components/MustacheEditor/mustache-completion.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { EditorState } from '@codemirror/state'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { mustacheCompletionSource } from './mustache-completion'; +import { availableVariables } from './alert-variables'; + +function makeContext(doc: string, pos: number): CompletionContext { + const state = EditorState.create({ doc }); + return new CompletionContext(state, pos, true); +} + +describe('mustacheCompletionSource', () => { + const source = mustacheCompletionSource(availableVariables('ROUTE_METRIC')); + + it('returns null outside a Mustache tag', () => { + const ctx = makeContext('Hello world', 5); + expect(source(ctx)).toBeNull(); + }); + + it('offers completions right after {{', () => { + const ctx = makeContext('Hello {{', 8); + const result = source(ctx)!; + expect(result).not.toBeNull(); + const paths = result.options.map((o) => o.label); + expect(paths).toContain('env.slug'); + expect(paths).toContain('alert.firedAt'); + }); + + it('narrows as user types', () => { + const ctx = makeContext('{{ale', 5); + const result = source(ctx)!; + const paths = result.options.map((o) => o.label); + expect(paths.every((p) => p.startsWith('ale'))).toBe(true); + expect(paths).toContain('alert.firedAt'); + expect(paths).not.toContain('env.slug'); + }); + + it('does not offer out-of-kind vars', () => { + const ctx = makeContext('{{exchange', 10); + const result = source(ctx)!; + // ROUTE_METRIC does not include exchange.* — expect no exchange. completions + expect(result.options).toHaveLength(0); + }); +}); diff --git a/ui/src/components/MustacheEditor/mustache-completion.ts b/ui/src/components/MustacheEditor/mustache-completion.ts new file mode 100644 index 00000000..9ad74d83 --- /dev/null +++ b/ui/src/components/MustacheEditor/mustache-completion.ts @@ -0,0 +1,43 @@ +import type { CompletionContext, CompletionResult, Completion } from '@codemirror/autocomplete'; +import type { AlertVariable } from './alert-variables'; + +/** Build a CodeMirror completion source that triggers after `{{` (with optional whitespace) + * and suggests variable paths from the given list. */ +export function mustacheCompletionSource(variables: readonly AlertVariable[]) { + return (context: CompletionContext): CompletionResult | null => { + // Look backward for `{{` optionally followed by whitespace, then an in-progress identifier. + const line = context.state.doc.lineAt(context.pos); + const textBefore = line.text.slice(0, context.pos - line.from); + const m = /\{\{\s*([a-zA-Z0-9_.]*)$/.exec(textBefore); + if (!m) return null; + + const partial = m[1]; + const from = context.pos - partial.length; + + const options: Completion[] = variables + .filter((v) => v.path.startsWith(partial)) + .map((v) => ({ + label: v.path, + type: v.mayBeNull ? 'variable' : 'constant', + detail: v.type, + info: v.mayBeNull + ? `${v.description} (may be null) · e.g. ${v.sampleValue}` + : `${v.description} · e.g. ${v.sampleValue}`, + // Inserting closes the Mustache tag; CM will remove the partial prefix. + apply: (view, _completion, completionFrom, to) => { + const insert = `${v.path}}}`; + view.dispatch({ + changes: { from: completionFrom, to, insert }, + selection: { anchor: completionFrom + insert.length }, + }); + }, + })); + + return { + from, + to: context.pos, + options, + validFor: /^[a-zA-Z0-9_.]*$/, + }; + }; +} diff --git a/ui/src/components/MustacheEditor/mustache-linter.test.ts b/ui/src/components/MustacheEditor/mustache-linter.test.ts new file mode 100644 index 00000000..38dc74b7 --- /dev/null +++ b/ui/src/components/MustacheEditor/mustache-linter.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { forEachDiagnostic } from '@codemirror/lint'; +import { mustacheLinter } from './mustache-linter'; +import { availableVariables } from './alert-variables'; + +function makeView(doc: string) { + return new EditorView({ + state: EditorState.create({ + doc, + extensions: [mustacheLinter(availableVariables('ROUTE_METRIC'))], + }), + }); +} + +async function diagnosticsFor(doc: string): Promise< + Array<{ severity: string; message: string; from: number; to: number }> +> { + const view = makeView(doc); + // @codemirror/lint debounces with a default 750ms delay — wait past that for the source to run. + await new Promise((r) => setTimeout(r, 900)); + const out: Array<{ severity: string; message: string; from: number; to: number }> = []; + forEachDiagnostic(view.state, (d, from, to) => + out.push({ severity: d.severity, message: d.message, from, to }), + ); + view.destroy(); + return out; +} + +describe('mustacheLinter', () => { + it('accepts a valid template with no warnings', async () => { + const diags = await diagnosticsFor('Rule {{rule.name}} in env {{env.slug}}'); + expect(diags).toEqual([]); + }); + + it('flags unclosed {{', async () => { + const diags = await diagnosticsFor('Hello {{alert.firedAt'); + expect(diags.find((d) => d.severity === 'error' && /unclosed/i.test(d.message))).toBeTruthy(); + }); + + it('warns on unknown variable', async () => { + const diags = await diagnosticsFor('{{exchange.id}}'); + const warn = diags.find((d) => d.severity === 'warning'); + expect(warn?.message).toMatch(/exchange\.id.*not available/); + }); +}); diff --git a/ui/src/components/MustacheEditor/mustache-linter.ts b/ui/src/components/MustacheEditor/mustache-linter.ts new file mode 100644 index 00000000..d88e1dc5 --- /dev/null +++ b/ui/src/components/MustacheEditor/mustache-linter.ts @@ -0,0 +1,62 @@ +import { linter, type Diagnostic } from '@codemirror/lint'; +import type { AlertVariable } from './alert-variables'; + +/** Lints a Mustache template for (a) unclosed `{{`, (b) references to out-of-scope variables. + * Unknown refs become amber warnings; unclosed `{{` becomes a red error. */ +export function mustacheLinter(allowed: readonly AlertVariable[]) { + return linter((view) => { + const diags: Diagnostic[] = []; + const text = view.state.doc.toString(); + + // 1. Unclosed / unmatched braces. + // A single `{{` without a matching `}}` before end-of-doc is an error. + let i = 0; + while (i < text.length) { + const open = text.indexOf('{{', i); + if (open === -1) break; + const close = text.indexOf('}}', open + 2); + if (close === -1) { + diags.push({ + from: open, + to: text.length, + severity: 'error', + message: 'Unclosed Mustache tag `{{` — add `}}` to close.', + }); + break; + } + i = close + 2; + } + + // 2. Stray `}}` with no preceding `{{` on the same token stream. + // Approximation: count opens/closes; if doc ends with more closes than opens, flag last. + const openCount = (text.match(/\{\{/g) ?? []).length; + const closeCount = (text.match(/\}\}/g) ?? []).length; + if (closeCount > openCount) { + const lastClose = text.lastIndexOf('}}'); + diags.push({ + from: lastClose, + to: lastClose + 2, + severity: 'error', + message: 'Unmatched `}}` — no opening `{{` for this close.', + }); + } + + // 3. Unknown variable references (amber warning). + const allowedSet = new Set(allowed.map((v) => v.path)); + const refRe = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g; + let m: RegExpExecArray | null; + while ((m = refRe.exec(text)) !== null) { + const ref = m[1]; + if (!allowedSet.has(ref)) { + diags.push({ + from: m.index, + to: m.index + m[0].length, + severity: 'warning', + message: `\`${ref}\` is not available for this rule kind — will render as literal.`, + }); + } + } + + return diags; + }); +}