Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
b2e89dcf8b
feat(compiler): add configurable error output modes
Problem: all parse errors went to vim.diagnostic with no way to silence
them or route them to the quickfix list. Users wanting quickfix-style
error navigation had no option.

Solution: add an errors field to ProviderConfig accepting false,
'diagnostic' (default), or 'quickfix'. false suppresses error handling
entirely. 'quickfix' converts parsed diagnostics to qflist items
(1-indexed), calls setqflist, and opens the window. On success,
'quickfix' mode clears the qflist the same way 'diagnostic' mode clears
vim.diagnostic.
2026-03-03 14:07:28 -05:00
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
6 changed files with 233 additions and 81 deletions

View file

@ -86,9 +86,18 @@ function M.compile(bufnr, name, provider, ctx)
return return
end end
local errors_mode = provider.errors
if errors_mode == nil then
errors_mode = 'diagnostic'
end
if result.code == 0 then if result.code == 0 then
log.dbg('compilation succeeded for buffer %d', bufnr) log.dbg('compilation succeeded for buffer %d', bufnr)
diagnostic.clear(bufnr) if errors_mode == 'diagnostic' then
diagnostic.clear(bufnr)
elseif errors_mode == 'quickfix' then
vim.fn.setqflist({}, 'r')
end
vim.api.nvim_exec_autocmds('User', { vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess', pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file }, data = { bufnr = bufnr, provider = name, output = output_file },
@ -105,8 +114,27 @@ function M.compile(bufnr, name, provider, ctx)
end end
else else
log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code) log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code)
if provider.error_parser then if provider.error_parser and errors_mode then
diagnostic.set(bufnr, name, provider.error_parser, result.stderr or '', ctx) local output = (result.stdout or '') .. (result.stderr or '')
if errors_mode == 'diagnostic' then
diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
elseif errors_mode == 'quickfix' then
local ok, diagnostics = pcall(provider.error_parser, output, ctx)
if ok and diagnostics and #diagnostics > 0 then
local items = {}
for _, d in ipairs(diagnostics) do
table.insert(items, {
bufnr = bufnr,
lnum = d.lnum + 1,
col = d.col + 1,
text = d.message,
type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E',
})
end
vim.fn.setqflist(items, 'r')
vim.cmd('copen')
end
end
end end
vim.api.nvim_exec_autocmds('User', { vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed', pattern = 'PreviewCompileFailed',

View file

@ -12,11 +12,11 @@ end
---@param bufnr integer ---@param bufnr integer
---@param name string ---@param name string
---@param error_parser fun(stderr: string, ctx: preview.Context): preview.Diagnostic[] ---@param error_parser fun(output: string, ctx: preview.Context): preview.Diagnostic[]
---@param stderr string ---@param output string
---@param ctx preview.Context ---@param ctx preview.Context
function M.set(bufnr, name, error_parser, stderr, ctx) function M.set(bufnr, name, error_parser, output, ctx)
local ok, diagnostics = pcall(error_parser, stderr, ctx) local ok, diagnostics = pcall(error_parser, output, ctx)
if not ok then if not ok then
log.dbg('error_parser for "%s" failed: %s', name, diagnostics) log.dbg('error_parser for "%s" failed: %s', name, diagnostics)
return return

View file

@ -5,7 +5,8 @@
---@field cwd? string|fun(ctx: preview.Context): string ---@field cwd? string|fun(ctx: preview.Context): string
---@field env? table<string, string> ---@field env? table<string, string>
---@field output? string|fun(ctx: preview.Context): string ---@field output? string|fun(ctx: preview.Context): string
---@field error_parser? fun(stderr: string, ctx: preview.Context): preview.Diagnostic[] ---@field error_parser? fun(output: string, ctx: preview.Context): preview.Diagnostic[]
---@field errors? false|'diagnostic'|'quickfix'
---@field clean? string[]|fun(ctx: preview.Context): string[] ---@field clean? string[]|fun(ctx: preview.Context): string[]
---@field open? boolean|string[] ---@field open? boolean|string[]

View file

@ -1,11 +1,11 @@
local M = {} local M = {}
---@param stderr string ---@param output string
---@return preview.Diagnostic[] ---@return preview.Diagnostic[]
local function parse_typst(stderr) local function parse_typst(output)
local diagnostics = {} local diagnostics = {}
for line in stderr:gmatch('[^\r\n]+') do for line in output:gmatch('[^\r\n]+') do
local file, lnum, col, severity, msg = line:match('^(.+):(%d+):(%d+): (%w+): (.+)$') local _, lnum, col, severity, msg = line:match('^(.+):(%d+):(%d+): (%w+): (.+)$')
if lnum then if lnum then
local sev = vim.diagnostic.severity.ERROR local sev = vim.diagnostic.severity.ERROR
if severity == 'warning' then if severity == 'warning' then
@ -16,18 +16,17 @@ local function parse_typst(stderr)
col = tonumber(col) - 1, col = tonumber(col) - 1,
message = msg, message = msg,
severity = sev, severity = sev,
source = file,
}) })
end end
end end
return diagnostics return diagnostics
end end
---@param stderr string ---@param output string
---@return preview.Diagnostic[] ---@return preview.Diagnostic[]
local function parse_latexmk(stderr) local function parse_latexmk(output)
local diagnostics = {} local diagnostics = {}
for line in stderr:gmatch('[^\r\n]+') do for line in output:gmatch('[^\r\n]+') do
local _, lnum, msg = line:match('^%.?/?(.+%.tex):(%d+): (.+)$') local _, lnum, msg = line:match('^%.?/?(.+%.tex):(%d+): (.+)$')
if lnum then if lnum then
table.insert(diagnostics, { table.insert(diagnostics, {
@ -51,41 +50,45 @@ local function parse_latexmk(stderr)
return diagnostics return diagnostics
end end
---@param stderr string ---@param output string
---@return preview.Diagnostic[] ---@return preview.Diagnostic[]
local function parse_pandoc(stderr) local function parse_pandoc(output)
local diagnostics = {} local diagnostics = {}
for line in stderr:gmatch('[^\r\n]+') do local lines = vim.split(output, '\n')
local lnum, col, msg = line:match('Error at .+ %(line (%d+), column (%d+)%): (.+)$') local i = 1
while i <= #lines do
local line = lines[i]
local lnum, col, msg = line:match('%(line (%d+), column (%d+)%):%s*(.*)$')
if lnum then if lnum then
table.insert(diagnostics, { if msg == '' then
lnum = tonumber(lnum) - 1, for j = i + 1, math.min(i + 2, #lines) do
col = tonumber(col) - 1, local next_line = lines[j]:match('^%s*(.+)$')
message = msg, if next_line and not next_line:match('^YAML parse exception') then
severity = vim.diagnostic.severity.ERROR, msg = next_line
}) break
else end
local ylnum, ycol, ymsg =
line:match('YAML parse exception at line (%d+), column (%d+)[,:]%s*(.+)$')
if ylnum then
table.insert(diagnostics, {
lnum = tonumber(ylnum) - 1,
col = tonumber(ycol) - 1,
message = ymsg,
severity = vim.diagnostic.severity.ERROR,
})
else
local errmsg = line:match('^pandoc: (.+)$')
if errmsg and not errmsg:match('^Error at') then
table.insert(diagnostics, {
lnum = 0,
col = 0,
message = errmsg,
severity = vim.diagnostic.severity.ERROR,
})
end end
end end
if msg ~= '' then
table.insert(diagnostics, {
lnum = tonumber(lnum) - 1,
col = tonumber(col) - 1,
message = msg,
severity = vim.diagnostic.severity.ERROR,
})
end
else
local errmsg = line:match('^pandoc: (.+)$')
if errmsg then
table.insert(diagnostics, {
lnum = 0,
col = 0,
message = errmsg,
severity = vim.diagnostic.severity.ERROR,
})
end
end end
i = i + 1
end end
return diagnostics return diagnostics
end end
@ -100,8 +103,8 @@ M.typst = {
output = function(ctx) output = function(ctx)
return (ctx.file:gsub('%.typ$', '.pdf')) return (ctx.file:gsub('%.typ$', '.pdf'))
end, end,
error_parser = function(stderr) error_parser = function(output)
return parse_typst(stderr) return parse_typst(output)
end, end,
open = true, open = true,
} }
@ -121,8 +124,8 @@ M.latex = {
output = function(ctx) output = function(ctx)
return (ctx.file:gsub('%.tex$', '.pdf')) return (ctx.file:gsub('%.tex$', '.pdf'))
end, end,
error_parser = function(stderr) error_parser = function(output)
return parse_latexmk(stderr) return parse_latexmk(output)
end, end,
clean = function(ctx) clean = function(ctx)
return { 'latexmk', '-c', ctx.file } return { 'latexmk', '-c', ctx.file }
@ -141,8 +144,8 @@ M.markdown = {
output = function(ctx) output = function(ctx)
return (ctx.file:gsub('%.md$', '.html')) return (ctx.file:gsub('%.md$', '.html'))
end, end,
error_parser = function(stderr) error_parser = function(output)
return parse_pandoc(stderr) return parse_pandoc(output)
end, end,
clean = function(ctx) clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) } return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
@ -171,8 +174,8 @@ M.github = {
output = function(ctx) output = function(ctx)
return (ctx.file:gsub('%.md$', '.html')) return (ctx.file:gsub('%.md$', '.html'))
end, end,
error_parser = function(stderr) error_parser = function(output)
return parse_pandoc(stderr) return parse_pandoc(output)
end, end,
clean = function(ctx) clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) } return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }

View file

@ -132,6 +132,108 @@ describe('compiler', function()
end) end)
end) end)
describe('errors mode', function()
it('errors = false suppresses error parser', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_false.txt')
vim.bo[bufnr].modified = false
local parser_called = false
local provider = {
cmd = { 'false' },
errors = false,
error_parser = function()
parser_called = true
return {}
end,
}
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_errors_false.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'falsecmd', provider, ctx)
vim.wait(2000, function()
return compiler._test.active[bufnr] == nil
end, 50)
assert.is_false(parser_called)
helpers.delete_buffer(bufnr)
end)
it('errors = quickfix populates quickfix list', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_qf.txt')
vim.bo[bufnr].modified = false
local provider = {
cmd = { 'sh', '-c', 'echo "line 1 error" >&2; exit 1' },
errors = 'quickfix',
error_parser = function()
return {
{ lnum = 0, col = 0, message = 'test error', severity = vim.diagnostic.severity.ERROR },
}
end,
}
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_errors_qf.txt',
root = '/tmp',
ft = 'text',
}
vim.fn.setqflist({}, 'r')
compiler.compile(bufnr, 'qfcmd', provider, ctx)
vim.wait(2000, function()
return compiler._test.active[bufnr] == nil
end, 50)
local qflist = vim.fn.getqflist()
assert.are.equal(1, #qflist)
assert.are.equal('test error', qflist[1].text)
assert.are.equal(1, qflist[1].lnum)
vim.fn.setqflist({}, 'r')
helpers.delete_buffer(bufnr)
end)
it('errors = quickfix clears quickfix on success', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_qf_clear.txt')
vim.bo[bufnr].modified = false
vim.fn.setqflist({ { text = 'old error', lnum = 1 } }, 'r')
assert.are.equal(1, #vim.fn.getqflist())
local provider = {
cmd = { 'true' },
errors = 'quickfix',
error_parser = function()
return {}
end,
}
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_errors_qf_clear.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'truecmd', provider, ctx)
vim.wait(2000, function()
return compiler._test.active[bufnr] == nil
end, 50)
assert.are.equal(0, #vim.fn.getqflist())
helpers.delete_buffer(bufnr)
end)
end)
describe('stop', function() describe('stop', function()
it('does nothing when no process is active', function() it('does nothing when no process is active', function()
assert.has_no.errors(function() assert.has_no.errors(function()

View file

@ -49,7 +49,7 @@ describe('presets', function()
assert.are.equal(22, diagnostics[1].col) assert.are.equal(22, diagnostics[1].col)
assert.are.equal('unexpected token', diagnostics[1].message) assert.are.equal('unexpected token', diagnostics[1].message)
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity) assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
assert.are.equal('main.typ', diagnostics[1].source) assert.is_nil(diagnostics[1].source)
assert.are.equal(11, diagnostics[2].lnum) assert.are.equal(11, diagnostics[2].lnum)
assert.are.equal(0, diagnostics[2].col) assert.are.equal(0, diagnostics[2].col)
assert.are.equal('unused variable', diagnostics[2].message) assert.are.equal('unused variable', diagnostics[2].message)
@ -105,14 +105,14 @@ describe('presets', function()
assert.is_true(presets.latex.open) assert.is_true(presets.latex.open)
end) end)
it('parses file-line-error format from stderr', function() it('parses file-line-error format from output', function()
local stderr = table.concat({ local output = table.concat({
'./document.tex:10: Undefined control sequence.', './document.tex:10: Undefined control sequence.',
'l.10 \\badcommand', 'l.10 \\badcommand',
'Collected error summary (may duplicate other messages):', 'Collected error summary (may duplicate other messages):',
" pdflatex: Command for 'pdflatex' gave return code 256", " pdflatex: Command for 'pdflatex' gave return code 256",
}, '\n') }, '\n')
local diagnostics = presets.latex.error_parser(stderr, tex_ctx) local diagnostics = presets.latex.error_parser(output, tex_ctx)
assert.is_table(diagnostics) assert.is_table(diagnostics)
assert.is_true(#diagnostics > 0) assert.is_true(#diagnostics > 0)
assert.are.equal(9, diagnostics[1].lnum) assert.are.equal(9, diagnostics[1].lnum)
@ -122,12 +122,12 @@ describe('presets', function()
end) end)
it('parses collected error summary', function() it('parses collected error summary', function()
local stderr = table.concat({ local output = table.concat({
'Latexmk: Errors, so I did not complete making targets', 'Latexmk: Errors, so I did not complete making targets',
'Collected error summary (may duplicate other messages):', 'Collected error summary (may duplicate other messages):',
" pdflatex: Command for 'pdflatex' gave return code 256", " pdflatex: Command for 'pdflatex' gave return code 256",
}, '\n') }, '\n')
local diagnostics = presets.latex.error_parser(stderr, tex_ctx) local diagnostics = presets.latex.error_parser(output, tex_ctx)
assert.is_table(diagnostics) assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics) assert.are.equal(1, #diagnostics)
assert.are.equal(0, diagnostics[1].lnum) assert.are.equal(0, diagnostics[1].lnum)
@ -185,9 +185,24 @@ describe('presets', function()
assert.is_true(presets.markdown.open) assert.is_true(presets.markdown.open)
end) end)
it('parses pandoc parse errors from stderr', function() it('parses YAML metadata errors with multiline message', function()
local stderr = 'Error at "source" (line 75, column 1): unexpected end of input' local output = table.concat({
local diagnostics = presets.markdown.error_parser(stderr, md_ctx) 'Error parsing YAML metadata at "/tmp/test.md" (line 1, column 1):',
'YAML parse exception at line 1, column 9:',
'mapping values are not allowed in this context',
}, '\n')
local diagnostics = presets.markdown.error_parser(output, md_ctx)
assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics)
assert.are.equal(0, diagnostics[1].lnum)
assert.are.equal(0, diagnostics[1].col)
assert.are.equal('mapping values are not allowed in this context', diagnostics[1].message)
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end)
it('parses Error at format', function()
local output = 'Error at "source" (line 75, column 1): unexpected end of input'
local diagnostics = presets.markdown.error_parser(output, md_ctx)
assert.is_table(diagnostics) assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics) assert.are.equal(1, #diagnostics)
assert.are.equal(74, diagnostics[1].lnum) assert.are.equal(74, diagnostics[1].lnum)
@ -196,20 +211,9 @@ describe('presets', function()
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity) assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end) end)
it('parses YAML parse exceptions from stderr', function() it('parses generic pandoc errors', function()
local stderr = local output = 'pandoc: Could not find data file templates/default.html5'
'YAML parse exception at line 3, column 2, while scanning a block scalar: did not find expected comment or line break' local diagnostics = presets.markdown.error_parser(output, md_ctx)
local diagnostics = presets.markdown.error_parser(stderr, md_ctx)
assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics)
assert.are.equal(2, diagnostics[1].lnum)
assert.are.equal(1, diagnostics[1].col)
assert.is_string(diagnostics[1].message)
end)
it('parses generic pandoc errors from stderr', function()
local stderr = 'pandoc: Could not find data file templates/default.html5'
local diagnostics = presets.markdown.error_parser(stderr, md_ctx)
assert.is_table(diagnostics) assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics) assert.are.equal(1, #diagnostics)
assert.are.equal(0, diagnostics[1].lnum) assert.are.equal(0, diagnostics[1].lnum)
@ -217,7 +221,7 @@ describe('presets', function()
assert.are.equal('Could not find data file templates/default.html5', diagnostics[1].message) assert.are.equal('Could not find data file templates/default.html5', diagnostics[1].message)
end) end)
it('returns empty table for clean stderr', function() it('returns empty table for clean output', function()
local diagnostics = presets.markdown.error_parser('', md_ctx) local diagnostics = presets.markdown.error_parser('', md_ctx)
assert.are.same({}, diagnostics) assert.are.same({}, diagnostics)
end) end)
@ -284,9 +288,23 @@ describe('presets', function()
assert.is_true(presets.github.open) assert.is_true(presets.github.open)
end) end)
it('parses pandoc parse errors from stderr', function() it('parses YAML metadata errors with multiline message', function()
local stderr = 'Error at "document.md" (line 12, column 5): unexpected "}" expecting letter' local output = table.concat({
local diagnostics = presets.github.error_parser(stderr, md_ctx) 'Error parsing YAML metadata at "/tmp/test.md" (line 1, column 1):',
'YAML parse exception at line 1, column 9:',
'mapping values are not allowed in this context',
}, '\n')
local diagnostics = presets.github.error_parser(output, md_ctx)
assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics)
assert.are.equal(0, diagnostics[1].lnum)
assert.are.equal(0, diagnostics[1].col)
assert.are.equal('mapping values are not allowed in this context', diagnostics[1].message)
end)
it('parses Error at format', function()
local output = 'Error at "document.md" (line 12, column 5): unexpected "}" expecting letter'
local diagnostics = presets.github.error_parser(output, md_ctx)
assert.is_table(diagnostics) assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics) assert.are.equal(1, #diagnostics)
assert.are.equal(11, diagnostics[1].lnum) assert.are.equal(11, diagnostics[1].lnum)
@ -295,7 +313,7 @@ describe('presets', function()
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity) assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end) end)
it('returns empty table for clean stderr', function() it('returns empty table for clean output', function()
local diagnostics = presets.github.error_parser('', md_ctx) local diagnostics = presets.github.error_parser('', md_ctx)
assert.are.same({}, diagnostics) assert.are.same({}, diagnostics)
end) end)