From 8ebe2ed80b8871ae6fe9ba6ca5f7553612cc6d1e Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Wed, 4 Mar 2026 13:52:36 -0500
Subject: [PATCH 01/29] 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.
---
doc/preview.nvim.txt | 1 +
lua/preview/presets.lua | 16 ++++++++++++
spec/presets_spec.lua | 55 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 72 insertions(+)
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index a3d4981..be89cde 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -157,6 +157,7 @@ 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.markdown` pandoc → HTML (standalone, embedded)
`presets.github` pandoc → HTML (GitHub-styled, `-f gfm` input)
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index fc20f89..88e84e8 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -137,6 +137,22 @@ 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.markdown = {
ft = 'markdown',
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index 8085a3c..8e9e89b 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -157,6 +157,61 @@ 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('markdown', function()
local md_ctx = {
bufnr = 1,
From 3a3a0783e832e0a40ef4159b59f368a62a6eece0 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Wed, 4 Mar 2026 13:53:01 -0500
Subject: [PATCH 02/29] 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.
---
doc/preview.nvim.txt | 1 +
lua/preview/presets.lua | 16 +++++++++++++
spec/presets_spec.lua | 51 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 68 insertions(+)
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index be89cde..a3021e4 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -158,6 +158,7 @@ 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)
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 88e84e8..1d573e1 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -153,6 +153,22 @@ M.pdflatex = {
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',
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index 8e9e89b..c4394c5 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -212,6 +212,57 @@ describe('presets', function()
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,
From 2a9110865b34a1bec087670174271ff0451f6d5d Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Wed, 4 Mar 2026 13:53:38 -0500
Subject: [PATCH 03/29] feat(presets): add asciidoctor preset
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
doc/preview.nvim.txt | 1 +
lua/preview/presets.lua | 43 +++++++++++++++++++++++++
spec/presets_spec.lua | 71 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 115 insertions(+)
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index a3021e4..e946126 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -161,6 +161,7 @@ Import them from `preview.presets`:
`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)
Enable presets with `preset_name = true`:
>lua
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 1d573e1..8891c11 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -93,6 +93,29 @@ 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',
@@ -219,4 +242,24 @@ 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,
+}
+
return M
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index c4394c5..55a00c4 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -447,4 +447,75 @@ 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)
end)
From 4665deee6c87e15bd8fd789fe724be68944a31ac Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Wed, 4 Mar 2026 13:54:01 -0500
Subject: [PATCH 04/29] 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).
---
doc/preview.nvim.txt | 1 +
lua/preview/presets.lua | 18 ++++++++++++++++
spec/presets_spec.lua | 48 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 67 insertions(+)
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index e946126..914f72d 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -162,6 +162,7 @@ Import them from `preview.presets`:
`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 8891c11..16e41ca 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -262,4 +262,22 @@ M.asciidoctor = {
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 55a00c4..9ce2cfc 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -518,4 +518,52 @@ describe('presets', 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 f408136d5a019abc32273486309a676b3e8b27fe Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Wed, 4 Mar 2026 13:54:19 -0500
Subject: [PATCH 05/29] refactor: apply stylua formatting to new preset code
---
lua/preview/presets.lua | 3 +--
spec/presets_spec.lua | 13 +++----------
2 files changed, 4 insertions(+), 12 deletions(-)
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 16e41ca..8a23766 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -98,8 +98,7 @@ end
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+): (.+)$')
+ local severity, _, lnum, msg = line:match('^asciidoctor: (%u+): (.+): line (%d+): (.+)$')
if lnum then
local sev = vim.diagnostic.severity.ERROR
if severity == 'WARNING' then
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index 9ce2cfc..ab030f0 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -477,10 +477,7 @@ describe('presets', function()
end)
it('returns clean command', function()
- assert.are.same(
- { 'rm', '-f', '/tmp/document.html' },
- presets.asciidoctor.clean(adoc_ctx)
- )
+ assert.are.same({ 'rm', '-f', '/tmp/document.html' }, presets.asciidoctor.clean(adoc_ctx))
end)
it('has open enabled', function()
@@ -498,16 +495,12 @@ describe('presets', function()
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('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 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)
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/29] 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/29] 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 08/29] 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 09/29] 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