Compose validate - a preflight linter & validator for compose files
This commit is contained in:
13 files changed
+2297
-15
No files matched your search
@@ -211,6 +211,14 @@
|
||||
defaultValue?: string; // The default value from compose syntax (e.g., ${VAR:-default})
|
||||
}
|
||||
|
||||
/** A Compose Validate finding placed on a source line (IDE-style lint marker). */
|
||||
export interface LintMarker {
|
||||
line: number; // 1-based
|
||||
severity: 'error' | 'warn' | 'info';
|
||||
ruleId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
language?: string;
|
||||
@@ -219,12 +227,17 @@
|
||||
onchange?: (value: string) => void;
|
||||
class?: string;
|
||||
variableMarkers?: VariableMarker[];
|
||||
/** Compose Validate findings rendered as line underlines + gutter icons. */
|
||||
lintMarkers?: LintMarker[];
|
||||
/** Fired when a lint gutter icon / underlined line is clicked (for the mini-modal). */
|
||||
onLintClick?: (line: number) => void;
|
||||
}
|
||||
|
||||
let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers: variableMarkersProp = [] }: Props = $props();
|
||||
let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers: variableMarkersProp = [], lintMarkers: lintMarkersProp = [], onLintClick }: Props = $props();
|
||||
|
||||
// Keep markers reactive - destructured props with defaults lose reactivity
|
||||
const variableMarkers = $derived(variableMarkersProp);
|
||||
const lintMarkers = $derived(lintMarkersProp);
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let view: EditorView | null = null;
|
||||
@@ -512,6 +525,126 @@
|
||||
initialSpacer: () => new VariableGutterMarker('required')
|
||||
});
|
||||
|
||||
// --- Compose Validate lint markers (line underline + clickable gutter icon) -------
|
||||
// Callback ref so the click handler updates without recreating the editor.
|
||||
let onLintClickRef: ((line: number) => void) | undefined = onLintClick;
|
||||
$effect(() => { onLintClickRef = onLintClick; });
|
||||
|
||||
// Inner SVG paths for the lucide icons used on the gutter (circle-x / triangle-alert
|
||||
// / info). Rendered inline because a CodeMirror GutterMarker can't mount a Svelte
|
||||
// component; currentColor follows the severity class colour.
|
||||
const LINT_ICON_SVG = {
|
||||
error: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
|
||||
warn: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>'
|
||||
} as const;
|
||||
|
||||
class LintGutterMarker extends GutterMarker {
|
||||
severity: 'error' | 'warn' | 'info';
|
||||
line: number;
|
||||
tooltip: string;
|
||||
constructor(severity: 'error' | 'warn' | 'info', line: number, tooltip: string) {
|
||||
super();
|
||||
this.severity = severity;
|
||||
this.line = line;
|
||||
this.tooltip = tooltip;
|
||||
}
|
||||
// Let CodeMirror reuse the DOM when the marker is unchanged, so an unrelated
|
||||
// keystroke doesn't rebuild the gutter icons.
|
||||
eq(other: LintGutterMarker) {
|
||||
return (
|
||||
this.severity === other.severity && this.line === other.line && this.tooltip === other.tooltip
|
||||
);
|
||||
}
|
||||
toDOM() {
|
||||
const el = document.createElement('span');
|
||||
el.className = `cm-lint-marker cm-lint-${this.severity}`;
|
||||
// The native title shows the actual finding(s) on hover (click for full details).
|
||||
el.title = this.tooltip;
|
||||
el.setAttribute('role', 'button');
|
||||
el.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">${LINT_ICON_SVG[this.severity]}</svg>`;
|
||||
el.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onLintClickRef?.(this.line);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
const updateLintEffect = StateEffect.define<LintMarker[]>();
|
||||
const currentLintField = StateField.define<LintMarker[]>({
|
||||
create: () => lintMarkers,
|
||||
update(v, tr) {
|
||||
for (const e of tr.effects) if (e.is(updateLintEffect)) return e.value;
|
||||
return v;
|
||||
}
|
||||
});
|
||||
|
||||
// Highest severity per line drives both the gutter icon and the underline colour.
|
||||
function worstBySeverityPerLine(markers: LintMarker[]): Map<number, 'error' | 'warn' | 'info'> {
|
||||
const rank = { error: 0, warn: 1, info: 2 } as const;
|
||||
const m = new Map<number, 'error' | 'warn' | 'info'>();
|
||||
for (const f of markers) {
|
||||
const prev = m.get(f.line);
|
||||
if (!prev || rank[f.severity] < rank[prev]) m.set(f.line, f.severity);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// All finding messages on a line, for the hover tooltip.
|
||||
function messagesForLine(markers: LintMarker[], line: number): string {
|
||||
const msgs = markers.filter((m) => m.line === line).map((m) => m.message);
|
||||
return msgs.join('\n');
|
||||
}
|
||||
|
||||
function buildLintGutter(doc: any, markers: LintMarker[]): RangeSet<GutterMarker> {
|
||||
const worst = worstBySeverityPerLine(markers);
|
||||
const builder: { from: number; marker: GutterMarker }[] = [];
|
||||
for (const [line, sev] of worst) {
|
||||
if (line < 1 || line > doc.lines) continue;
|
||||
builder.push({ from: doc.line(line).from, marker: new LintGutterMarker(sev, line, messagesForLine(markers, line)) });
|
||||
}
|
||||
builder.sort((a, b) => a.from - b.from);
|
||||
return RangeSet.of(builder.map((b) => b.marker.range(b.from)), true);
|
||||
}
|
||||
|
||||
function buildLintDecorations(doc: any, markers: LintMarker[]): DecorationSet {
|
||||
const worst = worstBySeverityPerLine(markers);
|
||||
const decos: { from: number; deco: Decoration }[] = [];
|
||||
for (const [line, sev] of worst) {
|
||||
if (line < 1 || line > doc.lines) continue;
|
||||
decos.push({
|
||||
from: doc.line(line).from,
|
||||
deco: Decoration.line({ class: `cm-lint-line cm-lint-line-${sev}` })
|
||||
});
|
||||
}
|
||||
decos.sort((a, b) => a.from - b.from);
|
||||
return Decoration.set(decos.map((d) => d.deco.range(d.from)), true);
|
||||
}
|
||||
|
||||
const lintGutterField = StateField.define<RangeSet<GutterMarker>>({
|
||||
create: (state) => buildLintGutter(state.doc, lintMarkers),
|
||||
update(v, tr) {
|
||||
for (const e of tr.effects) if (e.is(updateLintEffect)) return buildLintGutter(tr.state.doc, e.value);
|
||||
return tr.docChanged ? buildLintGutter(tr.state.doc, tr.state.field(currentLintField)) : v;
|
||||
}
|
||||
});
|
||||
|
||||
const lintDecorationsField = StateField.define<DecorationSet>({
|
||||
create: (state) => buildLintDecorations(state.doc, lintMarkers),
|
||||
update(v, tr) {
|
||||
for (const e of tr.effects) if (e.is(updateLintEffect)) return buildLintDecorations(tr.state.doc, e.value);
|
||||
return tr.docChanged ? buildLintDecorations(tr.state.doc, tr.state.field(currentLintField)) : v;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f)
|
||||
});
|
||||
|
||||
const lintGutter = gutter({
|
||||
class: 'cm-lint-gutter',
|
||||
markers: (view) => view.state.field(lintGutterField)
|
||||
});
|
||||
|
||||
// YAML Enter handler: after a key-only line ending with ":", indent one level
|
||||
// deeper than what the default indent service returns (it can't predict child
|
||||
// indent when no child content exists yet).
|
||||
@@ -732,6 +865,12 @@
|
||||
|
||||
// Always add variable markers gutter and value decorations (can be updated dynamically)
|
||||
extensions.push(currentMarkersField, variableMarkersField, variableGutter, valueDecorationsField);
|
||||
// Lint gutter/decorations only when this editor is a lint consumer (Compose
|
||||
// validate wires onLintClick, or passes markers up front). Otherwise every other
|
||||
// CodeEditor would render an empty 18px lint gutter column.
|
||||
if (onLintClickRef || lintMarkers.length > 0) {
|
||||
extensions.push(currentLintField, lintGutterField, lintDecorationsField, lintGutter);
|
||||
}
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
@@ -764,7 +903,9 @@
|
||||
dispatchTransactions
|
||||
});
|
||||
|
||||
// Push initial markers if provided
|
||||
// Push initial variable markers if provided. Lint markers need no initial
|
||||
// dispatch: currentLintField/gutter are created from lintMarkers, and the reactive
|
||||
// $effect below fires once on mount - dispatching here too would double it.
|
||||
if (variableMarkers.length > 0) {
|
||||
view.dispatch({
|
||||
effects: updateMarkersEffect.of(variableMarkers)
|
||||
@@ -772,6 +913,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Re-dispatch lint markers whenever the prop changes (reactive).
|
||||
$effect(() => {
|
||||
const markers = lintMarkers;
|
||||
if (view) view.dispatch({ effects: updateLintEffect.of(markers) });
|
||||
});
|
||||
|
||||
function destroyEditor() {
|
||||
if (markerUpdateTimer) {
|
||||
clearTimeout(markerUpdateTimer);
|
||||
@@ -808,6 +955,19 @@
|
||||
view?.focus();
|
||||
}
|
||||
|
||||
/** Scroll a 1-based line into view and place the cursor on it (used by validate jump-to). */
|
||||
export function scrollToLine(line: number) {
|
||||
if (!view) return;
|
||||
const doc = view.state.doc;
|
||||
if (line < 1 || line > doc.lines) return;
|
||||
const pos = doc.line(line).from;
|
||||
view.dispatch({
|
||||
selection: { anchor: pos },
|
||||
effects: EditorView.scrollIntoView(pos, { y: 'center' })
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
// Update variable markers - this is the key method for parent to call
|
||||
// Debounced to prevent flicker during fast typing
|
||||
export function updateVariableMarkers(markers: VariableMarker[], immediate = false) {
|
||||
@@ -962,6 +1122,38 @@
|
||||
box-shadow: 0 0 4px #10b981;
|
||||
}
|
||||
|
||||
/* --- Compose Validate lint markers ------------------------------------------- */
|
||||
div :global(.cm-lint-gutter) {
|
||||
width: 18px;
|
||||
}
|
||||
div :global(.cm-lint-marker) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 3px 2px;
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.12s ease, transform 0.08s ease;
|
||||
}
|
||||
div :global(.cm-lint-marker svg) { width: 13px; height: 13px; }
|
||||
div :global(.cm-lint-marker:hover) { opacity: 1; transform: scale(1.15); }
|
||||
div :global(.cm-lint-error) { color: #ef4444; }
|
||||
div :global(.cm-lint-warn) { color: #f59e0b; }
|
||||
div :global(.cm-lint-info) { color: #3b82f6; }
|
||||
|
||||
/* Line highlight: a faint tint by severity (no gutter bar). */
|
||||
div :global(.cm-lint-line-error) {
|
||||
background-color: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
div :global(.cm-lint-line-warn) {
|
||||
background-color: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
div :global(.cm-lint-line-info) {
|
||||
background-color: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
/* Variable value overlay widget - base styles */
|
||||
div :global(.var-value-overlay) {
|
||||
display: inline-block;
|
||||
|
||||
Reference in new issue
Block a user