From 2888c5bb09e88b1d0c64f7816edbf4d4d66afe53 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Tue, 3 Mar 2026 17:41:53 -0500
Subject: [PATCH 01/32] fix(reload): bind SSE server to port 0 for OS-assigned
port
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Problem: the SSE reload server hardcoded port 5554, causing silent
failure when that port was already in use. bind() would fail but its
return value was never checked; listen() would also error and silently
drop via the if err then return end guard. inject() still wrote the
dead EventSource URL into the HTML, so the browser would connect to
whatever was on 5554 — or nothing — and live reload would silently
stop working.
Solution: bind to port or 0 so the OS assigns a free port, then call
getsockname() after bind to capture the actual port into actual_port.
inject() reads actual_port in preference to the hardcoded constant,
and stop() resets it. PORT = 5554 is kept only as a last-resort
fallback in inject() if actual_port is unset.
---
lua/preview/reload.lua | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/lua/preview/reload.lua b/lua/preview/reload.lua
index fd64309..d2c1de9 100644
--- a/lua/preview/reload.lua
+++ b/lua/preview/reload.lua
@@ -2,6 +2,7 @@ local M = {}
local PORT = 5554
local server_handle = nil
+local actual_port = nil
local clients = {}
local function make_script(port)
@@ -14,12 +15,15 @@ local function make_script(port)
end
function M.start(port)
- port = port or PORT
if server_handle then
return
end
local server = vim.uv.new_tcp()
- server:bind('127.0.0.1', port)
+ server:bind('127.0.0.1', port or 0)
+ local sockname = server:getsockname()
+ if sockname then
+ actual_port = sockname.port
+ end
server:listen(128, function(err)
if err then
return
@@ -66,6 +70,7 @@ function M.stop()
server_handle:close()
server_handle = nil
end
+ actual_port = nil
end
function M.broadcast()
@@ -85,7 +90,7 @@ function M.broadcast()
end
function M.inject(path, port)
- port = port or PORT
+ port = actual_port or port or PORT
local f = io.open(path, 'r')
if not f then
return
From e661ea78e836eca09e01af1ccc24e94258817e9b Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 17:46:04 -0500
Subject: [PATCH 02/32] fix(reload): bind SSE server to port 0 for OS-assigned
port (#21)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Problem: the SSE reload server hardcoded port 5554, causing silent
failure when that port was already in use. bind() would fail but its
return value was never checked; listen() would also error and silently
drop via the if err then return end guard. inject() still wrote the
dead EventSource URL into the HTML, so the browser would connect to
whatever was on 5554 — or nothing — and live reload would silently
stop working.
Solution: bind to port or 0 so the OS assigns a free port, then call
getsockname() after bind to capture the actual port into actual_port.
inject() reads actual_port in preference to the hardcoded constant,
and stop() resets it. PORT = 5554 is kept only as a last-resort
fallback in inject() if actual_port is unset.
---
lua/preview/reload.lua | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/lua/preview/reload.lua b/lua/preview/reload.lua
index fd64309..d2c1de9 100644
--- a/lua/preview/reload.lua
+++ b/lua/preview/reload.lua
@@ -2,6 +2,7 @@ local M = {}
local PORT = 5554
local server_handle = nil
+local actual_port = nil
local clients = {}
local function make_script(port)
@@ -14,12 +15,15 @@ local function make_script(port)
end
function M.start(port)
- port = port or PORT
if server_handle then
return
end
local server = vim.uv.new_tcp()
- server:bind('127.0.0.1', port)
+ server:bind('127.0.0.1', port or 0)
+ local sockname = server:getsockname()
+ if sockname then
+ actual_port = sockname.port
+ end
server:listen(128, function(err)
if err then
return
@@ -66,6 +70,7 @@ function M.stop()
server_handle:close()
server_handle = nil
end
+ actual_port = nil
end
function M.broadcast()
@@ -85,7 +90,7 @@ function M.broadcast()
end
function M.inject(path, port)
- port = port or PORT
+ port = actual_port or port or PORT
local f = io.open(path, 'r')
if not f then
return
From ea783e9983dc637565bb95d94e33050d7194ff45 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Tue, 3 Mar 2026 17:49:23 -0500
Subject: [PATCH 03/32] fix(compiler): resolve output into ctx before
evaluating clean command
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Problem: M.clean() passes the raw ctx (no output field) to the
provider's clean function. Built-in presets work around this by
recomputing the output path inline, but custom providers using
ctx.output in their clean function receive nil.
Solution: resolve output_file from provider.output before eval, extend
ctx into resolved_ctx with the output field, and use resolved_ctx when
evaluating clean and cwd — consistent with how M.compile() handles args.
---
lua/preview/compiler.lua | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index e19cbf0..6f9b312 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -396,10 +396,16 @@ function M.clean(bufnr, name, provider, ctx)
return
end
- local cmd = eval_list(provider.clean, ctx)
- local cwd = ctx.root
+ 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 = eval_list(provider.clean, resolved_ctx)
+ local cwd = resolved_ctx.root
if provider.cwd then
- cwd = eval_string(provider.cwd, ctx)
+ cwd = eval_string(provider.cwd, resolved_ctx)
end
log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
From ec922a35645030ed29f4e7c528ff0f505e8957a8 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 17:59:58 -0500
Subject: [PATCH 04/32] fix(compiler): respect provider open config in M.open()
(#23)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(reload): bind SSE server to port 0 for OS-assigned port
Problem: the SSE reload server hardcoded port 5554, causing silent
failure when that port was already in use. bind() would fail but its
return value was never checked; listen() would also error and silently
drop via the if err then return end guard. inject() still wrote the
dead EventSource URL into the HTML, so the browser would connect to
whatever was on 5554 — or nothing — and live reload would silently
stop working.
Solution: bind to port or 0 so the OS assigns a free port, then call
getsockname() after bind to capture the actual port into actual_port.
inject() reads actual_port in preference to the hardcoded constant,
and stop() resets it. PORT = 5554 is kept only as a last-resort
fallback in inject() if actual_port is unset.
* fix(compiler): resolve output into ctx before evaluating clean command
Problem: M.clean() passes the raw ctx (no output field) to the
provider's clean function. Built-in presets work around this by
recomputing the output path inline, but custom providers using
ctx.output in their clean function receive nil.
Solution: resolve output_file from provider.output before eval, extend
ctx into resolved_ctx with the output field, and use resolved_ctx when
evaluating clean and cwd — consistent with how M.compile() handles args.
---
lua/preview/compiler.lua | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index e19cbf0..6f9b312 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -396,10 +396,16 @@ function M.clean(bufnr, name, provider, ctx)
return
end
- local cmd = eval_list(provider.clean, ctx)
- local cwd = ctx.root
+ 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 = eval_list(provider.clean, resolved_ctx)
+ local cwd = resolved_ctx.root
if provider.cwd then
- cwd = eval_string(provider.cwd, ctx)
+ cwd = eval_string(provider.cwd, resolved_ctx)
end
log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
From 54ef0c3c99f505098252bbb9e37a4b022b0d5ec8 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Wed, 4 Mar 2026 00:17:10 -0500
Subject: [PATCH 05/32] fix(compiler): guard active entry before clearing in
process callback (#22)
* fix(compiler): guard active entry before clearing in process callback
Problem: when M.compile() is called while a previous process is still
running, the old process's vim.schedule_wrap callback unconditionally
sets active[bufnr] = nil, wiping the new process from the tracking
table. status() incorrectly returns idle and stop() becomes a no-op
against the still-running process.
Solution: capture obj as an upvalue in each callback and only clear
active[bufnr] if it still points to the same process object.
* fix(compiler): hoist obj declaration before vim.system closure
Problem: lua-language-server flagged obj as an undefined global in
both vim.schedule_wrap callbacks because local obj = vim.system(...)
puts the variable out of scope inside the closure at declaration time.
At runtime the guard active[bufnr].obj == obj evaluated obj as nil,
so the clear was always skipped and the process remained tracked
indefinitely.
Solution: split into local obj / obj = vim.system(...) so the
upvalue is in scope when the closure is defined.
---
lua/preview/compiler.lua | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 6f9b312..ee55604 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -91,14 +91,17 @@ function M.compile(bufnr, name, provider, ctx)
table.concat(reload_cmd, ' ')
)
- local obj = vim.system(
+ local obj
+ obj = vim.system(
reload_cmd,
{
cwd = cwd,
env = provider.env,
},
vim.schedule_wrap(function(result)
- active[bufnr] = nil
+ if active[bufnr] and active[bufnr].obj == obj then
+ active[bufnr] = nil
+ end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
@@ -180,14 +183,17 @@ function M.compile(bufnr, name, provider, ctx)
log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
- local obj = vim.system(
+ local obj
+ obj = vim.system(
cmd,
{
cwd = cwd,
env = provider.env,
},
vim.schedule_wrap(function(result)
- active[bufnr] = nil
+ if active[bufnr] and active[bufnr].obj == obj then
+ active[bufnr] = nil
+ end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
From da3e3e4249eaabfe544934348f388023c06b4a4e Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Wed, 4 Mar 2026 00:20:41 -0500
Subject: [PATCH 06/32] fix(compiler): check output exists before opening from
long-running process (#24)
* fix(compiler): check output exists before opening from long-running process
Problem: for long-running processes (typst watch), M.compile() calls
the opener immediately after vim.system() returns, before the process
has produced any output. On first run the output file does not exist
yet, so the opener is called on a nonexistent path.
Solution: guard the open block with vim.uv.fs_stat so it only fires
if the output file already exists at spawn time.
* style(compiler): reformat long condition for stylua
---
lua/preview/compiler.lua | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index ee55604..8555881 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -147,7 +147,12 @@ function M.compile(bufnr, name, provider, ctx)
end)
)
- if provider.open and not opened[bufnr] and output_file ~= '' then
+ if
+ provider.open
+ and not opened[bufnr]
+ and output_file ~= ''
+ and vim.uv.fs_stat(output_file)
+ then
if provider.open == true then
vim.ui.open(output_file)
elseif type(provider.open) == 'table' then
From 4732696a623683be8ebb605125e2c008280f0c2c Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Wed, 4 Mar 2026 02:18:28 -0500
Subject: [PATCH 07/32] fix(compiler): one-shot builds and :Preview open
provider config (#25)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(compiler): skip reload command for one-shot builds
Problem: compile() unconditionally resolved the reload command, so
:Preview build on a provider with reload = function/table (e.g. typst)
would start a long-running process (typst watch) instead of a one-shot
compile (typst compile). Error diagnostics were also lost because
typst watch does not exit non-zero on input errors.
Solution: add an opts parameter to compile() with a oneshot flag.
M.build() passes { oneshot = true } so resolve_reload_cmd() is
skipped and the one-shot path is always taken. toggle() continues
to call compile() without the flag, preserving long-running behavior
for watch mode.
* fix(compiler): respect provider open config in M.open()
Problem: :Preview open always called vim.ui.open regardless of the
provider's open field. Providers configured with a custom opener
(e.g. open = { 'sioyek', '--new-instance' }) were ignored, so the
first build opened with sioyek but :Preview open fell back to the
system default.
Solution: init.lua resolves the provider's open config and passes it
to compiler.open(). If open_config is a table, the custom command is
spawned with the output path appended. Otherwise vim.ui.open is used
as before.
* fix: normalize notify prefix to [preview.nvim]: everywhere
Problem: some vim.notify calls used [preview.nvim] (no colon) while
others used [preview.nvim]: — inconsistent with the log module format.
Solution: add the missing colon and space to all notify calls in
init.lua and compiler.lua so every user-facing message matches the
[preview.nvim]: prefix used by the log module.
* ci: format
---
lua/preview/compiler.lua | 28 +++++++++++++++++++++-------
lua/preview/init.lua | 14 ++++++++------
2 files changed, 29 insertions(+), 13 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 8555881..4c78574 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -55,7 +55,9 @@ end
---@param name string
---@param provider preview.ProviderConfig
---@param ctx preview.Context
-function M.compile(bufnr, name, provider, ctx)
+function M.compile(bufnr, name, provider, ctx, opts)
+ opts = opts or {}
+
if vim.bo[bufnr].modified then
vim.cmd('silent! update')
end
@@ -81,7 +83,10 @@ function M.compile(bufnr, name, provider, ctx)
last_output[bufnr] = output_file
end
- local reload_cmd = resolve_reload_cmd(provider, resolved_ctx)
+ local reload_cmd
+ if not opts.oneshot then
+ reload_cmd = resolve_reload_cmd(provider, resolved_ctx)
+ end
if reload_cmd then
log.dbg(
@@ -403,7 +408,10 @@ end
---@param ctx preview.Context
function M.clean(bufnr, name, provider, ctx)
if not provider.clean then
- vim.notify('[preview.nvim] provider "' .. name .. '" has no clean command', vim.log.levels.WARN)
+ vim.notify(
+ '[preview.nvim]: provider "' .. name .. '" has no clean command',
+ vim.log.levels.WARN
+ )
return
end
@@ -427,10 +435,10 @@ function M.clean(bufnr, name, provider, ctx)
vim.schedule_wrap(function(result)
if result.code == 0 then
log.dbg('clean succeeded for buffer %d', bufnr)
- vim.notify('[preview.nvim] clean complete', vim.log.levels.INFO)
+ vim.notify('[preview.nvim]: clean complete', vim.log.levels.INFO)
else
log.dbg('clean failed for buffer %d (exit code %d)', bufnr, result.code)
- vim.notify('[preview.nvim] clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR)
+ vim.notify('[preview.nvim]: clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR)
end
end)
)
@@ -438,13 +446,19 @@ end
---@param bufnr integer
---@return boolean
-function M.open(bufnr)
+function M.open(bufnr, open_config)
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)
+ if type(open_config) == 'table' then
+ local open_cmd = vim.list_extend({}, open_config)
+ table.insert(open_cmd, output)
+ vim.system(open_cmd)
+ else
+ vim.ui.open(output)
+ end
return true
end
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index bbf70f1..acceea5 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -148,12 +148,12 @@ function M.build(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local name = M.resolve_provider(bufnr)
if not name then
- vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN)
+ vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
return
end
local ctx = M.build_context(bufnr)
local provider = config.providers[name]
- compiler.compile(bufnr, name, provider, ctx)
+ compiler.compile(bufnr, name, provider, ctx, { oneshot = true })
end
---@param bufnr? integer
@@ -167,7 +167,7 @@ function M.clean(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local name = M.resolve_provider(bufnr)
if not name then
- vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN)
+ vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
return
end
local ctx = M.build_context(bufnr)
@@ -180,7 +180,7 @@ function M.watch(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local name = M.resolve_provider(bufnr)
if not name then
- vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN)
+ vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
return
end
local provider = config.providers[name]
@@ -190,8 +190,10 @@ 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)
+ local name = M.resolve_provider(bufnr)
+ local open_config = name and config.providers[name] and config.providers[name].open
+ if not compiler.open(bufnr, open_config) then
+ vim.notify('[preview.nvim]: no output file available for this buffer', vim.log.levels.WARN)
end
end
From 75b855438a000586fadbc24b0e832cfde5f05b4c Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Wed, 4 Mar 2026 13:16:01 -0500
Subject: [PATCH 08/32] refactor: simplify command surface (#28)
* refactor: rename build to compile and watch to toggle in public API
Problem: the code used build/watch while the help file already
documented compile/toggle, creating a confusing mismatch.
Solution: rename M.build() to M.compile() and M.watch() to M.toggle()
in init.lua, update handler keys in commands.lua, and update the test
file to match.
* refactor(commands): make toggle the default subcommand
Problem: bare :Preview ran a one-shot compile, but users reaching for a
"preview" plugin expect it to start previewing (i.e. watch mode).
Solution: change the fallback subcommand from compile to toggle so
:Preview starts/stops auto-compile on save.
* refactor(commands): remove stop subcommand
Problem: :Preview stop had a subtle distinction from toggle-off (kill
process but keep autocmd) that nobody reaches for deliberately from
the command line.
Solution: remove stop from the command dispatch table. The Lua API
require('preview').stop() remains as a programmatic escape hatch.
* docs: update help file for new command surface and document reload
Problem: the help file listed compile as the default subcommand, still
included the stop subcommand, omitted the reload provider field, and
had a misleading claim about shipping with zero defaults.
Solution: make toggle the default in the commands section, remove stop
from subcommands, add reload to provider fields, fix the introduction
text, reorder API entries to match new primacy, and add an output path
override example addressing #26/#27.
---
doc/preview.nvim.txt | 47 ++++++++++++++++++++++++++++------------
lua/preview/commands.lua | 15 +++++--------
lua/preview/init.lua | 8 +++----
spec/commands_spec.lua | 15 ++++---------
4 files changed, 47 insertions(+), 38 deletions(-)
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index c8bc708..a3d4981 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -10,8 +10,8 @@ preview.nvim is an extensible framework for compiling documents asynchronously
in Neovim. It provides a unified interface for any compilation workflow —
LaTeX, Typst, Markdown, or anything else with a CLI compiler.
-The plugin ships with zero provider defaults. Users must explicitly configure
-their compiler commands. preview.nvim is purely an orchestration framework.
+The plugin ships with opt-in presets for common tools (Typst, LaTeX, Pandoc)
+and supports fully custom providers. See |preview.nvim-presets|.
==============================================================================
REQUIREMENTS *preview.nvim-requirements*
@@ -93,6 +93,14 @@ Provider fields:~
|vim.ui.open()|. A string[] is run as
a command with the output path appended.
+ `reload` boolean|string[]|function
+ Reload the output after recompilation.
+ `true` uses a built-in SSE server for
+ HTML files. A string[] is run as a
+ command. If a function, receives a
+ |preview.Context| and returns a
+ string[].
+
*preview.Context*
Context fields:~
@@ -115,6 +123,17 @@ Example overriding a preset field:~
})
<
+Example overriding the output path (e.g. latexmk `$out_dir`):~
+>lua
+ require('preview').setup({
+ latex = {
+ output = function(ctx)
+ return 'build/' .. vim.fn.fnamemodify(ctx.file, ':t:r') .. '.pdf'
+ end,
+ },
+ })
+<
+
Example with a fully custom provider (key is not a preset name):~
>lua
require('preview').setup({
@@ -160,30 +179,30 @@ COMMANDS *preview.nvim-commands*
Subcommands:~
- `compile` Compile the current buffer (default if omitted).
- `stop` Kill active compilation for the current buffer.
+ `toggle` Toggle auto-compile on save (default if omitted).
+ `compile` One-shot compile of the current buffer.
`clean` Run the provider's clean command.
- `toggle` Toggle auto-compile on save for the current buffer.
`open` Open the last compiled output without recompiling.
`status` Echo compilation status (idle, compiling, watching).
==============================================================================
API *preview.nvim-api*
-preview.compile({bufnr?}) *preview.compile()*
- Compile the document in the given buffer (default: current).
-
-preview.stop({bufnr?}) *preview.stop()*
- Kill the active compilation process for the buffer.
-
-preview.clean({bufnr?}) *preview.clean()*
- Run the provider's clean command for the buffer.
-
preview.toggle({bufnr?}) *preview.toggle()*
Toggle auto-compile for the buffer. When enabled, the buffer is
immediately compiled and automatically recompiled on each save
(`BufWritePost`). Call again to stop.
+preview.compile({bufnr?}) *preview.compile()*
+ One-shot compile the document in the given buffer (default: current).
+
+preview.stop({bufnr?}) *preview.stop()*
+ Kill the active compilation process for the buffer. Programmatic
+ escape hatch — not exposed as a subcommand.
+
+preview.clean({bufnr?}) *preview.clean()*
+ Run the provider's clean command for the buffer.
+
preview.open({bufnr?}) *preview.open()*
Open the last compiled output for the buffer without recompiling.
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index c91f4de..a2e0470 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -1,17 +1,14 @@
local M = {}
local handlers = {
- build = function()
- require('preview').build()
- end,
- stop = function()
- require('preview').stop()
+ compile = function()
+ require('preview').compile()
end,
clean = function()
require('preview').clean()
end,
- watch = function()
- require('preview').watch()
+ toggle = function()
+ require('preview').toggle()
end,
open = function()
require('preview').open()
@@ -33,7 +30,7 @@ local handlers = {
---@param args string
local function dispatch(args)
- local subcmd = args ~= '' and args or 'build'
+ local subcmd = args ~= '' and args or 'toggle'
local handler = handlers[subcmd]
if handler then
handler()
@@ -58,7 +55,7 @@ function M.setup()
complete = function(lead)
return complete(lead)
end,
- desc = 'Build, stop, clean, watch, open, or check status of document preview',
+ desc = 'Toggle, compile, clean, open, or check status of document preview',
})
end
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index acceea5..421ba65 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -39,10 +39,10 @@
---@class preview
---@field setup fun(opts?: table)
----@field build fun(bufnr?: integer)
+---@field compile fun(bufnr?: integer)
---@field stop fun(bufnr?: integer)
---@field clean fun(bufnr?: integer)
----@field watch fun(bufnr?: integer)
+---@field toggle fun(bufnr?: integer)
---@field open fun(bufnr?: integer)
---@field status fun(bufnr?: integer): preview.Status
---@field statusline fun(bufnr?: integer): string
@@ -144,7 +144,7 @@ function M.build_context(bufnr)
end
---@param bufnr? integer
-function M.build(bufnr)
+function M.compile(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local name = M.resolve_provider(bufnr)
if not name then
@@ -176,7 +176,7 @@ function M.clean(bufnr)
end
---@param bufnr? integer
-function M.watch(bufnr)
+function M.toggle(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local name = M.resolve_provider(bufnr)
if not name then
diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua
index 32da224..5cca5a2 100644
--- a/spec/commands_spec.lua
+++ b/spec/commands_spec.lua
@@ -14,17 +14,10 @@ describe('commands', function()
end)
describe('dispatch', function()
- it('does not error on :Preview with no provider', function()
+ it('does not error on :Preview compile with no provider', function()
require('preview.commands').setup()
assert.has_no.errors(function()
- vim.cmd('Preview build')
- end)
- end)
-
- it('does not error on :Preview stop', function()
- require('preview.commands').setup()
- assert.has_no.errors(function()
- vim.cmd('Preview stop')
+ vim.cmd('Preview compile')
end)
end)
@@ -42,10 +35,10 @@ describe('commands', function()
end)
end)
- it('does not error on :Preview watch with no provider', function()
+ it('does not error on :Preview toggle with no provider', function()
require('preview.commands').setup()
assert.has_no.errors(function()
- vim.cmd('Preview watch')
+ vim.cmd('Preview toggle')
end)
end)
end)
From c94df7c5d080984538f1ab028c19b34b2be95bac Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Wed, 4 Mar 2026 14:02:16 -0500
Subject: [PATCH 09/32] fix: lifecycle cleanup and defensive runtime checks
(#29)
* fix(commands): register VimLeavePre to call stop_all
Problem: spawned compiler processes and watching autocmds were never
cleaned up when Neovim exited, leaving orphaned processes running.
Solution: register a VimLeavePre autocmd in commands setup that calls
compiler.stop_all(), which kills active processes, unwatches all
buffers, and stops the reload server.
* fix(compiler): replace BufWipeout with BufUnload
Problem: cleanup autocmds used BufWipeout, which only fires for
:bwipeout. The common :bdelete path (used by most buffer managers
and nvim_buf_delete) fires BufUnload but not BufWipeout, so processes
and watches leaked on normal buffer deletion.
Solution: switch all three cleanup autocmds from BufWipeout to
BufUnload, which fires for both :bdelete and :bwipeout.
* fix(init): guard against unnamed buffer in public API
Problem: calling compile/toggle/clean/open on an unsaved scratch
buffer passed an empty string as ctx.file, producing nonsensical
output paths like ".pdf" and silently passing empty strings to
compiler binaries.
Solution: add an early return with a WARN notification in compile,
toggle, clean, and open when the buffer has no file name.
* fix(compiler): add fs_stat check to one-shot open path
Problem: the long-running process path already guarded opens with
vim.uv.fs_stat(), but the one-shot compile path and M.open() did not.
Compilation can exit 0 and produce no output, and output files can be
externally deleted between compile and open.
Solution: add the same fs_stat guard to the one-shot open branch and
to M.open() before attempting to launch the viewer.
* fix(compiler): check executable before spawning process
Problem: if a configured binary was missing or not in PATH, vim.system
would fail silently or with a cryptic OS error. The user had no
actionable feedback without running :checkhealth.
Solution: check vim.fn.executable() at the start of M.compile() and
notify with an ERROR-level message pointing to :checkhealth preview
if the binary is not found.
* fix(compiler): reformat one-shot open condition for line length
Problem: the added fs_stat condition exceeded stylua's line length
limit on the one-shot open guard.
Solution: split the boolean condition across multiple lines to match
the project's stylua formatting rules.
---
lua/preview/commands.lua | 6 +++++
lua/preview/compiler.lua | 25 ++++++++++++++---
lua/preview/init.lua | 16 +++++++++++
spec/commands_spec.lua | 13 +++++++++
spec/compiler_spec.lua | 29 ++++++++++++++++++++
spec/init_spec.lua | 58 ++++++++++++++++++++++++++++++++++++++++
6 files changed, 143 insertions(+), 4 deletions(-)
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index a2e0470..97a13f7 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -57,6 +57,12 @@ function M.setup()
end,
desc = 'Toggle, compile, clean, open, or check status of document preview',
})
+
+ vim.api.nvim_create_autocmd('VimLeavePre', {
+ callback = function()
+ require('preview.compiler').stop_all()
+ end,
+ })
end
return M
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 4c78574..74d6070 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -58,6 +58,14 @@ end
function M.compile(bufnr, name, provider, ctx, opts)
opts = opts or {}
+ if vim.fn.executable(provider.cmd[1]) ~= 1 then
+ vim.notify(
+ '[preview.nvim]: "' .. provider.cmd[1] .. '" is not executable (run :checkhealth preview)',
+ vim.log.levels.ERROR
+ )
+ return
+ end
+
if vim.bo[bufnr].modified then
vim.cmd('silent! update')
end
@@ -170,7 +178,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
active[bufnr] = { obj = obj, provider = name, output_file = output_file, is_reload = true }
- vim.api.nvim_create_autocmd('BufWipeout', {
+ vim.api.nvim_create_autocmd('BufUnload', {
buffer = bufnr,
once = true,
callback = function()
@@ -230,7 +238,12 @@ function M.compile(bufnr, name, provider, ctx, opts)
r.inject(output_file)
r.broadcast()
end
- if provider.open and not opened[bufnr] and output_file ~= '' then
+ if
+ provider.open
+ and not opened[bufnr]
+ and output_file ~= ''
+ and vim.uv.fs_stat(output_file)
+ then
if provider.open == true then
vim.ui.open(output_file)
elseif type(provider.open) == 'table' then
@@ -279,7 +292,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
active[bufnr] = { obj = obj, provider = name, output_file = output_file }
- vim.api.nvim_create_autocmd('BufWipeout', {
+ vim.api.nvim_create_autocmd('BufUnload', {
buffer = bufnr,
once = true,
callback = function()
@@ -374,7 +387,7 @@ function M.toggle(bufnr, name, provider, ctx_builder)
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', {
+ vim.api.nvim_create_autocmd('BufUnload', {
buffer = bufnr,
once = true,
callback = function()
@@ -452,6 +465,10 @@ function M.open(bufnr, open_config)
log.dbg('no last output file for buffer %d', bufnr)
return false
end
+ if not vim.uv.fs_stat(output) then
+ log.dbg('output file no longer exists for buffer %d: %s', bufnr, output)
+ return false
+ end
if type(open_config) == 'table' then
local open_cmd = vim.list_extend({}, open_config)
table.insert(open_cmd, output)
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index 421ba65..fd54d71 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -146,6 +146,10 @@ end
---@param bufnr? integer
function M.compile(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
+ if vim.api.nvim_buf_get_name(bufnr) == '' then
+ vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
+ return
+ end
local name = M.resolve_provider(bufnr)
if not name then
vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
@@ -165,6 +169,10 @@ end
---@param bufnr? integer
function M.clean(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
+ if vim.api.nvim_buf_get_name(bufnr) == '' then
+ vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
+ return
+ end
local name = M.resolve_provider(bufnr)
if not name then
vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
@@ -178,6 +186,10 @@ end
---@param bufnr? integer
function M.toggle(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
+ if vim.api.nvim_buf_get_name(bufnr) == '' then
+ vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
+ return
+ end
local name = M.resolve_provider(bufnr)
if not name then
vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
@@ -190,6 +202,10 @@ end
---@param bufnr? integer
function M.open(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
+ if vim.api.nvim_buf_get_name(bufnr) == '' then
+ vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
+ return
+ end
local name = M.resolve_provider(bufnr)
local open_config = name and config.providers[name] and config.providers[name].open
if not compiler.open(bufnr, open_config) then
diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua
index 5cca5a2..4e12e5d 100644
--- a/spec/commands_spec.lua
+++ b/spec/commands_spec.lua
@@ -11,6 +11,19 @@ describe('commands', function()
local cmds = vim.api.nvim_get_commands({})
assert.is_not_nil(cmds.Preview)
end)
+
+ it('registers VimLeavePre autocmd', function()
+ require('preview.commands').setup()
+ local aus = vim.api.nvim_get_autocmds({ event = 'VimLeavePre' })
+ local found = false
+ for _, au in ipairs(aus) do
+ if au.callback then
+ found = true
+ break
+ end
+ end
+ assert.is_true(found)
+ end)
end)
describe('dispatch', function()
diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua
index 2189347..cd1dd9f 100644
--- a/spec/compiler_spec.lua
+++ b/spec/compiler_spec.lua
@@ -99,6 +99,35 @@ describe('compiler', function()
helpers.delete_buffer(bufnr)
end)
+ it('notifies and returns when binary is not executable', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_nobin.txt')
+ vim.bo[bufnr].modified = false
+
+ local notified = false
+ local orig = vim.notify
+ vim.notify = function(msg)
+ if msg:find('not executable') then
+ notified = true
+ end
+ end
+
+ local provider = { cmd = { 'totally_nonexistent_binary_xyz_preview' } }
+ local ctx = {
+ bufnr = bufnr,
+ file = '/tmp/preview_test_nobin.txt',
+ root = '/tmp',
+ ft = 'text',
+ }
+
+ compiler.compile(bufnr, 'nobin', provider, ctx)
+ vim.notify = orig
+
+ assert.is_true(notified)
+ assert.is_nil(compiler._test.active[bufnr])
+ helpers.delete_buffer(bufnr)
+ end)
+
it('fires PreviewCompileFailed on non-zero exit', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_fail.txt')
diff --git a/spec/init_spec.lua b/spec/init_spec.lua
index 5c49276..f68438c 100644
--- a/spec/init_spec.lua
+++ b/spec/init_spec.lua
@@ -108,4 +108,62 @@ describe('preview', function()
helpers.delete_buffer(bufnr)
end)
end)
+
+ describe('unnamed buffer guard', function()
+ before_each(function()
+ helpers.reset_config({ typst = true })
+ preview = require('preview')
+ end)
+
+ local function capture_notify(fn)
+ local msg = nil
+ local orig = vim.notify
+ vim.notify = function(m)
+ msg = m
+ end
+ fn()
+ vim.notify = orig
+ return msg
+ end
+
+ it('compile warns on unnamed buffer', function()
+ local bufnr = helpers.create_buffer({}, 'typst')
+ local msg = capture_notify(function()
+ preview.compile(bufnr)
+ end)
+ assert.is_not_nil(msg)
+ assert.is_truthy(msg:find('no file name'))
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('toggle warns on unnamed buffer', function()
+ local bufnr = helpers.create_buffer({}, 'typst')
+ local msg = capture_notify(function()
+ preview.toggle(bufnr)
+ end)
+ assert.is_not_nil(msg)
+ assert.is_truthy(msg:find('no file name'))
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('clean warns on unnamed buffer', function()
+ local bufnr = helpers.create_buffer({}, 'typst')
+ local msg = capture_notify(function()
+ preview.clean(bufnr)
+ end)
+ assert.is_not_nil(msg)
+ assert.is_truthy(msg:find('no file name'))
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('open warns on unnamed buffer', function()
+ local bufnr = helpers.create_buffer({}, 'typst')
+ local msg = capture_notify(function()
+ preview.open(bufnr)
+ end)
+ assert.is_not_nil(msg)
+ assert.is_truthy(msg:find('no file name'))
+ helpers.delete_buffer(bufnr)
+ end)
+ end)
end)
From 180c6729835fc53c31c4ad288645fc71e622be07 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Wed, 4 Mar 2026 14:02:30 -0500
Subject: [PATCH 10/32] feat(presets): add pdflatex, tectonic, asciidoctor, and
quarto presets (#30)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(presets): add pdflatex preset
Adds a direct pdflatex preset for users who want single-pass
compilation without latexmk orchestration. Uses -file-line-error
for parseable diagnostics and reuses the existing parse_latexmk
error parser since both emit the same file:line: message format.
* feat(presets): add tectonic preset
Adds a tectonic preset for the modern Rust-based LaTeX engine, which
auto-downloads packages and requires no TeX installation. Reuses
parse_latexmk since tectonic emits the same file:line: message
diagnostic format.
* feat(presets): add asciidoctor preset
Adds an asciidoctor preset for AsciiDoc → HTML compilation with SSE
live-reload. Includes a parse_asciidoctor error parser handling the
"asciidoctor: SEVERITY: file: line N: message" format for both
ERROR and WARNING diagnostics.
* feat(presets): add quarto preset
Adds a quarto preset for .qmd scientific documents rendering to
self-contained HTML with SSE live-reload. Uses --embed-resources
to avoid a _files directory in the common case. No error_parser
since quarto errors are heterogeneous (mixed R/Python/pandoc output).
* refactor: apply stylua formatting to new preset code
---
doc/preview.nvim.txt | 4 +
lua/preview/presets.lua | 92 +++++++++++++++++
spec/presets_spec.lua | 218 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 314 insertions(+)
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index a3d4981..914f72d 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -157,8 +157,12 @@ Import them from `preview.presets`:
`presets.typst` typst compile → PDF
`presets.latex` latexmk -pdf → PDF (with clean support)
+ `presets.pdflatex` pdflatex → PDF (single pass, no latexmk)
+ `presets.tectonic` tectonic → PDF (Rust-based LaTeX engine)
`presets.markdown` pandoc → HTML (standalone, embedded)
`presets.github` pandoc → HTML (GitHub-styled, `-f gfm` input)
+ `presets.asciidoctor` asciidoctor → HTML (AsciiDoc with SSE reload)
+ `presets.quarto` quarto render → HTML (scientific publishing)
Enable presets with `preset_name = true`:
>lua
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index fc20f89..8a23766 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -93,6 +93,28 @@ local function parse_pandoc(output)
return diagnostics
end
+---@param output string
+---@return preview.Diagnostic[]
+local function parse_asciidoctor(output)
+ local diagnostics = {}
+ for line in output:gmatch('[^\r\n]+') do
+ local severity, _, lnum, msg = line:match('^asciidoctor: (%u+): (.+): line (%d+): (.+)$')
+ 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 = 0,
+ message = msg,
+ severity = sev,
+ })
+ end
+ end
+ return diagnostics
+end
+
---@type preview.ProviderConfig
M.typst = {
ft = 'typst',
@@ -137,6 +159,38 @@ M.latex = {
open = true,
}
+---@type preview.ProviderConfig
+M.pdflatex = {
+ ft = 'tex',
+ cmd = { 'pdflatex' },
+ args = function(ctx)
+ return { '-interaction=nonstopmode', '-file-line-error', '-synctex=1', ctx.file }
+ end,
+ output = function(ctx)
+ return (ctx.file:gsub('%.tex$', '.pdf'))
+ end,
+ error_parser = function(output)
+ return parse_latexmk(output)
+ end,
+ open = true,
+}
+
+---@type preview.ProviderConfig
+M.tectonic = {
+ ft = 'tex',
+ cmd = { 'tectonic' },
+ args = function(ctx)
+ return { ctx.file }
+ end,
+ output = function(ctx)
+ return (ctx.file:gsub('%.tex$', '.pdf'))
+ end,
+ error_parser = function(output)
+ return parse_latexmk(output)
+ end,
+ open = true,
+}
+
---@type preview.ProviderConfig
M.markdown = {
ft = 'markdown',
@@ -187,4 +241,42 @@ M.github = {
reload = true,
}
+---@type preview.ProviderConfig
+M.asciidoctor = {
+ ft = 'asciidoc',
+ cmd = { 'asciidoctor' },
+ args = function(ctx)
+ return { ctx.file, '-o', ctx.output }
+ end,
+ output = function(ctx)
+ return (ctx.file:gsub('%.adoc$', '.html'))
+ end,
+ error_parser = function(output)
+ return parse_asciidoctor(output)
+ end,
+ clean = function(ctx)
+ return { 'rm', '-f', (ctx.file:gsub('%.adoc$', '.html')) }
+ end,
+ open = true,
+ reload = true,
+}
+
+---@type preview.ProviderConfig
+M.quarto = {
+ ft = 'quarto',
+ cmd = { 'quarto' },
+ args = function(ctx)
+ return { 'render', ctx.file, '--to', 'html', '--embed-resources' }
+ end,
+ output = function(ctx)
+ return (ctx.file:gsub('%.qmd$', '.html'))
+ end,
+ clean = function(ctx)
+ local base = ctx.file:gsub('%.qmd$', '')
+ return { 'rm', '-rf', base .. '.html', base .. '_files' }
+ end,
+ open = true,
+ reload = true,
+}
+
return M
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index 8085a3c..ab030f0 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -157,6 +157,112 @@ describe('presets', function()
end)
end)
+ describe('pdflatex', function()
+ local tex_ctx = {
+ bufnr = 1,
+ file = '/tmp/document.tex',
+ root = '/tmp',
+ ft = 'tex',
+ }
+
+ it('has ft', function()
+ assert.are.equal('tex', presets.pdflatex.ft)
+ end)
+
+ it('has cmd', function()
+ assert.are.same({ 'pdflatex' }, presets.pdflatex.cmd)
+ end)
+
+ it('returns args with flags and file path', function()
+ local args = presets.pdflatex.args(tex_ctx)
+ assert.are.same(
+ { '-interaction=nonstopmode', '-file-line-error', '-synctex=1', '/tmp/document.tex' },
+ args
+ )
+ end)
+
+ it('returns pdf output path', function()
+ assert.are.equal('/tmp/document.pdf', presets.pdflatex.output(tex_ctx))
+ end)
+
+ it('has open enabled', function()
+ assert.is_true(presets.pdflatex.open)
+ end)
+
+ it('has no clean command', function()
+ assert.is_nil(presets.pdflatex.clean)
+ end)
+
+ it('has no reload', function()
+ assert.is_nil(presets.pdflatex.reload)
+ end)
+
+ it('parses file-line-error format', function()
+ local output = './document.tex:10: Undefined control sequence.'
+ local diagnostics = presets.pdflatex.error_parser(output, tex_ctx)
+ assert.are.equal(1, #diagnostics)
+ 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('returns empty table for clean output', function()
+ assert.are.same({}, presets.pdflatex.error_parser('', tex_ctx))
+ end)
+ end)
+
+ describe('tectonic', function()
+ local tex_ctx = {
+ bufnr = 1,
+ file = '/tmp/document.tex',
+ root = '/tmp',
+ ft = 'tex',
+ }
+
+ it('has ft', function()
+ assert.are.equal('tex', presets.tectonic.ft)
+ end)
+
+ it('has cmd', function()
+ assert.are.same({ 'tectonic' }, presets.tectonic.cmd)
+ end)
+
+ it('returns args with file path', function()
+ assert.are.same({ '/tmp/document.tex' }, presets.tectonic.args(tex_ctx))
+ end)
+
+ it('returns pdf output path', function()
+ assert.are.equal('/tmp/document.pdf', presets.tectonic.output(tex_ctx))
+ end)
+
+ it('has open enabled', function()
+ assert.is_true(presets.tectonic.open)
+ end)
+
+ it('has no clean command', function()
+ assert.is_nil(presets.tectonic.clean)
+ end)
+
+ it('has no reload', function()
+ assert.is_nil(presets.tectonic.reload)
+ end)
+
+ it('parses file-line-error format', function()
+ local output = './document.tex:5: Missing $ inserted.'
+ local diagnostics = presets.tectonic.error_parser(output, tex_ctx)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(4, diagnostics[1].lnum)
+ assert.are.equal(0, diagnostics[1].col)
+ assert.are.equal('Missing $ inserted.', diagnostics[1].message)
+ assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
+ end)
+
+ it('returns empty table for clean output', function()
+ assert.are.same({}, presets.tectonic.error_parser('', tex_ctx))
+ end)
+ end)
+
describe('markdown', function()
local md_ctx = {
bufnr = 1,
@@ -341,4 +447,116 @@ describe('presets', function()
assert.are.same({}, diagnostics)
end)
end)
+
+ describe('asciidoctor', function()
+ local adoc_ctx = {
+ bufnr = 1,
+ file = '/tmp/document.adoc',
+ root = '/tmp',
+ ft = 'asciidoc',
+ output = '/tmp/document.html',
+ }
+
+ it('has ft', function()
+ assert.are.equal('asciidoc', presets.asciidoctor.ft)
+ end)
+
+ it('has cmd', function()
+ assert.are.same({ 'asciidoctor' }, presets.asciidoctor.cmd)
+ end)
+
+ it('returns args with file and output', function()
+ assert.are.same(
+ { '/tmp/document.adoc', '-o', '/tmp/document.html' },
+ presets.asciidoctor.args(adoc_ctx)
+ )
+ end)
+
+ it('returns html output path', function()
+ assert.are.equal('/tmp/document.html', presets.asciidoctor.output(adoc_ctx))
+ end)
+
+ it('returns clean command', function()
+ assert.are.same({ 'rm', '-f', '/tmp/document.html' }, presets.asciidoctor.clean(adoc_ctx))
+ end)
+
+ it('has open enabled', function()
+ assert.is_true(presets.asciidoctor.open)
+ end)
+
+ it('has reload enabled for SSE', function()
+ assert.is_true(presets.asciidoctor.reload)
+ end)
+
+ it('parses error messages', function()
+ local output =
+ 'asciidoctor: ERROR: document.adoc: line 8: invalid part, must have at least one section'
+ local diagnostics = presets.asciidoctor.error_parser(output, adoc_ctx)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(7, diagnostics[1].lnum)
+ assert.are.equal(0, diagnostics[1].col)
+ assert.are.equal('invalid part, must have at least one section', diagnostics[1].message)
+ assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
+ end)
+
+ it('parses warning messages', function()
+ local output = 'asciidoctor: WARNING: document.adoc: line 52: section title out of sequence'
+ local diagnostics = presets.asciidoctor.error_parser(output, adoc_ctx)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(51, diagnostics[1].lnum)
+ assert.are.equal(vim.diagnostic.severity.WARN, diagnostics[1].severity)
+ end)
+
+ it('returns empty table for clean output', function()
+ assert.are.same({}, presets.asciidoctor.error_parser('', adoc_ctx))
+ end)
+ end)
+
+ describe('quarto', function()
+ local qmd_ctx = {
+ bufnr = 1,
+ file = '/tmp/document.qmd',
+ root = '/tmp',
+ ft = 'quarto',
+ output = '/tmp/document.html',
+ }
+
+ it('has ft', function()
+ assert.are.equal('quarto', presets.quarto.ft)
+ end)
+
+ it('has cmd', function()
+ assert.are.same({ 'quarto' }, presets.quarto.cmd)
+ end)
+
+ it('returns args with render subcommand and html format', function()
+ assert.are.same(
+ { 'render', '/tmp/document.qmd', '--to', 'html', '--embed-resources' },
+ presets.quarto.args(qmd_ctx)
+ )
+ end)
+
+ it('returns html output path', function()
+ assert.are.equal('/tmp/document.html', presets.quarto.output(qmd_ctx))
+ end)
+
+ it('returns clean command removing html and _files directory', function()
+ assert.are.same(
+ { 'rm', '-rf', '/tmp/document.html', '/tmp/document_files' },
+ presets.quarto.clean(qmd_ctx)
+ )
+ end)
+
+ it('has open enabled', function()
+ assert.is_true(presets.quarto.open)
+ end)
+
+ it('has reload enabled for SSE', function()
+ assert.is_true(presets.quarto.reload)
+ end)
+
+ it('has no error_parser', function()
+ assert.is_nil(presets.quarto.error_parser)
+ end)
+ end)
end)
From 50a21a787d6b2df4832a1201e5af0a5656ccee4d Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Wed, 4 Mar 2026 14:23:38 -0500
Subject: [PATCH 11/32] ci: scripts (#31)
---
.luarc.json | 8 +++++++-
.styluaignore | 1 +
flake.nix | 2 ++
scripts/ci.sh | 10 ++++++++++
4 files changed, 20 insertions(+), 1 deletion(-)
create mode 100644 .styluaignore
create mode 100755 scripts/ci.sh
diff --git a/.luarc.json b/.luarc.json
index 23646d3..3f6276a 100644
--- a/.luarc.json
+++ b/.luarc.json
@@ -2,7 +2,13 @@
"runtime.version": "LuaJIT",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim", "jit"],
- "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
+ "workspace.library": [
+ "$VIMRUNTIME/lua",
+ "${3rd}/luv/library",
+ "${3rd}/busted/library",
+ "${3rd}/luassert/library"
+ ],
"workspace.checkThirdParty": false,
+ "workspace.ignoreDir": [".direnv"],
"completion.callSnippet": "Replace"
}
diff --git a/.styluaignore b/.styluaignore
new file mode 100644
index 0000000..9b42106
--- /dev/null
+++ b/.styluaignore
@@ -0,0 +1 @@
+.direnv/
diff --git a/flake.nix b/flake.nix
index 7413113..0243f3e 100644
--- a/flake.nix
+++ b/flake.nix
@@ -16,6 +16,8 @@
forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
in
{
+ formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
+
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
packages = [
diff --git a/scripts/ci.sh b/scripts/ci.sh
new file mode 100755
index 0000000..e06bf09
--- /dev/null
+++ b/scripts/ci.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -eu
+
+nix develop --command stylua --check .
+git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet
+nix develop --command prettier --check .
+nix fmt
+git diff --exit-code -- '*.nix'
+nix develop --command lua-language-server --check . --checklevel=Warning
+nix develop --command busted
From dd27374833e12b41137dddc532c6a41ebb86fd7c Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Wed, 4 Mar 2026 14:28:52 -0500
Subject: [PATCH 12/32] fix(ci): resolve lua-language-server warnings (#32)
Problem: reload_spec.lua called io.open() without nil checks, causing
need-check-nil warnings. Adding ${3rd}/busted and ${3rd}/luassert to
workspace.library caused lua-language-server 3.7.4 to run diagnostics
on its own bundled meta files, surfacing pre-existing cast-local-type
bugs in luassert's annotations that are not ours to fix.
Solution: use assert(io.open(...)) in reload_spec.lua to satisfy the
nil check. Remove busted/luassert library paths from .luarc.json since
they only benefit spec/ which is not type-checked in CI. Narrow the
lua-language-server check in scripts/ci.sh to lua/ to match CI.
---
.luarc.json | 7 +------
flake.nix | 3 ++-
scripts/ci.sh | 2 +-
spec/reload_spec.lua | 8 ++++----
4 files changed, 8 insertions(+), 12 deletions(-)
diff --git a/.luarc.json b/.luarc.json
index 3f6276a..d44eb7c 100644
--- a/.luarc.json
+++ b/.luarc.json
@@ -2,12 +2,7 @@
"runtime.version": "LuaJIT",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim", "jit"],
- "workspace.library": [
- "$VIMRUNTIME/lua",
- "${3rd}/luv/library",
- "${3rd}/busted/library",
- "${3rd}/luassert/library"
- ],
+ "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
"workspace.checkThirdParty": false,
"workspace.ignoreDir": [".direnv"],
"completion.callSnippet": "Replace"
diff --git a/flake.nix b/flake.nix
index 0243f3e..91a0ab2 100644
--- a/flake.nix
+++ b/flake.nix
@@ -13,7 +13,8 @@
...
}:
let
- forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
+ forEachSystem =
+ f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
in
{
formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
diff --git a/scripts/ci.sh b/scripts/ci.sh
index e06bf09..98f6ff4 100755
--- a/scripts/ci.sh
+++ b/scripts/ci.sh
@@ -6,5 +6,5 @@ git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet
nix develop --command prettier --check .
nix fmt
git diff --exit-code -- '*.nix'
-nix develop --command lua-language-server --check . --checklevel=Warning
+nix develop --command lua-language-server --check lua/ --checklevel=Warning
nix develop --command busted
diff --git a/spec/reload_spec.lua b/spec/reload_spec.lua
index 12b7aac..68b2851 100644
--- a/spec/reload_spec.lua
+++ b/spec/reload_spec.lua
@@ -13,13 +13,13 @@ describe('reload', function()
describe('inject', function()
it('injects script before
hello