From 9d4f2e77cc2606d031ab87426387d8687a67d90d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:48:46 -0500 Subject: [PATCH 1/6] 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. --- lua/preview/commands.lua | 6 ++++++ spec/commands_spec.lua | 13 +++++++++++++ 2 files changed, 19 insertions(+) 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/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() From b1fe5597c610d0b38717526c114d4cf3a7323e5c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:49:09 -0500 Subject: [PATCH 2/6] 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. --- lua/preview/compiler.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 4c78574..50dbe1a 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -170,7 +170,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() @@ -279,7 +279,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 +374,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() From bc22350692cbb61cc067b50731ca4aa77d43f033 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:49:37 -0500 Subject: [PATCH 3/6] 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. --- lua/preview/init.lua | 16 ++++++++++++ spec/init_spec.lua | 58 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) 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/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 1456b3070aa20a60989f403776a184452c0393ac Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:49:50 -0500 Subject: [PATCH 4/6] 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. --- lua/preview/compiler.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 50dbe1a..293d7e9 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -230,7 +230,7 @@ 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 @@ -452,6 +452,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) From 781a9c481828634fee5e3b69e46b154460c57349 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:50:06 -0500 Subject: [PATCH 5/6] 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. --- lua/preview/compiler.lua | 8 ++++++++ spec/compiler_spec.lua | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 293d7e9..e545441 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 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') From f2432969bd49f32d73d3ee950be5b313946379b4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 13:50:32 -0500 Subject: [PATCH 6/6] 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/compiler.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index e545441..74d6070 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -238,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 ~= '' and vim.uv.fs_stat(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