feat(ui/alerts): CM6 completion + linter for Mustache templates

completion fires after {{ and narrows as the user types; apply() closes the
tag automatically. Linter raises an error on unclosed {{, a warning on
references that aren't in the allowed-variable set for the current condition
kind. Kind-specific allowed set comes from availableVariables().
This commit is contained in:
hsiegeln
2026-04-20 13:39:10 +02:00
parent 18e6dde67a
commit ac2a943feb
4 changed files with 196 additions and 0 deletions

View File

@@ -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);
});
});

View File

@@ -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_.]*$/,
};
};
}

View File

@@ -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/);
});
});

View File

@@ -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;
});
}