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 ', function() local path = os.tmpname() - local f = io.open(path, 'w') + local f = assert(io.open(path, 'w')) f:write('

hello

') f:close() reload.inject(path) - local fr = io.open(path, 'r') + local fr = assert(io.open(path, 'r')) local content = fr:read('*a') fr:close() os.remove(path) @@ -33,13 +33,13 @@ describe('reload', function() it('appends script when no ', function() local path = os.tmpname() - local f = io.open(path, 'w') + local f = assert(io.open(path, 'w')) f:write('

hello

') f:close() reload.inject(path) - local fr = io.open(path, 'r') + local fr = assert(io.open(path, 'r')) local content = fr:read('*a') fr:close() os.remove(path) From ec00648f7a68c0e4e17df47a24eddd96596650c6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:31:17 -0500 Subject: [PATCH 10/29] fix(ci): pass --configpath to lua-language-server (#33) Problem: lua-language-server --check lua/ treats lua/ as its workspace root and fails to find .luarc.json in the project root, so diagnostics globals (vim, jit) are not loaded and every vim.* reference is flagged as undefined-global. Solution: pass --configpath .luarc.json explicitly, matching what the GitHub CI action already does. --- scripts/ci.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci.sh b/scripts/ci.sh index 98f6ff4..849be6a 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 lua/ --checklevel=Warning +nix develop --command lua-language-server --check lua/ --configpath .luarc.json --checklevel=Warning nix develop --command busted From d4e7d8c2fddfd633b6cc93e915361f2d61bfc3b9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:33:25 -0500 Subject: [PATCH 11/29] fix(ci): use absolute path for lua-language-server --configpath (#34) Problem: --configpath is resolved relative to the workspace root passed to --check (lua/), not CWD. So .luarc.json was looked up at lua/.luarc.json and not found, leaving vim and jit as undefined globals. Solution: expand to an absolute path with $(pwd) at shell invocation time, matching what the GitHub CI action already does. --- scripts/ci.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci.sh b/scripts/ci.sh index 849be6a..37b1267 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 lua/ --configpath .luarc.json --checklevel=Warning +nix develop --command lua-language-server --check lua/ --configpath "$(pwd)/.luarc.json" --checklevel=Warning nix develop --command busted From 68e2e8223278e27cddfe9c6f0754e7594546e696 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:57:36 -0500 Subject: [PATCH 12/29] fix(presets): add --failure-level ERROR to asciidoctor, add clean to typst/pdflatex/tectonic, skip auto-open on one-shot compile (#35) Problem: asciidoctor exits 0 on errors so error_parser never ran. typst, pdflatex, and tectonic had no clean subcommand. auto-open fired on :Preview compile, surprising users who just want a build. Solution: pass --failure-level ERROR in asciidoctor args. Add clean commands to typst (rm pdf), pdflatex (rm pdf/aux/log/synctex.gz), and tectonic (rm pdf). Gate auto-open on not opts.oneshot so it only fires during toggle/watch mode. --- lua/preview/compiler.lua | 2 ++ lua/preview/presets.lua | 12 +++++++++++- spec/presets_spec.lua | 22 +++++++++++++++++----- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 74d6070..38a048e 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -162,6 +162,7 @@ function M.compile(bufnr, name, provider, ctx, opts) if provider.open + and not opts.oneshot and not opened[bufnr] and output_file ~= '' and vim.uv.fs_stat(output_file) @@ -240,6 +241,7 @@ function M.compile(bufnr, name, provider, ctx, opts) end if provider.open + and not opts.oneshot and not opened[bufnr] and output_file ~= '' and vim.uv.fs_stat(output_file) diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index 8a23766..f189e98 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -128,6 +128,9 @@ M.typst = { error_parser = function(output) return parse_typst(output) end, + clean = function(ctx) + return { 'rm', '-f', (ctx.file:gsub('%.typ$', '.pdf')) } + end, open = true, reload = function(ctx) return { 'typst', 'watch', ctx.file } @@ -172,6 +175,10 @@ M.pdflatex = { error_parser = function(output) return parse_latexmk(output) end, + clean = function(ctx) + local base = ctx.file:gsub('%.tex$', '') + return { 'rm', '-f', base .. '.pdf', base .. '.aux', base .. '.log', base .. '.synctex.gz' } + end, open = true, } @@ -188,6 +195,9 @@ M.tectonic = { error_parser = function(output) return parse_latexmk(output) end, + clean = function(ctx) + return { 'rm', '-f', (ctx.file:gsub('%.tex$', '.pdf')) } + end, open = true, } @@ -246,7 +256,7 @@ M.asciidoctor = { ft = 'asciidoc', cmd = { 'asciidoctor' }, args = function(ctx) - return { ctx.file, '-o', ctx.output } + return { '--failure-level', 'ERROR', ctx.file, '-o', ctx.output } end, output = function(ctx) return (ctx.file:gsub('%.adoc$', '.html')) diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index ab030f0..2a63d18 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -33,6 +33,10 @@ describe('presets', function() assert.are.equal('/tmp/document.pdf', output) end) + it('returns clean command', function() + assert.are.same({ 'rm', '-f', '/tmp/document.pdf' }, presets.typst.clean(ctx)) + end) + it('has open enabled', function() assert.is_true(presets.typst.open) end) @@ -189,8 +193,16 @@ describe('presets', function() assert.is_true(presets.pdflatex.open) end) - it('has no clean command', function() - assert.is_nil(presets.pdflatex.clean) + it('returns clean command removing pdf and aux files', function() + local clean = presets.pdflatex.clean(tex_ctx) + assert.are.same({ + 'rm', + '-f', + '/tmp/document.pdf', + '/tmp/document.aux', + '/tmp/document.log', + '/tmp/document.synctex.gz', + }, clean) end) it('has no reload', function() @@ -240,8 +252,8 @@ describe('presets', function() assert.is_true(presets.tectonic.open) end) - it('has no clean command', function() - assert.is_nil(presets.tectonic.clean) + it('returns clean command removing pdf', function() + assert.are.same({ 'rm', '-f', '/tmp/document.pdf' }, presets.tectonic.clean(tex_ctx)) end) it('has no reload', function() @@ -467,7 +479,7 @@ describe('presets', function() it('returns args with file and output', function() assert.are.same( - { '/tmp/document.adoc', '-o', '/tmp/document.html' }, + { '--failure-level', 'ERROR', '/tmp/document.adoc', '-o', '/tmp/document.html' }, presets.asciidoctor.args(adoc_ctx) ) end) From 7a11f39341cd0354cf68717c6094875c06ef76c0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:09:07 -0500 Subject: [PATCH 13/29] docs: pre-release polish (#36) * docs: pre-release polish Update README preset list to include pdflatex, tectonic, asciidoctor, and quarto. Fix custom provider FAQ example to use a non-preset key. Clarify open field fires on toggle/watch mode only, not :Preview compile. Expand intro to mention AsciiDoc and Quarto alongside existing tools. * docs: update slogan to universal document previewer * ci: format --- README.md | 17 +++++++++-------- doc/preview.nvim.txt | 12 +++++++----- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1d4446b..c7cc7f3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # preview.nvim -**Async document compilation for Neovim** +**Universal document previewer for Neovim** An extensible framework for compiling documents (LaTeX, Typst, Markdown, etc.) asynchronously with error diagnostics. @@ -8,7 +8,8 @@ asynchronously with error diagnostics. ## Features - Async compilation via `vim.system()` -- Built-in presets for Typst, LaTeX, Markdown, and GitHub-flavored Markdown +- Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), Markdown, + GitHub-flavored Markdown, AsciiDoc, and Quarto - Compiler errors as native `vim.diagnostic` - User events for extensibility (`PreviewCompileStarted`, `PreviewCompileSuccess`, `PreviewCompileFailed`) @@ -38,13 +39,13 @@ luarocks install preview.nvim ```lua require('preview').setup({ - typst = { - cmd = { 'typst', 'compile' }, + rst = { + cmd = { 'rst2html' }, args = function(ctx) - return { ctx.file } + return { ctx.file, ctx.output } end, output = function(ctx) - return ctx.file:gsub('%.typ$', '.pdf') + return ctx.file:gsub('%.rst$', '.html') end, }, }) @@ -61,8 +62,8 @@ require('preview').setup({ **Q: How do I automatically open the output file?** Set `open = true` on your provider (all built-in presets have this enabled) to -open the output with `vim.ui.open()` after the first successful compilation. For -a specific application, pass a command table: +open the output with `vim.ui.open()` after the first successful compilation in +toggle/watch mode. For a specific application, pass a command table: ```lua require('preview').setup({ diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index 914f72d..6cba09a 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -10,8 +10,9 @@ 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 opt-in presets for common tools (Typst, LaTeX, Pandoc) -and supports fully custom providers. See |preview.nvim-presets|. +The plugin ships with opt-in presets for common tools (Typst, LaTeX, Pandoc, +AsciiDoc, Quarto) and supports fully custom providers. +See |preview.nvim-presets|. ============================================================================== REQUIREMENTS *preview.nvim-requirements* @@ -89,9 +90,10 @@ Provider fields:~ |preview.Context|. `open` boolean|string[] Open the output file after the first - successful compilation. `true` uses - |vim.ui.open()|. A string[] is run as - a command with the output path appended. + successful compilation in toggle/watch + mode. `true` uses |vim.ui.open()|. A + string[] is run as a command with the + output path appended. `reload` boolean|string[]|function Reload the output after recompilation. From e8f93fb47ee85c6c571ec8f98492c1bf590fe223 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 15:09:45 -0500 Subject: [PATCH 14/29] doc: readme typo --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c7cc7f3..031d096 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ **Universal document previewer for Neovim** -An extensible framework for compiling documents (LaTeX, Typst, Markdown, etc.) -asynchronously with error diagnostics. +An extensible framework for compiling and previewing documents (LaTeX, Typst, +Markdown, etc.) asynchronously with error diagnostics. ## Features From 934ef037289a335b696d6940f172be3436febe1d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 15:10:40 -0500 Subject: [PATCH 15/29] doc: cleanup readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 031d096..58e1353 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Universal document previewer for Neovim** An extensible framework for compiling and previewing documents (LaTeX, Typst, -Markdown, etc.) asynchronously with error diagnostics. +Markdown, etc.). ## Features From df0765a27f59ab4d8256c02e91708eb4bf828cf6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 15:11:44 -0500 Subject: [PATCH 16/29] doc: cleanup --- README.html | 963 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 +- 2 files changed, 965 insertions(+), 2 deletions(-) create mode 100644 README.html diff --git a/README.html b/README.html new file mode 100644 index 0000000..b6600fc --- /dev/null +++ b/README.html @@ -0,0 +1,963 @@ + + + + + + + README + + + + +

