preview.nvim/lua/preview/compiler.lua
Barrett Ruth 56d110a74e
fix(presets): correct error parsers for real compiler output
Problem: all three built-in error parsers were broken against real
compiler output. Typst set source to the relative file path, overriding
the provider name. LaTeX errors go to stdout but the parser only
received stderr. Pandoc's pattern matched "Error at" but not the real
"Error parsing YAML metadata at" format, and single-line parsing missed
multiline messages.

Solution: pass combined stdout+stderr to error_parser so LaTeX stdout
errors are visible. Remove source = file from the Typst parser so
diagnostic.lua defaults it to the provider name. Rewrite the Pandoc
parser with line-based lookahead: match (line N, column N) regardless
of prefix text, skip YAML parse exception lines when looking ahead for
the human-readable message. Rename stderr param to output throughout
diagnostic.lua, presets.lua, and init.lua annotations.
2026-03-03 14:07:00 -05:00

302 lines
7.3 KiB
Lua

local M = {}
local diagnostic = require('preview.diagnostic')
local log = require('preview.log')
---@type table<integer, preview.Process>
local active = {}
---@type table<integer, integer>
local watching = {}
---@type table<integer, true>
local opened = {}
---@type table<integer, string>
local last_output = {}
local debounce_timers = {}
local DEBOUNCE_MS = 500
---@param val string[]|fun(ctx: preview.Context): string[]
---@param ctx preview.Context
---@return string[]
local function eval_list(val, ctx)
if type(val) == 'function' then
return val(ctx)
end
return val
end
---@param val string|fun(ctx: preview.Context): string
---@param ctx preview.Context
---@return string
local function eval_string(val, ctx)
if type(val) == 'function' then
return val(ctx)
end
return val
end
---@param bufnr integer
---@param name string
---@param provider preview.ProviderConfig
---@param ctx preview.Context
function M.compile(bufnr, name, provider, ctx)
if vim.bo[bufnr].modified then
vim.cmd('silent! update')
end
if active[bufnr] then
log.dbg('killing existing process for buffer %d before recompile', bufnr)
M.stop(bufnr)
end
local cmd = vim.list_extend({}, provider.cmd)
if provider.args then
vim.list_extend(cmd, eval_list(provider.args, ctx))
end
local cwd = ctx.root
if provider.cwd then
cwd = eval_string(provider.cwd, ctx)
end
local output_file = ''
if provider.output then
output_file = eval_string(provider.output, ctx)
end
if output_file ~= '' then
last_output[bufnr] = output_file
end
log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
local obj = vim.system(
cmd,
{
cwd = cwd,
env = provider.env,
},
vim.schedule_wrap(function(result)
active[bufnr] = nil
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
if result.code == 0 then
log.dbg('compilation succeeded for buffer %d', bufnr)
diagnostic.clear(bufnr)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file },
})
if provider.open and not opened[bufnr] and output_file ~= '' then
if provider.open == true then
vim.ui.open(output_file)
elseif type(provider.open) == 'table' then
local open_cmd = vim.list_extend({}, provider.open)
table.insert(open_cmd, output_file)
vim.system(open_cmd)
end
opened[bufnr] = true
end
else
log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code)
if provider.error_parser then
local output = (result.stdout or '') .. (result.stderr or '')
diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
end
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
data = {
bufnr = bufnr,
provider = name,
code = result.code,
stderr = result.stderr or '',
},
})
end
end)
)
active[bufnr] = { obj = obj, provider = name, output_file = output_file }
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
once = true,
callback = function()
M.stop(bufnr)
last_output[bufnr] = nil
end,
})
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileStarted',
data = { bufnr = bufnr, provider = name },
})
end
---@param bufnr integer
function M.stop(bufnr)
local proc = active[bufnr]
if not proc then
return
end
log.dbg('stopping process for buffer %d', bufnr)
---@type fun(self: table, signal: string|integer)
local kill = proc.obj.kill
kill(proc.obj, 'sigterm')
local timer = vim.uv.new_timer()
if timer then
timer:start(5000, 0, function()
timer:close()
if active[bufnr] and active[bufnr].obj == proc.obj then
kill(proc.obj, 'sigkill')
active[bufnr] = nil
end
end)
end
end
function M.stop_all()
for bufnr, _ in pairs(active) do
M.stop(bufnr)
end
for bufnr, _ in pairs(watching) do
M.unwatch(bufnr)
end
end
---@param bufnr integer
---@param name string
---@param provider preview.ProviderConfig
---@param ctx_builder fun(bufnr: integer): preview.Context
function M.toggle(bufnr, name, provider, ctx_builder)
if watching[bufnr] then
M.unwatch(bufnr)
return
end
local au_id = vim.api.nvim_create_autocmd('BufWritePost', {
buffer = bufnr,
callback = function()
if debounce_timers[bufnr] then
debounce_timers[bufnr]:stop()
else
debounce_timers[bufnr] = vim.uv.new_timer()
end
debounce_timers[bufnr]:start(
DEBOUNCE_MS,
0,
vim.schedule_wrap(function()
local ctx = ctx_builder(bufnr)
M.compile(bufnr, name, provider, ctx)
end)
)
end,
})
watching[bufnr] = au_id
log.dbg('watching buffer %d with provider "%s"', bufnr, name)
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
once = true,
callback = function()
M.unwatch(bufnr)
opened[bufnr] = nil
end,
})
M.compile(bufnr, name, provider, ctx_builder(bufnr))
end
---@param bufnr integer
function M.unwatch(bufnr)
local au_id = watching[bufnr]
if not au_id then
return
end
vim.api.nvim_del_autocmd(au_id)
if debounce_timers[bufnr] then
debounce_timers[bufnr]:stop()
debounce_timers[bufnr]:close()
debounce_timers[bufnr] = nil
end
watching[bufnr] = nil
log.dbg('unwatched buffer %d', bufnr)
end
---@param bufnr integer
---@param name string
---@param provider preview.ProviderConfig
---@param ctx preview.Context
function M.clean(bufnr, name, provider, ctx)
if not provider.clean then
vim.notify('[preview.nvim] provider "' .. name .. '" has no clean command', vim.log.levels.WARN)
return
end
local cmd = eval_list(provider.clean, ctx)
local cwd = ctx.root
if provider.cwd then
cwd = eval_string(provider.cwd, ctx)
end
log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
vim.system(
cmd,
{ cwd = cwd },
vim.schedule_wrap(function(result)
if result.code == 0 then
log.dbg('clean succeeded for buffer %d', bufnr)
vim.notify('[preview.nvim] clean complete', vim.log.levels.INFO)
else
log.dbg('clean failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[preview.nvim] clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR)
end
end)
)
end
---@param bufnr integer
---@return boolean
function M.open(bufnr)
local output = last_output[bufnr]
if not output then
log.dbg('no last output file for buffer %d', bufnr)
return false
end
vim.ui.open(output)
return true
end
---@param bufnr integer
---@return preview.Status
function M.status(bufnr)
local proc = active[bufnr]
if proc then
return {
compiling = true,
watching = watching[bufnr] ~= nil,
provider = proc.provider,
output_file = proc.output_file,
}
end
return { compiling = false, watching = watching[bufnr] ~= nil }
end
M._test = {
active = active,
watching = watching,
opened = opened,
last_output = last_output,
debounce_timers = debounce_timers,
}
return M