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:
44
ui/src/components/MustacheEditor/mustache-completion.test.ts
Normal file
44
ui/src/components/MustacheEditor/mustache-completion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
43
ui/src/components/MustacheEditor/mustache-completion.ts
Normal file
43
ui/src/components/MustacheEditor/mustache-completion.ts
Normal 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_.]*$/,
|
||||
};
|
||||
};
|
||||
}
|
||||
47
ui/src/components/MustacheEditor/mustache-linter.test.ts
Normal file
47
ui/src/components/MustacheEditor/mustache-linter.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
62
ui/src/components/MustacheEditor/mustache-linter.ts
Normal file
62
ui/src/components/MustacheEditor/mustache-linter.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user