preview.nvim

+

Universal document previewer for Neovim

+

An extensible framework for compiling and previewing any +documents (LaTeX, Typst, Markdown, etc.)—diagnostics included.

+

Features

+
    +
  • Async compilation via vim.system()
  • +
  • Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), +Markdown, GitHub-flavored Markdown, AsciiDoc, and Quarto
  • +
  • Compiler errors as native vim.diagnostic
  • +
  • User events for extensibility (PreviewCompileStarted, +PreviewCompileSuccess, +PreviewCompileFailed)
  • +
+

Requirements

+
    +
  • Neovim 0.11+
  • +
+

Installation

+

Install with your package manager of choice or via luarocks:

+
luarocks install preview.nvim
+

Documentation

+
:help preview.nvim
+

FAQ

+

Q: How do I define a custom provider?

+
require('preview').setup({
+  rst = {
+    cmd = { 'rst2html' },
+    args = function(ctx)
+      return { ctx.file, ctx.output }
+    end,
+    output = function(ctx)
+      return ctx.file:gsub('%.rst$', '.html')
+    end,
+  },
+})
+

Q: How do I override a preset?

+
require('preview').setup({
+  typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } },
+})
+

Q: How do I automatically open the output file?

+

Set open = true on your provider (all built-in presets +have this enabled) to open the output with vim.ui.open() +after the first successful compilation in toggle/watch mode. For a +specific application, pass a command table:

