From 2d212aa220356f37d57a061e05b2fb4fdd23728c Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 00:25:49 -0500
Subject: [PATCH 01/46] refactor(config): replace array preset syntax with
preset_name = true (#3)
* refactor(config): replace array preset syntax with preset_name = true
Problem: setup() mixed array entries (preset names) and hash entries
(custom providers keyed by filetype), requiring verbose
vim.tbl_deep_extend boilerplate to override presets.
Solution: unify under a single key=value model. Keys are preset names
or filetypes; true registers the preset as-is, a table deep-merges
with the matching preset (or registers a custom provider if no preset
matches), and false is a no-op. Array entries are dropped. Also adds
-f gfm to presets.github args so pandoc parses input as GFM.
* ci: format
* fix(presets): parenthesize gsub output to suppress redundant-return-value
---
README.md | 13 ++++-----
doc/preview.nvim.txt | 59 +++++++++++++++++++++--------------------
lua/preview/init.lua | 16 ++++++-----
lua/preview/presets.lua | 12 +++++----
spec/init_spec.lua | 12 ++++-----
spec/presets_spec.lua | 17 +++++++++++-
6 files changed, 73 insertions(+), 56 deletions(-)
diff --git a/README.md b/README.md
index dcdf689..3e85634 100644
--- a/README.md
+++ b/README.md
@@ -55,23 +55,20 @@ require('preview').setup({
**Q: How do I override a preset?**
```lua
-local presets = require('preview.presets')
require('preview').setup({
- typst = vim.tbl_deep_extend('force', presets.typst, {
- env = { TYPST_FONT_PATHS = '/usr/share/fonts' },
- }),
+ typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } },
})
```
**Q: How do I automatically open the output file?**
Set `open = true` on your provider (all built-in presets have this enabled) to
-open the output with `vim.ui.open()` after the first successful compilation.
-For a specific application, pass a command table:
+open the output with `vim.ui.open()` after the first successful compilation. For
+a specific application, pass a command table:
```lua
-typst = vim.tbl_deep_extend('force', presets.typst, {
- open = { 'sioyek', '--new-instance' },
+require('preview').setup({
+ typst = { open = { 'sioyek', '--new-instance' } },
})
```
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index 3ed587a..753fefc 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -42,9 +42,18 @@ Configure via `require('preview').setup()`.
*preview.setup()*
setup({opts?})
- `opts` is a mixed table. Array entries are preset names (see
- |preview.nvim-presets|). Hash entries with table values are custom
- provider configs keyed by filetype.
+ `opts` is a table where keys are preset names or filetypes. For each
+ key `k` with value `v` (excluding `debug`):
+
+ - If `k` is a preset name and `v` is `true`, the preset is registered
+ as-is under its filetype.
+ - If `k` is a preset name and `v` is a table, it is deep-merged with
+ the preset and registered under the preset's filetype.
+ - If `k` is not a preset name and `v` is a table, it is registered
+ directly as a custom provider keyed by filetype `k`.
+ - If `v` is `false`, the entry is skipped (no-op).
+
+ See |preview.nvim-presets| for available preset names.
Fields:~
@@ -91,33 +100,28 @@ Context fields:~
`root` string Project root (git root or file directory).
`ft` string Filetype.
-Example using preset names:~
+Example enabling presets:~
>lua
- require('preview').setup({ 'typst', 'latex', 'markdown' })
+ require('preview').setup({ typst = true, latex = true, github = true })
<
-Example with a custom provider:~
+Example overriding a preset field:~
>lua
require('preview').setup({
- typst = {
- cmd = { 'typst', 'compile' },
+ typst = { open = { 'sioyek', '--new-instance' } },
+ })
+<
+
+Example with a fully custom provider (key is not a preset name):~
+>lua
+ require('preview').setup({
+ rst = {
+ cmd = { 'rst2html' },
args = function(ctx)
return { ctx.file }
end,
output = function(ctx)
- return ctx.file:gsub('%.typ$', '.pdf')
- end,
- error_parser = function(stderr, ctx)
- local diagnostics = {}
- for line, col, msg in stderr:gmatch('error:.-(%d+):(%d+):%s*(.-)%\n') do
- table.insert(diagnostics, {
- lnum = tonumber(line) - 1,
- col = tonumber(col) - 1,
- message = msg,
- severity = vim.diagnostic.severity.ERROR,
- })
- end
- return diagnostics
+ return ctx.file:gsub('%.rst$', '.html')
end,
},
})
@@ -132,20 +136,17 @@ Import them from `preview.presets`:
`presets.typst` typst compile → PDF
`presets.latex` latexmk -pdf → PDF (with clean support)
`presets.markdown` pandoc → HTML (standalone, embedded)
- `presets.github` pandoc → HTML (GitHub-styled)
+ `presets.github` pandoc → HTML (GitHub-styled, `-f gfm` input)
-Pass preset names as array entries to `setup()`:
+Enable presets with `preset_name = true`:
>lua
- require('preview').setup({ 'typst', 'latex', 'markdown' })
+ require('preview').setup({ typst = true, latex = true, github = true })
<
-Override individual fields using `vim.tbl_deep_extend`:
+Override individual fields by passing a table instead of `true`:
>lua
- local presets = require('preview.presets')
require('preview').setup({
- typst = vim.tbl_deep_extend('force', presets.typst, {
- env = { TYPST_FONT_PATHS = '/usr/share/fonts' },
- }),
+ typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } },
})
<
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index 968b201..e2cf794 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -68,15 +68,17 @@ function M.setup(opts)
if k == 'debug' then
vim.validate('preview.setup opts.debug', v, { 'boolean', 'string' })
debug = v
- elseif type(k) == 'number' then
- vim.validate('preview.setup preset name', v, 'string')
- local preset = presets[v]
+ elseif type(k) ~= 'number' then
+ local preset = presets[k]
if preset then
- providers[preset.ft] = preset
+ if v == true then
+ providers[preset.ft] = preset
+ elseif type(v) == 'table' then
+ providers[preset.ft] = vim.tbl_deep_extend('force', preset, v)
+ end
+ elseif type(v) == 'table' then
+ providers[k] = v
end
- else
- vim.validate('preview.setup provider config', v, 'table')
- providers[k] = v
end
end
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 3579a8d..8a414e8 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -8,7 +8,7 @@ M.typst = {
return { ctx.file }
end,
output = function(ctx)
- return ctx.file:gsub('%.typ$', '.pdf')
+ return (ctx.file:gsub('%.typ$', '.pdf'))
end,
open = { 'xdg-open' },
}
@@ -21,7 +21,7 @@ M.latex = {
return { '-pdf', '-interaction=nonstopmode', ctx.file }
end,
output = function(ctx)
- return ctx.file:gsub('%.tex$', '.pdf')
+ return (ctx.file:gsub('%.tex$', '.pdf'))
end,
clean = function(ctx)
return { 'latexmk', '-c', ctx.file }
@@ -38,7 +38,7 @@ M.markdown = {
return { ctx.file, '-s', '--embed-resources', '-o', output }
end,
output = function(ctx)
- return ctx.file:gsub('%.md$', '.html')
+ return (ctx.file:gsub('%.md$', '.html'))
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
@@ -53,17 +53,19 @@ M.github = {
args = function(ctx)
local output = ctx.file:gsub('%.md$', '.html')
return {
+ '-f',
+ 'gfm',
ctx.file,
'-s',
'--embed-resources',
'--css',
- 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/github.css',
+ 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',
output,
}
end,
output = function(ctx)
- return ctx.file:gsub('%.md$', '.html')
+ return (ctx.file:gsub('%.md$', '.html'))
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
diff --git a/spec/init_spec.lua b/spec/init_spec.lua
index 7921aeb..5b97c8a 100644
--- a/spec/init_spec.lua
+++ b/spec/init_spec.lua
@@ -22,7 +22,7 @@ describe('preview', function()
assert.are.same({}, config.providers)
end)
- it('accepts full provider config via hash entry', function()
+ it('merges override table with matching preset', function()
helpers.reset_config({
typst = {
cmd = { 'typst', 'compile' },
@@ -33,8 +33,8 @@ describe('preview', function()
assert.is_not_nil(config.providers.typst)
end)
- it('resolves array preset names to provider configs', function()
- helpers.reset_config({ 'typst', 'markdown' })
+ it('resolves preset = true to provider config', function()
+ helpers.reset_config({ typst = true, markdown = true })
local config = require('preview').get_config()
local presets = require('preview.presets')
assert.are.same(presets.typst, config.providers.typst)
@@ -42,14 +42,14 @@ describe('preview', function()
end)
it('resolves latex preset under tex filetype', function()
- helpers.reset_config({ 'latex' })
+ helpers.reset_config({ latex = true })
local config = require('preview').get_config()
local presets = require('preview.presets')
assert.are.same(presets.latex, config.providers.tex)
end)
it('resolves github preset under markdown filetype', function()
- helpers.reset_config({ 'github' })
+ helpers.reset_config({ github = true })
local config = require('preview').get_config()
local presets = require('preview.presets')
assert.are.same(presets.github, config.providers.markdown)
@@ -59,7 +59,7 @@ describe('preview', function()
describe('resolve_provider', function()
before_each(function()
helpers.reset_config({
- typst = { cmd = { 'typst', 'compile' } },
+ typst = true,
})
preview = require('preview')
end)
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index 2eba98f..b8572c5 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -139,16 +139,31 @@ describe('presets', function()
local args = presets.github.args(md_ctx)
assert.is_table(args)
assert.are.same({
+ '-f',
+ 'gfm',
'/tmp/document.md',
'-s',
'--embed-resources',
'--css',
- 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/github.css',
+ 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',
'/tmp/document.html',
}, args)
end)
+ it('args include -f and gfm flags', function()
+ local args = presets.github.args(md_ctx)
+ local idx = nil
+ for i, v in ipairs(args) do
+ if v == '-f' then
+ idx = i
+ break
+ end
+ end
+ assert.is_not_nil(idx)
+ assert.are.equal('gfm', args[idx + 1])
+ end)
+
it('returns html output path', function()
local output = presets.github.output(md_ctx)
assert.is_string(output)
From 0b16ff7178e41ed5e389769d8704061ec2ede7d7 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 00:49:10 -0500
Subject: [PATCH 02/46] Refactor/preset name true syntax (#4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor(config): replace array preset syntax with preset_name = true
Problem: setup() mixed array entries (preset names) and hash entries
(custom providers keyed by filetype), requiring verbose
vim.tbl_deep_extend boilerplate to override presets.
Solution: unify under a single key=value model. Keys are preset names
or filetypes; true registers the preset as-is, a table deep-merges
with the matching preset (or registers a custom provider if no preset
matches), and false is a no-op. Array entries are dropped. Also adds
-f gfm to presets.github args so pandoc parses input as GFM.
* ci: format
* fix(presets): parenthesize gsub output to suppress redundant-return-value
* ci: remove superfluous things
* refactor: remove PreviewWatch* events and clean up docs
Problem: PreviewWatchStarted/PreviewWatchStopped were redundant with
the status() API, and the doc had a wrong author, stale INSTALLATION
format, and "watch mode" language left over from the watch → toggle
rename.
Solution: Remove the events and their tests. Fix the doc author,
rename INSTALLATION → SETUP to match sibling plugins, replace "watch
mode" with "auto-compile" throughout, and drop the events from EVENTS.
---
README.md | 2 --
doc/preview.nvim.txt | 27 +++++++--------------
lua/preview/compiler.lua | 10 --------
spec/compiler_spec.lua | 51 ----------------------------------------
4 files changed, 8 insertions(+), 82 deletions(-)
diff --git a/README.md b/README.md
index 3e85634..da5f3cf 100644
--- a/README.md
+++ b/README.md
@@ -71,5 +71,3 @@ require('preview').setup({
typst = { open = { 'sioyek', '--new-instance' } },
})
```
-
-See `:h preview.nvim` for more information.
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index 753fefc..0b44a91 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -1,6 +1,6 @@
*preview.nvim.txt* Async document compilation for Neovim
-Author: Raphael
+Author: Barrett Ruth
License: MIT
==============================================================================
@@ -20,19 +20,14 @@ REQUIREMENTS *preview.nvim-requirements
- A compiler binary for each configured provider (e.g. `typst`, `latexmk`)
==============================================================================
-INSTALLATION *preview.nvim-installation*
+SETUP *preview.nvim-setup*
-With luarocks (recommended):
->
- :Rocks install preview.nvim
-<
-
-With lazy.nvim:
->lua
+Load preview.nvim with your package manager. For example, with lazy.nvim: >lua
{
'barrettruth/preview.nvim',
}
<
+Call |preview.setup()| to configure providers before use.
==============================================================================
CONFIGURATION *preview.nvim-configuration*
@@ -161,7 +156,7 @@ 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, watching).
+ `status` Echo compilation status (idle, compiling, toggled).
==============================================================================
API *preview.nvim-api*
@@ -176,9 +171,9 @@ preview.clean({bufnr?}) *preview.clean()*
Run the provider's clean command for the buffer.
preview.toggle({bufnr?}) *preview.toggle()*
- Toggle watch mode for the buffer. When enabled, the buffer is
+ Toggle auto-compile for the buffer. When enabled, the buffer is
immediately compiled and automatically recompiled on each save
- (`BufWritePost`). Call again to stop watching.
+ (`BufWritePost`). Call again to stop.
preview.status({bufnr?}) *preview.status()*
Returns a |preview.Status| table.
@@ -187,7 +182,7 @@ preview.status({bufnr?}) *preview.status()*
Status fields:~
`compiling` boolean Whether compilation is active.
- `watching` boolean Whether watch mode is active.
+ `watching` boolean Whether auto-compile is active.
`provider` string? Name of the active provider.
`output_file` string? Path to the output file.
@@ -208,12 +203,6 @@ preview.nvim fires User autocmds with structured data:
`PreviewCompileFailed` Compilation failed (non-zero exit).
data: `{ bufnr, provider, code, stderr }`
-`PreviewWatchStarted` Watch mode enabled for a buffer.
- data: `{ bufnr, provider }`
-
-`PreviewWatchStopped` Watch mode disabled for a buffer.
- data: `{ bufnr }`
-
Example:~
>lua
vim.api.nvim_create_autocmd('User', {
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 6907524..b0c7402 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -188,11 +188,6 @@ function M.toggle(bufnr, name, provider, ctx_builder)
end,
})
- vim.api.nvim_exec_autocmds('User', {
- pattern = 'PreviewWatchStarted',
- data = { bufnr = bufnr, provider = name },
- })
-
M.compile(bufnr, name, provider, ctx_builder(bufnr))
end
@@ -205,11 +200,6 @@ function M.unwatch(bufnr)
vim.api.nvim_del_autocmd(au_id)
watching[bufnr] = nil
log.dbg('unwatched buffer %d', bufnr)
-
- vim.api.nvim_exec_autocmds('User', {
- pattern = 'PreviewWatchStopped',
- data = { bufnr = bufnr },
- })
end
---@param bufnr integer
diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua
index 7360ceb..6802dee 100644
--- a/spec/compiler_spec.lua
+++ b/spec/compiler_spec.lua
@@ -190,31 +190,6 @@ describe('compiler', function()
helpers.delete_buffer(bufnr)
end)
- it('fires PreviewWatchStarted event', function()
- local bufnr = helpers.create_buffer({ 'hello' }, 'text')
- vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_event.txt')
-
- local fired = false
- vim.api.nvim_create_autocmd('User', {
- pattern = 'PreviewWatchStarted',
- once = true,
- callback = function()
- fired = true
- end,
- })
-
- local provider = { cmd = { 'echo', 'ok' } }
- local ctx_builder = function(b)
- return { bufnr = b, file = '/tmp/preview_test_watch_event.txt', root = '/tmp', ft = 'text' }
- end
-
- compiler.toggle(bufnr, 'echo', provider, ctx_builder)
- assert.is_true(fired)
-
- compiler.unwatch(bufnr)
- helpers.delete_buffer(bufnr)
- end)
-
it('toggles off when called again', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_toggle.txt')
@@ -233,32 +208,6 @@ describe('compiler', function()
helpers.delete_buffer(bufnr)
end)
- it('fires PreviewWatchStopped on unwatch', function()
- local bufnr = helpers.create_buffer({ 'hello' }, 'text')
- vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_stop.txt')
-
- local stopped = false
- vim.api.nvim_create_autocmd('User', {
- pattern = 'PreviewWatchStopped',
- once = true,
- callback = function()
- stopped = true
- end,
- })
-
- local provider = { cmd = { 'echo', 'ok' } }
- local ctx_builder = function(b)
- return { bufnr = b, file = '/tmp/preview_test_watch_stop.txt', root = '/tmp', ft = 'text' }
- end
-
- compiler.toggle(bufnr, 'echo', provider, ctx_builder)
- compiler.unwatch(bufnr)
- assert.is_true(stopped)
- assert.is_nil(compiler._test.watching[bufnr])
-
- helpers.delete_buffer(bufnr)
- end)
-
it('stop_all clears watches', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_stopall.txt')
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 03/46] 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 04/46] 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 05/46] 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 06/46] 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 07/46] 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 08/46] 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 09/46] 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 10/46] 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 11/46] 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 12/46] 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 13/46] 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 14/46] 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 15/46] 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 16/46] 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