feat(config): add mermaid diagnostics configuration
This commit is contained in:
1 parent
77b718c77f
commit
13e638e77b
1 file changed
+420
@@ -0,0 +1,420 @@
|
||||
--- Mermaid syntax diagnostics for `mermaid` buffers and ```mermaid fences in
|
||||
--- markdown/mdx.
|
||||
---
|
||||
--- Why not a language server: there is no packaged mermaid LSP (nothing on npm,
|
||||
--- nothing in mason), and no client-side parser is trustworthy. The tree-sitter
|
||||
--- mermaid grammar is stale enough to reject valid mermaid — `graph`, `style`,
|
||||
--- `classDef`, `click`, `linkStyle`, `box`, `create`, `critical`, frontmatter
|
||||
--- and `note for` all produce ERROR nodes (measured: 12 of 40 valid samples) —
|
||||
--- so it is used for highlighting only, where a mis-parse costs colors instead
|
||||
--- of a false error.
|
||||
---
|
||||
--- Instead mermaid's own parsers decide, hosted in a persistent node process
|
||||
--- (`scripts/mermaid-validator.mjs`, protocol documented there). That process
|
||||
--- needs `mermaid` + `jsdom` in a private workspace under `stdpath("data")`;
|
||||
--- the first validation installs them with `npm` and reports progress. It exits
|
||||
--- itself after five idle minutes so its ~300MB heap is not resident for the
|
||||
--- whole session, and is respawned on demand.
|
||||
--- @module config.mermaid
|
||||
|
||||
local M = {}
|
||||
|
||||
local NS = vim.api.nvim_create_namespace "mermaid"
|
||||
local ROOT = vim.fs.joinpath(vim.fn.stdpath "data", "mermaid-validator")
|
||||
local SCRIPT = vim.fs.joinpath(ROOT, "validator.mjs")
|
||||
local SOURCE = vim.fs.joinpath(vim.fn.stdpath "config", "scripts", "mermaid-validator.mjs")
|
||||
local IDLE_MS = 300000
|
||||
local DEBOUNCE_MS = 400
|
||||
|
||||
-- pinned so a mermaid major bump cannot silently change what counts as valid
|
||||
local PACKAGE_JSON = table.concat({
|
||||
"{",
|
||||
' "name": "nvim-mermaid-validator",',
|
||||
' "private": true,',
|
||||
' "type": "module",',
|
||||
' "dependencies": {',
|
||||
' "jsdom": "^30.0.0",',
|
||||
' "mermaid": "^11.12.0"',
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
--- Fenced blocks whose info string names mermaid. `@_lang` is not consumed by
|
||||
--- the match, it only feeds the predicate.
|
||||
local FENCE_QUERY = [[
|
||||
(fenced_code_block
|
||||
(info_string (language) @_lang)
|
||||
(code_fence_content) @content
|
||||
(#any-of? @_lang "mermaid" "mmd"))
|
||||
]]
|
||||
|
||||
--- @class MermaidBlock
|
||||
--- @field row integer 0-based buffer row where the block's first line lives
|
||||
--- @field text string block source, as sent to the validator
|
||||
|
||||
--- @class MermaidResult
|
||||
--- @field ok boolean
|
||||
--- @field line? integer 1-based line within the block
|
||||
--- @field col? integer 0-based UTF-16 column
|
||||
--- @field message? string
|
||||
|
||||
--- @class MermaidBufState
|
||||
--- @field timer uv.uv_timer_t debounce timer
|
||||
--- @field seq integer validation pass counter, guards out-of-order responses
|
||||
--- @field results table<string, MermaidResult> validated blocks, keyed by text
|
||||
|
||||
--- @type table<integer, MermaidBufState>
|
||||
local buffers = {}
|
||||
|
||||
--- @type table<integer, fun(result: MermaidResult)> in-flight requests by id
|
||||
local pending = {}
|
||||
local next_id = 0
|
||||
|
||||
local job --- @type vim.SystemObj?
|
||||
local tail = "" --- @type string incomplete stdout line
|
||||
local deps = "unknown" --- @type "unknown"|"installing"|"ready"|"failed"
|
||||
local deps_waiters = {} --- @type fun(ok: boolean)[]
|
||||
|
||||
--- @param msg string
|
||||
--- @param level integer
|
||||
local function notify(msg, level) vim.notify(msg, level, { title = "mermaid" }) end
|
||||
|
||||
--- @param path string
|
||||
--- @return string?
|
||||
local function read_file(path)
|
||||
local file = io.open(path, "r")
|
||||
if not file then return nil end
|
||||
local content = file:read "a"
|
||||
file:close()
|
||||
return content
|
||||
end
|
||||
|
||||
--- Write the workspace files, skipping writes that would change nothing. The
|
||||
--- validator is copied out of the config rather than run in place so node
|
||||
--- resolves `mermaid`/`jsdom` from the workspace's own `node_modules`.
|
||||
--- @return boolean ok
|
||||
local function write_workspace()
|
||||
local script = read_file(SOURCE)
|
||||
if not script then
|
||||
notify("validator script missing: " .. SOURCE, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
vim.fn.mkdir(ROOT, "p")
|
||||
for path, content in pairs { [SCRIPT] = script, [vim.fs.joinpath(ROOT, "package.json")] = PACKAGE_JSON } do
|
||||
if read_file(path) ~= content then
|
||||
local out = io.open(path, "w")
|
||||
if not out then
|
||||
notify("cannot write " .. path, vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
out:write(content)
|
||||
out:close()
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
--- @return boolean
|
||||
local function deps_installed()
|
||||
for _, dep in ipairs { "mermaid", "jsdom" } do
|
||||
if not vim.uv.fs_stat(vim.fs.joinpath(ROOT, "node_modules", dep, "package.json")) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
--- @param ok boolean
|
||||
local function resolve_deps(ok)
|
||||
deps = ok and "ready" or "failed"
|
||||
local waiters = deps_waiters
|
||||
deps_waiters = {}
|
||||
for _, waiter in ipairs(waiters) do
|
||||
waiter(ok)
|
||||
end
|
||||
end
|
||||
|
||||
--- Make sure the workspace exists with its dependencies installed, then call
|
||||
--- `cb`. A failure is remembered: the install is not retried this session.
|
||||
--- @param cb fun(ok: boolean)
|
||||
local function ensure_deps(cb)
|
||||
if deps == "ready" then return cb(true) end
|
||||
if deps == "failed" then return cb(false) end
|
||||
deps_waiters[#deps_waiters + 1] = cb
|
||||
if deps == "installing" then return end
|
||||
deps = "installing"
|
||||
|
||||
if vim.fn.executable "node" == 0 or vim.fn.executable "npm" == 0 then
|
||||
notify("node and npm are required for mermaid diagnostics", vim.log.levels.WARN)
|
||||
return resolve_deps(false)
|
||||
end
|
||||
if not write_workspace() then return resolve_deps(false) end
|
||||
if deps_installed() then return resolve_deps(true) end
|
||||
|
||||
notify("installing mermaid validator dependencies...", vim.log.levels.INFO)
|
||||
vim.system(
|
||||
{ "npm", "install", "--omit=dev", "--no-audit", "--no-fund", "--loglevel=error" },
|
||||
{ cwd = ROOT, text = true },
|
||||
vim.schedule_wrap(function(result)
|
||||
if result.code == 0 and deps_installed() then
|
||||
notify("mermaid validator ready", vim.log.levels.INFO)
|
||||
return resolve_deps(true)
|
||||
end
|
||||
notify("npm install failed: " .. vim.trim(result.stderr or ""), vim.log.levels.ERROR)
|
||||
resolve_deps(false)
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
--- @param line string one JSON response from the validator
|
||||
local function handle_response(line)
|
||||
local ok, response = pcall(vim.json.decode, line)
|
||||
if not ok or type(response) ~= "table" or not response.id then return end
|
||||
local cb = pending[response.id]
|
||||
pending[response.id] = nil
|
||||
if cb then cb(response) end
|
||||
end
|
||||
|
||||
--- @param err string?
|
||||
--- @param data string?
|
||||
local function on_stdout(err, data)
|
||||
if err or not data then return end
|
||||
tail = tail .. data
|
||||
while true do
|
||||
local newline = tail:find "\n"
|
||||
if not newline then break end
|
||||
local line = tail:sub(1, newline - 1)
|
||||
tail = tail:sub(newline + 1)
|
||||
if line ~= "" then vim.schedule(function() handle_response(line) end) end
|
||||
end
|
||||
end
|
||||
|
||||
--- The validator writes to stderr only when it cannot run: a broken install, an
|
||||
--- incompatible node, or a crash mid-session.
|
||||
--- @param err string?
|
||||
--- @param data string?
|
||||
local function on_stderr(err, data)
|
||||
if err or not data or vim.trim(data) == "" then return end
|
||||
vim.schedule(function() notify(vim.trim(data), vim.log.levels.ERROR) end)
|
||||
end
|
||||
|
||||
--- Responses for in-flight requests will never arrive; the affected blocks are
|
||||
--- absent from the result cache, so the next pass re-queues them.
|
||||
local function on_exit()
|
||||
job, tail, pending = nil, "", {}
|
||||
end
|
||||
|
||||
--- Start the validator unless it is already running (it exits itself on idle).
|
||||
--- @param cb fun(ok: boolean)
|
||||
local function ensure_job(cb)
|
||||
if job then return cb(true) end
|
||||
ensure_deps(function(ok)
|
||||
if not ok then return cb(false) end
|
||||
if job then return cb(true) end
|
||||
local started, handle = pcall(
|
||||
vim.system,
|
||||
{ "node", SCRIPT, "--idle-ms", tostring(IDLE_MS) },
|
||||
{ cwd = ROOT, text = true, stdin = true, stdout = on_stdout, stderr = on_stderr },
|
||||
vim.schedule_wrap(on_exit)
|
||||
)
|
||||
if not started then
|
||||
notify("cannot start validator: " .. tostring(handle), vim.log.levels.ERROR)
|
||||
return cb(false)
|
||||
end
|
||||
job = handle
|
||||
cb(true)
|
||||
end)
|
||||
end
|
||||
|
||||
--- @param text string
|
||||
--- @param cb fun(result: MermaidResult)
|
||||
local function request(text, cb)
|
||||
if not job then return end
|
||||
next_id = next_id + 1
|
||||
pending[next_id] = cb
|
||||
local id = next_id
|
||||
if not pcall(job.write, job, vim.json.encode { id = id, text = text } .. "\n") then pending[id] = nil end
|
||||
end
|
||||
|
||||
--- Mermaid sources in `buf`: the whole buffer for a mermaid file, every
|
||||
--- ```mermaid fence for markdown/mdx.
|
||||
--- @param buf integer
|
||||
--- @return MermaidBlock[]
|
||||
local function blocks(buf)
|
||||
if vim.bo[buf].filetype == "mermaid" then
|
||||
return { { row = 0, text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") } }
|
||||
end
|
||||
|
||||
local ok, parser = pcall(vim.treesitter.get_parser, buf)
|
||||
-- mdx buffers (`markdown.mdx`) parse as markdown; anything else has no fences
|
||||
if not ok or not parser or parser:lang() ~= "markdown" then return {} end
|
||||
|
||||
local query = vim.treesitter.query.parse("markdown", FENCE_QUERY)
|
||||
local found = {}
|
||||
for _, tree in ipairs(parser:parse(true)) do
|
||||
for id, node in query:iter_captures(tree:root(), buf) do
|
||||
if query.captures[id] == "content" then
|
||||
found[#found + 1] = { row = node:start(), text = vim.treesitter.get_node_text(node, buf) }
|
||||
end
|
||||
end
|
||||
end
|
||||
return found
|
||||
end
|
||||
|
||||
--- The validator reports UTF-16 columns (javascript string indices).
|
||||
--- @param line string
|
||||
--- @param col integer
|
||||
--- @return integer
|
||||
local function byte_col(line, col)
|
||||
local ok, byte = pcall(vim.str_byteindex, line, "utf-16", col, false)
|
||||
if ok then return byte end
|
||||
return math.min(col, #line)
|
||||
end
|
||||
|
||||
--- @param buf integer
|
||||
--- @param block MermaidBlock
|
||||
--- @param result MermaidResult
|
||||
--- @return vim.Diagnostic
|
||||
local function diagnostic(buf, block, result)
|
||||
local lnum = math.max(0, math.min(block.row + (result.line or 1) - 1, vim.api.nvim_buf_line_count(buf) - 1))
|
||||
local line = vim.api.nvim_buf_get_lines(buf, lnum, lnum + 1, false)[1] or ""
|
||||
local col = byte_col(line, result.col or 0)
|
||||
return {
|
||||
lnum = lnum,
|
||||
col = col,
|
||||
-- mermaid points at a single position; underlining the rest of the line
|
||||
-- shows which statement it choked on
|
||||
end_lnum = lnum,
|
||||
end_col = math.max(col, #line),
|
||||
severity = vim.diagnostic.severity.ERROR,
|
||||
source = "mermaid",
|
||||
message = result.message or "parse error",
|
||||
}
|
||||
end
|
||||
|
||||
--- @param buf integer
|
||||
--- @param found MermaidBlock[]
|
||||
--- @param results table<string, MermaidResult>
|
||||
local function publish(buf, found, results)
|
||||
if not vim.api.nvim_buf_is_valid(buf) then return end
|
||||
local diagnostics = {}
|
||||
for _, block in ipairs(found) do
|
||||
local result = results[block.text]
|
||||
if result and not result.ok then diagnostics[#diagnostics + 1] = diagnostic(buf, block, result) end
|
||||
end
|
||||
vim.diagnostic.set(NS, buf, diagnostics)
|
||||
end
|
||||
|
||||
--- Validate every mermaid block in `buf` and replace its diagnostics.
|
||||
---
|
||||
--- Results are cached by block text, so editing one diagram in a document full
|
||||
--- of them only re-validates that diagram. Passes are sequenced: a response
|
||||
--- that arrives after a newer pass started is dropped instead of publishing
|
||||
--- positions for text that has since moved.
|
||||
--- @param buf? integer defaults to the current buffer
|
||||
--- @param force? boolean ignore cached results
|
||||
function M.validate(buf, force)
|
||||
buf = (buf == nil or buf == 0) and vim.api.nvim_get_current_buf() or buf
|
||||
local state = buffers[buf]
|
||||
if not state or not vim.api.nvim_buf_is_valid(buf) then return end
|
||||
-- astrocore marks oversized buffers; re-parsing those on every keystroke
|
||||
-- costs more than the diagnostics are worth
|
||||
if vim.b[buf].large_buf then return end
|
||||
|
||||
local found = blocks(buf)
|
||||
local previous = force and {} or state.results
|
||||
local results, queue, queued = {}, {}, {}
|
||||
for _, block in ipairs(found) do
|
||||
if vim.trim(block.text) ~= "" then
|
||||
local cached = previous[block.text]
|
||||
if cached then
|
||||
results[block.text] = cached
|
||||
elseif not queued[block.text] then
|
||||
queued[block.text] = true
|
||||
queue[#queue + 1] = block
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
state.seq = state.seq + 1
|
||||
state.results = results
|
||||
local seq = state.seq
|
||||
|
||||
if #queue == 0 then return publish(buf, found, results) end
|
||||
|
||||
ensure_job(function(ok)
|
||||
if not ok or buffers[buf] ~= state or state.seq ~= seq then return end
|
||||
local remaining = #queue
|
||||
for _, block in ipairs(queue) do
|
||||
request(block.text, function(result)
|
||||
if buffers[buf] ~= state or state.seq ~= seq then return end
|
||||
results[block.text] = result
|
||||
remaining = remaining - 1
|
||||
if remaining == 0 then publish(buf, found, results) end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- @param buf integer
|
||||
local function detach(buf)
|
||||
local state = buffers[buf]
|
||||
if not state then return end
|
||||
buffers[buf] = nil
|
||||
state.timer:stop()
|
||||
state.timer:close()
|
||||
pcall(vim.api.nvim_del_augroup_by_name, "mermaid_buf_" .. buf)
|
||||
if vim.api.nvim_buf_is_valid(buf) then vim.diagnostic.reset(NS, buf) end
|
||||
end
|
||||
|
||||
--- Start validating `buf` on changes.
|
||||
---
|
||||
--- Re-entrant: the driving FileType autocmd fires again on every `setfiletype`
|
||||
--- and on `:edit` reloads, and an already-attached buffer just gets re-checked
|
||||
--- (its content may have been re-read from disk).
|
||||
--- @param buf integer
|
||||
function M.attach(buf)
|
||||
if buffers[buf] then return M.validate(buf) end
|
||||
local state = { timer = assert(vim.uv.new_timer()), seq = 0, results = {} }
|
||||
buffers[buf] = state
|
||||
|
||||
local group = vim.api.nvim_create_augroup("mermaid_buf_" .. buf, { clear = true })
|
||||
vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI", "InsertLeave" }, {
|
||||
group = group,
|
||||
buffer = buf,
|
||||
desc = "Re-validate mermaid diagrams after edits",
|
||||
callback = function()
|
||||
state.timer:stop()
|
||||
state.timer:start(DEBOUNCE_MS, 0, vim.schedule_wrap(function() M.validate(buf) end))
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost" }, {
|
||||
group = group,
|
||||
buffer = buf,
|
||||
desc = "Re-validate mermaid diagrams on read and write",
|
||||
callback = function() M.validate(buf) end,
|
||||
})
|
||||
-- `nvim_buf_attach`'s `on_detach` is not usable here: it also fires for a
|
||||
-- `:edit` reload, and its scheduled callback then tears down state that the
|
||||
-- reload's FileType has already re-attached
|
||||
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
|
||||
group = group,
|
||||
buffer = buf,
|
||||
desc = "Stop validating mermaid diagrams in a discarded buffer",
|
||||
callback = function() detach(buf) end,
|
||||
})
|
||||
|
||||
M.validate(buf)
|
||||
end
|
||||
|
||||
--- `:MermaidCheck` — re-validate the current buffer, ignoring cached results.
|
||||
function M.check()
|
||||
local buf = vim.api.nvim_get_current_buf()
|
||||
if buffers[buf] then
|
||||
M.validate(buf, true)
|
||||
else
|
||||
M.attach(buf)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in new issue
Block a user