+
require('preview').setup({
+  typst = { open = { 'sioyek', '--new-instance' } },
+})
+ + + diff --git a/README.md b/README.md index 58e1353..286e1ac 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ **Universal document previewer for Neovim** -An extensible framework for compiling and previewing documents (LaTeX, Typst, -Markdown, etc.). +An extensible framework for compiling and previewing *any* documents (LaTeX, Typst, +Markdown, etc.)—diagnostics included. ## Features From 239f8a47692f45c2ff7b98639057da641562482e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:48:30 -0500 Subject: [PATCH 17/29] fix(compiler): defer open until successful compile, close viewer on :bd (#38) * fix(compiler): defer open until successful compile, close viewer on :bd Problem: For long-running providers (e.g. `typst watch`), the viewer was opened immediately on toggle start by checking if the output file existed on disk. A stale PDF from a prior session satisfied that check, so a failed compile still opened the viewer. Additionally, viewer processes spawned via a table `open` command were untracked, so `:bd` killed the compiler but left the viewer running. Solution: Replace the immediate open with a `vim.uv.new_fs_event` directory watcher that fires only when the output file's `mtime` advances past its pre-compile value, proving the current session wrote it. Add `viewer_procs` and `open_watchers` tables with `close_viewer` and `stop_open_watcher` helpers; all `BufUnload` paths and `stop_all` now tear down both. Extract `do_open` to deduplicate the open branching logic across three call sites. * docs: document viewer auto-close behaviour and limitations in `open` field * ci: format --- doc/preview.nvim.txt | 7 ++- lua/preview/compiler.lua | 115 +++++++++++++++++++++++++++++---------- 2 files changed, 93 insertions(+), 29 deletions(-) diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index 6cba09a..e2747d6 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -93,7 +93,12 @@ Provider fields:~ successful compilation in toggle/watch mode. `true` uses |vim.ui.open()|. A string[] is run as a command with the - output path appended. + output path appended. When a string[] + is used the viewer process is tracked + and sent SIGTERM when the buffer is + deleted. `true` and single-instance + apps (e.g. Chrome) do not support + auto-close. `reload` boolean|string[]|function Reload the output after recompilation. diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 38a048e..c5a81c2 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -15,10 +15,49 @@ local opened = {} ---@type table local last_output = {} +---@type table +local viewer_procs = {} + +---@type table +local open_watchers = {} + local debounce_timers = {} local DEBOUNCE_MS = 500 +---@param bufnr integer +local function stop_open_watcher(bufnr) + local w = open_watchers[bufnr] + if w then + w:stop() + w:close() + open_watchers[bufnr] = nil + end +end + +---@param bufnr integer +local function close_viewer(bufnr) + local obj = viewer_procs[bufnr] + if obj then + local kill = obj.kill + kill(obj, 'sigterm') + viewer_procs[bufnr] = nil + end +end + +---@param bufnr integer +---@param output_file string +---@param open_config boolean|string[] +local function do_open(bufnr, output_file, open_config) + if open_config == true then + vim.ui.open(output_file) + elseif type(open_config) == 'table' then + local open_cmd = vim.list_extend({}, open_config) + table.insert(open_cmd, output_file) + viewer_procs[bufnr] = vim.system(open_cmd) + end +end + ---@param val string[]|fun(ctx: preview.Context): string[] ---@param ctx preview.Context ---@return string[] @@ -160,21 +199,40 @@ function M.compile(bufnr, name, provider, ctx, opts) end) ) - if - provider.open - and not opts.oneshot - 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 - local open_cmd = vim.list_extend({}, provider.open) - table.insert(open_cmd, output_file) - vim.system(open_cmd) + if provider.open and not opts.oneshot and not opened[bufnr] and output_file ~= '' then + local pre_stat = vim.uv.fs_stat(output_file) + local pre_mtime = pre_stat and pre_stat.mtime.sec or 0 + local out_dir = vim.fn.fnamemodify(output_file, ':h') + local out_name = vim.fn.fnamemodify(output_file, ':t') + stop_open_watcher(bufnr) + local watcher = vim.uv.new_fs_event() + if watcher then + open_watchers[bufnr] = watcher + watcher:start( + out_dir, + {}, + vim.schedule_wrap(function(err, filename, _events) + if err or vim.fn.fnamemodify(filename or '', ':t') ~= out_name then + return + end + if opened[bufnr] then + stop_open_watcher(bufnr) + return + end + if not vim.api.nvim_buf_is_valid(bufnr) then + stop_open_watcher(bufnr) + return + end + local new_stat = vim.uv.fs_stat(output_file) + if not (new_stat and new_stat.mtime.sec > pre_mtime) then + return + end + stop_open_watcher(bufnr) + do_open(bufnr, output_file, provider.open) + opened[bufnr] = true + end) + ) end - opened[bufnr] = true end active[bufnr] = { obj = obj, provider = name, output_file = output_file, is_reload = true } @@ -184,6 +242,8 @@ function M.compile(bufnr, name, provider, ctx, opts) once = true, callback = function() M.stop(bufnr) + stop_open_watcher(bufnr) + close_viewer(bufnr) last_output[bufnr] = nil end, }) @@ -246,13 +306,7 @@ function M.compile(bufnr, name, provider, ctx, opts) 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 - local open_cmd = vim.list_extend({}, provider.open) - table.insert(open_cmd, output_file) - vim.system(open_cmd) - end + do_open(bufnr, output_file, provider.open) opened[bufnr] = true end else @@ -299,6 +353,7 @@ function M.compile(bufnr, name, provider, ctx, opts) once = true, callback = function() M.stop(bufnr) + close_viewer(bufnr) last_output[bufnr] = nil end, }) @@ -339,6 +394,12 @@ function M.stop_all() for bufnr, _ in pairs(watching) do M.unwatch(bufnr) end + for bufnr, _ in pairs(open_watchers) do + stop_open_watcher(bufnr) + end + for bufnr, _ in pairs(viewer_procs) do + close_viewer(bufnr) + end require('preview.reload').stop() end @@ -394,6 +455,8 @@ function M.toggle(bufnr, name, provider, ctx_builder) once = true, callback = function() M.unwatch(bufnr) + stop_open_watcher(bufnr) + close_viewer(bufnr) opened[bufnr] = nil end, }) @@ -471,13 +534,7 @@ function M.open(bufnr, open_config) 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) - vim.system(open_cmd) - else - vim.ui.open(output) - end + do_open(bufnr, output, open_config) return true end @@ -502,6 +559,8 @@ M._test = { opened = opened, last_output = last_output, debounce_timers = debounce_timers, + viewer_procs = viewer_procs, + open_watchers = open_watchers, } return M From c8e3a88434bfa7800d9d48cea39fdbc86c3da94d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:33:58 -0500 Subject: [PATCH 18/29] fix: stream stderr for long-running providers, clear errors on success (#40) Problem: Long-running providers (e.g. `typst watch`) never exit on compile error, so the exit callback never fired and diagnostics/quickfix were never populated. The `typst watch` `reload` command also lacked `--diagnostic-format short`, producing unparseable verbose output. Solution: Add a `stderr` streaming callback to the long-running `vim.system` call that accumulates chunks and re-parses on each new chunk, populating diagnostics or quickfix in real time. When the fs_event fires (successful compile), clear `stderr_acc` and the reported errors. Add `--diagnostic-format short` to the typst `reload` command to match the one-shot `args` format. --- lua/preview/compiler.lua | 43 ++++++++++++++++++++++++++++++++++++++++ lua/preview/presets.lua | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index c5a81c2..3f897ad 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -143,12 +143,45 @@ function M.compile(bufnr, name, provider, ctx, opts) table.concat(reload_cmd, ' ') ) + local stderr_acc = {} local obj obj = vim.system( reload_cmd, { cwd = cwd, env = provider.env, + stderr = vim.schedule_wrap(function(err, data) + if not data or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + stderr_acc[#stderr_acc + 1] = data + local errors_mode = provider.errors + if errors_mode == nil then + errors_mode = 'diagnostic' + end + if provider.error_parser and errors_mode then + local output = table.concat(stderr_acc) + if errors_mode == 'diagnostic' then + diagnostic.set(bufnr, name, provider.error_parser, output, ctx) + elseif errors_mode == 'quickfix' then + local ok, diags = pcall(provider.error_parser, output, ctx) + if ok and diags and #diags > 0 then + local items = {} + for _, d in ipairs(diags) do + table.insert(items, { + bufnr = bufnr, + lnum = d.lnum + 1, + col = d.col + 1, + text = d.message, + type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E', + }) + end + vim.fn.setqflist(items, 'r') + vim.cmd('copen') + end + end + end + end), }, vim.schedule_wrap(function(result) if active[bufnr] and active[bufnr].obj == obj then @@ -228,6 +261,16 @@ function M.compile(bufnr, name, provider, ctx, opts) return end stop_open_watcher(bufnr) + stderr_acc = {} + local errors_mode = provider.errors + if errors_mode == nil then + errors_mode = 'diagnostic' + end + if errors_mode == 'diagnostic' then + diagnostic.clear(bufnr) + elseif errors_mode == 'quickfix' then + vim.fn.setqflist({}, 'r') + end do_open(bufnr, output_file, provider.open) opened[bufnr] = true end) diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index f189e98..3591a21 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -133,7 +133,7 @@ M.typst = { end, open = true, reload = function(ctx) - return { 'typst', 'watch', ctx.file } + return { 'typst', 'watch', '--diagnostic-format', 'short', ctx.file } end, } From 3e6ba580e4e3c8dc544c0994659757ed12066c3f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:23:06 -0500 Subject: [PATCH 19/29] fix: quickfix support for long-running providers (#41) * fix(compiler): open quickfix in background, retain focus on source buffer * fix(compiler): use cwindow and win_gotoid for quickfix focus management * fix: unused var warning and update typst reload test for short format * fix: remove testing files --- README.html | 963 --------------------------------------- README.md | 4 +- lua/preview/compiler.lua | 16 +- spec/presets_spec.lua | 4 +- 4 files changed, 17 insertions(+), 970 deletions(-) delete mode 100644 README.html diff --git a/README.html b/README.html deleted file mode 100644 index b6600fc..0000000 --- a/README.html +++ /dev/null @@ -1,963 +0,0 @@ - - - - - - - README - - - - -

