420 lines
14 KiB
Lua
420 lines
14 KiB
Lua
--- 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
|