From acc4dfc8502a07819c6479bbfa3278e3785c8873 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Tue, 3 Mar 2026 13:33:13 -0500
Subject: [PATCH 01/45] feat(compiler): debounce compilation in toggle mode
Problem: in toggle mode, each BufWritePost immediately spawned a new
compilation, killing any in-flight process. Rapid saves wasted cycles
on compilers like latexmk.
Solution: add a 500ms debounce timer per buffer. The BufWritePost
callback starts/restarts the timer instead of compiling immediately.
Timers are cleaned up on unwatch and BufWipeout.
---
lua/preview/compiler.lua | 22 ++++++++++++++++++++--
1 file changed, 20 insertions(+), 2 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index b0c7402..57a066b 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -12,6 +12,11 @@ local watching = {}
---@type table
local opened = {}
+---@type table
+local debounce_timers = {}
+
+local DEBOUNCE_MS = 500
+
---@param val string[]|fun(ctx: preview.Context): string[]
---@param ctx preview.Context
---@return string[]
@@ -171,8 +176,15 @@ function M.toggle(bufnr, name, provider, ctx_builder)
local au_id = vim.api.nvim_create_autocmd('BufWritePost', {
buffer = bufnr,
callback = function()
- local ctx = ctx_builder(bufnr)
- M.compile(bufnr, name, provider, ctx)
+ 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,
})
@@ -198,6 +210,11 @@ function M.unwatch(bufnr)
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
@@ -254,6 +271,7 @@ M._test = {
active = active,
watching = watching,
opened = opened,
+ debounce_timers = debounce_timers,
}
return M
From cfe101c6c4fb1e1bf24761881407b3f9798f0a7e Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:37:02 -0500
Subject: [PATCH 02/45] feat(commands): add :Preview open subcommand (#6)
Problem: after closing a viewer, there was no way to re-open the last
compiled output without recompiling.
Solution: track the most recent output file per buffer in a `last_output`
table that persists after compilation finishes. Add `compiler.open()`,
`M.open()`, and wire it into the command dispatch.
---
lua/preview/commands.lua | 6 ++++--
lua/preview/compiler.lua | 21 +++++++++++++++++++++
lua/preview/init.lua | 9 +++++++++
spec/commands_spec.lua | 7 +++++++
spec/compiler_spec.lua | 35 +++++++++++++++++++++++++++++++++++
5 files changed, 76 insertions(+), 2 deletions(-)
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index 8e08236..ad63c97 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -1,6 +1,6 @@
local M = {}
-local subcommands = { 'compile', 'stop', 'clean', 'toggle', 'status' }
+local subcommands = { 'compile', 'stop', 'clean', 'toggle', 'open', 'status' }
---@param args string
local function dispatch(args)
@@ -14,6 +14,8 @@ local function dispatch(args)
require('preview').clean()
elseif subcmd == 'toggle' then
require('preview').toggle()
+ elseif subcmd == 'open' then
+ require('preview').open()
elseif subcmd == 'status' then
local s = require('preview').status()
local parts = {}
@@ -47,7 +49,7 @@ function M.setup()
complete = function(lead)
return complete(lead)
end,
- desc = 'Compile, stop, clean, toggle, or check status of document preview',
+ desc = 'Compile, stop, clean, toggle, open, or check status of document preview',
})
end
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index b0c7402..50ab0ba 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -12,6 +12,9 @@ local watching = {}
---@type table
local opened = {}
+---@type table
+local last_output = {}
+
---@param val string[]|fun(ctx: preview.Context): string[]
---@param ctx preview.Context
---@return string[]
@@ -61,6 +64,10 @@ function M.compile(bufnr, name, provider, ctx)
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(
@@ -117,6 +124,7 @@ function M.compile(bufnr, name, provider, ctx)
once = true,
callback = function()
M.stop(bufnr)
+ last_output[bufnr] = nil
end,
})
@@ -235,6 +243,18 @@ function M.clean(bufnr, name, provider, ctx)
)
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)
@@ -254,6 +274,7 @@ M._test = {
active = active,
watching = watching,
opened = opened,
+ last_output = last_output,
}
return M
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index e2cf794..641da4a 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -39,6 +39,7 @@
---@field stop fun(bufnr?: integer)
---@field clean fun(bufnr?: integer)
---@field toggle fun(bufnr?: integer)
+---@field open fun(bufnr?: integer)
---@field status fun(bufnr?: integer): preview.Status
---@field get_config fun(): preview.Config
local M = {}
@@ -166,6 +167,14 @@ function M.toggle(bufnr)
compiler.toggle(bufnr, name, provider, M.build_context)
end
+---@param bufnr? integer
+function M.open(bufnr)
+ bufnr = bufnr or vim.api.nvim_get_current_buf()
+ if not compiler.open(bufnr) then
+ vim.notify('[preview.nvim] no output file available for this buffer', vim.log.levels.WARN)
+ end
+end
+
---@class preview.Status
---@field compiling boolean
---@field watching boolean
diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua
index be9a616..931174f 100644
--- a/spec/commands_spec.lua
+++ b/spec/commands_spec.lua
@@ -35,6 +35,13 @@ describe('commands', function()
end)
end)
+ it('does not error on :Preview open', function()
+ require('preview.commands').setup()
+ assert.has_no.errors(function()
+ vim.cmd('Preview open')
+ end)
+ end)
+
it('does not error on :Preview toggle with no provider', function()
require('preview.commands').setup()
assert.has_no.errors(function()
diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua
index 6802dee..2046ef2 100644
--- a/spec/compiler_spec.lua
+++ b/spec/compiler_spec.lua
@@ -174,6 +174,41 @@ describe('compiler', function()
end)
end)
+ describe('open', function()
+ it('returns false when no output exists', function()
+ assert.is_false(compiler.open(999))
+ end)
+
+ it('returns true after compilation stores output', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_open.txt')
+ vim.bo[bufnr].modified = false
+
+ local provider = {
+ cmd = { 'true' },
+ output = function()
+ return '/tmp/preview_test_open.pdf'
+ end,
+ }
+ local ctx = {
+ bufnr = bufnr,
+ file = '/tmp/preview_test_open.txt',
+ root = '/tmp',
+ ft = 'text',
+ }
+
+ compiler.compile(bufnr, 'testprov', provider, ctx)
+ assert.is_not_nil(compiler._test.last_output[bufnr])
+ assert.are.equal('/tmp/preview_test_open.pdf', compiler._test.last_output[bufnr])
+
+ vim.wait(2000, function()
+ return compiler._test.active[bufnr] == nil
+ end, 50)
+
+ helpers.delete_buffer(bufnr)
+ end)
+ end)
+
describe('toggle', function()
it('registers autocmd and tracks in watching table', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
From 187474bb3df20a59e77d8175d14ace687e6915a9 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:37:16 -0500
Subject: [PATCH 03/45] refactor(presets): replace xdg-open with vim.ui.open
(#7)
Problem: all presets hardcoded `open = { 'xdg-open' }`, making them
Linux-only. The compiler already handles `open = true` via
`vim.ui.open()`, which is cross-platform.
Solution: change all four presets to `open = true`.
---
lua/preview/presets.lua | 8 ++++----
spec/presets_spec.lua | 8 ++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 8a414e8..e04862b 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -10,7 +10,7 @@ M.typst = {
output = function(ctx)
return (ctx.file:gsub('%.typ$', '.pdf'))
end,
- open = { 'xdg-open' },
+ open = true,
}
---@type preview.ProviderConfig
@@ -26,7 +26,7 @@ M.latex = {
clean = function(ctx)
return { 'latexmk', '-c', ctx.file }
end,
- open = { 'xdg-open' },
+ open = true,
}
---@type preview.ProviderConfig
@@ -43,7 +43,7 @@ M.markdown = {
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
end,
- open = { 'xdg-open' },
+ open = true,
}
---@type preview.ProviderConfig
@@ -70,7 +70,7 @@ M.github = {
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
end,
- open = { 'xdg-open' },
+ open = true,
}
return M
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index b8572c5..d213389 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -34,7 +34,7 @@ describe('presets', function()
end)
it('has open enabled', function()
- assert.are.same({ 'xdg-open' }, presets.typst.open)
+ assert.is_true(presets.typst.open)
end)
end)
@@ -73,7 +73,7 @@ describe('presets', function()
end)
it('has open enabled', function()
- assert.are.same({ 'xdg-open' }, presets.latex.open)
+ assert.is_true(presets.latex.open)
end)
end)
@@ -115,7 +115,7 @@ describe('presets', function()
end)
it('has open enabled', function()
- assert.are.same({ 'xdg-open' }, presets.markdown.open)
+ assert.is_true(presets.markdown.open)
end)
end)
@@ -177,7 +177,7 @@ describe('presets', function()
end)
it('has open enabled', function()
- assert.are.same({ 'xdg-open' }, presets.github.open)
+ assert.is_true(presets.github.open)
end)
end)
end)
From bf2f4a78e23febad48673fb4767ef83d9263534e Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:37:36 -0500
Subject: [PATCH 04/45] feat: add statusline function (#10)
Problem: no way to expose compiling/watching state to statusline
plugins like lualine or heirline without polling status() and
formatting it manually.
Solution: add `require('preview').statusline()` that returns
'compiling', 'watching', or '' for direct use in statusline components.
---
lua/preview/init.lua | 14 ++++++++++++++
spec/init_spec.lua | 8 ++++++++
2 files changed, 22 insertions(+)
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index 641da4a..f6d0006 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -41,6 +41,7 @@
---@field toggle fun(bufnr?: integer)
---@field open fun(bufnr?: integer)
---@field status fun(bufnr?: integer): preview.Status
+---@field statusline fun(bufnr?: integer): string
---@field get_config fun(): preview.Config
local M = {}
@@ -188,6 +189,19 @@ function M.status(bufnr)
return compiler.status(bufnr)
end
+---@param bufnr? integer
+---@return string
+function M.statusline(bufnr)
+ bufnr = bufnr or vim.api.nvim_get_current_buf()
+ local s = compiler.status(bufnr)
+ if s.compiling then
+ return 'compiling'
+ elseif s.watching then
+ return 'watching'
+ end
+ return ''
+end
+
M._test = {
---@diagnostic disable-next-line: assign-type-mismatch
reset = function()
diff --git a/spec/init_spec.lua b/spec/init_spec.lua
index 5b97c8a..5c49276 100644
--- a/spec/init_spec.lua
+++ b/spec/init_spec.lua
@@ -100,4 +100,12 @@ describe('preview', function()
helpers.delete_buffer(bufnr)
end)
end)
+
+ describe('statusline', function()
+ it('returns empty string when idle', function()
+ local bufnr = helpers.create_buffer({})
+ assert.are.equal('', preview.statusline(bufnr))
+ helpers.delete_buffer(bufnr)
+ end)
+ end)
end)
From b00b169bf5bee7a4a3c812ba81236027766e7ebe Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:42:44 -0500
Subject: [PATCH 05/45] feat(compiler): debounce compilation in toggle mode
(#8)
Problem: in toggle mode, each BufWritePost immediately spawned a new
compilation, killing any in-flight process. Rapid saves wasted cycles
on compilers like latexmk.
Solution: add a 500ms debounce timer per buffer. The BufWritePost
callback starts/restarts the timer instead of compiling immediately.
Timers are cleaned up on unwatch and BufWipeout.
---
lua/preview/compiler.lua | 25 +++++++++++++++++++++++--
1 file changed, 23 insertions(+), 2 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 50ab0ba..6e3f5cf 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -15,6 +15,10 @@ local opened = {}
---@type table
local last_output = {}
+local debounce_timers = {}
+
+local DEBOUNCE_MS = 500
+
---@param val string[]|fun(ctx: preview.Context): string[]
---@param ctx preview.Context
---@return string[]
@@ -179,8 +183,19 @@ function M.toggle(bufnr, name, provider, ctx_builder)
local au_id = vim.api.nvim_create_autocmd('BufWritePost', {
buffer = bufnr,
callback = function()
- local ctx = ctx_builder(bufnr)
- M.compile(bufnr, name, provider, ctx)
+ 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,
})
@@ -206,6 +221,11 @@ function M.unwatch(bufnr)
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
@@ -275,6 +295,7 @@ M._test = {
watching = watching,
opened = opened,
last_output = last_output,
+ debounce_timers = debounce_timers,
}
return M
From 277daa63cabbb05325341fde9e80cff98c8471db Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:42:59 -0500
Subject: [PATCH 06/45] feat(presets): add error parsers for built-in presets
(#9)
Problem: none of the four presets defined an error_parser, so the
diagnostic infrastructure went unused out of the box.
Solution: add parsers for typst (file:line:col short format), latexmk
(pdflatex file-line-error + summary), and pandoc (parse errors, YAML
exceptions, generic errors). Enable machine-parseable output flags in
typst and latex args. Pandoc parser is shared between markdown and
github presets.
---
lua/preview/presets.lua | 111 ++++++++++++++++++++++++++++++++++-
spec/presets_spec.lua | 126 +++++++++++++++++++++++++++++++++++++++-
2 files changed, 232 insertions(+), 5 deletions(-)
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index e04862b..196114b 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -1,15 +1,108 @@
local M = {}
+---@param stderr string
+---@return preview.Diagnostic[]
+local function parse_typst(stderr)
+ local diagnostics = {}
+ for line in stderr:gmatch('[^\r\n]+') do
+ local file, lnum, col, severity, msg = line:match('^(.+):(%d+):(%d+): (%w+): (.+)$')
+ if lnum then
+ local sev = vim.diagnostic.severity.ERROR
+ if severity == 'warning' then
+ sev = vim.diagnostic.severity.WARN
+ end
+ table.insert(diagnostics, {
+ lnum = tonumber(lnum) - 1,
+ col = tonumber(col) - 1,
+ message = msg,
+ severity = sev,
+ source = file,
+ })
+ end
+ end
+ return diagnostics
+end
+
+---@param stderr string
+---@return preview.Diagnostic[]
+local function parse_latexmk(stderr)
+ local diagnostics = {}
+ for line in stderr:gmatch('[^\r\n]+') do
+ local _, lnum, msg = line:match('^%.?/?(.+%.tex):(%d+): (.+)$')
+ if lnum then
+ table.insert(diagnostics, {
+ lnum = tonumber(lnum) - 1,
+ col = 0,
+ message = msg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ else
+ local rule_msg = line:match('^%s+(%S.+gave return code %d+)$')
+ if rule_msg then
+ table.insert(diagnostics, {
+ lnum = 0,
+ col = 0,
+ message = rule_msg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ end
+ end
+ end
+ return diagnostics
+end
+
+---@param stderr string
+---@return preview.Diagnostic[]
+local function parse_pandoc(stderr)
+ local diagnostics = {}
+ for line in stderr:gmatch('[^\r\n]+') do
+ local lnum, col, msg = line:match('Error at .+ %(line (%d+), column (%d+)%): (.+)$')
+ if lnum then
+ table.insert(diagnostics, {
+ lnum = tonumber(lnum) - 1,
+ col = tonumber(col) - 1,
+ message = msg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ else
+ 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
+ return diagnostics
+end
+
---@type preview.ProviderConfig
M.typst = {
ft = 'typst',
cmd = { 'typst', 'compile' },
args = function(ctx)
- return { ctx.file }
+ return { '--diagnostic-format', 'short', ctx.file }
end,
output = function(ctx)
return (ctx.file:gsub('%.typ$', '.pdf'))
end,
+ error_parser = function(stderr)
+ return parse_typst(stderr)
+ end,
open = true,
}
@@ -18,11 +111,19 @@ M.latex = {
ft = 'tex',
cmd = { 'latexmk' },
args = function(ctx)
- return { '-pdf', '-interaction=nonstopmode', ctx.file }
+ return {
+ '-pdf',
+ '-interaction=nonstopmode',
+ '-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S',
+ ctx.file,
+ }
end,
output = function(ctx)
return (ctx.file:gsub('%.tex$', '.pdf'))
end,
+ error_parser = function(stderr)
+ return parse_latexmk(stderr)
+ end,
clean = function(ctx)
return { 'latexmk', '-c', ctx.file }
end,
@@ -40,6 +141,9 @@ M.markdown = {
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
end,
+ error_parser = function(stderr)
+ return parse_pandoc(stderr)
+ end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
end,
@@ -67,6 +171,9 @@ M.github = {
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
end,
+ error_parser = function(stderr)
+ return parse_pandoc(stderr)
+ end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
end,
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index d213389..33f3a91 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -21,10 +21,10 @@ describe('presets', function()
assert.are.same({ 'typst', 'compile' }, presets.typst.cmd)
end)
- it('returns args with file path', function()
+ it('returns args with diagnostic format and file path', function()
local args = presets.typst.args(ctx)
assert.is_table(args)
- assert.are.same({ '/tmp/document.typ' }, args)
+ assert.are.same({ '--diagnostic-format', 'short', '/tmp/document.typ' }, args)
end)
it('returns pdf output path', function()
@@ -36,6 +36,30 @@ describe('presets', function()
it('has open enabled', function()
assert.is_true(presets.typst.open)
end)
+
+ it('parses errors from stderr', function()
+ local stderr = table.concat({
+ 'main.typ:5:23: error: unexpected token',
+ 'main.typ:12:1: warning: unused variable',
+ }, '\n')
+ local diagnostics = presets.typst.error_parser(stderr, ctx)
+ assert.is_table(diagnostics)
+ assert.are.equal(2, #diagnostics)
+ assert.are.equal(4, diagnostics[1].lnum)
+ assert.are.equal(22, diagnostics[1].col)
+ assert.are.equal('unexpected token', diagnostics[1].message)
+ assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
+ assert.are.equal('main.typ', diagnostics[1].source)
+ assert.are.equal(11, diagnostics[2].lnum)
+ assert.are.equal(0, diagnostics[2].col)
+ assert.are.equal('unused variable', diagnostics[2].message)
+ assert.are.equal(vim.diagnostic.severity.WARN, diagnostics[2].severity)
+ end)
+
+ it('returns empty table for clean stderr', function()
+ local diagnostics = presets.typst.error_parser('', ctx)
+ assert.are.same({}, diagnostics)
+ end)
end)
describe('latex', function()
@@ -57,7 +81,12 @@ describe('presets', function()
it('returns args with pdf flag and file path', function()
local args = presets.latex.args(tex_ctx)
assert.is_table(args)
- assert.are.same({ '-pdf', '-interaction=nonstopmode', '/tmp/document.tex' }, args)
+ assert.are.same({
+ '-pdf',
+ '-interaction=nonstopmode',
+ '-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S',
+ '/tmp/document.tex',
+ }, args)
end)
it('returns pdf output path', function()
@@ -75,6 +104,44 @@ describe('presets', function()
it('has open enabled', function()
assert.is_true(presets.latex.open)
end)
+
+ it('parses file-line-error format from stderr', function()
+ local stderr = table.concat({
+ './document.tex:10: Undefined control sequence.',
+ 'l.10 \\badcommand',
+ 'Collected error summary (may duplicate other messages):',
+ " pdflatex: Command for 'pdflatex' gave return code 256",
+ }, '\n')
+ local diagnostics = presets.latex.error_parser(stderr, tex_ctx)
+ assert.is_table(diagnostics)
+ assert.is_true(#diagnostics > 0)
+ assert.are.equal(9, diagnostics[1].lnum)
+ assert.are.equal(0, diagnostics[1].col)
+ assert.are.equal('Undefined control sequence.', diagnostics[1].message)
+ assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
+ end)
+
+ it('parses collected error summary', function()
+ local stderr = table.concat({
+ 'Latexmk: Errors, so I did not complete making targets',
+ 'Collected error summary (may duplicate other messages):',
+ " pdflatex: Command for 'pdflatex' gave return code 256",
+ }, '\n')
+ local diagnostics = presets.latex.error_parser(stderr, tex_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(
+ "pdflatex: Command for 'pdflatex' gave return code 256",
+ diagnostics[1].message
+ )
+ end)
+
+ it('returns empty table for clean stderr', function()
+ local diagnostics = presets.latex.error_parser('', tex_ctx)
+ assert.are.same({}, diagnostics)
+ end)
end)
describe('markdown', function()
@@ -117,6 +184,43 @@ describe('presets', function()
it('has open enabled', function()
assert.is_true(presets.markdown.open)
end)
+
+ it('parses pandoc parse errors from stderr', function()
+ local stderr = 'Error at "source" (line 75, column 1): unexpected end of input'
+ local diagnostics = presets.markdown.error_parser(stderr, md_ctx)
+ assert.is_table(diagnostics)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(74, diagnostics[1].lnum)
+ assert.are.equal(0, diagnostics[1].col)
+ assert.are.equal('unexpected end of input', diagnostics[1].message)
+ assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
+ end)
+
+ it('parses YAML parse exceptions from stderr', function()
+ local stderr =
+ '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(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.are.equal(1, #diagnostics)
+ assert.are.equal(0, diagnostics[1].lnum)
+ assert.are.equal(0, diagnostics[1].col)
+ assert.are.equal('Could not find data file templates/default.html5', diagnostics[1].message)
+ end)
+
+ it('returns empty table for clean stderr', function()
+ local diagnostics = presets.markdown.error_parser('', md_ctx)
+ assert.are.same({}, diagnostics)
+ end)
end)
describe('github', function()
@@ -179,5 +283,21 @@ describe('presets', function()
it('has open enabled', function()
assert.is_true(presets.github.open)
end)
+
+ it('parses pandoc parse errors from stderr', function()
+ local stderr = 'Error at "document.md" (line 12, column 5): unexpected "}" expecting letter'
+ local diagnostics = presets.github.error_parser(stderr, md_ctx)
+ assert.is_table(diagnostics)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(11, diagnostics[1].lnum)
+ assert.are.equal(4, diagnostics[1].col)
+ assert.are.equal('unexpected "}" expecting letter', diagnostics[1].message)
+ assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
+ end)
+
+ it('returns empty table for clean stderr', function()
+ local diagnostics = presets.github.error_parser('', md_ctx)
+ assert.are.same({}, diagnostics)
+ end)
end)
end)
From 87fc00059c6867824b7757fb77fe45821c1d40e2 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Tue, 3 Mar 2026 13:44:43 -0500
Subject: [PATCH 07/45] doc: cleanup readme
---
README.md | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index da5f3cf..1d4446b 100644
--- a/README.md
+++ b/README.md
@@ -8,16 +8,14 @@ asynchronously with error diagnostics.
## Features
- Async compilation via `vim.system()`
+- Built-in presets for Typst, LaTeX, Markdown, and GitHub-flavored Markdown
- Compiler errors as native `vim.diagnostic`
- User events for extensibility (`PreviewCompileStarted`,
`PreviewCompileSuccess`, `PreviewCompileFailed`)
-- Built-in presets for Typst, LaTeX, Markdown, and GitHub-flavored Markdown
-- `:checkhealth` integration
-- Zero dependencies beyond Neovim 0.11.0+
## Requirements
-- Neovim 0.11.0+
+- Neovim 0.11+
## Installation
From 0f353446b6b0ff68415aedf33e24fb0006cfbe97 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 14:14:59 -0500
Subject: [PATCH 08/45] fix(presets): correct error parsers for real compiler
output (#11)
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.
---
lua/preview/compiler.lua | 3 +-
lua/preview/diagnostic.lua | 8 ++--
lua/preview/init.lua | 2 +-
lua/preview/presets.lua | 93 ++++++++++++++++++++------------------
spec/presets_spec.lua | 74 ++++++++++++++++++------------
5 files changed, 101 insertions(+), 79 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 6e3f5cf..a247f2e 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -106,7 +106,8 @@ function M.compile(bufnr, name, provider, ctx)
else
log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code)
if provider.error_parser then
- diagnostic.set(bufnr, name, provider.error_parser, result.stderr or '', ctx)
+ 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',
diff --git a/lua/preview/diagnostic.lua b/lua/preview/diagnostic.lua
index d81ec64..abd4105 100644
--- a/lua/preview/diagnostic.lua
+++ b/lua/preview/diagnostic.lua
@@ -12,11 +12,11 @@ end
---@param bufnr integer
---@param name string
----@param error_parser fun(stderr: string, ctx: preview.Context): preview.Diagnostic[]
----@param stderr string
+---@param error_parser fun(output: string, ctx: preview.Context): preview.Diagnostic[]
+---@param output string
---@param ctx preview.Context
-function M.set(bufnr, name, error_parser, stderr, ctx)
- local ok, diagnostics = pcall(error_parser, stderr, ctx)
+function M.set(bufnr, name, error_parser, output, ctx)
+ local ok, diagnostics = pcall(error_parser, output, ctx)
if not ok then
log.dbg('error_parser for "%s" failed: %s', name, diagnostics)
return
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index f6d0006..d122e61 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -5,7 +5,7 @@
---@field cwd? string|fun(ctx: preview.Context): string
---@field env? table
---@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 clean? string[]|fun(ctx: preview.Context): string[]
---@field open? boolean|string[]
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 196114b..8b9faab 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -1,11 +1,11 @@
local M = {}
----@param stderr string
+---@param output string
---@return preview.Diagnostic[]
-local function parse_typst(stderr)
+local function parse_typst(output)
local diagnostics = {}
- for line in stderr:gmatch('[^\r\n]+') do
- local file, lnum, col, severity, msg = line:match('^(.+):(%d+):(%d+): (%w+): (.+)$')
+ for line in output:gmatch('[^\r\n]+') do
+ local _, lnum, col, severity, msg = line:match('^(.+):(%d+):(%d+): (%w+): (.+)$')
if lnum then
local sev = vim.diagnostic.severity.ERROR
if severity == 'warning' then
@@ -16,18 +16,17 @@ local function parse_typst(stderr)
col = tonumber(col) - 1,
message = msg,
severity = sev,
- source = file,
})
end
end
return diagnostics
end
----@param stderr string
+---@param output string
---@return preview.Diagnostic[]
-local function parse_latexmk(stderr)
+local function parse_latexmk(output)
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+): (.+)$')
if lnum then
table.insert(diagnostics, {
@@ -51,41 +50,45 @@ local function parse_latexmk(stderr)
return diagnostics
end
----@param stderr string
+---@param output string
---@return preview.Diagnostic[]
-local function parse_pandoc(stderr)
+local function parse_pandoc(output)
local diagnostics = {}
- for line in stderr:gmatch('[^\r\n]+') do
- local lnum, col, msg = line:match('Error at .+ %(line (%d+), column (%d+)%): (.+)$')
+ local lines = vim.split(output, '\n')
+ 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
- table.insert(diagnostics, {
- lnum = tonumber(lnum) - 1,
- col = tonumber(col) - 1,
- message = msg,
- severity = vim.diagnostic.severity.ERROR,
- })
- else
- 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,
- })
+ if msg == '' then
+ for j = i + 1, math.min(i + 2, #lines) do
+ local next_line = lines[j]:match('^%s*(.+)$')
+ if next_line and not next_line:match('^YAML parse exception') then
+ msg = next_line
+ break
+ 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
+ i = i + 1
end
return diagnostics
end
@@ -100,8 +103,8 @@ M.typst = {
output = function(ctx)
return (ctx.file:gsub('%.typ$', '.pdf'))
end,
- error_parser = function(stderr)
- return parse_typst(stderr)
+ error_parser = function(output)
+ return parse_typst(output)
end,
open = true,
}
@@ -121,8 +124,8 @@ M.latex = {
output = function(ctx)
return (ctx.file:gsub('%.tex$', '.pdf'))
end,
- error_parser = function(stderr)
- return parse_latexmk(stderr)
+ error_parser = function(output)
+ return parse_latexmk(output)
end,
clean = function(ctx)
return { 'latexmk', '-c', ctx.file }
@@ -141,8 +144,8 @@ M.markdown = {
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
end,
- error_parser = function(stderr)
- return parse_pandoc(stderr)
+ error_parser = function(output)
+ return parse_pandoc(output)
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
@@ -171,8 +174,8 @@ M.github = {
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
end,
- error_parser = function(stderr)
- return parse_pandoc(stderr)
+ error_parser = function(output)
+ return parse_pandoc(output)
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index 33f3a91..904a4f4 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -49,7 +49,7 @@ describe('presets', function()
assert.are.equal(22, diagnostics[1].col)
assert.are.equal('unexpected token', diagnostics[1].message)
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(0, diagnostics[2].col)
assert.are.equal('unused variable', diagnostics[2].message)
@@ -105,14 +105,14 @@ describe('presets', function()
assert.is_true(presets.latex.open)
end)
- it('parses file-line-error format from stderr', function()
- local stderr = table.concat({
+ it('parses file-line-error format from output', function()
+ local output = table.concat({
'./document.tex:10: Undefined control sequence.',
'l.10 \\badcommand',
'Collected error summary (may duplicate other messages):',
" pdflatex: Command for 'pdflatex' gave return code 256",
}, '\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_true(#diagnostics > 0)
assert.are.equal(9, diagnostics[1].lnum)
@@ -122,12 +122,12 @@ describe('presets', function()
end)
it('parses collected error summary', function()
- local stderr = table.concat({
+ local output = table.concat({
'Latexmk: Errors, so I did not complete making targets',
'Collected error summary (may duplicate other messages):',
" pdflatex: Command for 'pdflatex' gave return code 256",
}, '\n')
- local diagnostics = presets.latex.error_parser(stderr, tex_ctx)
+ local diagnostics = presets.latex.error_parser(output, tex_ctx)
assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics)
assert.are.equal(0, diagnostics[1].lnum)
@@ -185,9 +185,24 @@ describe('presets', function()
assert.is_true(presets.markdown.open)
end)
- it('parses pandoc parse errors from stderr', function()
- local stderr = 'Error at "source" (line 75, column 1): unexpected end of input'
- local diagnostics = presets.markdown.error_parser(stderr, md_ctx)
+ it('parses YAML metadata errors with multiline message', function()
+ local output = table.concat({
+ '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.are.equal(1, #diagnostics)
assert.are.equal(74, diagnostics[1].lnum)
@@ -196,20 +211,9 @@ describe('presets', function()
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end)
- it('parses YAML parse exceptions from stderr', function()
- local stderr =
- '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(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)
+ it('parses generic pandoc errors', function()
+ local output = 'pandoc: Could not find data file templates/default.html5'
+ 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)
@@ -217,7 +221,7 @@ describe('presets', function()
assert.are.equal('Could not find data file templates/default.html5', diagnostics[1].message)
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)
assert.are.same({}, diagnostics)
end)
@@ -284,9 +288,23 @@ describe('presets', function()
assert.is_true(presets.github.open)
end)
- it('parses pandoc parse errors from stderr', function()
- local stderr = 'Error at "document.md" (line 12, column 5): unexpected "}" expecting letter'
- local diagnostics = presets.github.error_parser(stderr, md_ctx)
+ it('parses YAML metadata errors with multiline message', function()
+ local output = table.concat({
+ '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.are.equal(1, #diagnostics)
assert.are.equal(11, diagnostics[1].lnum)
@@ -295,7 +313,7 @@ describe('presets', function()
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
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)
assert.are.same({}, diagnostics)
end)
From 7995d6422d1fee18958a0fdce3699193a0af24f8 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 14:18:28 -0500
Subject: [PATCH 09/45] feat(compiler): notify on toggle watch start and stop
(#13)
Problem: :Preview toggle gave no feedback, leaving the user to guess
whether watching was enabled or disabled.
Solution: emit vim.notify messages when toggling on ("watching with
\"\"") and off ("watching stopped"). Also normalize the
[preview.nvim] prefix in commands.lua to include the colon.
---
lua/preview/commands.lua | 4 ++--
lua/preview/compiler.lua | 2 ++
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index ad63c97..f82c726 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -27,9 +27,9 @@ local function dispatch(args)
if s.watching then
table.insert(parts, 'watching')
end
- vim.notify('[preview.nvim] ' .. table.concat(parts, ', '), vim.log.levels.INFO)
+ vim.notify('[preview.nvim]: ' .. table.concat(parts, ', '), vim.log.levels.INFO)
else
- vim.notify('[preview.nvim] unknown subcommand: ' .. subcmd, vim.log.levels.ERROR)
+ vim.notify('[preview.nvim]: unknown subcommand: ' .. subcmd, vim.log.levels.ERROR)
end
end
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index a247f2e..052c4f0 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -178,6 +178,7 @@ end
function M.toggle(bufnr, name, provider, ctx_builder)
if watching[bufnr] then
M.unwatch(bufnr)
+ vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO)
return
end
@@ -202,6 +203,7 @@ function M.toggle(bufnr, name, provider, ctx_builder)
watching[bufnr] = au_id
log.dbg('watching buffer %d with provider "%s"', bufnr, name)
+ vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO)
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
From 253ca05da3b6a1d455328b2b74c1f7bb6c81fae5 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 14:57:44 -0500
Subject: [PATCH 10/45] feat(compiler): add configurable error output modes
(#14)
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.
---
lua/preview/compiler.lua | 33 +++++++++++--
lua/preview/init.lua | 1 +
spec/compiler_spec.lua | 102 +++++++++++++++++++++++++++++++++++++++
3 files changed, 133 insertions(+), 3 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 052c4f0..0643ccd 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -86,9 +86,18 @@ function M.compile(bufnr, name, provider, ctx)
return
end
+ local errors_mode = provider.errors
+ if errors_mode == nil then
+ errors_mode = 'diagnostic'
+ end
+
if result.code == 0 then
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', {
pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file },
@@ -105,9 +114,27 @@ function M.compile(bufnr, name, provider, ctx)
end
else
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
local output = (result.stdout or '') .. (result.stderr or '')
- diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
+ 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
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index d122e61..4a44a33 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -6,6 +6,7 @@
---@field env? table
---@field output? string|fun(ctx: preview.Context): string
---@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 open? boolean|string[]
diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua
index 2046ef2..2189347 100644
--- a/spec/compiler_spec.lua
+++ b/spec/compiler_spec.lua
@@ -132,6 +132,108 @@ describe('compiler', function()
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()
it('does nothing when no process is active', function()
assert.has_no.errors(function()
From 7ed4b61c988dc1f400bebe634e3b212936f5c96d Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 15:03:48 -0500
Subject: [PATCH 11/45] refactor(commands): derive completion from dispatch
table (#15)
---
lua/preview/commands.lua | 35 ++++++++++++++++++++++-------------
1 file changed, 22 insertions(+), 13 deletions(-)
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index f82c726..2c52c5a 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -1,22 +1,22 @@
local M = {}
-local subcommands = { 'compile', 'stop', 'clean', 'toggle', 'open', 'status' }
-
----@param args string
-local function dispatch(args)
- local subcmd = args ~= '' and args or 'compile'
-
- if subcmd == 'compile' then
+local handlers = {
+ compile = function()
require('preview').compile()
- elseif subcmd == 'stop' then
+ end,
+ stop = function()
require('preview').stop()
- elseif subcmd == 'clean' then
+ end,
+ clean = function()
require('preview').clean()
- elseif subcmd == 'toggle' then
+ end,
+ toggle = function()
require('preview').toggle()
- elseif subcmd == 'open' then
+ end,
+ open = function()
require('preview').open()
- elseif subcmd == 'status' then
+ end,
+ status = function()
local s = require('preview').status()
local parts = {}
if s.compiling then
@@ -28,6 +28,15 @@ local function dispatch(args)
table.insert(parts, 'watching')
end
vim.notify('[preview.nvim]: ' .. table.concat(parts, ', '), vim.log.levels.INFO)
+ end,
+}
+
+---@param args string
+local function dispatch(args)
+ local subcmd = args ~= '' and args or 'compile'
+ local handler = handlers[subcmd]
+ if handler then
+ handler()
else
vim.notify('[preview.nvim]: unknown subcommand: ' .. subcmd, vim.log.levels.ERROR)
end
@@ -38,7 +47,7 @@ end
local function complete(lead)
return vim.tbl_filter(function(s)
return s:find(lead, 1, true) == 1
- end, subcommands)
+ end, vim.tbl_keys(handlers))
end
function M.setup()
From 4c22f84b31ebfee1b8fd59dbb254917582f3985e Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 15:04:03 -0500
Subject: [PATCH 12/45] feat(init): validate provider config eagerly in setup
(#16)
---
lua/preview/init.lua | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index 4a44a33..f4f2831 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -85,6 +85,20 @@ function M.setup(opts)
end
end
+ for ft, provider in pairs(providers) do
+ local prefix = 'providers.' .. ft
+ vim.validate(prefix .. '.cmd', provider.cmd, 'table')
+ vim.validate(prefix .. '.cmd[1]', provider.cmd[1], 'string')
+ vim.validate(prefix .. '.args', provider.args, { 'table', 'function' }, true)
+ vim.validate(prefix .. '.cwd', provider.cwd, { 'string', 'function' }, true)
+ vim.validate(prefix .. '.output', provider.output, { 'string', 'function' }, true)
+ vim.validate(prefix .. '.error_parser', provider.error_parser, 'function', true)
+ vim.validate(prefix .. '.errors', provider.errors, function(x)
+ return x == nil or x == false or x == 'diagnostic' or x == 'quickfix'
+ end, 'false, "diagnostic", or "quickfix"')
+ vim.validate(prefix .. '.open', provider.open, { 'boolean', 'table' }, true)
+ end
+
config = vim.tbl_deep_extend('force', default_config, {
debug = debug,
providers = providers,
From 99263dec9f33712eacf001b703acff5166a9f6a6 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 15:12:14 -0500
Subject: [PATCH 13/45] refactor(compiler): resolve output before args (#17)
Problem: presets that need the output path in their args function
(markdown, github) had to recompute it inline, duplicating the same
gsub expression already in the output field.
Solution: resolve output_file first in M.compile, then extend ctx with
output = output_file into a resolved_ctx before evaluating args and cwd.
Presets can now reference ctx.output directly. Add output? to the
preview.Context type annotation.
---
lua/preview/compiler.lua | 16 +++++++++-------
lua/preview/init.lua | 1 +
lua/preview/presets.lua | 6 ++----
spec/presets_spec.lua | 2 ++
4 files changed, 14 insertions(+), 11 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 0643ccd..eca9f4d 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -53,19 +53,21 @@ function M.compile(bufnr, name, provider, ctx)
M.stop(bufnr)
end
+ local output_file = ''
+ if provider.output then
+ output_file = eval_string(provider.output, ctx)
+ end
+
+ local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file })
+
local cmd = vim.list_extend({}, provider.cmd)
if provider.args then
- vim.list_extend(cmd, eval_list(provider.args, ctx))
+ vim.list_extend(cmd, eval_list(provider.args, resolved_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)
+ cwd = eval_string(provider.cwd, resolved_ctx)
end
if output_file ~= '' then
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index f4f2831..2bee03a 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -19,6 +19,7 @@
---@field file string
---@field root string
---@field ft string
+---@field output? string
---@class preview.Diagnostic
---@field lnum integer
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 8b9faab..c1de0df 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -138,8 +138,7 @@ M.markdown = {
ft = 'markdown',
cmd = { 'pandoc' },
args = function(ctx)
- local output = ctx.file:gsub('%.md$', '.html')
- return { ctx.file, '-s', '--embed-resources', '-o', output }
+ return { ctx.file, '-s', '--embed-resources', '-o', ctx.output }
end,
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
@@ -158,7 +157,6 @@ M.github = {
ft = 'markdown',
cmd = { 'pandoc' },
args = function(ctx)
- local output = ctx.file:gsub('%.md$', '.html')
return {
'-f',
'gfm',
@@ -168,7 +166,7 @@ M.github = {
'--css',
'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',
- output,
+ ctx.output,
}
end,
output = function(ctx)
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index 904a4f4..6eaa613 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -150,6 +150,7 @@ describe('presets', function()
file = '/tmp/document.md',
root = '/tmp',
ft = 'markdown',
+ output = '/tmp/document.html',
}
it('has ft', function()
@@ -233,6 +234,7 @@ describe('presets', function()
file = '/tmp/document.md',
root = '/tmp',
ft = 'markdown',
+ output = '/tmp/document.html',
}
it('has ft', function()
From bce3cec0e66654eb7b13ad46c5dbe8f9209e72de Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 15:12:28 -0500
Subject: [PATCH 14/45] docs: update help file for recent additions (#18)
---
doc/preview.nvim.txt | 22 +++++++++++++++++++---
1 file changed, 19 insertions(+), 3 deletions(-)
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index 0b44a91..c8bc708 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -74,9 +74,16 @@ Provider fields:~
`output` string|function Output file path. If a function,
receives a |preview.Context|.
- `error_parser` function Receives (stderr, |preview.Context|)
+ `error_parser` function Receives (output, |preview.Context|)
and returns vim.Diagnostic[].
+ `errors` false|'diagnostic'|'quickfix'
+ How parse errors are reported.
+ `false` suppresses error handling.
+ `'quickfix'` populates the quickfix
+ list and opens it. Default:
+ `'diagnostic'`.
+
`clean` string[]|function Command to remove build artifacts.
If a function, receives a
|preview.Context|.
@@ -85,7 +92,6 @@ Provider fields:~
successful compilation. `true` uses
|vim.ui.open()|. A string[] is run as
a command with the output path appended.
- Presets default to `{ 'xdg-open' }`.
*preview.Context*
Context fields:~
@@ -94,6 +100,8 @@ Context fields:~
`file` string Absolute file path.
`root` string Project root (git root or file directory).
`ft` string Filetype.
+ `output` string? Resolved output file path (set after `output`
+ is evaluated, available to `args` functions).
Example enabling presets:~
>lua
@@ -156,7 +164,8 @@ COMMANDS *preview.nvim-commands*
`stop` Kill active compilation for the current buffer.
`clean` Run the provider's clean command.
`toggle` Toggle auto-compile on save for the current buffer.
- `status` Echo compilation status (idle, compiling, toggled).
+ `open` Open the last compiled output without recompiling.
+ `status` Echo compilation status (idle, compiling, watching).
==============================================================================
API *preview.nvim-api*
@@ -175,9 +184,16 @@ preview.toggle({bufnr?}) *preview.toggle()*
immediately compiled and automatically recompiled on each save
(`BufWritePost`). Call again to stop.
+preview.open({bufnr?}) *preview.open()*
+ Open the last compiled output for the buffer without recompiling.
+
preview.status({bufnr?}) *preview.status()*
Returns a |preview.Status| table.
+preview.statusline({bufnr?}) *preview.statusline()*
+ Returns a short status string for statusline integration:
+ `'compiling'`, `'watching'`, or `''` (idle).
+
*preview.Status*
Status fields:~
From 62961c854168cec9ae30e52c3e1b2e914248f1f2 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 16:41:47 -0500
Subject: [PATCH 15/45] feat: unified reload field for live-preview (SSE +
long-running watch) (#19)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(reload): add SSE live-reload server module
Problem: HTML output from pandoc has no live-reload; the browser must
be refreshed manually after each compile.
Solution: add lua/preview/reload.lua — a minimal SSE-only TCP server.
start() binds 127.0.0.1:5554 and keeps EventSource connections alive;
broadcast() pushes a reload event to all clients; inject() appends an
EventSource script before
hello