preview.nvim

-

Universal document previewer for Neovim

-

An extensible framework for compiling and previewing any -documents (LaTeX, Typst, Markdown, etc.)—diagnostics included.

-

Features

-
    -
  • Async compilation via vim.system()
  • -
  • Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), -Markdown, GitHub-flavored Markdown, AsciiDoc, and Quarto
  • -
  • Compiler errors as native vim.diagnostic
  • -
  • User events for extensibility (PreviewCompileStarted, -PreviewCompileSuccess, -PreviewCompileFailed)
  • -
-

Requirements

-
    -
  • Neovim 0.11+
  • -
-

Installation

-

Install with your package manager of choice or via luarocks:

-
luarocks install preview.nvim
-

Documentation

-
:help preview.nvim
-

FAQ

-

Q: How do I define a custom provider?

-
require('preview').setup({
-  rst = {
-    cmd = { 'rst2html' },
-    args = function(ctx)
-      return { ctx.file, ctx.output }
-    end,
-    output = function(ctx)
-      return ctx.file:gsub('%.rst$', '.html')
-    end,
-  },
-})
-

Q: How do I override a preset?

-
require('preview').setup({
-  typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } },
-})
-

Q: How do I automatically open the output file?

-

Set open = true on your provider (all built-in presets -have this enabled) to open the output with vim.ui.open() -after the first successful compilation in toggle/watch mode. For a -specific application, pass a command table:

