From e661ea78e836eca09e01af1ccc24e94258817e9b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:46:04 -0500 Subject: [PATCH 01/30] fix(reload): bind SSE server to port 0 for OS-assigned port (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the SSE reload server hardcoded port 5554, causing silent failure when that port was already in use. bind() would fail but its return value was never checked; listen() would also error and silently drop via the if err then return end guard. inject() still wrote the dead EventSource URL into the HTML, so the browser would connect to whatever was on 5554 — or nothing — and live reload would silently stop working. Solution: bind to port or 0 so the OS assigns a free port, then call getsockname() after bind to capture the actual port into actual_port. inject() reads actual_port in preference to the hardcoded constant, and stop() resets it. PORT = 5554 is kept only as a last-resort fallback in inject() if actual_port is unset. --- lua/preview/reload.lua | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lua/preview/reload.lua b/lua/preview/reload.lua index fd64309..d2c1de9 100644 --- a/lua/preview/reload.lua +++ b/lua/preview/reload.lua @@ -2,6 +2,7 @@ local M = {} local PORT = 5554 local server_handle = nil +local actual_port = nil local clients = {} local function make_script(port) @@ -14,12 +15,15 @@ local function make_script(port) end function M.start(port) - port = port or PORT if server_handle then return end local server = vim.uv.new_tcp() - server:bind('127.0.0.1', port) + server:bind('127.0.0.1', port or 0) + local sockname = server:getsockname() + if sockname then + actual_port = sockname.port + end server:listen(128, function(err) if err then return @@ -66,6 +70,7 @@ function M.stop() server_handle:close() server_handle = nil end + actual_port = nil end function M.broadcast() @@ -85,7 +90,7 @@ function M.broadcast() end function M.inject(path, port) - port = port or PORT + port = actual_port or port or PORT local f = io.open(path, 'r') if not f then return From ec922a35645030ed29f4e7c528ff0f505e8957a8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:59:58 -0500 Subject: [PATCH 02/30] fix(compiler): respect provider open config in M.open() (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(reload): bind SSE server to port 0 for OS-assigned port Problem: the SSE reload server hardcoded port 5554, causing silent failure when that port was already in use. bind() would fail but its return value was never checked; listen() would also error and silently drop via the if err then return end guard. inject() still wrote the dead EventSource URL into the HTML, so the browser would connect to whatever was on 5554 — or nothing — and live reload would silently stop working. Solution: bind to port or 0 so the OS assigns a free port, then call getsockname() after bind to capture the actual port into actual_port. inject() reads actual_port in preference to the hardcoded constant, and stop() resets it. PORT = 5554 is kept only as a last-resort fallback in inject() if actual_port is unset. * fix(compiler): resolve output into ctx before evaluating clean command Problem: M.clean() passes the raw ctx (no output field) to the provider's clean function. Built-in presets work around this by recomputing the output path inline, but custom providers using ctx.output in their clean function receive nil. Solution: resolve output_file from provider.output before eval, extend ctx into resolved_ctx with the output field, and use resolved_ctx when evaluating clean and cwd — consistent with how M.compile() handles args. --- lua/preview/compiler.lua | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index e19cbf0..6f9b312 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -396,10 +396,16 @@ function M.clean(bufnr, name, provider, ctx) return end - local cmd = eval_list(provider.clean, ctx) - local cwd = ctx.root + local output_file = '' + if provider.output then + output_file = eval_string(provider.output, ctx) + end + local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file }) + + local cmd = eval_list(provider.clean, resolved_ctx) + local cwd = resolved_ctx.root if provider.cwd then - cwd = eval_string(provider.cwd, ctx) + cwd = eval_string(provider.cwd, resolved_ctx) end log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' ')) From 54ef0c3c99f505098252bbb9e37a4b022b0d5ec8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:17:10 -0500 Subject: [PATCH 03/30] fix(compiler): guard active entry before clearing in process callback (#22) * fix(compiler): guard active entry before clearing in process callback Problem: when M.compile() is called while a previous process is still running, the old process's vim.schedule_wrap callback unconditionally sets active[bufnr] = nil, wiping the new process from the tracking table. status() incorrectly returns idle and stop() becomes a no-op against the still-running process. Solution: capture obj as an upvalue in each callback and only clear active[bufnr] if it still points to the same process object. * fix(compiler): hoist obj declaration before vim.system closure Problem: lua-language-server flagged obj as an undefined global in both vim.schedule_wrap callbacks because local obj = vim.system(...) puts the variable out of scope inside the closure at declaration time. At runtime the guard active[bufnr].obj == obj evaluated obj as nil, so the clear was always skipped and the process remained tracked indefinitely. Solution: split into local obj / obj = vim.system(...) so the upvalue is in scope when the closure is defined. --- lua/preview/compiler.lua | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 6f9b312..ee55604 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -91,14 +91,17 @@ function M.compile(bufnr, name, provider, ctx) table.concat(reload_cmd, ' ') ) - local obj = vim.system( + local obj + obj = vim.system( reload_cmd, { cwd = cwd, env = provider.env, }, vim.schedule_wrap(function(result) - active[bufnr] = nil + if active[bufnr] and active[bufnr].obj == obj then + active[bufnr] = nil + end if not vim.api.nvim_buf_is_valid(bufnr) then return end @@ -180,14 +183,17 @@ function M.compile(bufnr, name, provider, ctx) log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' ')) - local obj = vim.system( + local obj + obj = vim.system( cmd, { cwd = cwd, env = provider.env, }, vim.schedule_wrap(function(result) - active[bufnr] = nil + if active[bufnr] and active[bufnr].obj == obj then + active[bufnr] = nil + end if not vim.api.nvim_buf_is_valid(bufnr) then return end From da3e3e4249eaabfe544934348f388023c06b4a4e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:20:41 -0500 Subject: [PATCH 04/30] fix(compiler): check output exists before opening from long-running process (#24) * fix(compiler): check output exists before opening from long-running process Problem: for long-running processes (typst watch), M.compile() calls the opener immediately after vim.system() returns, before the process has produced any output. On first run the output file does not exist yet, so the opener is called on a nonexistent path. Solution: guard the open block with vim.uv.fs_stat so it only fires if the output file already exists at spawn time. * style(compiler): reformat long condition for stylua --- lua/preview/compiler.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index ee55604..8555881 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -147,7 +147,12 @@ function M.compile(bufnr, name, provider, ctx) end) ) - if provider.open and not opened[bufnr] and output_file ~= '' then + if + provider.open + and not opened[bufnr] + and output_file ~= '' + and vim.uv.fs_stat(output_file) + then if provider.open == true then vim.ui.open(output_file) elseif type(provider.open) == 'table' then From 4732696a623683be8ebb605125e2c008280f0c2c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 02:18:28 -0500 Subject: [PATCH 05/30] fix(compiler): one-shot builds and :Preview open provider config (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(compiler): skip reload command for one-shot builds Problem: compile() unconditionally resolved the reload command, so :Preview build on a provider with reload = function/table (e.g. typst) would start a long-running process (typst watch) instead of a one-shot compile (typst compile). Error diagnostics were also lost because typst watch does not exit non-zero on input errors. Solution: add an opts parameter to compile() with a oneshot flag. M.build() passes { oneshot = true } so resolve_reload_cmd() is skipped and the one-shot path is always taken. toggle() continues to call compile() without the flag, preserving long-running behavior for watch mode. * fix(compiler): respect provider open config in M.open() Problem: :Preview open always called vim.ui.open regardless of the provider's open field. Providers configured with a custom opener (e.g. open = { 'sioyek', '--new-instance' }) were ignored, so the first build opened with sioyek but :Preview open fell back to the system default. Solution: init.lua resolves the provider's open config and passes it to compiler.open(). If open_config is a table, the custom command is spawned with the output path appended. Otherwise vim.ui.open is used as before. * fix: normalize notify prefix to [preview.nvim]: everywhere Problem: some vim.notify calls used [preview.nvim] (no colon) while others used [preview.nvim]: — inconsistent with the log module format. Solution: add the missing colon and space to all notify calls in init.lua and compiler.lua so every user-facing message matches the [preview.nvim]: prefix used by the log module. * ci: format --- lua/preview/compiler.lua | 28 +++++++++++++++++++++------- lua/preview/init.lua | 14 ++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 8555881..4c78574 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -55,7 +55,9 @@ end ---@param name string ---@param provider preview.ProviderConfig ---@param ctx preview.Context -function M.compile(bufnr, name, provider, ctx) +function M.compile(bufnr, name, provider, ctx, opts) + opts = opts or {} + if vim.bo[bufnr].modified then vim.cmd('silent! update') end @@ -81,7 +83,10 @@ function M.compile(bufnr, name, provider, ctx) last_output[bufnr] = output_file end - local reload_cmd = resolve_reload_cmd(provider, resolved_ctx) + local reload_cmd + if not opts.oneshot then + reload_cmd = resolve_reload_cmd(provider, resolved_ctx) + end if reload_cmd then log.dbg( @@ -403,7 +408,10 @@ end ---@param ctx preview.Context function M.clean(bufnr, name, provider, ctx) if not provider.clean then - vim.notify('[preview.nvim] provider "' .. name .. '" has no clean command', vim.log.levels.WARN) + vim.notify( + '[preview.nvim]: provider "' .. name .. '" has no clean command', + vim.log.levels.WARN + ) return end @@ -427,10 +435,10 @@ function M.clean(bufnr, name, provider, ctx) vim.schedule_wrap(function(result) if result.code == 0 then log.dbg('clean succeeded for buffer %d', bufnr) - vim.notify('[preview.nvim] clean complete', vim.log.levels.INFO) + vim.notify('[preview.nvim]: clean complete', vim.log.levels.INFO) else log.dbg('clean failed for buffer %d (exit code %d)', bufnr, result.code) - vim.notify('[preview.nvim] clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR) + vim.notify('[preview.nvim]: clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR) end end) ) @@ -438,13 +446,19 @@ end ---@param bufnr integer ---@return boolean -function M.open(bufnr) +function M.open(bufnr, open_config) local output = last_output[bufnr] if not output then log.dbg('no last output file for buffer %d', bufnr) return false end - vim.ui.open(output) + if type(open_config) == 'table' then + local open_cmd = vim.list_extend({}, open_config) + table.insert(open_cmd, output) + vim.system(open_cmd) + else + vim.ui.open(output) + end return true end diff --git a/lua/preview/init.lua b/lua/preview/init.lua index bbf70f1..acceea5 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -148,12 +148,12 @@ function M.build(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local name = M.resolve_provider(bufnr) if not name then - vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN) + vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN) return end local ctx = M.build_context(bufnr) local provider = config.providers[name] - compiler.compile(bufnr, name, provider, ctx) + compiler.compile(bufnr, name, provider, ctx, { oneshot = true }) end ---@param bufnr? integer @@ -167,7 +167,7 @@ function M.clean(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local name = M.resolve_provider(bufnr) if not name then - vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN) + vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN) return end local ctx = M.build_context(bufnr) @@ -180,7 +180,7 @@ function M.watch(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() local name = M.resolve_provider(bufnr) if not name then - vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN) + vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN) return end local provider = config.providers[name] @@ -190,8 +190,10 @@ end ---@param bufnr? integer function M.open(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() - if not compiler.open(bufnr) then - vim.notify('[preview.nvim] no output file available for this buffer', vim.log.levels.WARN) + local name = M.resolve_provider(bufnr) + local open_config = name and config.providers[name] and config.providers[name].open + if not compiler.open(bufnr, open_config) then + vim.notify('[preview.nvim]: no output file available for this buffer', vim.log.levels.WARN) end end From 75b855438a000586fadbc24b0e832cfde5f05b4c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:16:01 -0500 Subject: [PATCH 06/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 07/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 08/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 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