#!/usr/bin/env node // Persistent mermaid syntax validator, driven by lua/config/mermaid.lua. // // mermaid's own parsers are the only authoritative syntax check: tree-sitter's // mermaid grammar rejects a large amount of valid mermaid (`graph`, `style`, // `classDef`, `click`, `box`, `create`, ... measured at 12/40 valid samples), // so it is used for highlighting only and every diagnostic comes from here. // // Protocol: newline-delimited JSON, one request per line on stdin, one // response per line on stdout, correlated by `id`. // -> {"id":1,"text":"flowchart TD\n A --> B\n"} // <- {"id":1,"ok":true} // <- {"id":1,"ok":false,"line":2,"col":8,"message":"Expecting 'SQE', ..."} // `line` is 1-based and `col` is 0-based, both relative to the request text. // // The process exits on stdin EOF and after `--idle-ms` without a request, so // the ~300MB mermaid+jsdom heap is not resident for the whole nvim session. const idleArg = process.argv.indexOf("--idle-ms"); const IDLE_MS = idleArg === -1 ? 300_000 : Number(process.argv[idleArg + 1]) || 300_000; // mermaid reaches for DOMPurify (and therefore a DOM) while parsing several // diagram types, so a jsdom window has to exist before it is imported. const { JSDOM } = await import("jsdom"); const dom = new JSDOM("", { pretendToBeVisual: true }); for (const key of ["window", "document", "DOMParser", "Element", "Node", "SVGElement", "HTMLElement", "navigator"]) { const value = key === "window" ? dom.window : dom.window[key]; // node >= 21 exposes `navigator` as a getter-only global Object.defineProperty(globalThis, key, { value, configurable: true, writable: key !== "navigator" }); } const mermaid = (await import("mermaid")).default; mermaid.initialize({ startOnLoad: false, suppressErrorRendering: true }); /** Drop everything mermaid strips before parsing, keeping a line map. * * mermaid removes frontmatter, `%%` comments, `%%{init}%%` directives and * leading blank lines before a diagram parser ever sees the text, so the line * numbers it reports count the *stripped* text. Doing the same removals here * makes mermaid's own pass a no-op and `map` translates back: reduced line i * (0-based) came from original line `map[i]` (0-based). * @param {string} text * @returns {{text: string, map: number[]}} */ function preprocess(text) { const lines = text.split("\n"); const kept = []; const map = []; let index = 0; // frontmatter: only when `---` opens the very first non-blank line if (/^\s*---[ \t]*$/.test(lines[index] ?? "")) { for (let scan = index + 1; scan < lines.length; scan++) { if (/^\s*---[ \t]*$/.test(lines[scan])) { index = scan + 1; break; } } } for (; index < lines.length; index++) { const line = lines[index]; if (/^\s*%%(?!\{)/.test(line)) continue; // comment line if (/^\s*%%\{/.test(line)) { // directive: single line, or spanning lines until the `}%%` close while (!/\}%%/.test(lines[index]) && index + 1 < lines.length) index++; continue; } if (kept.length === 0 && /^\s*$/.test(line)) continue; // leading blank kept.push(line); map.push(index); } return { text: kept.join("\n"), map }; } /** Position of a parse failure within the request text. * @param {any} error * @returns {{line: number, col: number}} */ function position(error) { const loc = error?.hash?.loc; // jison parsers: 1-based lines, 0-based columns if (typeof loc?.first_line === "number") { return { line: loc.first_line, col: typeof loc.first_column === "number" ? loc.first_column : 0 }; } const message = String(error?.message ?? error); // langium parsers and the lexers report position in prose only const both = /\bline (\d+), column (\d+)/.exec(message); if (both) return { line: Number(both[1]), col: Math.max(0, Number(both[2]) - 1) }; const lineOnly = /\bline (\d+)/.exec(message); if (lineOnly) return { line: Number(lineOnly[1]), col: 0 }; return { line: 1, col: 0 }; } /** Human-readable one-line reason, with position prose and echoed source removed. * @param {any} error * @returns {string} */ function describe(error) { if (error?.name === "UnknownDiagramError") return "unknown diagram type"; const lines = String(error?.message ?? error) .split("\n") .map((line) => line.trim()) .filter(Boolean); // jison spells the failure out on a trailing `Expecting ...` line, after an // excerpt of the source and a caret line that are noise in a diagnostic const detail = lines.find((line) => /^(Expecting|Expected|Unrecognized|Invalid|Unknown)/.test(line)); const text = (detail ?? lines[lines.length - 1] ?? "parse error") .replace(/^Parsing failed:\s*/, "") .replace(/^Parse error on line \d+(, column \d+)?:?\s*/, "") .replace(/^Lexical error on line \d+\.\s*/, ""); return text.length > 300 ? `${text.slice(0, 297)}...` : text; } /** @param {string} text @returns {Promise} */ async function validate(text) { const { text: stripped, map } = preprocess(text); try { await mermaid.parse(stripped); return { ok: true }; } catch (error) { const { line, col } = position(error); // map back to the request text; an out-of-range line means mermaid pointed // past the stripped text, so fall back to its last line const mapped = map[line - 1] ?? map[map.length - 1] ?? 0; return { ok: false, line: mapped + 1, col, message: describe(error) }; } } let idleTimer; function resetIdleTimer() { clearTimeout(idleTimer); idleTimer = setTimeout(() => process.exit(0), IDLE_MS); idleTimer.unref?.(); } const queue = []; let draining = false; async function drain() { if (draining) return; draining = true; while (queue.length) { const request = queue.shift(); const result = await validate(String(request.text ?? "")); process.stdout.write(`${JSON.stringify({ id: request.id, ...result })}\n`); } draining = false; } let buffer = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { resetIdleTimer(); buffer += chunk; let newline = buffer.indexOf("\n"); while (newline !== -1) { const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); newline = buffer.indexOf("\n"); if (!line) continue; try { queue.push(JSON.parse(line)); } catch { // a malformed request line cannot be correlated to a response; drop it } } void drain(); }); process.stdin.on("end", () => process.exit(0)); process.stdout.on("error", () => process.exit(0)); resetIdleTimer();