From 7a7c407d97d9115c05cc162e050c3e2b48dddc77 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Wed, 4 Mar 2026 13:10:34 -0500
Subject: [PATCH 01/30] 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.
---
lua/preview/commands.lua | 8 ++++----
lua/preview/init.lua | 8 ++++----
spec/commands_spec.lua | 8 ++++----
3 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index c91f4de..eec51c9 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -1,8 +1,8 @@
local M = {}
local handlers = {
- build = function()
- require('preview').build()
+ compile = function()
+ require('preview').compile()
end,
stop = function()
require('preview').stop()
@@ -10,8 +10,8 @@ local handlers = {
clean = function()
require('preview').clean()
end,
- watch = function()
- require('preview').watch()
+ toggle = function()
+ require('preview').toggle()
end,
open = function()
require('preview').open()
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..9b63f87 100644
--- a/spec/commands_spec.lua
+++ b/spec/commands_spec.lua
@@ -14,10 +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')
+ vim.cmd('Preview compile')
end)
end)
@@ -42,10 +42,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 46cbf41612dc8455ec98e81788c984dd30452c03 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Wed, 4 Mar 2026 13:10:52 -0500
Subject: [PATCH 02/30] 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.
---
lua/preview/commands.lua | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index eec51c9..14199ab 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -33,7 +33,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 +58,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
From 1d0077ab600122c3a10bd0807bf7c33f5a29c1d4 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Wed, 4 Mar 2026 13:11:14 -0500
Subject: [PATCH 03/30] 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.
---
lua/preview/commands.lua | 3 ---
spec/commands_spec.lua | 7 -------
2 files changed, 10 deletions(-)
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index 14199ab..a2e0470 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -4,9 +4,6 @@ local handlers = {
compile = function()
require('preview').compile()
end,
- stop = function()
- require('preview').stop()
- end,
clean = function()
require('preview').clean()
end,
diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua
index 9b63f87..5cca5a2 100644
--- a/spec/commands_spec.lua
+++ b/spec/commands_spec.lua
@@ -21,13 +21,6 @@ describe('commands', function()
end)
end)
- it('does not error on :Preview stop', function()
- require('preview.commands').setup()
- assert.has_no.errors(function()
- vim.cmd('Preview stop')
- end)
- end)
-
it('does not error on :Preview status', function()
require('preview.commands').setup()
assert.has_no.errors(function()
From 098f822ec2fca44a8eec0787ba9142dd97caf2d2 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Wed, 4 Mar 2026 13:11:57 -0500
Subject: [PATCH 04/30] 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 +++++++++++++++++++++++++++++++-------------
1 file changed, 33 insertions(+), 14 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.
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 05/30] 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 06/30] 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 07/30] 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 86a3523f2d4735a6d1aeaa5f5be5389aa014f61e Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Wed, 4 Mar 2026 14:21:12 -0500
Subject: [PATCH 08/30] ci: scripts
---
.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 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 09/30] 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 10/30] 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