preview.nvim/spec/compiler_spec.lua
Barrett Ruth c94df7c5d0
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.
2026-03-04 14:02:16 -05:00

420 lines
12 KiB
Lua

local helpers = require('spec.helpers')
describe('compiler', function()
local compiler
before_each(function()
helpers.reset_config()
compiler = require('preview.compiler')
end)
describe('compile', function()
it('spawns a process and tracks it in active table', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test.txt')
vim.bo[bufnr].modified = false
local provider = { cmd = { 'echo', 'ok' } }
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'echo', provider, ctx)
local active = compiler._test.active
assert.is_not_nil(active[bufnr])
assert.are.equal('echo', active[bufnr].provider)
vim.wait(2000, function()
return active[bufnr] == nil
end, 50)
assert.is_nil(active[bufnr])
helpers.delete_buffer(bufnr)
end)
it('fires PreviewCompileStarted event', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_event.txt')
vim.bo[bufnr].modified = false
local fired = false
vim.api.nvim_create_autocmd('User', {
pattern = 'PreviewCompileStarted',
once = true,
callback = function()
fired = true
end,
})
local provider = { cmd = { 'echo', 'ok' } }
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_event.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'echo', provider, ctx)
assert.is_true(fired)
vim.wait(2000, function()
return compiler._test.active[bufnr] == nil
end, 50)
helpers.delete_buffer(bufnr)
end)
it('fires PreviewCompileSuccess on exit code 0', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_success.txt')
vim.bo[bufnr].modified = false
local succeeded = false
vim.api.nvim_create_autocmd('User', {
pattern = 'PreviewCompileSuccess',
once = true,
callback = function()
succeeded = true
end,
})
local provider = { cmd = { 'true' } }
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_success.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'truecmd', provider, ctx)
vim.wait(2000, function()
return succeeded
end, 50)
assert.is_true(succeeded)
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')
vim.bo[bufnr].modified = false
local failed = false
vim.api.nvim_create_autocmd('User', {
pattern = 'PreviewCompileFailed',
once = true,
callback = function()
failed = true
end,
})
local provider = { cmd = { 'false' } }
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_fail.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'falsecmd', provider, ctx)
vim.wait(2000, function()
return failed
end, 50)
assert.is_true(failed)
helpers.delete_buffer(bufnr)
end)
end)
describe('errors mode', function()
it('errors = false suppresses error parser', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_false.txt')
vim.bo[bufnr].modified = false
local parser_called = false
local provider = {
cmd = { 'false' },
errors = false,
error_parser = function()
parser_called = true
return {}
end,
}
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_errors_false.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'falsecmd', provider, ctx)
vim.wait(2000, function()
return compiler._test.active[bufnr] == nil
end, 50)
assert.is_false(parser_called)
helpers.delete_buffer(bufnr)
end)
it('errors = quickfix populates quickfix list', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_qf.txt')
vim.bo[bufnr].modified = false
local provider = {
cmd = { 'sh', '-c', 'echo "line 1 error" >&2; exit 1' },
errors = 'quickfix',
error_parser = function()
return {
{ lnum = 0, col = 0, message = 'test error', severity = vim.diagnostic.severity.ERROR },
}
end,
}
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_errors_qf.txt',
root = '/tmp',
ft = 'text',
}
vim.fn.setqflist({}, 'r')
compiler.compile(bufnr, 'qfcmd', provider, ctx)
vim.wait(2000, function()
return compiler._test.active[bufnr] == nil
end, 50)
local qflist = vim.fn.getqflist()
assert.are.equal(1, #qflist)
assert.are.equal('test error', qflist[1].text)
assert.are.equal(1, qflist[1].lnum)
vim.fn.setqflist({}, 'r')
helpers.delete_buffer(bufnr)
end)
it('errors = quickfix clears quickfix on success', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_qf_clear.txt')
vim.bo[bufnr].modified = false
vim.fn.setqflist({ { text = 'old error', lnum = 1 } }, 'r')
assert.are.equal(1, #vim.fn.getqflist())
local provider = {
cmd = { 'true' },
errors = 'quickfix',
error_parser = function()
return {}
end,
}
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_errors_qf_clear.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'truecmd', provider, ctx)
vim.wait(2000, function()
return compiler._test.active[bufnr] == nil
end, 50)
assert.are.equal(0, #vim.fn.getqflist())
helpers.delete_buffer(bufnr)
end)
end)
describe('stop', function()
it('does nothing when no process is active', function()
assert.has_no.errors(function()
compiler.stop(999)
end)
end)
end)
describe('status', function()
it('returns idle for buffer with no process', function()
local s = compiler.status(42)
assert.is_false(s.compiling)
end)
it('returns compiling during active process', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_status.txt')
vim.bo[bufnr].modified = false
local provider = { cmd = { 'sleep', '10' } }
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_status.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'sleepcmd', provider, ctx)
local s = compiler.status(bufnr)
assert.is_true(s.compiling)
assert.are.equal('sleepcmd', s.provider)
compiler.stop(bufnr)
vim.wait(2000, function()
return compiler._test.active[bufnr] == nil
end, 50)
helpers.delete_buffer(bufnr)
end)
end)
describe('open', function()
it('returns false when no output exists', function()
assert.is_false(compiler.open(999))
end)
it('returns true after compilation stores output', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_open.txt')
vim.bo[bufnr].modified = false
local provider = {
cmd = { 'true' },
output = function()
return '/tmp/preview_test_open.pdf'
end,
}
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_open.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'testprov', provider, ctx)
assert.is_not_nil(compiler._test.last_output[bufnr])
assert.are.equal('/tmp/preview_test_open.pdf', compiler._test.last_output[bufnr])
vim.wait(2000, function()
return compiler._test.active[bufnr] == nil
end, 50)
helpers.delete_buffer(bufnr)
end)
end)
describe('toggle', function()
it('registers autocmd and tracks in watching table', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch.txt')
local provider = { cmd = { 'echo', 'ok' } }
local ctx_builder = function(b)
return { bufnr = b, file = '/tmp/preview_test_watch.txt', root = '/tmp', ft = 'text' }
end
compiler.toggle(bufnr, 'echo', provider, ctx_builder)
assert.is_not_nil(compiler._test.watching[bufnr])
helpers.delete_buffer(bufnr)
end)
it('toggles off when called again', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_toggle.txt')
local provider = { cmd = { 'echo', 'ok' } }
local ctx_builder = function(b)
return { bufnr = b, file = '/tmp/preview_test_watch_toggle.txt', root = '/tmp', ft = 'text' }
end
compiler.toggle(bufnr, 'echo', provider, ctx_builder)
assert.is_not_nil(compiler._test.watching[bufnr])
compiler.toggle(bufnr, 'echo', provider, ctx_builder)
assert.is_nil(compiler._test.watching[bufnr])
helpers.delete_buffer(bufnr)
end)
it('stop_all clears watches', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_stopall.txt')
local provider = { cmd = { 'echo', 'ok' } }
local ctx_builder = function(b)
return {
bufnr = b,
file = '/tmp/preview_test_watch_stopall.txt',
root = '/tmp',
ft = 'text',
}
end
compiler.toggle(bufnr, 'echo', provider, ctx_builder)
assert.is_not_nil(compiler._test.watching[bufnr])
compiler.stop_all()
assert.is_nil(compiler._test.watching[bufnr])
helpers.delete_buffer(bufnr)
end)
it('status includes watching state', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_status.txt')
local s = compiler.status(bufnr)
assert.is_false(s.watching)
local provider = { cmd = { 'echo', 'ok' } }
local ctx_builder = function(b)
return { bufnr = b, file = '/tmp/preview_test_watch_status.txt', root = '/tmp', ft = 'text' }
end
compiler.toggle(bufnr, 'echo', provider, ctx_builder)
s = compiler.status(bufnr)
assert.is_true(s.watching)
compiler.unwatch(bufnr)
helpers.delete_buffer(bufnr)
end)
end)
end)