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 01/36] 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 02/36] 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 03/36] 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 04/36] 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 05/36] 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 06/36] 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