-
require('preview').setup({
-  typst = { open = { 'sioyek', '--new-instance' } },
-})
- - - diff --git a/README.md b/README.md index 286e1ac..18bc192 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ **Universal document previewer for Neovim** -An extensible framework for compiling and previewing *any* documents (LaTeX, Typst, -Markdown, etc.)—diagnostics included. +An extensible framework for compiling and previewing _any_ documents (LaTeX, +Typst, Markdown, etc.)—diagnostics included. ## Features diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 3f897ad..a3b9c47 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -150,7 +150,7 @@ function M.compile(bufnr, name, provider, ctx, opts) { cwd = cwd, env = provider.env, - stderr = vim.schedule_wrap(function(err, data) + stderr = vim.schedule_wrap(function(_err, data) if not data or not vim.api.nvim_buf_is_valid(bufnr) then return end @@ -177,7 +177,9 @@ function M.compile(bufnr, name, provider, ctx, opts) }) end vim.fn.setqflist(items, 'r') - vim.cmd('copen') + local win = vim.fn.win_getid() + vim.cmd.cwindow() + vim.fn.win_gotoid(win) end end end @@ -215,7 +217,9 @@ function M.compile(bufnr, name, provider, ctx, opts) }) end vim.fn.setqflist(items, 'r') - vim.cmd('copen') + local win = vim.fn.win_getid() + vim.cmd.cwindow() + vim.fn.win_gotoid(win) end end end @@ -270,6 +274,7 @@ function M.compile(bufnr, name, provider, ctx, opts) diagnostic.clear(bufnr) elseif errors_mode == 'quickfix' then vim.fn.setqflist({}, 'r') + vim.cmd.cwindow() end do_open(bufnr, output_file, provider.open) opened[bufnr] = true @@ -331,6 +336,7 @@ function M.compile(bufnr, name, provider, ctx, opts) diagnostic.clear(bufnr) elseif errors_mode == 'quickfix' then vim.fn.setqflist({}, 'r') + vim.cmd.cwindow() end vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileSuccess', @@ -372,7 +378,9 @@ function M.compile(bufnr, name, provider, ctx, opts) }) end vim.fn.setqflist(items, 'r') - vim.cmd('copen') + local win = vim.fn.win_getid() + vim.cmd.cwindow() + vim.fn.win_gotoid(win) end end end diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index 2a63d18..2160dfa 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -50,7 +50,9 @@ describe('presets', function() assert.is_table(result) assert.are.equal('typst', result[1]) assert.are.equal('watch', result[2]) - assert.are.equal(ctx.file, result[3]) + assert.are.equal('--diagnostic-format', result[3]) + assert.are.equal('short', result[4]) + assert.are.equal(ctx.file, result[5]) end) it('parses errors from stderr', function() From cf8fd02e6d844f203ef958e24c75656ca4ba20d3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:23:57 -0500 Subject: [PATCH 20/29] Add video demonstration to README Added a video demonstration to the README. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 18bc192..8e0f56f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ An extensible framework for compiling and previewing _any_ documents (LaTeX, Typst, Markdown, etc.)—diagnostics included. + + ## Features - Async compilation via `vim.system()` From 8107f8c0acd616d3d315c2d25e1b1179de554f0a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 17:28:07 -0500 Subject: [PATCH 21/29] doc: improve error phrasing, remove redundant feautre --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 8e0f56f..aa9dd91 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,7 @@ Typst, Markdown, etc.)—diagnostics included. - Async compilation via `vim.system()` - Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), Markdown, GitHub-flavored Markdown, AsciiDoc, and Quarto -- Compiler errors as native `vim.diagnostic` -- User events for extensibility (`PreviewCompileStarted`, - `PreviewCompileSuccess`, `PreviewCompileFailed`) +- Compiler errors via `vim.diagnostic` or quickfix ## Requirements From bb9ca987e10883ecb89a84feac5f1e32ba5a7bd8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:12:33 -0500 Subject: [PATCH 22/29] Add note about previewer auto-close feature --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index aa9dd91..4160267 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Typst, Markdown, etc.)—diagnostics included. - Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), Markdown, GitHub-flavored Markdown, AsciiDoc, and Quarto - Compiler errors via `vim.diagnostic` or quickfix +- Previewer auto-close on buffer deletion ## Requirements From f1aed82f4251215b0927b607e30d2358ac3d4231 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:30:56 -0500 Subject: [PATCH 23/29] feat: add `detach` provider field and `vim.g.preview` config support (#42) Problem: viewer processes launched via a string[] `open` command were always killed on buffer deletion with no way to opt out. Configuring the plugin also required an explicit `setup()` call in a `config` hook, preventing config from being declared before the plugin loads. Solution: add a `detach` boolean to `ProviderConfig` that skips SIGTERM on buffer unload. Auto-call `setup()` from `vim.g.preview` at module load time, enabling config via lazy.nvim's `init` hook. Update vimdoc and README accordingly. --- README.md | 14 ++++++++++++-- doc/preview.nvim.txt | 15 +++++++++++++-- lua/preview/compiler.lua | 12 +++++++++--- lua/preview/init.lua | 6 ++++++ 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4160267..3bbb203 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,18 @@ Typst, Markdown, etc.)—diagnostics included. ## Installation -Install with your package manager of choice or via -[luarocks](https://luarocks.org/modules/barrettruth/preview.nvim): +With lazy.nvim: + +```lua +{ + 'barrettruth/preview.nvim', + init = function() + vim.g.preview = { typst = true, latex = true } + end, +} +``` + +Or via [luarocks](https://luarocks.org/modules/barrettruth/preview.nvim): ``` luarocks install preview.nvim diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index e2747d6..c64db62 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -23,12 +23,17 @@ REQUIREMENTS *preview.nvim-requirements ============================================================================== SETUP *preview.nvim-setup* -Load preview.nvim with your package manager. For example, with lazy.nvim: >lua +With lazy.nvim, set |vim.g.preview| in `init` so configuration is applied +before the plugin loads: >lua { 'barrettruth/preview.nvim', + init = function() + vim.g.preview = { typst = true, latex = true } + end, } < -Call |preview.setup()| to configure providers before use. +Alternatively, call |preview.setup()| directly in a `config` function or +anywhere the plugin is already loaded. ============================================================================== CONFIGURATION *preview.nvim-configuration* @@ -108,6 +113,12 @@ Provider fields:~ |preview.Context| and returns a string[]. + `detach` boolean When `true`, the viewer process opened + via a string[] `open` command is not + sent SIGTERM when the buffer is deleted. + Has no effect when `open` is `true`. + Default: `false`. + *preview.Context* Context fields:~ diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index a3b9c47..a193ad2 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -291,7 +291,9 @@ function M.compile(bufnr, name, provider, ctx, opts) callback = function() M.stop(bufnr) stop_open_watcher(bufnr) - close_viewer(bufnr) + if not provider.detach then + close_viewer(bufnr) + end last_output[bufnr] = nil end, }) @@ -404,7 +406,9 @@ function M.compile(bufnr, name, provider, ctx, opts) once = true, callback = function() M.stop(bufnr) - close_viewer(bufnr) + if not provider.detach then + close_viewer(bufnr) + end last_output[bufnr] = nil end, }) @@ -507,7 +511,9 @@ function M.toggle(bufnr, name, provider, ctx_builder) callback = function() M.unwatch(bufnr) stop_open_watcher(bufnr) - close_viewer(bufnr) + if not provider.detach then + close_viewer(bufnr) + end opened[bufnr] = nil end, }) diff --git a/lua/preview/init.lua b/lua/preview/init.lua index fd54d71..322b893 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -10,6 +10,7 @@ ---@field clean? string[]|fun(ctx: preview.Context): string[] ---@field open? boolean|string[] ---@field reload? boolean|string[]|fun(ctx: preview.Context): string[] +---@field detach? boolean ---@class preview.Config ---@field debug boolean|string @@ -101,6 +102,7 @@ function M.setup(opts) end, 'false, "diagnostic", or "quickfix"') vim.validate(prefix .. '.open', provider.open, { 'boolean', 'table' }, true) vim.validate(prefix .. '.reload', provider.reload, { 'boolean', 'table', 'function' }, true) + vim.validate(prefix .. '.detach', provider.detach, 'boolean', true) end config = vim.tbl_deep_extend('force', default_config, { @@ -246,4 +248,8 @@ M._test = { end, } +if vim.g.preview then + M.setup(vim.g.preview) +end + return M From 7895b67c21d9cabcf9a3ff3bfb107754027127fa Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:39:00 -0500 Subject: [PATCH 24/29] docs: replace all `setup()` references with `vim.g.preview` (#43) --- README.md | 12 ++++++------ doc/preview.nvim.txt | 34 +++++++++++++--------------------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 3bbb203..3ffbc38 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ luarocks install preview.nvim **Q: How do I define a custom provider?** ```lua -require('preview').setup({ +vim.g.preview = { rst = { cmd = { 'rst2html' }, args = function(ctx) @@ -59,15 +59,15 @@ require('preview').setup({ return ctx.file:gsub('%.rst$', '.html') end, }, -}) +} ``` **Q: How do I override a preset?** ```lua -require('preview').setup({ +vim.g.preview = { typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } }, -}) +} ``` **Q: How do I automatically open the output file?** @@ -77,7 +77,7 @@ open the output with `vim.ui.open()` after the first successful compilation in toggle/watch mode. For a specific application, pass a command table: ```lua -require('preview').setup({ +vim.g.preview = { typst = { open = { 'sioyek', '--new-instance' } }, -}) +} ``` diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt index c64db62..2566301 100644 --- a/doc/preview.nvim.txt +++ b/doc/preview.nvim.txt @@ -23,8 +23,7 @@ REQUIREMENTS *preview.nvim-requirements ============================================================================== SETUP *preview.nvim-setup* -With lazy.nvim, set |vim.g.preview| in `init` so configuration is applied -before the plugin loads: >lua +Set |vim.g.preview| before the plugin loads: >lua { 'barrettruth/preview.nvim', init = function() @@ -32,19 +31,12 @@ before the plugin loads: >lua end, } < -Alternatively, call |preview.setup()| directly in a `config` function or -anywhere the plugin is already loaded. ============================================================================== CONFIGURATION *preview.nvim-configuration* -Configure via `require('preview').setup()`. - - *preview.setup()* -setup({opts?}) - - `opts` is a table where keys are preset names or filetypes. For each - key `k` with value `v` (excluding `debug`): +Configure by setting |vim.g.preview| to a table where keys are preset names +or filetypes. For each key `k` with value `v` (excluding `debug`): - If `k` is a preset name and `v` is `true`, the preset is registered as-is under its filetype. @@ -131,30 +123,30 @@ Context fields:~ Example enabling presets:~ >lua - require('preview').setup({ typst = true, latex = true, github = true }) + vim.g.preview = { typst = true, latex = true, github = true } < Example overriding a preset field:~ >lua - require('preview').setup({ + vim.g.preview = { typst = { open = { 'sioyek', '--new-instance' } }, - }) + } < Example overriding the output path (e.g. latexmk `$out_dir`):~ >lua - require('preview').setup({ + vim.g.preview = { 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({ + vim.g.preview = { rst = { cmd = { 'rst2html' }, args = function(ctx) @@ -164,7 +156,7 @@ Example with a fully custom provider (key is not a preset name):~ return ctx.file:gsub('%.rst$', '.html') end, }, - }) + } < ============================================================================== @@ -184,14 +176,14 @@ Import them from `preview.presets`: Enable presets with `preset_name = true`: >lua - require('preview').setup({ typst = true, latex = true, github = true }) + vim.g.preview = { typst = true, latex = true, github = true } < Override individual fields by passing a table instead of `true`: >lua - require('preview').setup({ + vim.g.preview = { typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } }, - }) + } < ============================================================================== From 837c97cd09540d34d52afd035bdcd4b641fb1c21 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:38:29 -0500 Subject: [PATCH 25/29] docs: rewrite vimdoc to match `pending.txt` conventions (#44) Problem: The vimdoc used `preview.nvim.txt` filename and `*preview.nvim-xyz*` tags, inconsistent with other plugins. Solution: Rename to `preview.txt`, normalize tags to `*preview-xyz*`, add contents/install sections, and use `{field} (type)` formatting. --- doc/preview.nvim.txt | 277 ------------------------------------------- doc/preview.txt | 274 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 277 deletions(-) delete mode 100644 doc/preview.nvim.txt create mode 100644 doc/preview.txt diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt deleted file mode 100644 index 2566301..0000000 --- a/doc/preview.nvim.txt +++ /dev/null @@ -1,277 +0,0 @@ -*preview.nvim.txt* Async document compilation for Neovim - -Author: Barrett Ruth -License: MIT - -============================================================================== -INTRODUCTION *preview.nvim* - -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 opt-in presets for common tools (Typst, LaTeX, Pandoc, -AsciiDoc, Quarto) and supports fully custom providers. -See |preview.nvim-presets|. - -============================================================================== -REQUIREMENTS *preview.nvim-requirements* - -- Neovim >= 0.11.0 -- A compiler binary for each configured provider (e.g. `typst`, `latexmk`) - -============================================================================== -SETUP *preview.nvim-setup* - -Set |vim.g.preview| before the plugin loads: >lua - { - 'barrettruth/preview.nvim', - init = function() - vim.g.preview = { typst = true, latex = true } - end, - } -< - -============================================================================== -CONFIGURATION *preview.nvim-configuration* - -Configure by setting |vim.g.preview| to a table where keys are preset names -or filetypes. For each key `k` with value `v` (excluding `debug`): - - - If `k` is a preset name and `v` is `true`, the preset is registered - as-is under its filetype. - - If `k` is a preset name and `v` is a table, it is deep-merged with - the preset and registered under the preset's filetype. - - If `k` is not a preset name and `v` is a table, it is registered - directly as a custom provider keyed by filetype `k`. - - If `v` is `false`, the entry is skipped (no-op). - - See |preview.nvim-presets| for available preset names. - - Fields:~ - - `debug` boolean|string Enable debug logging. A string value - is treated as a log file path. - Default: `false` - - *preview.ProviderConfig* -Provider fields:~ - - `cmd` string[] The compiler command (required). - - `args` string[]|function Additional arguments. If a function, - receives a |preview.Context| and returns - a string[]. - - `cwd` string|function Working directory. If a function, - receives a |preview.Context|. Default: - git root or file directory. - - `env` table Environment variables. - - `output` string|function Output file path. If a function, - receives a |preview.Context|. - - `error_parser` function Receives (output, |preview.Context|) - and returns vim.Diagnostic[]. - - `errors` false|'diagnostic'|'quickfix' - How parse errors are reported. - `false` suppresses error handling. - `'quickfix'` populates the quickfix - list and opens it. Default: - `'diagnostic'`. - - `clean` string[]|function Command to remove build artifacts. - If a function, receives a - |preview.Context|. - - `open` boolean|string[] Open the output file after the first - successful compilation in toggle/watch - mode. `true` uses |vim.ui.open()|. A - string[] is run as a command with the - output path appended. When a string[] - is used the viewer process is tracked - and sent SIGTERM when the buffer is - deleted. `true` and single-instance - apps (e.g. Chrome) do not support - auto-close. - - `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[]. - - `detach` boolean When `true`, the viewer process opened - via a string[] `open` command is not - sent SIGTERM when the buffer is deleted. - Has no effect when `open` is `true`. - Default: `false`. - - *preview.Context* -Context fields:~ - - `bufnr` integer Buffer number. - `file` string Absolute file path. - `root` string Project root (git root or file directory). - `ft` string Filetype. - `output` string? Resolved output file path (set after `output` - is evaluated, available to `args` functions). - -Example enabling presets:~ ->lua - vim.g.preview = { typst = true, latex = true, github = true } -< - -Example overriding a preset field:~ ->lua - vim.g.preview = { - typst = { open = { 'sioyek', '--new-instance' } }, - } -< - -Example overriding the output path (e.g. latexmk `$out_dir`):~ ->lua - vim.g.preview = { - 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 - vim.g.preview = { - rst = { - cmd = { 'rst2html' }, - args = function(ctx) - return { ctx.file } - end, - output = function(ctx) - return ctx.file:gsub('%.rst$', '.html') - end, - }, - } -< - -============================================================================== -PRESETS *preview.nvim-presets* - -preview.nvim ships with pre-built provider configurations for common tools. -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 - vim.g.preview = { typst = true, latex = true, github = true } -< - -Override individual fields by passing a table instead of `true`: ->lua - vim.g.preview = { - typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } }, - } -< - -============================================================================== -COMMANDS *preview.nvim-commands* - -:Preview [subcommand] *:Preview* - - Subcommands:~ - - `toggle` Toggle auto-compile on save (default if omitted). - `compile` One-shot compile of the current buffer. - `clean` Run the provider's clean command. - `open` Open the last compiled output without recompiling. - `status` Echo compilation status (idle, compiling, watching). - -============================================================================== -API *preview.nvim-api* - -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. - -preview.status({bufnr?}) *preview.status()* - Returns a |preview.Status| table. - -preview.statusline({bufnr?}) *preview.statusline()* - Returns a short status string for statusline integration: - `'compiling'`, `'watching'`, or `''` (idle). - - *preview.Status* -Status fields:~ - - `compiling` boolean Whether compilation is active. - `watching` boolean Whether auto-compile is active. - `provider` string? Name of the active provider. - `output_file` string? Path to the output file. - -preview.get_config() *preview.get_config()* - Returns the resolved |preview.Config|. - -============================================================================== -EVENTS *preview.nvim-events* - -preview.nvim fires User autocmds with structured data: - -`PreviewCompileStarted` Compilation began. - data: `{ bufnr, provider }` - -`PreviewCompileSuccess` Compilation succeeded (exit code 0). - data: `{ bufnr, provider, output }` - -`PreviewCompileFailed` Compilation failed (non-zero exit). - data: `{ bufnr, provider, code, stderr }` - -Example:~ ->lua - vim.api.nvim_create_autocmd('User', { - pattern = 'PreviewCompileSuccess', - callback = function(args) - local data = args.data - vim.notify('Compiled ' .. data.output .. ' with ' .. data.provider) - end, - }) -< - -============================================================================== -HEALTH *preview.nvim-health* - -Run `:checkhealth preview` to verify: - -- Neovim version >= 0.11.0 -- Each configured provider's binary is executable -- Each configured provider's opener binary (if any) is executable -- Each configured provider's filetype mapping is valid - -============================================================================== - vim:tw=78:ts=8:ft=help:norl: diff --git a/doc/preview.txt b/doc/preview.txt new file mode 100644 index 0000000..0e54a62 --- /dev/null +++ b/doc/preview.txt @@ -0,0 +1,274 @@ +*preview.txt* Async document compilation for Neovim + +Author: Barrett Ruth +License: MIT + +============================================================================== +INTRODUCTION *preview.nvim* + +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 opt-in presets for common tools (Typst, LaTeX, Pandoc, +AsciiDoc, Quarto) and supports fully custom providers. +See |preview-presets|. + +============================================================================== +CONTENTS *preview-contents* + + 1. Introduction ............................................. |preview.nvim| + 2. Requirements ..................................... |preview-requirements| + 3. Install ............................................... |preview-install| + 4. Configuration ........................................... |preview-config| + 5. Presets ............................................... |preview-presets| + 6. Commands ............................................. |preview-commands| + 7. Lua API ................................................... |preview-api| + 8. Events ............................................... |preview-events| + 9. Health ............................................... |preview-health| + +============================================================================== +REQUIREMENTS *preview-requirements* + +- Neovim >= 0.11.0 +- A compiler binary for each configured provider (e.g. `typst`, `latexmk`) + +============================================================================== +INSTALL *preview-install* + +Install with lazy.nvim: >lua + { 'barrettruth/preview.nvim' } +< + +No `setup()` call is needed. The plugin loads automatically when +|vim.g.preview| is set. See |preview-config|. + +============================================================================== +CONFIGURATION *preview-config* + +Configure by setting |vim.g.preview| to a table where keys are preset names +or filetypes. For each key `k` with value `v` (excluding `debug`): + + - If `k` is a preset name and `v` is `true`, the preset is registered + as-is under its filetype. + - If `k` is a preset name and `v` is a table, it is deep-merged with + the preset and registered under the preset's filetype. + - If `k` is not a preset name and `v` is a table, it is registered + directly as a custom provider keyed by filetype `k`. + - If `v` is `false`, the entry is skipped (no-op). + +See |preview-presets| for available preset names. + + *preview.ProviderConfig* +Provider fields: ~ + + {cmd} (string[]) Compiler command (required). + + {args} (string[]|function) Additional arguments. If a function, + receives a |preview.Context| and + returns a string[]. + + {cwd} (string|function) Working directory. If a function, + receives a |preview.Context|. + Default: git root or file directory. + + {env} (table) Environment variables. + + {output} (string|function) Output file path. If a function, + receives a |preview.Context|. + + {error_parser} (function) Receives (output, |preview.Context|) + and returns vim.Diagnostic[]. + + {errors} (false|'diagnostic'|'quickfix') + How parse errors are reported. + `false` suppresses error handling. + `'quickfix'` populates the quickfix + list and opens it. + Default: `'diagnostic'`. + + {clean} (string[]|function) Command to remove build artifacts. + If a function, receives a + |preview.Context|. + + {open} (boolean|string[]) Open the output file after the first + successful compilation in toggle/watch + mode. `true` uses |vim.ui.open()|. A + string[] is run as a command with the + output path appended. When a string[] + is used the viewer process is tracked + and sent SIGTERM when the buffer is + deleted. `true` and single-instance + apps (e.g. Chrome) do not support + auto-close. + + {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[]. + + {detach} (boolean) When `true`, the viewer process opened + via a string[] `open` command is not + sent SIGTERM when the buffer is + deleted. Has no effect when `open` is + `true`. Default: `false`. + + *preview.Context* +Context fields: ~ + + {bufnr} (integer) Buffer number. + {file} (string) Absolute file path. + {root} (string) Project root (git root or file directory). + {ft} (string) Filetype. + {output} (string?) Resolved output file path (set after `output` + is evaluated, available to `args` functions). + +Global options: ~ + + {debug} (boolean|string) Enable debug logging. A string value is treated + as a log file path. Default: `false`. + +Example enabling presets: >lua + vim.g.preview = { typst = true, latex = true, github = true } +< + +Example overriding a preset field: >lua + vim.g.preview = { + typst = { open = { 'sioyek', '--new-instance' } }, + } +< + +Example overriding the output path (e.g. latexmk `$out_dir`): >lua + vim.g.preview = { + 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 + vim.g.preview = { + rst = { + cmd = { 'rst2html' }, + args = function(ctx) + return { ctx.file } + end, + output = function(ctx) + return ctx.file:gsub('%.rst$', '.html') + end, + }, + } +< + +============================================================================== +PRESETS *preview-presets* + +Built-in provider configurations. Enable with `preset_name = true` or +override individual fields by passing a table instead: >lua + vim.g.preview = { typst = true, latex = true, github = true } +< + + `typst` typst compile → PDF + `latex` latexmk -pdf → PDF (with clean support) + `pdflatex` pdflatex → PDF (single pass, no latexmk) + `tectonic` tectonic → PDF (Rust-based LaTeX engine) + `markdown` pandoc → HTML (standalone, embedded) + `github` pandoc → HTML (GitHub-styled, `-f gfm` input) + `asciidoctor` asciidoctor → HTML (AsciiDoc with SSE reload) + `quarto` quarto render → HTML (scientific publishing) + +============================================================================== +COMMANDS *preview-commands* + +:Preview [subcommand] *:Preview* + + Subcommands: ~ + + `toggle` Toggle auto-compile on save (default if omitted). + `compile` One-shot compile of the current buffer. + `clean` Run the provider's clean command. + `open` Open the last compiled output without recompiling. + `status` Echo compilation status (idle, compiling, watching). + +============================================================================== +LUA API *preview-api* + +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. + +preview.status({bufnr?}) *preview.status()* + Returns a |preview.Status| table. + +preview.statusline({bufnr?}) *preview.statusline()* + Returns a short status string for statusline integration: + `'compiling'`, `'watching'`, or `''` (idle). + +preview.get_config() *preview.get_config()* + Returns the resolved |preview.Config|. + + *preview.Status* +Status fields: ~ + + {compiling} (boolean) Whether compilation is active. + {watching} (boolean) Whether auto-compile is active. + {provider} (string?) Name of the active provider. + {output_file} (string?) Path to the output file. + +============================================================================== +EVENTS *preview-events* + +preview.nvim fires User autocmds with structured data: + +`PreviewCompileStarted` Compilation began. + data: `{ bufnr, provider }` + +`PreviewCompileSuccess` Compilation succeeded (exit code 0). + data: `{ bufnr, provider, output }` + +`PreviewCompileFailed` Compilation failed (non-zero exit). + data: `{ bufnr, provider, code, stderr }` + +Example: >lua + vim.api.nvim_create_autocmd('User', { + pattern = 'PreviewCompileSuccess', + callback = function(args) + local data = args.data + vim.notify('Compiled ' .. data.output .. ' with ' .. data.provider) + end, + }) +< + +============================================================================== +HEALTH *preview-health* + +Run |:checkhealth| preview to verify your setup: >vim + :checkhealth preview +< + +Checks: ~ +- Neovim version >= 0.11.0 +- Each configured provider's binary is executable +- Each configured provider's opener binary (if any) is executable + +============================================================================== + vim:tw=78:ts=8:ft=help:norl: From 23aa8acc55402d0a53fc5d14e0d2563a721f0eb0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:32:33 -0500 Subject: [PATCH 26/29] feat: add `plantuml` preset (#45) Problem: PlantUML (`.puml`) diagrams have no built-in preview support, and Neovim lacks filetype detection for PlantUML files. Solution: Add a `plantuml` preset that compiles to SVG via `plantuml -tsvg`, with an error parser for `Error line N` diagnostics. Register `.puml` and `.pu` extensions via `vim.filetype.add` when the preset is configured. Add `plantuml` to the nix dev shell. --- flake.nix | 1 + lua/preview/init.lua | 6 ++++++ lua/preview/presets.lua | 31 +++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/flake.nix b/flake.nix index 91a0ab2..4ae4479 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,7 @@ pkgs.stylua pkgs.selene pkgs.lua-language-server + pkgs.plantuml ]; }; }); diff --git a/lua/preview/init.lua b/lua/preview/init.lua index 322b893..7cb982b 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -105,6 +105,12 @@ function M.setup(opts) vim.validate(prefix .. '.detach', provider.detach, 'boolean', true) end + if providers['plantuml'] then + vim.filetype.add({ + extension = { puml = 'plantuml', pu = 'plantuml' }, + }) + end + config = vim.tbl_deep_extend('force', default_config, { debug = debug, providers = providers, diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index 3591a21..e7c51e3 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -271,6 +271,37 @@ M.asciidoctor = { reload = true, } +---@type preview.ProviderConfig +M.plantuml = { + ft = 'plantuml', + cmd = { 'plantuml' }, + args = function(ctx) + return { '-tsvg', ctx.file } + end, + output = function(ctx) + return (ctx.file:gsub('%.puml$', '.svg')) + end, + error_parser = function(output) + local diagnostics = {} + for line in output:gmatch('[^\r\n]+') do + local lnum = line:match('^Error line (%d+) in file:') + if lnum then + table.insert(diagnostics, { + lnum = tonumber(lnum) - 1, + col = 0, + message = line, + severity = vim.diagnostic.severity.ERROR, + }) + end + end + return diagnostics + end, + clean = function(ctx) + return { 'rm', '-f', (ctx.file:gsub('%.puml$', '.svg')) } + end, + open = true, +} + ---@type preview.ProviderConfig M.quarto = { ft = 'quarto', From 31dcf9c91fb607ff3307b51992cef1c8b8a46ff3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:44:33 -0500 Subject: [PATCH 27/29] feat: add `mermaid` preset (#46) Problem: no built-in support for compiling mermaid diagrams via `mmdc`. Solution: add a `mermaid` preset that compiles `.mmd` files to SVG and parses `Parse error on line N` diagnostics from stderr. Add `mermaid-cli` to the nix dev shell. --- flake.nix | 1 + lua/preview/presets.lua | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/flake.nix b/flake.nix index 4ae4479..d5bdae4 100644 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,7 @@ pkgs.selene pkgs.lua-language-server pkgs.plantuml + pkgs.mermaid-cli ]; }; }); diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index e7c51e3..1b5333e 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -302,6 +302,37 @@ M.plantuml = { open = true, } +---@type preview.ProviderConfig +M.mermaid = { + ft = 'mermaid', + cmd = { 'mmdc' }, + args = function(ctx) + return { '-i', ctx.file, '-o', ctx.output } + end, + output = function(ctx) + return (ctx.file:gsub('%.mmd$', '.svg')) + end, + error_parser = function(output) + local diagnostics = {} + for line in output:gmatch('[^\r\n]+') do + local lnum = line:match('^%s*Parse error on line (%d+)') + if lnum then + table.insert(diagnostics, { + lnum = tonumber(lnum) - 1, + col = 0, + message = line, + severity = vim.diagnostic.severity.ERROR, + }) + end + end + return diagnostics + end, + clean = function(ctx) + return { 'rm', '-f', (ctx.file:gsub('%.mmd$', '.svg')) } + end, + open = true, +} + ---@type preview.ProviderConfig M.quarto = { ft = 'quarto', From 6f090fdcf33155cb116b898109c6dcaa106e6e4a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Mar 2026 10:55:03 -0500 Subject: [PATCH 28/29] build: split nix dev shell into `default` and `presets` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the single dev shell mixed dev tooling (linters, test runner) with preset compiler tools, causing heavy rebuilds (e.g. Chromium for `mermaid-cli`) for contributors who only need the dev tools. Solution: extract dev tooling into a shared `devTools` list and expose two shells — `default` for development and `presets` for running all built-in preset compilers (`typst`, `texliveMedium`, `tectonic`, `pandoc`, `asciidoctor`, `quarto`, `plantuml`, `mermaid-cli`). --- flake.nix | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/flake.nix b/flake.nix index 4ae4479..636f4d0 100644 --- a/flake.nix +++ b/flake.nix @@ -19,9 +19,9 @@ { formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); - devShells = forEachSystem (pkgs: { - default = pkgs.mkShell { - packages = [ + devShells = forEachSystem (pkgs: + let + devTools = [ (pkgs.luajit.withPackages ( ps: with ps; [ busted @@ -32,9 +32,24 @@ pkgs.stylua pkgs.selene pkgs.lua-language-server - pkgs.plantuml ]; - }; - }); + in + { + default = pkgs.mkShell { + packages = devTools; + }; + presets = pkgs.mkShell { + packages = devTools ++ [ + pkgs.typst + pkgs.texliveMedium + pkgs.tectonic + pkgs.pandoc + pkgs.asciidoctor + pkgs.quarto + pkgs.plantuml + pkgs.mermaid-cli + ]; + }; + }); }; } From 9fe68dd159fd4f2405f57d5026ea40f0c7b251ae Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:03:43 -0500 Subject: [PATCH 29/29] docs: document `plantuml` and `mermaid` presets (#47) Problem: the README and vimdoc presets list omitted `plantuml` and `mermaid` after both were added. Solution: add both presets to the vimdoc table and the README features blurb. --- README.md | 2 +- doc/preview.txt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ffbc38..80a2650 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Typst, Markdown, etc.)—diagnostics included. - Async compilation via `vim.system()` - Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), Markdown, - GitHub-flavored Markdown, AsciiDoc, and Quarto + GitHub-flavored Markdown, AsciiDoc, PlantUML, Mermaid, and Quarto - Compiler errors via `vim.diagnostic` or quickfix - Previewer auto-close on buffer deletion diff --git a/doc/preview.txt b/doc/preview.txt index 0e54a62..383a8f5 100644 --- a/doc/preview.txt +++ b/doc/preview.txt @@ -11,7 +11,7 @@ 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 opt-in presets for common tools (Typst, LaTeX, Pandoc, -AsciiDoc, Quarto) and supports fully custom providers. +AsciiDoc, PlantUML, Mermaid, Quarto) and supports fully custom providers. See |preview-presets|. ============================================================================== @@ -180,6 +180,8 @@ override individual fields by passing a table instead: >lua `markdown` pandoc → HTML (standalone, embedded) `github` pandoc → HTML (GitHub-styled, `-f gfm` input) `asciidoctor` asciidoctor → HTML (AsciiDoc with SSE reload) + `plantuml` plantuml → SVG (UML diagrams, `.puml`) + `mermaid` mmdc → SVG (Mermaid diagrams, `.mmd`) `quarto` quarto render → HTML (scientific publishing) ==============================================================================