From 942438f8171880207461ebb9d800b4acb827f602 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Mon, 2 Mar 2026 21:23:40 -0500
Subject: [PATCH 01/49] feat: rename
---
README.md | 42 +++--
doc/{render.nvim.txt => preview.nvim.txt} | 142 +++++++++------
flake.nix | 2 +-
lua/preview/commands.lua | 54 ++++++
lua/{render => preview}/compiler.lua | 103 ++++++++---
lua/{render => preview}/diagnostic.lua | 8 +-
lua/preview/health.lua | 31 ++++
lua/{render => preview}/init.lua | 87 +++++-----
lua/{render => preview}/log.lua | 2 +-
lua/preview/presets.lua | 40 +++++
lua/render/commands.lua | 47 -----
lua/render/health.lua | 42 -----
plugin/preview.lua | 12 ++
plugin/render.lua | 12 --
...-1.rockspec => preview.nvim-scm-1.rockspec | 6 +-
spec/commands_spec.lua | 31 ++--
spec/compiler_spec.lua | 163 ++++++++++++++++--
spec/diagnostic_spec.lua | 2 +-
spec/helpers.lua | 4 +-
spec/init_spec.lua | 47 ++---
spec/presets_spec.lua | 88 ++++++++++
21 files changed, 660 insertions(+), 305 deletions(-)
rename doc/{render.nvim.txt => preview.nvim.txt} (51%)
create mode 100644 lua/preview/commands.lua
rename lua/{render => preview}/compiler.lua (60%)
rename lua/{render => preview}/diagnostic.lua (80%)
create mode 100644 lua/preview/health.lua
rename lua/{render => preview}/init.lua (60%)
rename lua/{render => preview}/log.lua (90%)
create mode 100644 lua/preview/presets.lua
delete mode 100644 lua/render/commands.lua
delete mode 100644 lua/render/health.lua
create mode 100644 plugin/preview.lua
delete mode 100644 plugin/render.lua
rename render.nvim-scm-1.rockspec => preview.nvim-scm-1.rockspec (67%)
create mode 100644 spec/presets_spec.lua
diff --git a/README.md b/README.md
index 6c2ca46..c373df2 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# render.nvim
+# preview.nvim
Async document compilation for Neovim.
@@ -10,32 +10,47 @@ your own providers.
- Async compilation via `vim.system()`
- Compiler errors as native `vim.diagnostic`
-- User events for extensibility (`RenderCompileStarted`, `RenderCompileSuccess`,
- `RenderCompileFailed`)
+- User events for extensibility (`PreviewCompileStarted`, `PreviewCompileSuccess`,
+ `PreviewCompileFailed`)
- `:checkhealth` integration
-- Zero dependencies beyond Neovim 0.10.0+
+- Zero dependencies beyond Neovim 0.11.0+
## Requirements
-- Neovim >= 0.10.0
+- Neovim >= 0.11.0
- A compiler binary for each provider you configure
## Installation
```lua
-- lazy.nvim
-{ 'barrettruth/render.nvim' }
+{ 'barrettruth/preview.nvim' }
```
```vim
" luarocks
-:Rocks install render.nvim
+:Rocks install preview.nvim
```
## Configuration
+Use built-in presets for common tools:
+
```lua
-vim.g.render = {
+local presets = require('preview.presets')
+vim.g.preview = {
+ providers = {
+ typst = presets.typst,
+ tex = presets.latex,
+ markdown = presets.markdown,
+ },
+}
+```
+
+Or define providers manually:
+
+```lua
+vim.g.preview = {
providers = {
typst = {
cmd = { 'typst', 'compile' },
@@ -46,19 +61,10 @@ vim.g.render = {
return ctx.file:gsub('%.typ$', '.pdf')
end,
},
- latexmk = {
- cmd = { 'latexmk' },
- args = { '-pdf', '-interaction=nonstopmode' },
- clean = { 'latexmk', '-c' },
- },
- },
- providers_by_ft = {
- typst = 'typst',
- tex = 'latexmk',
},
}
```
## Documentation
-See `:help render.nvim` for full documentation.
+See `:help preview.nvim` for full documentation.
diff --git a/doc/render.nvim.txt b/doc/preview.nvim.txt
similarity index 51%
rename from doc/render.nvim.txt
rename to doc/preview.nvim.txt
index 48dacfb..d48daf6 100644
--- a/doc/render.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -1,83 +1,80 @@
-*render.nvim.txt* Async document compilation for Neovim
+*preview.nvim.txt* Async document compilation for Neovim
Author: Raphael
License: MIT
==============================================================================
-INTRODUCTION *render.nvim*
+INTRODUCTION *preview.nvim*
-render.nvim is an extensible framework for compiling documents asynchronously
+preview.nvim is an extensible framework for compiling documents asynchronously
in Neovim. It provides a unified interface for any compilation workflow —
LaTeX, Typst, Markdown, or anything else with a CLI compiler.
The plugin ships with zero provider defaults. Users must explicitly configure
-their compiler commands. render.nvim is purely an orchestration framework.
+their compiler commands. preview.nvim is purely an orchestration framework.
==============================================================================
-REQUIREMENTS *render.nvim-requirements*
+REQUIREMENTS *preview.nvim-requirements*
-- Neovim >= 0.10.0
+- Neovim >= 0.11.0
- A compiler binary for each configured provider (e.g. `typst`, `latexmk`)
==============================================================================
-INSTALLATION *render.nvim-installation*
+INSTALLATION *preview.nvim-installation*
With luarocks (recommended):
>
- :Rocks install render.nvim
+ :Rocks install preview.nvim
<
With lazy.nvim:
>lua
{
- 'barrettruth/render.nvim',
+ 'barrettruth/preview.nvim',
}
<
==============================================================================
-CONFIGURATION *render.nvim-configuration*
+CONFIGURATION *preview.nvim-configuration*
-Configure via the `vim.g.render` global table before the plugin loads.
+Configure via the `vim.g.preview` global table before the plugin loads.
- *render.Config*
+ *preview.Config*
Fields:~
`debug` boolean|string Enable debug logging. A string value
is treated as a log file path.
Default: `false`
- `providers` table Provider configurations keyed by name.
- Default: `{}`
+ `providers` table Provider configurations keyed by
+ filetype. Default: `{}`
- `providers_by_ft` table Maps filetypes to provider names.
- Default: `{}`
-
- *render.ProviderConfig*
+ *preview.ProviderConfig*
Provider fields:~
`cmd` string[] The compiler command (required).
`args` string[]|function Additional arguments. If a function,
- receives a |render.Context| and returns
+ receives a |preview.Context| and returns
a string[].
`cwd` string|function Working directory. If a function,
- receives a |render.Context|. Default:
+ 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 |render.Context|.
+ receives a |preview.Context|.
- `error_parser` function Receives (stderr, |render.Context|)
+ `error_parser` function Receives (stderr, |preview.Context|)
and returns vim.Diagnostic[].
`clean` string[]|function Command to remove build artifacts.
If a function, receives a
- |render.Context|.
+ |preview.Context|.
- *render.Context*
+ *preview.Context*
Context fields:~
`bufnr` integer Buffer number.
@@ -87,7 +84,7 @@ Context fields:~
Example:~
>lua
- vim.g.render = {
+ vim.g.preview = {
providers = {
typst = {
cmd = { 'typst', 'compile' },
@@ -110,74 +107,117 @@ Example:~
return diagnostics
end,
},
- latexmk = {
+ tex = {
cmd = { 'latexmk' },
args = { '-pdf', '-interaction=nonstopmode' },
clean = { 'latexmk', '-c' },
},
},
- providers_by_ft = {
- typst = 'typst',
- tex = 'latexmk',
+ }
+<
+
+==============================================================================
+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.markdown` pandoc → PDF
+
+Example:~
+>lua
+ local presets = require('preview.presets')
+ vim.g.preview = {
+ providers = {
+ typst = presets.typst,
+ tex = presets.latex,
+ markdown = presets.markdown,
+ },
+ }
+<
+
+Override individual fields with `vim.tbl_deep_extend`:
+>lua
+ local presets = require('preview.presets')
+ vim.g.preview = {
+ providers = {
+ typst = vim.tbl_deep_extend('force', presets.typst, {
+ env = { TYPST_FONT_PATHS = '/usr/share/fonts' },
+ }),
},
}
<
==============================================================================
-COMMANDS *render.nvim-commands*
+COMMANDS *preview.nvim-commands*
-:Render [subcommand] *:Render*
+:Preview [subcommand] *:Preview*
Subcommands:~
`compile` Compile the current buffer (default if omitted).
`stop` Kill active compilation for the current buffer.
`clean` Run the provider's clean command.
- `status` Echo compilation status (idle or compiling).
+ `watch` Toggle auto-compile on save for the current buffer.
+ `status` Echo compilation status (idle, compiling, watching).
==============================================================================
-API *render.nvim-api*
+API *preview.nvim-api*
-render.compile({bufnr?}) *render.compile()*
+preview.compile({bufnr?}) *preview.compile()*
Compile the document in the given buffer (default: current).
-render.stop({bufnr?}) *render.stop()*
+preview.stop({bufnr?}) *preview.stop()*
Kill the active compilation process for the buffer.
-render.clean({bufnr?}) *render.clean()*
+preview.clean({bufnr?}) *preview.clean()*
Run the provider's clean command for the buffer.
-render.status({bufnr?}) *render.status()*
- Returns a |render.Status| table.
+preview.watch({bufnr?}) *preview.watch()*
+ Toggle watch mode for the buffer. When enabled, the buffer is
+ automatically compiled on each save (`BufWritePost`). Call again
+ to stop watching.
- *render.Status*
+preview.status({bufnr?}) *preview.status()*
+ Returns a |preview.Status| table.
+
+ *preview.Status*
Status fields:~
`compiling` boolean Whether compilation is active.
+ `watching` boolean Whether watch mode is active.
`provider` string? Name of the active provider.
`output_file` string? Path to the output file.
-render.get_config() *render.get_config()*
- Returns the resolved |render.Config|.
+preview.get_config() *preview.get_config()*
+ Returns the resolved |preview.Config|.
==============================================================================
-EVENTS *render.nvim-events*
+EVENTS *preview.nvim-events*
-render.nvim fires User autocmds with structured data:
+preview.nvim fires User autocmds with structured data:
-`RenderCompileStarted` Compilation began.
+`PreviewCompileStarted` Compilation began.
data: `{ bufnr, provider }`
-`RenderCompileSuccess` Compilation succeeded (exit code 0).
+`PreviewCompileSuccess` Compilation succeeded (exit code 0).
data: `{ bufnr, provider, output }`
-`RenderCompileFailed` Compilation failed (non-zero exit).
+`PreviewCompileFailed` Compilation failed (non-zero exit).
data: `{ bufnr, provider, code, stderr }`
+`PreviewWatchStarted` Watch mode enabled for a buffer.
+ data: `{ bufnr, provider }`
+
+`PreviewWatchStopped` Watch mode disabled for a buffer.
+ data: `{ bufnr }`
+
Example:~
>lua
vim.api.nvim_create_autocmd('User', {
- pattern = 'RenderCompileSuccess',
+ pattern = 'PreviewCompileSuccess',
callback = function(args)
local data = args.data
vim.notify('Compiled ' .. data.output .. ' with ' .. data.provider)
@@ -186,13 +226,13 @@ Example:~
<
==============================================================================
-HEALTH *render.nvim-health*
+HEALTH *preview.nvim-health*
-Run `:checkhealth render` to verify:
+Run `:checkhealth preview` to verify:
-- Neovim version >= 0.10.0
+- Neovim version >= 0.11.0
- Each configured provider's binary is executable
-- Filetype-to-provider mappings are valid
+- Each configured provider's filetype mapping is valid
==============================================================================
vim:tw=78:ts=8:ft=help:norl:
diff --git a/flake.nix b/flake.nix
index 90c489e..7413113 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,5 +1,5 @@
{
- description = "render.nvim — async document compilation for Neovim";
+ description = "preview.nvim — async document compilation for Neovim";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
new file mode 100644
index 0000000..0545a86
--- /dev/null
+++ b/lua/preview/commands.lua
@@ -0,0 +1,54 @@
+local M = {}
+
+local subcommands = { 'compile', 'stop', 'clean', 'watch', 'status' }
+
+---@param args string
+local function dispatch(args)
+ local subcmd = args ~= '' and args or 'compile'
+
+ if subcmd == 'compile' then
+ require('preview').compile()
+ elseif subcmd == 'stop' then
+ require('preview').stop()
+ elseif subcmd == 'clean' then
+ require('preview').clean()
+ elseif subcmd == 'watch' then
+ require('preview').watch()
+ elseif subcmd == 'status' then
+ local s = require('preview').status()
+ local parts = {}
+ if s.compiling then
+ table.insert(parts, 'compiling with "' .. s.provider .. '"')
+ else
+ table.insert(parts, 'idle')
+ end
+ if s.watching then
+ table.insert(parts, 'watching')
+ end
+ vim.notify('[preview.nvim] ' .. table.concat(parts, ', '), vim.log.levels.INFO)
+ else
+ vim.notify('[preview.nvim] unknown subcommand: ' .. subcmd, vim.log.levels.ERROR)
+ end
+end
+
+---@param lead string
+---@return string[]
+local function complete(lead)
+ return vim.tbl_filter(function(s)
+ return s:find(lead, 1, true) == 1
+ end, subcommands)
+end
+
+function M.setup()
+ vim.api.nvim_create_user_command('Preview', function(opts)
+ dispatch(opts.args)
+ end, {
+ nargs = '?',
+ complete = function(lead)
+ return complete(lead)
+ end,
+ desc = 'Compile, stop, clean, watch, or check status of document preview',
+ })
+end
+
+return M
diff --git a/lua/render/compiler.lua b/lua/preview/compiler.lua
similarity index 60%
rename from lua/render/compiler.lua
rename to lua/preview/compiler.lua
index 7d2a1d4..dc1a564 100644
--- a/lua/render/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -1,13 +1,16 @@
local M = {}
-local diagnostic = require('render.diagnostic')
-local log = require('render.log')
+local diagnostic = require('preview.diagnostic')
+local log = require('preview.log')
----@type table
+---@type table
local active = {}
----@param val string[]|fun(ctx: render.Context): string[]
----@param ctx render.Context
+---@type table
+local watching = {}
+
+---@param val string[]|fun(ctx: preview.Context): string[]
+---@param ctx preview.Context
---@return string[]
local function eval_list(val, ctx)
if type(val) == 'function' then
@@ -16,8 +19,8 @@ local function eval_list(val, ctx)
return val
end
----@param val string|fun(ctx: render.Context): string
----@param ctx render.Context
+---@param val string|fun(ctx: preview.Context): string
+---@param ctx preview.Context
---@return string
local function eval_string(val, ctx)
if type(val) == 'function' then
@@ -28,8 +31,8 @@ end
---@param bufnr integer
---@param name string
----@param provider render.ProviderConfig
----@param ctx render.Context
+---@param provider preview.ProviderConfig
+---@param ctx preview.Context
function M.compile(bufnr, name, provider, ctx)
if vim.bo[bufnr].modified then
vim.cmd('silent! update')
@@ -70,7 +73,7 @@ function M.compile(bufnr, name, provider, ctx)
log.dbg('compilation succeeded for buffer %d', bufnr)
diagnostic.clear(bufnr)
vim.api.nvim_exec_autocmds('User', {
- pattern = 'RenderCompileSuccess',
+ pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file },
})
else
@@ -79,7 +82,7 @@ function M.compile(bufnr, name, provider, ctx)
diagnostic.set(bufnr, name, provider.error_parser, result.stderr or '', ctx)
end
vim.api.nvim_exec_autocmds('User', {
- pattern = 'RenderCompileFailed',
+ pattern = 'PreviewCompileFailed',
data = {
bufnr = bufnr,
provider = name,
@@ -102,7 +105,7 @@ function M.compile(bufnr, name, provider, ctx)
})
vim.api.nvim_exec_autocmds('User', {
- pattern = 'RenderCompileStarted',
+ pattern = 'PreviewCompileStarted',
data = { bufnr = bufnr, provider = name },
})
end
@@ -134,15 +137,69 @@ function M.stop_all()
for bufnr, _ in pairs(active) do
M.stop(bufnr)
end
+ for bufnr, _ in pairs(watching) do
+ M.unwatch(bufnr)
+ end
end
---@param bufnr integer
---@param name string
----@param provider render.ProviderConfig
----@param ctx render.Context
+---@param provider preview.ProviderConfig
+---@param ctx_builder fun(bufnr: integer): preview.Context
+function M.watch(bufnr, name, provider, ctx_builder)
+ if watching[bufnr] then
+ M.unwatch(bufnr)
+ return
+ end
+
+ local au_id = vim.api.nvim_create_autocmd('BufWritePost', {
+ buffer = bufnr,
+ callback = function()
+ local ctx = ctx_builder(bufnr)
+ M.compile(bufnr, name, provider, ctx)
+ end,
+ })
+
+ watching[bufnr] = au_id
+ log.dbg('watching buffer %d with provider "%s"', bufnr, name)
+
+ vim.api.nvim_create_autocmd('BufWipeout', {
+ buffer = bufnr,
+ once = true,
+ callback = function()
+ M.unwatch(bufnr)
+ end,
+ })
+
+ vim.api.nvim_exec_autocmds('User', {
+ pattern = 'PreviewWatchStarted',
+ data = { bufnr = bufnr, provider = name },
+ })
+end
+
+---@param bufnr integer
+function M.unwatch(bufnr)
+ local au_id = watching[bufnr]
+ if not au_id then
+ return
+ end
+ vim.api.nvim_del_autocmd(au_id)
+ watching[bufnr] = nil
+ log.dbg('unwatched buffer %d', bufnr)
+
+ vim.api.nvim_exec_autocmds('User', {
+ pattern = 'PreviewWatchStopped',
+ data = { bufnr = bufnr },
+ })
+end
+
+---@param bufnr integer
+---@param name string
+---@param provider preview.ProviderConfig
+---@param ctx preview.Context
function M.clean(bufnr, name, provider, ctx)
if not provider.clean then
- vim.notify('[render.nvim] provider "' .. name .. '" has no clean command', vim.log.levels.WARN)
+ vim.notify('[preview.nvim] provider "' .. name .. '" has no clean command', vim.log.levels.WARN)
return
end
@@ -160,27 +217,33 @@ function M.clean(bufnr, name, provider, ctx)
vim.schedule_wrap(function(result)
if result.code == 0 then
log.dbg('clean succeeded for buffer %d', bufnr)
- vim.notify('[render.nvim] clean complete', vim.log.levels.INFO)
+ vim.notify('[preview.nvim] clean complete', vim.log.levels.INFO)
else
log.dbg('clean failed for buffer %d (exit code %d)', bufnr, result.code)
- vim.notify('[render.nvim] clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR)
+ vim.notify('[preview.nvim] clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR)
end
end)
)
end
---@param bufnr integer
----@return render.Status
+---@return preview.Status
function M.status(bufnr)
local proc = active[bufnr]
if proc then
- return { compiling = true, provider = proc.provider, output_file = proc.output_file }
+ return {
+ compiling = true,
+ watching = watching[bufnr] ~= nil,
+ provider = proc.provider,
+ output_file = proc.output_file,
+ }
end
- return { compiling = false }
+ return { compiling = false, watching = watching[bufnr] ~= nil }
end
M._test = {
active = active,
+ watching = watching,
}
return M
diff --git a/lua/render/diagnostic.lua b/lua/preview/diagnostic.lua
similarity index 80%
rename from lua/render/diagnostic.lua
rename to lua/preview/diagnostic.lua
index a526219..d81ec64 100644
--- a/lua/render/diagnostic.lua
+++ b/lua/preview/diagnostic.lua
@@ -1,8 +1,8 @@
local M = {}
-local log = require('render.log')
+local log = require('preview.log')
-local ns = vim.api.nvim_create_namespace('render')
+local ns = vim.api.nvim_create_namespace('preview')
---@param bufnr integer
function M.clear(bufnr)
@@ -12,9 +12,9 @@ end
---@param bufnr integer
---@param name string
----@param error_parser fun(stderr: string, ctx: render.Context): render.Diagnostic[]
+---@param error_parser fun(stderr: string, ctx: preview.Context): preview.Diagnostic[]
---@param stderr string
----@param ctx render.Context
+---@param ctx preview.Context
function M.set(bufnr, name, error_parser, stderr, ctx)
local ok, diagnostics = pcall(error_parser, stderr, ctx)
if not ok then
diff --git a/lua/preview/health.lua b/lua/preview/health.lua
new file mode 100644
index 0000000..251dbfa
--- /dev/null
+++ b/lua/preview/health.lua
@@ -0,0 +1,31 @@
+local M = {}
+
+function M.check()
+ vim.health.start('preview.nvim')
+
+ if vim.fn.has('nvim-0.11.0') == 1 then
+ vim.health.ok('Neovim 0.11.0+ detected')
+ else
+ vim.health.error('preview.nvim requires Neovim 0.11.0+')
+ end
+
+ local config = require('preview').get_config()
+
+ local provider_count = vim.tbl_count(config.providers)
+ if provider_count == 0 then
+ vim.health.warn('no providers configured')
+ else
+ vim.health.ok(provider_count .. ' provider(s) configured')
+ end
+
+ for ft, provider in pairs(config.providers) do
+ local bin = provider.cmd[1]
+ if vim.fn.executable(bin) == 1 then
+ vim.health.ok('filetype "' .. ft .. '": ' .. bin .. ' found')
+ else
+ vim.health.error('filetype "' .. ft .. '": ' .. bin .. ' not found')
+ end
+ end
+end
+
+return M
diff --git a/lua/render/init.lua b/lua/preview/init.lua
similarity index 60%
rename from lua/render/init.lua
rename to lua/preview/init.lua
index 73f3057..1030bf7 100644
--- a/lua/render/init.lua
+++ b/lua/preview/init.lua
@@ -1,24 +1,23 @@
----@class render.ProviderConfig
+---@class preview.ProviderConfig
---@field cmd string[]
----@field args? string[]|fun(ctx: render.Context): string[]
----@field cwd? string|fun(ctx: render.Context): string
+---@field args? string[]|fun(ctx: preview.Context): string[]
+---@field cwd? string|fun(ctx: preview.Context): string
---@field env? table
----@field output? string|fun(ctx: render.Context): string
----@field error_parser? fun(stderr: string, ctx: render.Context): render.Diagnostic[]
----@field clean? string[]|fun(ctx: render.Context): string[]
+---@field output? string|fun(ctx: preview.Context): string
+---@field error_parser? fun(stderr: string, ctx: preview.Context): preview.Diagnostic[]
+---@field clean? string[]|fun(ctx: preview.Context): string[]
----@class render.Config
+---@class preview.Config
---@field debug boolean|string
----@field providers table
----@field providers_by_ft table
+---@field providers table
----@class render.Context
+---@class preview.Context
---@field bufnr integer
---@field file string
---@field root string
---@field ft string
----@class render.Diagnostic
+---@class preview.Diagnostic
---@field lnum integer
---@field col integer
---@field message string
@@ -27,30 +26,30 @@
---@field end_col? integer
---@field source? string
----@class render.Process
+---@class preview.Process
---@field obj table
---@field provider string
---@field output_file string
----@class render
+---@class preview
---@field compile fun(bufnr?: integer)
---@field stop fun(bufnr?: integer)
---@field clean fun(bufnr?: integer)
----@field status fun(bufnr?: integer): render.Status
----@field get_config fun(): render.Config
+---@field watch fun(bufnr?: integer)
+---@field status fun(bufnr?: integer): preview.Status
+---@field get_config fun(): preview.Config
local M = {}
-local compiler = require('render.compiler')
-local log = require('render.log')
+local compiler = require('preview.compiler')
+local log = require('preview.log')
----@type render.Config
+---@type preview.Config
local default_config = {
debug = false,
providers = {},
- providers_by_ft = {},
}
----@type render.Config
+---@type preview.Config
local config = vim.deepcopy(default_config)
local initialized = false
@@ -61,17 +60,14 @@ local function init()
end
initialized = true
- local opts = vim.g.render or {}
+ local opts = vim.g.preview or {}
- vim.validate('render config', opts, 'table')
+ vim.validate('preview config', opts, 'table')
if opts.debug ~= nil then
- vim.validate('render config.debug', opts.debug, { 'boolean', 'string' })
+ vim.validate('preview config.debug', opts.debug, { 'boolean', 'string' })
end
if opts.providers ~= nil then
- vim.validate('render config.providers', opts.providers, 'table')
- end
- if opts.providers_by_ft ~= nil then
- vim.validate('render config.providers_by_ft', opts.providers_by_ft, 'table')
+ vim.validate('preview config.providers', opts.providers, 'table')
end
config = vim.tbl_deep_extend('force', default_config, opts)
@@ -79,7 +75,7 @@ local function init()
log.dbg('initialized with %d providers', vim.tbl_count(config.providers))
end
----@return render.Config
+---@return preview.Config
function M.get_config()
init()
return config
@@ -91,20 +87,15 @@ function M.resolve_provider(bufnr)
init()
bufnr = bufnr or vim.api.nvim_get_current_buf()
local ft = vim.bo[bufnr].filetype
- local name = config.providers_by_ft[ft]
- if not name then
- log.dbg('no provider mapped for filetype: %s', ft)
+ if not config.providers[ft] then
+ log.dbg('no provider configured for filetype: %s', ft)
return nil
end
- if not config.providers[name] then
- log.dbg('provider "%s" mapped for ft "%s" but not configured', name, ft)
- return nil
- end
- return name
+ return ft
end
---@param bufnr? integer
----@return render.Context
+---@return preview.Context
function M.build_context(bufnr)
init()
bufnr = bufnr or vim.api.nvim_get_current_buf()
@@ -124,7 +115,7 @@ function M.compile(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local name = M.resolve_provider(bufnr)
if not name then
- vim.notify('[render.nvim] no provider configured for this filetype', vim.log.levels.WARN)
+ vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN)
return
end
local provider = config.providers[name]
@@ -145,7 +136,7 @@ function M.clean(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local name = M.resolve_provider(bufnr)
if not name then
- vim.notify('[render.nvim] no provider configured for this filetype', vim.log.levels.WARN)
+ vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN)
return
end
local provider = config.providers[name]
@@ -153,13 +144,27 @@ function M.clean(bufnr)
compiler.clean(bufnr, name, provider, ctx)
end
----@class render.Status
+---@param bufnr? integer
+function M.watch(bufnr)
+ init()
+ bufnr = bufnr or vim.api.nvim_get_current_buf()
+ local name = M.resolve_provider(bufnr)
+ if not name then
+ vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN)
+ return
+ end
+ local provider = config.providers[name]
+ compiler.watch(bufnr, name, provider, M.build_context)
+end
+
+---@class preview.Status
---@field compiling boolean
+---@field watching boolean
---@field provider? string
---@field output_file? string
---@param bufnr? integer
----@return render.Status
+---@return preview.Status
function M.status(bufnr)
init()
bufnr = bufnr or vim.api.nvim_get_current_buf()
diff --git a/lua/render/log.lua b/lua/preview/log.lua
similarity index 90%
rename from lua/render/log.lua
rename to lua/preview/log.lua
index 3896978..6b6d5c9 100644
--- a/lua/render/log.lua
+++ b/lua/preview/log.lua
@@ -20,7 +20,7 @@ function M.dbg(msg, ...)
if not enabled then
return
end
- local formatted = '[render.nvim]: ' .. string.format(msg, ...)
+ local formatted = '[preview.nvim]: ' .. string.format(msg, ...)
if log_file then
local f = io.open(log_file, 'a')
if f then
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
new file mode 100644
index 0000000..124d00e
--- /dev/null
+++ b/lua/preview/presets.lua
@@ -0,0 +1,40 @@
+local M = {}
+
+---@type preview.ProviderConfig
+M.typst = {
+ cmd = { 'typst', 'compile' },
+ args = function(ctx)
+ return { ctx.file }
+ end,
+ output = function(ctx)
+ return ctx.file:gsub('%.typ$', '.pdf')
+ end,
+}
+
+---@type preview.ProviderConfig
+M.latex = {
+ cmd = { 'latexmk' },
+ args = function(ctx)
+ return { '-pdf', '-interaction=nonstopmode', ctx.file }
+ end,
+ output = function(ctx)
+ return ctx.file:gsub('%.tex$', '.pdf')
+ end,
+ clean = function(ctx)
+ return { 'latexmk', '-c', ctx.file }
+ end,
+}
+
+---@type preview.ProviderConfig
+M.markdown = {
+ cmd = { 'pandoc' },
+ args = function(ctx)
+ local output = ctx.file:gsub('%.md$', '.pdf')
+ return { ctx.file, '-o', output }
+ end,
+ output = function(ctx)
+ return ctx.file:gsub('%.md$', '.pdf')
+ end,
+}
+
+return M
diff --git a/lua/render/commands.lua b/lua/render/commands.lua
deleted file mode 100644
index 925c204..0000000
--- a/lua/render/commands.lua
+++ /dev/null
@@ -1,47 +0,0 @@
-local M = {}
-
-local subcommands = { 'compile', 'stop', 'clean', 'status' }
-
----@param args string
-local function dispatch(args)
- local subcmd = args ~= '' and args or 'compile'
-
- if subcmd == 'compile' then
- require('render').compile()
- elseif subcmd == 'stop' then
- require('render').stop()
- elseif subcmd == 'clean' then
- require('render').clean()
- elseif subcmd == 'status' then
- local s = require('render').status()
- if s.compiling then
- vim.notify('[render.nvim] compiling with "' .. s.provider .. '"', vim.log.levels.INFO)
- else
- vim.notify('[render.nvim] idle', vim.log.levels.INFO)
- end
- else
- vim.notify('[render.nvim] unknown subcommand: ' .. subcmd, vim.log.levels.ERROR)
- end
-end
-
----@param lead string
----@return string[]
-local function complete(lead)
- return vim.tbl_filter(function(s)
- return s:find(lead, 1, true) == 1
- end, subcommands)
-end
-
-function M.setup()
- vim.api.nvim_create_user_command('Render', function(opts)
- dispatch(opts.args)
- end, {
- nargs = '?',
- complete = function(lead)
- return complete(lead)
- end,
- desc = 'Compile, stop, clean, or check status of document rendering',
- })
-end
-
-return M
diff --git a/lua/render/health.lua b/lua/render/health.lua
deleted file mode 100644
index 25623a5..0000000
--- a/lua/render/health.lua
+++ /dev/null
@@ -1,42 +0,0 @@
-local M = {}
-
-function M.check()
- vim.health.start('render.nvim')
-
- if vim.fn.has('nvim-0.10.0') == 1 then
- vim.health.ok('Neovim 0.10.0+ detected')
- else
- vim.health.error('render.nvim requires Neovim 0.10.0+')
- end
-
- local config = require('render').get_config()
-
- local provider_count = vim.tbl_count(config.providers)
- if provider_count == 0 then
- vim.health.warn('no providers configured')
- else
- vim.health.ok(provider_count .. ' provider(s) configured')
- end
-
- for name, provider in pairs(config.providers) do
- local bin = provider.cmd[1]
- if vim.fn.executable(bin) == 1 then
- vim.health.ok('provider "' .. name .. '": ' .. bin .. ' found')
- else
- vim.health.error('provider "' .. name .. '": ' .. bin .. ' not found')
- end
- end
-
- local ft_count = vim.tbl_count(config.providers_by_ft)
- if ft_count > 0 then
- for ft, name in pairs(config.providers_by_ft) do
- if config.providers[name] then
- vim.health.ok('filetype "' .. ft .. '" -> provider "' .. name .. '"')
- else
- vim.health.error('filetype "' .. ft .. '" maps to unknown provider "' .. name .. '"')
- end
- end
- end
-end
-
-return M
diff --git a/plugin/preview.lua b/plugin/preview.lua
new file mode 100644
index 0000000..eefacf8
--- /dev/null
+++ b/plugin/preview.lua
@@ -0,0 +1,12 @@
+if vim.g.loaded_preview then
+ return
+end
+vim.g.loaded_preview = 1
+
+require('preview.commands').setup()
+
+vim.api.nvim_create_autocmd('VimLeavePre', {
+ callback = function()
+ require('preview.compiler').stop_all()
+ end,
+})
diff --git a/plugin/render.lua b/plugin/render.lua
deleted file mode 100644
index 6bcb837..0000000
--- a/plugin/render.lua
+++ /dev/null
@@ -1,12 +0,0 @@
-if vim.g.loaded_render then
- return
-end
-vim.g.loaded_render = 1
-
-require('render.commands').setup()
-
-vim.api.nvim_create_autocmd('VimLeavePre', {
- callback = function()
- require('render.compiler').stop_all()
- end,
-})
diff --git a/render.nvim-scm-1.rockspec b/preview.nvim-scm-1.rockspec
similarity index 67%
rename from render.nvim-scm-1.rockspec
rename to preview.nvim-scm-1.rockspec
index 0233c56..44064e8 100644
--- a/render.nvim-scm-1.rockspec
+++ b/preview.nvim-scm-1.rockspec
@@ -1,14 +1,14 @@
rockspec_format = '3.0'
-package = 'render.nvim'
+package = 'preview.nvim'
version = 'scm-1'
source = {
- url = 'git+https://github.com/barrettruth/render.nvim.git',
+ url = 'git+https://github.com/barrettruth/preview.nvim.git',
}
description = {
summary = 'Async document compilation for Neovim',
- homepage = 'https://github.com/barrettruth/render.nvim',
+ homepage = 'https://github.com/barrettruth/preview.nvim',
license = 'MIT',
}
diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua
index 87b251c..c0615d1 100644
--- a/spec/commands_spec.lua
+++ b/spec/commands_spec.lua
@@ -6,32 +6,39 @@ describe('commands', function()
end)
describe('setup', function()
- it('creates the :Render command', function()
- require('render.commands').setup()
+ it('creates the :Preview command', function()
+ require('preview.commands').setup()
local cmds = vim.api.nvim_get_commands({})
- assert.is_not_nil(cmds.Render)
+ assert.is_not_nil(cmds.Preview)
end)
end)
describe('dispatch', function()
- it('does not error on :Render with no provider', function()
- require('render.commands').setup()
+ it('does not error on :Preview with no provider', function()
+ require('preview.commands').setup()
assert.has_no.errors(function()
- vim.cmd('Render compile')
+ vim.cmd('Preview compile')
end)
end)
- it('does not error on :Render stop', function()
- require('render.commands').setup()
+ it('does not error on :Preview stop', function()
+ require('preview.commands').setup()
assert.has_no.errors(function()
- vim.cmd('Render stop')
+ vim.cmd('Preview stop')
end)
end)
- it('does not error on :Render status', function()
- require('render.commands').setup()
+ it('does not error on :Preview status', function()
+ require('preview.commands').setup()
assert.has_no.errors(function()
- vim.cmd('Render status')
+ vim.cmd('Preview status')
+ end)
+ end)
+
+ it('does not error on :Preview watch with no provider', function()
+ require('preview.commands').setup()
+ assert.has_no.errors(function()
+ vim.cmd('Preview watch')
end)
end)
end)
diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua
index 19a701c..23ed118 100644
--- a/spec/compiler_spec.lua
+++ b/spec/compiler_spec.lua
@@ -5,19 +5,19 @@ describe('compiler', function()
before_each(function()
helpers.reset_config()
- compiler = require('render.compiler')
+ compiler = require('preview.compiler')
end)
describe('compile', function()
it('spawns a process and tracks it in active table', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
- vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test.txt')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test.txt')
vim.bo[bufnr].modified = false
local provider = { cmd = { 'echo', 'ok' } }
local ctx = {
bufnr = bufnr,
- file = '/tmp/render_test.txt',
+ file = '/tmp/preview_test.txt',
root = '/tmp',
ft = 'text',
}
@@ -35,14 +35,14 @@ describe('compiler', function()
helpers.delete_buffer(bufnr)
end)
- it('fires RenderCompileStarted event', function()
+ it('fires PreviewCompileStarted event', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
- vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_event.txt')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_event.txt')
vim.bo[bufnr].modified = false
local fired = false
vim.api.nvim_create_autocmd('User', {
- pattern = 'RenderCompileStarted',
+ pattern = 'PreviewCompileStarted',
once = true,
callback = function()
fired = true
@@ -52,7 +52,7 @@ describe('compiler', function()
local provider = { cmd = { 'echo', 'ok' } }
local ctx = {
bufnr = bufnr,
- file = '/tmp/render_test_event.txt',
+ file = '/tmp/preview_test_event.txt',
root = '/tmp',
ft = 'text',
}
@@ -67,14 +67,14 @@ describe('compiler', function()
helpers.delete_buffer(bufnr)
end)
- it('fires RenderCompileSuccess on exit code 0', function()
+ it('fires PreviewCompileSuccess on exit code 0', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
- vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_success.txt')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_success.txt')
vim.bo[bufnr].modified = false
local succeeded = false
vim.api.nvim_create_autocmd('User', {
- pattern = 'RenderCompileSuccess',
+ pattern = 'PreviewCompileSuccess',
once = true,
callback = function()
succeeded = true
@@ -84,7 +84,7 @@ describe('compiler', function()
local provider = { cmd = { 'true' } }
local ctx = {
bufnr = bufnr,
- file = '/tmp/render_test_success.txt',
+ file = '/tmp/preview_test_success.txt',
root = '/tmp',
ft = 'text',
}
@@ -99,14 +99,14 @@ describe('compiler', function()
helpers.delete_buffer(bufnr)
end)
- it('fires RenderCompileFailed on non-zero exit', function()
+ it('fires PreviewCompileFailed on non-zero exit', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
- vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_fail.txt')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_fail.txt')
vim.bo[bufnr].modified = false
local failed = false
vim.api.nvim_create_autocmd('User', {
- pattern = 'RenderCompileFailed',
+ pattern = 'PreviewCompileFailed',
once = true,
callback = function()
failed = true
@@ -116,7 +116,7 @@ describe('compiler', function()
local provider = { cmd = { 'false' } }
local ctx = {
bufnr = bufnr,
- file = '/tmp/render_test_fail.txt',
+ file = '/tmp/preview_test_fail.txt',
root = '/tmp',
ft = 'text',
}
@@ -148,13 +148,13 @@ describe('compiler', function()
it('returns compiling during active process', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
- vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_status.txt')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_status.txt')
vim.bo[bufnr].modified = false
local provider = { cmd = { 'sleep', '10' } }
local ctx = {
bufnr = bufnr,
- file = '/tmp/render_test_status.txt',
+ file = '/tmp/preview_test_status.txt',
root = '/tmp',
ft = 'text',
}
@@ -173,4 +173,133 @@ describe('compiler', function()
helpers.delete_buffer(bufnr)
end)
end)
+
+ describe('watch', function()
+ it('registers autocmd and tracks in watching table', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch.txt')
+
+ local provider = { cmd = { 'echo', 'ok' } }
+ local ctx_builder = function(b)
+ return { bufnr = b, file = '/tmp/preview_test_watch.txt', root = '/tmp', ft = 'text' }
+ end
+
+ compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ assert.is_not_nil(compiler._test.watching[bufnr])
+
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('fires PreviewWatchStarted event', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_event.txt')
+
+ local fired = false
+ vim.api.nvim_create_autocmd('User', {
+ pattern = 'PreviewWatchStarted',
+ once = true,
+ callback = function()
+ fired = true
+ end,
+ })
+
+ local provider = { cmd = { 'echo', 'ok' } }
+ local ctx_builder = function(b)
+ return { bufnr = b, file = '/tmp/preview_test_watch_event.txt', root = '/tmp', ft = 'text' }
+ end
+
+ compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ assert.is_true(fired)
+
+ compiler.unwatch(bufnr)
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('toggles off when called again', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_toggle.txt')
+
+ local provider = { cmd = { 'echo', 'ok' } }
+ local ctx_builder = function(b)
+ return { bufnr = b, file = '/tmp/preview_test_watch_toggle.txt', root = '/tmp', ft = 'text' }
+ end
+
+ compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ assert.is_not_nil(compiler._test.watching[bufnr])
+
+ compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ assert.is_nil(compiler._test.watching[bufnr])
+
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('fires PreviewWatchStopped on unwatch', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_stop.txt')
+
+ local stopped = false
+ vim.api.nvim_create_autocmd('User', {
+ pattern = 'PreviewWatchStopped',
+ once = true,
+ callback = function()
+ stopped = true
+ end,
+ })
+
+ local provider = { cmd = { 'echo', 'ok' } }
+ local ctx_builder = function(b)
+ return { bufnr = b, file = '/tmp/preview_test_watch_stop.txt', root = '/tmp', ft = 'text' }
+ end
+
+ compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ compiler.unwatch(bufnr)
+ assert.is_true(stopped)
+ assert.is_nil(compiler._test.watching[bufnr])
+
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('stop_all clears watches', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_stopall.txt')
+
+ local provider = { cmd = { 'echo', 'ok' } }
+ local ctx_builder = function(b)
+ return {
+ bufnr = b,
+ file = '/tmp/preview_test_watch_stopall.txt',
+ root = '/tmp',
+ ft = 'text',
+ }
+ end
+
+ compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ assert.is_not_nil(compiler._test.watching[bufnr])
+
+ compiler.stop_all()
+ assert.is_nil(compiler._test.watching[bufnr])
+
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('status includes watching state', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_status.txt')
+
+ local s = compiler.status(bufnr)
+ assert.is_false(s.watching)
+
+ local provider = { cmd = { 'echo', 'ok' } }
+ local ctx_builder = function(b)
+ return { bufnr = b, file = '/tmp/preview_test_watch_status.txt', root = '/tmp', ft = 'text' }
+ end
+
+ compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ s = compiler.status(bufnr)
+ assert.is_true(s.watching)
+
+ compiler.unwatch(bufnr)
+ helpers.delete_buffer(bufnr)
+ end)
+ end)
end)
diff --git a/spec/diagnostic_spec.lua b/spec/diagnostic_spec.lua
index fba0a01..05ccc6d 100644
--- a/spec/diagnostic_spec.lua
+++ b/spec/diagnostic_spec.lua
@@ -5,7 +5,7 @@ describe('diagnostic', function()
before_each(function()
helpers.reset_config()
- diagnostic = require('render.diagnostic')
+ diagnostic = require('preview.diagnostic')
end)
describe('clear', function()
diff --git a/spec/helpers.lua b/spec/helpers.lua
index 605f3b5..9790250 100644
--- a/spec/helpers.lua
+++ b/spec/helpers.lua
@@ -20,8 +20,8 @@ function M.delete_buffer(bufnr)
end
function M.reset_config(opts)
- vim.g.render = opts
- require('render')._test.reset()
+ vim.g.preview = opts
+ require('preview')._test.reset()
end
return M
diff --git a/spec/init_spec.lua b/spec/init_spec.lua
index 1005e16..3316b63 100644
--- a/spec/init_spec.lua
+++ b/spec/init_spec.lua
@@ -1,30 +1,29 @@
local helpers = require('spec.helpers')
-describe('render', function()
- local render
+describe('preview', function()
+ local preview
before_each(function()
helpers.reset_config()
- render = require('render')
+ preview = require('preview')
end)
describe('config', function()
it('accepts nil config', function()
assert.has_no.errors(function()
- render.get_config()
+ preview.get_config()
end)
end)
it('applies default values', function()
- local config = render.get_config()
+ local config = preview.get_config()
assert.is_false(config.debug)
assert.are.same({}, config.providers)
- assert.are.same({}, config.providers_by_ft)
end)
it('merges user config with defaults', function()
helpers.reset_config({ debug = true })
- local config = require('render').get_config()
+ local config = require('preview').get_config()
assert.is_true(config.debug)
assert.are.same({}, config.providers)
end)
@@ -37,13 +36,9 @@ describe('render', function()
args = { '%s' },
},
},
- providers_by_ft = {
- typst = 'typst',
- },
})
- local config = require('render').get_config()
+ local config = require('preview').get_config()
assert.is_not_nil(config.providers.typst)
- assert.are.equal('typst', config.providers_by_ft.typst)
end)
end)
@@ -53,34 +48,20 @@ describe('render', function()
providers = {
typst = { cmd = { 'typst', 'compile' } },
},
- providers_by_ft = {
- typst = 'typst',
- },
})
- render = require('render')
+ preview = require('preview')
end)
- it('returns provider name for mapped filetype', function()
+ it('returns filetype when provider exists', function()
local bufnr = helpers.create_buffer({}, 'typst')
- local name = render.resolve_provider(bufnr)
+ local name = preview.resolve_provider(bufnr)
assert.are.equal('typst', name)
helpers.delete_buffer(bufnr)
end)
- it('returns nil for unmapped filetype', function()
+ it('returns nil for unconfigured filetype', function()
local bufnr = helpers.create_buffer({}, 'lua')
- local name = render.resolve_provider(bufnr)
- assert.is_nil(name)
- helpers.delete_buffer(bufnr)
- end)
-
- it('returns nil when provider name maps to missing config', function()
- helpers.reset_config({
- providers = {},
- providers_by_ft = { typst = 'typst' },
- })
- local bufnr = helpers.create_buffer({}, 'typst')
- local name = require('render').resolve_provider(bufnr)
+ local name = preview.resolve_provider(bufnr)
assert.is_nil(name)
helpers.delete_buffer(bufnr)
end)
@@ -89,7 +70,7 @@ describe('render', function()
describe('build_context', function()
it('builds context from buffer', function()
local bufnr = helpers.create_buffer({}, 'typst')
- local ctx = render.build_context(bufnr)
+ local ctx = preview.build_context(bufnr)
assert.are.equal(bufnr, ctx.bufnr)
assert.are.equal('typst', ctx.ft)
assert.is_string(ctx.file)
@@ -101,7 +82,7 @@ describe('render', function()
describe('status', function()
it('returns idle when nothing is compiling', function()
local bufnr = helpers.create_buffer({})
- local s = render.status(bufnr)
+ local s = preview.status(bufnr)
assert.is_false(s.compiling)
assert.is_nil(s.provider)
helpers.delete_buffer(bufnr)
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
new file mode 100644
index 0000000..7f137cb
--- /dev/null
+++ b/spec/presets_spec.lua
@@ -0,0 +1,88 @@
+describe('presets', function()
+ local presets
+
+ before_each(function()
+ presets = require('preview.presets')
+ end)
+
+ local ctx = {
+ bufnr = 1,
+ file = '/tmp/document.typ',
+ root = '/tmp',
+ ft = 'typst',
+ }
+
+ describe('typst', function()
+ it('has cmd', function()
+ assert.are.same({ 'typst', 'compile' }, presets.typst.cmd)
+ end)
+
+ it('returns args with file path', function()
+ local args = presets.typst.args(ctx)
+ assert.is_table(args)
+ assert.are.same({ '/tmp/document.typ' }, args)
+ end)
+
+ it('returns pdf output path', function()
+ local output = presets.typst.output(ctx)
+ assert.is_string(output)
+ assert.are.equal('/tmp/document.pdf', output)
+ end)
+ end)
+
+ describe('latex', function()
+ local tex_ctx = {
+ bufnr = 1,
+ file = '/tmp/document.tex',
+ root = '/tmp',
+ ft = 'tex',
+ }
+
+ it('has cmd', function()
+ assert.are.same({ 'latexmk' }, presets.latex.cmd)
+ end)
+
+ it('returns args with pdf flag and file path', function()
+ local args = presets.latex.args(tex_ctx)
+ assert.is_table(args)
+ assert.are.same({ '-pdf', '-interaction=nonstopmode', '/tmp/document.tex' }, args)
+ end)
+
+ it('returns pdf output path', function()
+ local output = presets.latex.output(tex_ctx)
+ assert.is_string(output)
+ assert.are.equal('/tmp/document.pdf', output)
+ end)
+
+ it('returns clean command', function()
+ local clean = presets.latex.clean(tex_ctx)
+ assert.is_table(clean)
+ assert.are.same({ 'latexmk', '-c', '/tmp/document.tex' }, clean)
+ end)
+ end)
+
+ describe('markdown', function()
+ local md_ctx = {
+ bufnr = 1,
+ file = '/tmp/document.md',
+ root = '/tmp',
+ ft = 'markdown',
+ }
+
+ it('has cmd', function()
+ assert.are.same({ 'pandoc' }, presets.markdown.cmd)
+ end)
+
+ it('returns args with file and output flag', function()
+ local args = presets.markdown.args(md_ctx)
+ assert.is_table(args)
+ assert.are.same({ '/tmp/document.md', '-o', '/tmp/document.pdf' }, args)
+ end)
+
+ it('returns pdf output path', function()
+ local output = presets.markdown.output(md_ctx)
+ assert.is_string(output)
+ assert.are.equal('/tmp/document.pdf', output)
+ end)
+ end)
+end)
From c62c930454cdc10bcef961b490cda70b8e756496 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Mon, 2 Mar 2026 21:30:11 -0500
Subject: [PATCH 02/49] ci: format
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index c373df2..eefe9a6 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,8 @@ your own providers.
- Async compilation via `vim.system()`
- Compiler errors as native `vim.diagnostic`
-- User events for extensibility (`PreviewCompileStarted`, `PreviewCompileSuccess`,
- `PreviewCompileFailed`)
+- User events for extensibility (`PreviewCompileStarted`,
+ `PreviewCompileSuccess`, `PreviewCompileFailed`)
- `:checkhealth` integration
- Zero dependencies beyond Neovim 0.11.0+
From 673573044f1ba2ef840060b25e3187565d9382b3 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Mon, 2 Mar 2026 23:37:44 -0500
Subject: [PATCH 03/49] =?UTF-8?q?feat:=20rename=20watch=20=E2=86=92=20togg?=
=?UTF-8?q?le,=20auto-compile=20on=20start,=20built-in=20opener?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Problem: :Preview watch only registered a BufWritePost autocmd without
compiling immediately, required boilerplate to open output files after
first compilation, and was misleadingly named.
Solution: Rename watch → toggle throughout. M.toggle now compiles
immediately on activation. Add an open field to ProviderConfig: true
calls vim.ui.open(), a string[] runs the command with the output path
appended, tracked per-buffer so the file opens only once. All presets
default to { 'xdg-open' }. Health check validates opener binaries.
Guard the async compile callback against invalid buffer ids.
---
README.md | 102 ++++++++++++++++++---------------
doc/preview.nvim.txt | 120 +++++++++++++++++++--------------------
lua/preview/commands.lua | 8 +--
lua/preview/compiler.lua | 22 ++++++-
lua/preview/health.lua | 8 +++
lua/preview/init.lua | 63 ++++++++++----------
lua/preview/presets.lua | 40 ++++++++++++-
spec/commands_spec.lua | 4 +-
spec/compiler_spec.lua | 16 +++---
spec/helpers.lua | 7 ++-
spec/init_spec.lua | 44 ++++++++------
spec/presets_spec.lua | 88 ++++++++++++++++++++++++++--
12 files changed, 346 insertions(+), 176 deletions(-)
diff --git a/README.md b/README.md
index eefe9a6..dcdf689 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,9 @@
# preview.nvim
-Async document compilation for Neovim.
+**Async document compilation for Neovim**
-A framework for compiling documents (LaTeX, Typst, Markdown, etc.)
-asynchronously with error diagnostics. Ships with zero defaults — you configure
-your own providers.
+An extensible framework for compiling documents (LaTeX, Typst, Markdown, etc.)
+asynchronously with error diagnostics.
## Features
@@ -12,59 +11,68 @@ your own providers.
- Compiler errors as native `vim.diagnostic`
- User events for extensibility (`PreviewCompileStarted`,
`PreviewCompileSuccess`, `PreviewCompileFailed`)
+- Built-in presets for Typst, LaTeX, Markdown, and GitHub-flavored Markdown
- `:checkhealth` integration
- Zero dependencies beyond Neovim 0.11.0+
## Requirements
-- Neovim >= 0.11.0
-- A compiler binary for each provider you configure
+- Neovim 0.11.0+
## Installation
-```lua
--- lazy.nvim
-{ 'barrettruth/preview.nvim' }
+Install with your package manager of choice or via
+[luarocks](https://luarocks.org/modules/barrettruth/preview.nvim):
+
```
-
-```vim
-" luarocks
-:Rocks install preview.nvim
-```
-
-## Configuration
-
-Use built-in presets for common tools:
-
-```lua
-local presets = require('preview.presets')
-vim.g.preview = {
- providers = {
- typst = presets.typst,
- tex = presets.latex,
- markdown = presets.markdown,
- },
-}
-```
-
-Or define providers manually:
-
-```lua
-vim.g.preview = {
- providers = {
- typst = {
- cmd = { 'typst', 'compile' },
- args = function(ctx)
- return { ctx.file }
- end,
- output = function(ctx)
- return ctx.file:gsub('%.typ$', '.pdf')
- end,
- },
- },
-}
+luarocks install preview.nvim
```
## Documentation
-See `:help preview.nvim` for full documentation.
+```vim
+:help preview.nvim
+```
+
+## FAQ
+
+**Q: How do I define a custom provider?**
+
+```lua
+require('preview').setup({
+ typst = {
+ cmd = { 'typst', 'compile' },
+ args = function(ctx)
+ return { ctx.file }
+ end,
+ output = function(ctx)
+ return ctx.file:gsub('%.typ$', '.pdf')
+ end,
+ },
+})
+```
+
+**Q: How do I override a preset?**
+
+```lua
+local presets = require('preview.presets')
+require('preview').setup({
+ typst = vim.tbl_deep_extend('force', presets.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.
+For a specific application, pass a command table:
+
+```lua
+typst = vim.tbl_deep_extend('force', presets.typst, {
+ open = { 'sioyek', '--new-instance' },
+})
+```
+
+See `:h preview.nvim` for more information.
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index d48daf6..3ed587a 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -37,18 +37,21 @@ With lazy.nvim:
==============================================================================
CONFIGURATION *preview.nvim-configuration*
-Configure via the `vim.g.preview` global table before the plugin loads.
+Configure via `require('preview').setup()`.
- *preview.Config*
-Fields:~
+ *preview.setup()*
+setup({opts?})
+
+ `opts` is a mixed table. Array entries are preset names (see
+ |preview.nvim-presets|). Hash entries with table values are custom
+ provider configs keyed by filetype.
+
+ Fields:~
`debug` boolean|string Enable debug logging. A string value
is treated as a log file path.
Default: `false`
- `providers` table Provider configurations keyed by
- filetype. Default: `{}`
-
*preview.ProviderConfig*
Provider fields:~
@@ -74,6 +77,12 @@ Provider fields:~
If a function, receives a
|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.
+ Presets default to `{ 'xdg-open' }`.
+
*preview.Context*
Context fields:~
@@ -82,38 +91,36 @@ Context fields:~
`root` string Project root (git root or file directory).
`ft` string Filetype.
-Example:~
+Example using preset names:~
>lua
- vim.g.preview = {
- providers = {
- typst = {
- cmd = { 'typst', 'compile' },
- args = function(ctx)
- return { ctx.file }
- end,
- output = function(ctx)
- return ctx.file:gsub('%.typ$', '.pdf')
- end,
- error_parser = function(stderr, ctx)
- local diagnostics = {}
- for line, col, msg in stderr:gmatch('error:.-(%d+):(%d+):%s*(.-)%\n') do
- table.insert(diagnostics, {
- lnum = tonumber(line) - 1,
- col = tonumber(col) - 1,
- message = msg,
- severity = vim.diagnostic.severity.ERROR,
- })
- end
- return diagnostics
- end,
- },
- tex = {
- cmd = { 'latexmk' },
- args = { '-pdf', '-interaction=nonstopmode' },
- clean = { 'latexmk', '-c' },
- },
+ require('preview').setup({ 'typst', 'latex', 'markdown' })
+<
+
+Example with a custom provider:~
+>lua
+ require('preview').setup({
+ typst = {
+ cmd = { 'typst', 'compile' },
+ args = function(ctx)
+ return { ctx.file }
+ end,
+ output = function(ctx)
+ return ctx.file:gsub('%.typ$', '.pdf')
+ end,
+ error_parser = function(stderr, ctx)
+ local diagnostics = {}
+ for line, col, msg in stderr:gmatch('error:.-(%d+):(%d+):%s*(.-)%\n') do
+ table.insert(diagnostics, {
+ lnum = tonumber(line) - 1,
+ col = tonumber(col) - 1,
+ message = msg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ end
+ return diagnostics
+ end,
},
- }
+ })
<
==============================================================================
@@ -122,32 +129,24 @@ 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.markdown` pandoc → PDF
+ `presets.typst` typst compile → PDF
+ `presets.latex` latexmk -pdf → PDF (with clean support)
+ `presets.markdown` pandoc → HTML (standalone, embedded)
+ `presets.github` pandoc → HTML (GitHub-styled)
-Example:~
+Pass preset names as array entries to `setup()`:
>lua
- local presets = require('preview.presets')
- vim.g.preview = {
- providers = {
- typst = presets.typst,
- tex = presets.latex,
- markdown = presets.markdown,
- },
- }
+ require('preview').setup({ 'typst', 'latex', 'markdown' })
<
-Override individual fields with `vim.tbl_deep_extend`:
+Override individual fields using `vim.tbl_deep_extend`:
>lua
local presets = require('preview.presets')
- vim.g.preview = {
- providers = {
- typst = vim.tbl_deep_extend('force', presets.typst, {
- env = { TYPST_FONT_PATHS = '/usr/share/fonts' },
- }),
- },
- }
+ require('preview').setup({
+ typst = vim.tbl_deep_extend('force', presets.typst, {
+ env = { TYPST_FONT_PATHS = '/usr/share/fonts' },
+ }),
+ })
<
==============================================================================
@@ -160,7 +159,7 @@ COMMANDS *preview.nvim-commands*
`compile` Compile the current buffer (default if omitted).
`stop` Kill active compilation for the current buffer.
`clean` Run the provider's clean command.
- `watch` Toggle auto-compile on save for the current buffer.
+ `toggle` Toggle auto-compile on save for the current buffer.
`status` Echo compilation status (idle, compiling, watching).
==============================================================================
@@ -175,10 +174,10 @@ preview.stop({bufnr?}) *preview.stop()*
preview.clean({bufnr?}) *preview.clean()*
Run the provider's clean command for the buffer.
-preview.watch({bufnr?}) *preview.watch()*
+preview.toggle({bufnr?}) *preview.toggle()*
Toggle watch mode for the buffer. When enabled, the buffer is
- automatically compiled on each save (`BufWritePost`). Call again
- to stop watching.
+ immediately compiled and automatically recompiled on each save
+ (`BufWritePost`). Call again to stop watching.
preview.status({bufnr?}) *preview.status()*
Returns a |preview.Status| table.
@@ -232,6 +231,7 @@ 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
==============================================================================
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index 0545a86..8e08236 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -1,6 +1,6 @@
local M = {}
-local subcommands = { 'compile', 'stop', 'clean', 'watch', 'status' }
+local subcommands = { 'compile', 'stop', 'clean', 'toggle', 'status' }
---@param args string
local function dispatch(args)
@@ -12,8 +12,8 @@ local function dispatch(args)
require('preview').stop()
elseif subcmd == 'clean' then
require('preview').clean()
- elseif subcmd == 'watch' then
- require('preview').watch()
+ elseif subcmd == 'toggle' then
+ require('preview').toggle()
elseif subcmd == 'status' then
local s = require('preview').status()
local parts = {}
@@ -47,7 +47,7 @@ function M.setup()
complete = function(lead)
return complete(lead)
end,
- desc = 'Compile, stop, clean, watch, or check status of document preview',
+ desc = 'Compile, stop, clean, toggle, or check status of document preview',
})
end
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index dc1a564..6907524 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -9,6 +9,9 @@ local active = {}
---@type table
local watching = {}
+---@type table
+local opened = {}
+
---@param val string[]|fun(ctx: preview.Context): string[]
---@param ctx preview.Context
---@return string[]
@@ -68,6 +71,9 @@ function M.compile(bufnr, name, provider, ctx)
},
vim.schedule_wrap(function(result)
active[bufnr] = nil
+ if not vim.api.nvim_buf_is_valid(bufnr) then
+ return
+ end
if result.code == 0 then
log.dbg('compilation succeeded for buffer %d', bufnr)
@@ -76,6 +82,16 @@ function M.compile(bufnr, name, provider, ctx)
pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file },
})
+ if provider.open and not opened[bufnr] and 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
+ opened[bufnr] = true
+ end
else
log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code)
if provider.error_parser then
@@ -146,7 +162,7 @@ end
---@param name string
---@param provider preview.ProviderConfig
---@param ctx_builder fun(bufnr: integer): preview.Context
-function M.watch(bufnr, name, provider, ctx_builder)
+function M.toggle(bufnr, name, provider, ctx_builder)
if watching[bufnr] then
M.unwatch(bufnr)
return
@@ -168,6 +184,7 @@ function M.watch(bufnr, name, provider, ctx_builder)
once = true,
callback = function()
M.unwatch(bufnr)
+ opened[bufnr] = nil
end,
})
@@ -175,6 +192,8 @@ function M.watch(bufnr, name, provider, ctx_builder)
pattern = 'PreviewWatchStarted',
data = { bufnr = bufnr, provider = name },
})
+
+ M.compile(bufnr, name, provider, ctx_builder(bufnr))
end
---@param bufnr integer
@@ -244,6 +263,7 @@ end
M._test = {
active = active,
watching = watching,
+ opened = opened,
}
return M
diff --git a/lua/preview/health.lua b/lua/preview/health.lua
index 251dbfa..196100d 100644
--- a/lua/preview/health.lua
+++ b/lua/preview/health.lua
@@ -25,6 +25,14 @@ function M.check()
else
vim.health.error('filetype "' .. ft .. '": ' .. bin .. ' not found')
end
+ if type(provider.open) == 'table' then
+ local opener = provider.open[1]
+ if vim.fn.executable(opener) == 1 then
+ vim.health.ok('filetype "' .. ft .. '": opener ' .. opener .. ' found')
+ else
+ vim.health.error('filetype "' .. ft .. '": opener ' .. opener .. ' not found')
+ end
+ end
end
end
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index 1030bf7..968b201 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -1,4 +1,5 @@
---@class preview.ProviderConfig
+---@field ft? string
---@field cmd string[]
---@field args? string[]|fun(ctx: preview.Context): string[]
---@field cwd? string|fun(ctx: preview.Context): string
@@ -6,6 +7,7 @@
---@field output? string|fun(ctx: preview.Context): string
---@field error_parser? fun(stderr: string, ctx: preview.Context): preview.Diagnostic[]
---@field clean? string[]|fun(ctx: preview.Context): string[]
+---@field open? boolean|string[]
---@class preview.Config
---@field debug boolean|string
@@ -32,10 +34,11 @@
---@field output_file string
---@class preview
+---@field setup fun(opts?: table)
---@field compile fun(bufnr?: integer)
---@field stop fun(bufnr?: integer)
---@field clean fun(bufnr?: integer)
----@field watch fun(bufnr?: integer)
+---@field toggle fun(bufnr?: integer)
---@field status fun(bufnr?: integer): preview.Status
---@field get_config fun(): preview.Config
local M = {}
@@ -52,39 +55,48 @@ local default_config = {
---@type preview.Config
local config = vim.deepcopy(default_config)
-local initialized = false
+---@param opts? table
+function M.setup(opts)
+ opts = opts or {}
+ vim.validate('preview.setup opts', opts, 'table')
-local function init()
- if initialized then
- return
- end
- initialized = true
+ local presets = require('preview.presets')
+ local providers = {}
+ local debug = false
- local opts = vim.g.preview or {}
-
- vim.validate('preview config', opts, 'table')
- if opts.debug ~= nil then
- vim.validate('preview config.debug', opts.debug, { 'boolean', 'string' })
- end
- if opts.providers ~= nil then
- vim.validate('preview config.providers', opts.providers, 'table')
+ for k, v in pairs(opts) do
+ if k == 'debug' then
+ vim.validate('preview.setup opts.debug', v, { 'boolean', 'string' })
+ debug = v
+ elseif type(k) == 'number' then
+ vim.validate('preview.setup preset name', v, 'string')
+ local preset = presets[v]
+ if preset then
+ providers[preset.ft] = preset
+ end
+ else
+ vim.validate('preview.setup provider config', v, 'table')
+ providers[k] = v
+ end
end
- config = vim.tbl_deep_extend('force', default_config, opts)
+ config = vim.tbl_deep_extend('force', default_config, {
+ debug = debug,
+ providers = providers,
+ })
+
log.set_enabled(config.debug)
log.dbg('initialized with %d providers', vim.tbl_count(config.providers))
end
---@return preview.Config
function M.get_config()
- init()
return config
end
---@param bufnr? integer
---@return string?
function M.resolve_provider(bufnr)
- init()
bufnr = bufnr or vim.api.nvim_get_current_buf()
local ft = vim.bo[bufnr].filetype
if not config.providers[ft] then
@@ -97,7 +109,6 @@ end
---@param bufnr? integer
---@return preview.Context
function M.build_context(bufnr)
- init()
bufnr = bufnr or vim.api.nvim_get_current_buf()
local file = vim.api.nvim_buf_get_name(bufnr)
local root = vim.fs.root(bufnr, { '.git' }) or vim.fn.fnamemodify(file, ':h')
@@ -111,42 +122,38 @@ end
---@param bufnr? integer
function M.compile(bufnr)
- init()
bufnr = bufnr or vim.api.nvim_get_current_buf()
local name = M.resolve_provider(bufnr)
if not name then
vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN)
return
end
- local provider = config.providers[name]
local ctx = M.build_context(bufnr)
+ local provider = config.providers[name]
compiler.compile(bufnr, name, provider, ctx)
end
---@param bufnr? integer
function M.stop(bufnr)
- init()
bufnr = bufnr or vim.api.nvim_get_current_buf()
compiler.stop(bufnr)
end
---@param bufnr? integer
function M.clean(bufnr)
- init()
bufnr = bufnr or vim.api.nvim_get_current_buf()
local name = M.resolve_provider(bufnr)
if not name then
vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN)
return
end
- local provider = config.providers[name]
local ctx = M.build_context(bufnr)
+ local provider = config.providers[name]
compiler.clean(bufnr, name, provider, ctx)
end
---@param bufnr? integer
-function M.watch(bufnr)
- init()
+function M.toggle(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local name = M.resolve_provider(bufnr)
if not name then
@@ -154,7 +161,7 @@ function M.watch(bufnr)
return
end
local provider = config.providers[name]
- compiler.watch(bufnr, name, provider, M.build_context)
+ compiler.toggle(bufnr, name, provider, M.build_context)
end
---@class preview.Status
@@ -166,7 +173,6 @@ end
---@param bufnr? integer
---@return preview.Status
function M.status(bufnr)
- init()
bufnr = bufnr or vim.api.nvim_get_current_buf()
return compiler.status(bufnr)
end
@@ -174,7 +180,6 @@ end
M._test = {
---@diagnostic disable-next-line: assign-type-mismatch
reset = function()
- initialized = false
config = vim.deepcopy(default_config)
end,
}
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 124d00e..3579a8d 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -2,6 +2,7 @@ local M = {}
---@type preview.ProviderConfig
M.typst = {
+ ft = 'typst',
cmd = { 'typst', 'compile' },
args = function(ctx)
return { ctx.file }
@@ -9,10 +10,12 @@ M.typst = {
output = function(ctx)
return ctx.file:gsub('%.typ$', '.pdf')
end,
+ open = { 'xdg-open' },
}
---@type preview.ProviderConfig
M.latex = {
+ ft = 'tex',
cmd = { 'latexmk' },
args = function(ctx)
return { '-pdf', '-interaction=nonstopmode', ctx.file }
@@ -23,18 +26,49 @@ M.latex = {
clean = function(ctx)
return { 'latexmk', '-c', ctx.file }
end,
+ open = { 'xdg-open' },
}
---@type preview.ProviderConfig
M.markdown = {
+ ft = 'markdown',
cmd = { 'pandoc' },
args = function(ctx)
- local output = ctx.file:gsub('%.md$', '.pdf')
- return { ctx.file, '-o', output }
+ local output = ctx.file:gsub('%.md$', '.html')
+ return { ctx.file, '-s', '--embed-resources', '-o', output }
end,
output = function(ctx)
- return ctx.file:gsub('%.md$', '.pdf')
+ return ctx.file:gsub('%.md$', '.html')
end,
+ clean = function(ctx)
+ return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
+ end,
+ open = { 'xdg-open' },
+}
+
+---@type preview.ProviderConfig
+M.github = {
+ ft = 'markdown',
+ cmd = { 'pandoc' },
+ args = function(ctx)
+ local output = ctx.file:gsub('%.md$', '.html')
+ return {
+ ctx.file,
+ '-s',
+ '--embed-resources',
+ '--css',
+ 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/github.css',
+ '-o',
+ output,
+ }
+ end,
+ output = function(ctx)
+ return ctx.file:gsub('%.md$', '.html')
+ end,
+ clean = function(ctx)
+ return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
+ end,
+ open = { 'xdg-open' },
}
return M
diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua
index c0615d1..be9a616 100644
--- a/spec/commands_spec.lua
+++ b/spec/commands_spec.lua
@@ -35,10 +35,10 @@ describe('commands', function()
end)
end)
- it('does not error on :Preview watch with no provider', function()
+ it('does not error on :Preview toggle with no provider', function()
require('preview.commands').setup()
assert.has_no.errors(function()
- vim.cmd('Preview watch')
+ vim.cmd('Preview toggle')
end)
end)
end)
diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua
index 23ed118..7360ceb 100644
--- a/spec/compiler_spec.lua
+++ b/spec/compiler_spec.lua
@@ -174,7 +174,7 @@ describe('compiler', function()
end)
end)
- describe('watch', function()
+ describe('toggle', function()
it('registers autocmd and tracks in watching table', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch.txt')
@@ -184,7 +184,7 @@ describe('compiler', function()
return { bufnr = b, file = '/tmp/preview_test_watch.txt', root = '/tmp', ft = 'text' }
end
- compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ compiler.toggle(bufnr, 'echo', provider, ctx_builder)
assert.is_not_nil(compiler._test.watching[bufnr])
helpers.delete_buffer(bufnr)
@@ -208,7 +208,7 @@ describe('compiler', function()
return { bufnr = b, file = '/tmp/preview_test_watch_event.txt', root = '/tmp', ft = 'text' }
end
- compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ compiler.toggle(bufnr, 'echo', provider, ctx_builder)
assert.is_true(fired)
compiler.unwatch(bufnr)
@@ -224,10 +224,10 @@ describe('compiler', function()
return { bufnr = b, file = '/tmp/preview_test_watch_toggle.txt', root = '/tmp', ft = 'text' }
end
- compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ compiler.toggle(bufnr, 'echo', provider, ctx_builder)
assert.is_not_nil(compiler._test.watching[bufnr])
- compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ compiler.toggle(bufnr, 'echo', provider, ctx_builder)
assert.is_nil(compiler._test.watching[bufnr])
helpers.delete_buffer(bufnr)
@@ -251,7 +251,7 @@ describe('compiler', function()
return { bufnr = b, file = '/tmp/preview_test_watch_stop.txt', root = '/tmp', ft = 'text' }
end
- compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ compiler.toggle(bufnr, 'echo', provider, ctx_builder)
compiler.unwatch(bufnr)
assert.is_true(stopped)
assert.is_nil(compiler._test.watching[bufnr])
@@ -273,7 +273,7 @@ describe('compiler', function()
}
end
- compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ compiler.toggle(bufnr, 'echo', provider, ctx_builder)
assert.is_not_nil(compiler._test.watching[bufnr])
compiler.stop_all()
@@ -294,7 +294,7 @@ describe('compiler', function()
return { bufnr = b, file = '/tmp/preview_test_watch_status.txt', root = '/tmp', ft = 'text' }
end
- compiler.watch(bufnr, 'echo', provider, ctx_builder)
+ compiler.toggle(bufnr, 'echo', provider, ctx_builder)
s = compiler.status(bufnr)
assert.is_true(s.watching)
diff --git a/spec/helpers.lua b/spec/helpers.lua
index 9790250..dd1ed7e 100644
--- a/spec/helpers.lua
+++ b/spec/helpers.lua
@@ -20,8 +20,11 @@ function M.delete_buffer(bufnr)
end
function M.reset_config(opts)
- vim.g.preview = opts
- require('preview')._test.reset()
+ local preview = require('preview')
+ preview._test.reset()
+ if opts then
+ preview.setup(opts)
+ end
end
return M
diff --git a/spec/init_spec.lua b/spec/init_spec.lua
index 3316b63..7921aeb 100644
--- a/spec/init_spec.lua
+++ b/spec/init_spec.lua
@@ -9,13 +9,7 @@ describe('preview', function()
end)
describe('config', function()
- it('accepts nil config', function()
- assert.has_no.errors(function()
- preview.get_config()
- end)
- end)
-
- it('applies default values', function()
+ it('returns defaults before setup is called', function()
local config = preview.get_config()
assert.is_false(config.debug)
assert.are.same({}, config.providers)
@@ -28,26 +22,44 @@ describe('preview', function()
assert.are.same({}, config.providers)
end)
- it('accepts full provider config', function()
+ it('accepts full provider config via hash entry', function()
helpers.reset_config({
- providers = {
- typst = {
- cmd = { 'typst', 'compile' },
- args = { '%s' },
- },
+ typst = {
+ cmd = { 'typst', 'compile' },
+ args = { '%s' },
},
})
local config = require('preview').get_config()
assert.is_not_nil(config.providers.typst)
end)
+
+ it('resolves array preset names to provider configs', function()
+ helpers.reset_config({ 'typst', 'markdown' })
+ local config = require('preview').get_config()
+ local presets = require('preview.presets')
+ assert.are.same(presets.typst, config.providers.typst)
+ assert.are.same(presets.markdown, config.providers.markdown)
+ end)
+
+ it('resolves latex preset under tex filetype', function()
+ helpers.reset_config({ 'latex' })
+ local config = require('preview').get_config()
+ local presets = require('preview.presets')
+ assert.are.same(presets.latex, config.providers.tex)
+ end)
+
+ it('resolves github preset under markdown filetype', function()
+ helpers.reset_config({ 'github' })
+ local config = require('preview').get_config()
+ local presets = require('preview.presets')
+ assert.are.same(presets.github, config.providers.markdown)
+ end)
end)
describe('resolve_provider', function()
before_each(function()
helpers.reset_config({
- providers = {
- typst = { cmd = { 'typst', 'compile' } },
- },
+ typst = { cmd = { 'typst', 'compile' } },
})
preview = require('preview')
end)
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index 7f137cb..2eba98f 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -13,6 +13,10 @@ describe('presets', function()
}
describe('typst', function()
+ it('has ft', function()
+ assert.are.equal('typst', presets.typst.ft)
+ end)
+
it('has cmd', function()
assert.are.same({ 'typst', 'compile' }, presets.typst.cmd)
end)
@@ -28,6 +32,10 @@ describe('presets', function()
assert.is_string(output)
assert.are.equal('/tmp/document.pdf', output)
end)
+
+ it('has open enabled', function()
+ assert.are.same({ 'xdg-open' }, presets.typst.open)
+ end)
end)
describe('latex', function()
@@ -38,6 +46,10 @@ describe('presets', function()
ft = 'tex',
}
+ it('has ft', function()
+ assert.are.equal('tex', presets.latex.ft)
+ end)
+
it('has cmd', function()
assert.are.same({ 'latexmk' }, presets.latex.cmd)
end)
@@ -59,6 +71,10 @@ describe('presets', function()
assert.is_table(clean)
assert.are.same({ 'latexmk', '-c', '/tmp/document.tex' }, clean)
end)
+
+ it('has open enabled', function()
+ assert.are.same({ 'xdg-open' }, presets.latex.open)
+ end)
end)
describe('markdown', function()
@@ -69,20 +85,84 @@ describe('presets', function()
ft = 'markdown',
}
+ it('has ft', function()
+ assert.are.equal('markdown', presets.markdown.ft)
+ end)
+
it('has cmd', function()
assert.are.same({ 'pandoc' }, presets.markdown.cmd)
end)
- it('returns args with file and output flag', function()
+ it('returns args with standalone and embed-resources flags', function()
local args = presets.markdown.args(md_ctx)
assert.is_table(args)
- assert.are.same({ '/tmp/document.md', '-o', '/tmp/document.pdf' }, args)
+ assert.are.same(
+ { '/tmp/document.md', '-s', '--embed-resources', '-o', '/tmp/document.html' },
+ args
+ )
end)
- it('returns pdf output path', function()
+ it('returns html output path', function()
local output = presets.markdown.output(md_ctx)
assert.is_string(output)
- assert.are.equal('/tmp/document.pdf', output)
+ assert.are.equal('/tmp/document.html', output)
+ end)
+
+ it('returns clean command', function()
+ local clean = presets.markdown.clean(md_ctx)
+ assert.is_table(clean)
+ assert.are.same({ 'rm', '-f', '/tmp/document.html' }, clean)
+ end)
+
+ it('has open enabled', function()
+ assert.are.same({ 'xdg-open' }, presets.markdown.open)
+ end)
+ end)
+
+ describe('github', function()
+ local md_ctx = {
+ bufnr = 1,
+ file = '/tmp/document.md',
+ root = '/tmp',
+ ft = 'markdown',
+ }
+
+ it('has ft', function()
+ assert.are.equal('markdown', presets.github.ft)
+ end)
+
+ it('has cmd', function()
+ assert.are.same({ 'pandoc' }, presets.github.cmd)
+ end)
+
+ it('returns args with standalone, embed-resources, and css flags', function()
+ local args = presets.github.args(md_ctx)
+ assert.is_table(args)
+ assert.are.same({
+ '/tmp/document.md',
+ '-s',
+ '--embed-resources',
+ '--css',
+ 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/github.css',
+ '-o',
+ '/tmp/document.html',
+ }, args)
+ end)
+
+ it('returns html output path', function()
+ local output = presets.github.output(md_ctx)
+ assert.is_string(output)
+ assert.are.equal('/tmp/document.html', output)
+ end)
+
+ it('returns clean command', function()
+ local clean = presets.github.clean(md_ctx)
+ assert.is_table(clean)
+ assert.are.same({ 'rm', '-f', '/tmp/document.html' }, clean)
+ end)
+
+ it('has open enabled', function()
+ assert.are.same({ 'xdg-open' }, presets.github.open)
end)
end)
end)
From 2d212aa220356f37d57a061e05b2fb4fdd23728c Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 00:25:49 -0500
Subject: [PATCH 04/49] refactor(config): replace array preset syntax with
preset_name = true (#3)
* refactor(config): replace array preset syntax with preset_name = true
Problem: setup() mixed array entries (preset names) and hash entries
(custom providers keyed by filetype), requiring verbose
vim.tbl_deep_extend boilerplate to override presets.
Solution: unify under a single key=value model. Keys are preset names
or filetypes; true registers the preset as-is, a table deep-merges
with the matching preset (or registers a custom provider if no preset
matches), and false is a no-op. Array entries are dropped. Also adds
-f gfm to presets.github args so pandoc parses input as GFM.
* ci: format
* fix(presets): parenthesize gsub output to suppress redundant-return-value
---
README.md | 13 ++++-----
doc/preview.nvim.txt | 59 +++++++++++++++++++++--------------------
lua/preview/init.lua | 16 ++++++-----
lua/preview/presets.lua | 12 +++++----
spec/init_spec.lua | 12 ++++-----
spec/presets_spec.lua | 17 +++++++++++-
6 files changed, 73 insertions(+), 56 deletions(-)
diff --git a/README.md b/README.md
index dcdf689..3e85634 100644
--- a/README.md
+++ b/README.md
@@ -55,23 +55,20 @@ require('preview').setup({
**Q: How do I override a preset?**
```lua
-local presets = require('preview.presets')
require('preview').setup({
- typst = vim.tbl_deep_extend('force', presets.typst, {
- env = { TYPST_FONT_PATHS = '/usr/share/fonts' },
- }),
+ 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.
-For a specific application, pass a command table:
+open the output with `vim.ui.open()` after the first successful compilation. For
+a specific application, pass a command table:
```lua
-typst = vim.tbl_deep_extend('force', presets.typst, {
- open = { 'sioyek', '--new-instance' },
+require('preview').setup({
+ typst = { open = { 'sioyek', '--new-instance' } },
})
```
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index 3ed587a..753fefc 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -42,9 +42,18 @@ Configure via `require('preview').setup()`.
*preview.setup()*
setup({opts?})
- `opts` is a mixed table. Array entries are preset names (see
- |preview.nvim-presets|). Hash entries with table values are custom
- provider configs keyed by filetype.
+ `opts` is 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:~
@@ -91,33 +100,28 @@ Context fields:~
`root` string Project root (git root or file directory).
`ft` string Filetype.
-Example using preset names:~
+Example enabling presets:~
>lua
- require('preview').setup({ 'typst', 'latex', 'markdown' })
+ require('preview').setup({ typst = true, latex = true, github = true })
<
-Example with a custom provider:~
+Example overriding a preset field:~
>lua
require('preview').setup({
- typst = {
- cmd = { 'typst', 'compile' },
+ typst = { open = { 'sioyek', '--new-instance' } },
+ })
+<
+
+Example with a fully custom provider (key is not a preset name):~
+>lua
+ require('preview').setup({
+ rst = {
+ cmd = { 'rst2html' },
args = function(ctx)
return { ctx.file }
end,
output = function(ctx)
- return ctx.file:gsub('%.typ$', '.pdf')
- end,
- error_parser = function(stderr, ctx)
- local diagnostics = {}
- for line, col, msg in stderr:gmatch('error:.-(%d+):(%d+):%s*(.-)%\n') do
- table.insert(diagnostics, {
- lnum = tonumber(line) - 1,
- col = tonumber(col) - 1,
- message = msg,
- severity = vim.diagnostic.severity.ERROR,
- })
- end
- return diagnostics
+ return ctx.file:gsub('%.rst$', '.html')
end,
},
})
@@ -132,20 +136,17 @@ Import them from `preview.presets`:
`presets.typst` typst compile → PDF
`presets.latex` latexmk -pdf → PDF (with clean support)
`presets.markdown` pandoc → HTML (standalone, embedded)
- `presets.github` pandoc → HTML (GitHub-styled)
+ `presets.github` pandoc → HTML (GitHub-styled, `-f gfm` input)
-Pass preset names as array entries to `setup()`:
+Enable presets with `preset_name = true`:
>lua
- require('preview').setup({ 'typst', 'latex', 'markdown' })
+ require('preview').setup({ typst = true, latex = true, github = true })
<
-Override individual fields using `vim.tbl_deep_extend`:
+Override individual fields by passing a table instead of `true`:
>lua
- local presets = require('preview.presets')
require('preview').setup({
- typst = vim.tbl_deep_extend('force', presets.typst, {
- env = { TYPST_FONT_PATHS = '/usr/share/fonts' },
- }),
+ typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } },
})
<
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index 968b201..e2cf794 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -68,15 +68,17 @@ function M.setup(opts)
if k == 'debug' then
vim.validate('preview.setup opts.debug', v, { 'boolean', 'string' })
debug = v
- elseif type(k) == 'number' then
- vim.validate('preview.setup preset name', v, 'string')
- local preset = presets[v]
+ elseif type(k) ~= 'number' then
+ local preset = presets[k]
if preset then
- providers[preset.ft] = preset
+ if v == true then
+ providers[preset.ft] = preset
+ elseif type(v) == 'table' then
+ providers[preset.ft] = vim.tbl_deep_extend('force', preset, v)
+ end
+ elseif type(v) == 'table' then
+ providers[k] = v
end
- else
- vim.validate('preview.setup provider config', v, 'table')
- providers[k] = v
end
end
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 3579a8d..8a414e8 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -8,7 +8,7 @@ M.typst = {
return { ctx.file }
end,
output = function(ctx)
- return ctx.file:gsub('%.typ$', '.pdf')
+ return (ctx.file:gsub('%.typ$', '.pdf'))
end,
open = { 'xdg-open' },
}
@@ -21,7 +21,7 @@ M.latex = {
return { '-pdf', '-interaction=nonstopmode', ctx.file }
end,
output = function(ctx)
- return ctx.file:gsub('%.tex$', '.pdf')
+ return (ctx.file:gsub('%.tex$', '.pdf'))
end,
clean = function(ctx)
return { 'latexmk', '-c', ctx.file }
@@ -38,7 +38,7 @@ M.markdown = {
return { ctx.file, '-s', '--embed-resources', '-o', output }
end,
output = function(ctx)
- return ctx.file:gsub('%.md$', '.html')
+ return (ctx.file:gsub('%.md$', '.html'))
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
@@ -53,17 +53,19 @@ M.github = {
args = function(ctx)
local output = ctx.file:gsub('%.md$', '.html')
return {
+ '-f',
+ 'gfm',
ctx.file,
'-s',
'--embed-resources',
'--css',
- 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/github.css',
+ 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',
output,
}
end,
output = function(ctx)
- return ctx.file:gsub('%.md$', '.html')
+ return (ctx.file:gsub('%.md$', '.html'))
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
diff --git a/spec/init_spec.lua b/spec/init_spec.lua
index 7921aeb..5b97c8a 100644
--- a/spec/init_spec.lua
+++ b/spec/init_spec.lua
@@ -22,7 +22,7 @@ describe('preview', function()
assert.are.same({}, config.providers)
end)
- it('accepts full provider config via hash entry', function()
+ it('merges override table with matching preset', function()
helpers.reset_config({
typst = {
cmd = { 'typst', 'compile' },
@@ -33,8 +33,8 @@ describe('preview', function()
assert.is_not_nil(config.providers.typst)
end)
- it('resolves array preset names to provider configs', function()
- helpers.reset_config({ 'typst', 'markdown' })
+ it('resolves preset = true to provider config', function()
+ helpers.reset_config({ typst = true, markdown = true })
local config = require('preview').get_config()
local presets = require('preview.presets')
assert.are.same(presets.typst, config.providers.typst)
@@ -42,14 +42,14 @@ describe('preview', function()
end)
it('resolves latex preset under tex filetype', function()
- helpers.reset_config({ 'latex' })
+ helpers.reset_config({ latex = true })
local config = require('preview').get_config()
local presets = require('preview.presets')
assert.are.same(presets.latex, config.providers.tex)
end)
it('resolves github preset under markdown filetype', function()
- helpers.reset_config({ 'github' })
+ helpers.reset_config({ github = true })
local config = require('preview').get_config()
local presets = require('preview.presets')
assert.are.same(presets.github, config.providers.markdown)
@@ -59,7 +59,7 @@ describe('preview', function()
describe('resolve_provider', function()
before_each(function()
helpers.reset_config({
- typst = { cmd = { 'typst', 'compile' } },
+ typst = true,
})
preview = require('preview')
end)
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index 2eba98f..b8572c5 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -139,16 +139,31 @@ describe('presets', function()
local args = presets.github.args(md_ctx)
assert.is_table(args)
assert.are.same({
+ '-f',
+ 'gfm',
'/tmp/document.md',
'-s',
'--embed-resources',
'--css',
- 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/github.css',
+ 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',
'/tmp/document.html',
}, args)
end)
+ it('args include -f and gfm flags', function()
+ local args = presets.github.args(md_ctx)
+ local idx = nil
+ for i, v in ipairs(args) do
+ if v == '-f' then
+ idx = i
+ break
+ end
+ end
+ assert.is_not_nil(idx)
+ assert.are.equal('gfm', args[idx + 1])
+ end)
+
it('returns html output path', function()
local output = presets.github.output(md_ctx)
assert.is_string(output)
From 0b16ff7178e41ed5e389769d8704061ec2ede7d7 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 00:49:10 -0500
Subject: [PATCH 05/49] Refactor/preset name true syntax (#4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor(config): replace array preset syntax with preset_name = true
Problem: setup() mixed array entries (preset names) and hash entries
(custom providers keyed by filetype), requiring verbose
vim.tbl_deep_extend boilerplate to override presets.
Solution: unify under a single key=value model. Keys are preset names
or filetypes; true registers the preset as-is, a table deep-merges
with the matching preset (or registers a custom provider if no preset
matches), and false is a no-op. Array entries are dropped. Also adds
-f gfm to presets.github args so pandoc parses input as GFM.
* ci: format
* fix(presets): parenthesize gsub output to suppress redundant-return-value
* ci: remove superfluous things
* refactor: remove PreviewWatch* events and clean up docs
Problem: PreviewWatchStarted/PreviewWatchStopped were redundant with
the status() API, and the doc had a wrong author, stale INSTALLATION
format, and "watch mode" language left over from the watch → toggle
rename.
Solution: Remove the events and their tests. Fix the doc author,
rename INSTALLATION → SETUP to match sibling plugins, replace "watch
mode" with "auto-compile" throughout, and drop the events from EVENTS.
---
README.md | 2 --
doc/preview.nvim.txt | 27 +++++++--------------
lua/preview/compiler.lua | 10 --------
spec/compiler_spec.lua | 51 ----------------------------------------
4 files changed, 8 insertions(+), 82 deletions(-)
diff --git a/README.md b/README.md
index 3e85634..da5f3cf 100644
--- a/README.md
+++ b/README.md
@@ -71,5 +71,3 @@ require('preview').setup({
typst = { open = { 'sioyek', '--new-instance' } },
})
```
-
-See `:h preview.nvim` for more information.
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index 753fefc..0b44a91 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -1,6 +1,6 @@
*preview.nvim.txt* Async document compilation for Neovim
-Author: Raphael
+Author: Barrett Ruth
License: MIT
==============================================================================
@@ -20,19 +20,14 @@ REQUIREMENTS *preview.nvim-requirements
- A compiler binary for each configured provider (e.g. `typst`, `latexmk`)
==============================================================================
-INSTALLATION *preview.nvim-installation*
+SETUP *preview.nvim-setup*
-With luarocks (recommended):
->
- :Rocks install preview.nvim
-<
-
-With lazy.nvim:
->lua
+Load preview.nvim with your package manager. For example, with lazy.nvim: >lua
{
'barrettruth/preview.nvim',
}
<
+Call |preview.setup()| to configure providers before use.
==============================================================================
CONFIGURATION *preview.nvim-configuration*
@@ -161,7 +156,7 @@ COMMANDS *preview.nvim-commands*
`stop` Kill active compilation for the current buffer.
`clean` Run the provider's clean command.
`toggle` Toggle auto-compile on save for the current buffer.
- `status` Echo compilation status (idle, compiling, watching).
+ `status` Echo compilation status (idle, compiling, toggled).
==============================================================================
API *preview.nvim-api*
@@ -176,9 +171,9 @@ preview.clean({bufnr?}) *preview.clean()*
Run the provider's clean command for the buffer.
preview.toggle({bufnr?}) *preview.toggle()*
- Toggle watch mode for the buffer. When enabled, the buffer is
+ Toggle auto-compile for the buffer. When enabled, the buffer is
immediately compiled and automatically recompiled on each save
- (`BufWritePost`). Call again to stop watching.
+ (`BufWritePost`). Call again to stop.
preview.status({bufnr?}) *preview.status()*
Returns a |preview.Status| table.
@@ -187,7 +182,7 @@ preview.status({bufnr?}) *preview.status()*
Status fields:~
`compiling` boolean Whether compilation is active.
- `watching` boolean Whether watch mode is active.
+ `watching` boolean Whether auto-compile is active.
`provider` string? Name of the active provider.
`output_file` string? Path to the output file.
@@ -208,12 +203,6 @@ preview.nvim fires User autocmds with structured data:
`PreviewCompileFailed` Compilation failed (non-zero exit).
data: `{ bufnr, provider, code, stderr }`
-`PreviewWatchStarted` Watch mode enabled for a buffer.
- data: `{ bufnr, provider }`
-
-`PreviewWatchStopped` Watch mode disabled for a buffer.
- data: `{ bufnr }`
-
Example:~
>lua
vim.api.nvim_create_autocmd('User', {
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 6907524..b0c7402 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -188,11 +188,6 @@ function M.toggle(bufnr, name, provider, ctx_builder)
end,
})
- vim.api.nvim_exec_autocmds('User', {
- pattern = 'PreviewWatchStarted',
- data = { bufnr = bufnr, provider = name },
- })
-
M.compile(bufnr, name, provider, ctx_builder(bufnr))
end
@@ -205,11 +200,6 @@ function M.unwatch(bufnr)
vim.api.nvim_del_autocmd(au_id)
watching[bufnr] = nil
log.dbg('unwatched buffer %d', bufnr)
-
- vim.api.nvim_exec_autocmds('User', {
- pattern = 'PreviewWatchStopped',
- data = { bufnr = bufnr },
- })
end
---@param bufnr integer
diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua
index 7360ceb..6802dee 100644
--- a/spec/compiler_spec.lua
+++ b/spec/compiler_spec.lua
@@ -190,31 +190,6 @@ describe('compiler', function()
helpers.delete_buffer(bufnr)
end)
- it('fires PreviewWatchStarted event', function()
- local bufnr = helpers.create_buffer({ 'hello' }, 'text')
- vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_event.txt')
-
- local fired = false
- vim.api.nvim_create_autocmd('User', {
- pattern = 'PreviewWatchStarted',
- once = true,
- callback = function()
- fired = true
- end,
- })
-
- local provider = { cmd = { 'echo', 'ok' } }
- local ctx_builder = function(b)
- return { bufnr = b, file = '/tmp/preview_test_watch_event.txt', root = '/tmp', ft = 'text' }
- end
-
- compiler.toggle(bufnr, 'echo', provider, ctx_builder)
- assert.is_true(fired)
-
- compiler.unwatch(bufnr)
- helpers.delete_buffer(bufnr)
- end)
-
it('toggles off when called again', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_toggle.txt')
@@ -233,32 +208,6 @@ describe('compiler', function()
helpers.delete_buffer(bufnr)
end)
- it('fires PreviewWatchStopped on unwatch', function()
- local bufnr = helpers.create_buffer({ 'hello' }, 'text')
- vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_stop.txt')
-
- local stopped = false
- vim.api.nvim_create_autocmd('User', {
- pattern = 'PreviewWatchStopped',
- once = true,
- callback = function()
- stopped = true
- end,
- })
-
- local provider = { cmd = { 'echo', 'ok' } }
- local ctx_builder = function(b)
- return { bufnr = b, file = '/tmp/preview_test_watch_stop.txt', root = '/tmp', ft = 'text' }
- end
-
- compiler.toggle(bufnr, 'echo', provider, ctx_builder)
- compiler.unwatch(bufnr)
- assert.is_true(stopped)
- assert.is_nil(compiler._test.watching[bufnr])
-
- helpers.delete_buffer(bufnr)
- end)
-
it('stop_all clears watches', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_stopall.txt')
From cfe101c6c4fb1e1bf24761881407b3f9798f0a7e Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:37:02 -0500
Subject: [PATCH 06/49] feat(commands): add :Preview open subcommand (#6)
Problem: after closing a viewer, there was no way to re-open the last
compiled output without recompiling.
Solution: track the most recent output file per buffer in a `last_output`
table that persists after compilation finishes. Add `compiler.open()`,
`M.open()`, and wire it into the command dispatch.
---
lua/preview/commands.lua | 6 ++++--
lua/preview/compiler.lua | 21 +++++++++++++++++++++
lua/preview/init.lua | 9 +++++++++
spec/commands_spec.lua | 7 +++++++
spec/compiler_spec.lua | 35 +++++++++++++++++++++++++++++++++++
5 files changed, 76 insertions(+), 2 deletions(-)
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index 8e08236..ad63c97 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -1,6 +1,6 @@
local M = {}
-local subcommands = { 'compile', 'stop', 'clean', 'toggle', 'status' }
+local subcommands = { 'compile', 'stop', 'clean', 'toggle', 'open', 'status' }
---@param args string
local function dispatch(args)
@@ -14,6 +14,8 @@ local function dispatch(args)
require('preview').clean()
elseif subcmd == 'toggle' then
require('preview').toggle()
+ elseif subcmd == 'open' then
+ require('preview').open()
elseif subcmd == 'status' then
local s = require('preview').status()
local parts = {}
@@ -47,7 +49,7 @@ function M.setup()
complete = function(lead)
return complete(lead)
end,
- desc = 'Compile, stop, clean, toggle, or check status of document preview',
+ desc = 'Compile, stop, clean, toggle, open, or check status of document preview',
})
end
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index b0c7402..50ab0ba 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -12,6 +12,9 @@ local watching = {}
---@type table
local opened = {}
+---@type table
+local last_output = {}
+
---@param val string[]|fun(ctx: preview.Context): string[]
---@param ctx preview.Context
---@return string[]
@@ -61,6 +64,10 @@ function M.compile(bufnr, name, provider, ctx)
output_file = eval_string(provider.output, ctx)
end
+ if output_file ~= '' then
+ last_output[bufnr] = output_file
+ end
+
log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
local obj = vim.system(
@@ -117,6 +124,7 @@ function M.compile(bufnr, name, provider, ctx)
once = true,
callback = function()
M.stop(bufnr)
+ last_output[bufnr] = nil
end,
})
@@ -235,6 +243,18 @@ function M.clean(bufnr, name, provider, ctx)
)
end
+---@param bufnr integer
+---@return boolean
+function M.open(bufnr)
+ local output = last_output[bufnr]
+ if not output then
+ log.dbg('no last output file for buffer %d', bufnr)
+ return false
+ end
+ vim.ui.open(output)
+ return true
+end
+
---@param bufnr integer
---@return preview.Status
function M.status(bufnr)
@@ -254,6 +274,7 @@ M._test = {
active = active,
watching = watching,
opened = opened,
+ last_output = last_output,
}
return M
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index e2cf794..641da4a 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -39,6 +39,7 @@
---@field stop fun(bufnr?: integer)
---@field clean fun(bufnr?: integer)
---@field toggle fun(bufnr?: integer)
+---@field open fun(bufnr?: integer)
---@field status fun(bufnr?: integer): preview.Status
---@field get_config fun(): preview.Config
local M = {}
@@ -166,6 +167,14 @@ function M.toggle(bufnr)
compiler.toggle(bufnr, name, provider, M.build_context)
end
+---@param bufnr? integer
+function M.open(bufnr)
+ bufnr = bufnr or vim.api.nvim_get_current_buf()
+ if not compiler.open(bufnr) then
+ vim.notify('[preview.nvim] no output file available for this buffer', vim.log.levels.WARN)
+ end
+end
+
---@class preview.Status
---@field compiling boolean
---@field watching boolean
diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua
index be9a616..931174f 100644
--- a/spec/commands_spec.lua
+++ b/spec/commands_spec.lua
@@ -35,6 +35,13 @@ describe('commands', function()
end)
end)
+ it('does not error on :Preview open', function()
+ require('preview.commands').setup()
+ assert.has_no.errors(function()
+ vim.cmd('Preview open')
+ end)
+ end)
+
it('does not error on :Preview toggle with no provider', function()
require('preview.commands').setup()
assert.has_no.errors(function()
diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua
index 6802dee..2046ef2 100644
--- a/spec/compiler_spec.lua
+++ b/spec/compiler_spec.lua
@@ -174,6 +174,41 @@ describe('compiler', function()
end)
end)
+ describe('open', function()
+ it('returns false when no output exists', function()
+ assert.is_false(compiler.open(999))
+ end)
+
+ it('returns true after compilation stores output', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_open.txt')
+ vim.bo[bufnr].modified = false
+
+ local provider = {
+ cmd = { 'true' },
+ output = function()
+ return '/tmp/preview_test_open.pdf'
+ end,
+ }
+ local ctx = {
+ bufnr = bufnr,
+ file = '/tmp/preview_test_open.txt',
+ root = '/tmp',
+ ft = 'text',
+ }
+
+ compiler.compile(bufnr, 'testprov', provider, ctx)
+ assert.is_not_nil(compiler._test.last_output[bufnr])
+ assert.are.equal('/tmp/preview_test_open.pdf', compiler._test.last_output[bufnr])
+
+ vim.wait(2000, function()
+ return compiler._test.active[bufnr] == nil
+ end, 50)
+
+ helpers.delete_buffer(bufnr)
+ end)
+ end)
+
describe('toggle', function()
it('registers autocmd and tracks in watching table', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
From 187474bb3df20a59e77d8175d14ace687e6915a9 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:37:16 -0500
Subject: [PATCH 07/49] refactor(presets): replace xdg-open with vim.ui.open
(#7)
Problem: all presets hardcoded `open = { 'xdg-open' }`, making them
Linux-only. The compiler already handles `open = true` via
`vim.ui.open()`, which is cross-platform.
Solution: change all four presets to `open = true`.
---
lua/preview/presets.lua | 8 ++++----
spec/presets_spec.lua | 8 ++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 8a414e8..e04862b 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -10,7 +10,7 @@ M.typst = {
output = function(ctx)
return (ctx.file:gsub('%.typ$', '.pdf'))
end,
- open = { 'xdg-open' },
+ open = true,
}
---@type preview.ProviderConfig
@@ -26,7 +26,7 @@ M.latex = {
clean = function(ctx)
return { 'latexmk', '-c', ctx.file }
end,
- open = { 'xdg-open' },
+ open = true,
}
---@type preview.ProviderConfig
@@ -43,7 +43,7 @@ M.markdown = {
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
end,
- open = { 'xdg-open' },
+ open = true,
}
---@type preview.ProviderConfig
@@ -70,7 +70,7 @@ M.github = {
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
end,
- open = { 'xdg-open' },
+ open = true,
}
return M
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index b8572c5..d213389 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -34,7 +34,7 @@ describe('presets', function()
end)
it('has open enabled', function()
- assert.are.same({ 'xdg-open' }, presets.typst.open)
+ assert.is_true(presets.typst.open)
end)
end)
@@ -73,7 +73,7 @@ describe('presets', function()
end)
it('has open enabled', function()
- assert.are.same({ 'xdg-open' }, presets.latex.open)
+ assert.is_true(presets.latex.open)
end)
end)
@@ -115,7 +115,7 @@ describe('presets', function()
end)
it('has open enabled', function()
- assert.are.same({ 'xdg-open' }, presets.markdown.open)
+ assert.is_true(presets.markdown.open)
end)
end)
@@ -177,7 +177,7 @@ describe('presets', function()
end)
it('has open enabled', function()
- assert.are.same({ 'xdg-open' }, presets.github.open)
+ assert.is_true(presets.github.open)
end)
end)
end)
From bf2f4a78e23febad48673fb4767ef83d9263534e Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:37:36 -0500
Subject: [PATCH 08/49] feat: add statusline function (#10)
Problem: no way to expose compiling/watching state to statusline
plugins like lualine or heirline without polling status() and
formatting it manually.
Solution: add `require('preview').statusline()` that returns
'compiling', 'watching', or '' for direct use in statusline components.
---
lua/preview/init.lua | 14 ++++++++++++++
spec/init_spec.lua | 8 ++++++++
2 files changed, 22 insertions(+)
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index 641da4a..f6d0006 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -41,6 +41,7 @@
---@field toggle fun(bufnr?: integer)
---@field open fun(bufnr?: integer)
---@field status fun(bufnr?: integer): preview.Status
+---@field statusline fun(bufnr?: integer): string
---@field get_config fun(): preview.Config
local M = {}
@@ -188,6 +189,19 @@ function M.status(bufnr)
return compiler.status(bufnr)
end
+---@param bufnr? integer
+---@return string
+function M.statusline(bufnr)
+ bufnr = bufnr or vim.api.nvim_get_current_buf()
+ local s = compiler.status(bufnr)
+ if s.compiling then
+ return 'compiling'
+ elseif s.watching then
+ return 'watching'
+ end
+ return ''
+end
+
M._test = {
---@diagnostic disable-next-line: assign-type-mismatch
reset = function()
diff --git a/spec/init_spec.lua b/spec/init_spec.lua
index 5b97c8a..5c49276 100644
--- a/spec/init_spec.lua
+++ b/spec/init_spec.lua
@@ -100,4 +100,12 @@ describe('preview', function()
helpers.delete_buffer(bufnr)
end)
end)
+
+ describe('statusline', function()
+ it('returns empty string when idle', function()
+ local bufnr = helpers.create_buffer({})
+ assert.are.equal('', preview.statusline(bufnr))
+ helpers.delete_buffer(bufnr)
+ end)
+ end)
end)
From b00b169bf5bee7a4a3c812ba81236027766e7ebe Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:42:44 -0500
Subject: [PATCH 09/49] feat(compiler): debounce compilation in toggle mode
(#8)
Problem: in toggle mode, each BufWritePost immediately spawned a new
compilation, killing any in-flight process. Rapid saves wasted cycles
on compilers like latexmk.
Solution: add a 500ms debounce timer per buffer. The BufWritePost
callback starts/restarts the timer instead of compiling immediately.
Timers are cleaned up on unwatch and BufWipeout.
---
lua/preview/compiler.lua | 25 +++++++++++++++++++++++--
1 file changed, 23 insertions(+), 2 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 50ab0ba..6e3f5cf 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -15,6 +15,10 @@ local opened = {}
---@type table
local last_output = {}
+local debounce_timers = {}
+
+local DEBOUNCE_MS = 500
+
---@param val string[]|fun(ctx: preview.Context): string[]
---@param ctx preview.Context
---@return string[]
@@ -179,8 +183,19 @@ function M.toggle(bufnr, name, provider, ctx_builder)
local au_id = vim.api.nvim_create_autocmd('BufWritePost', {
buffer = bufnr,
callback = function()
- local ctx = ctx_builder(bufnr)
- M.compile(bufnr, name, provider, ctx)
+ if debounce_timers[bufnr] then
+ debounce_timers[bufnr]:stop()
+ else
+ debounce_timers[bufnr] = vim.uv.new_timer()
+ end
+ debounce_timers[bufnr]:start(
+ DEBOUNCE_MS,
+ 0,
+ vim.schedule_wrap(function()
+ local ctx = ctx_builder(bufnr)
+ M.compile(bufnr, name, provider, ctx)
+ end)
+ )
end,
})
@@ -206,6 +221,11 @@ function M.unwatch(bufnr)
return
end
vim.api.nvim_del_autocmd(au_id)
+ if debounce_timers[bufnr] then
+ debounce_timers[bufnr]:stop()
+ debounce_timers[bufnr]:close()
+ debounce_timers[bufnr] = nil
+ end
watching[bufnr] = nil
log.dbg('unwatched buffer %d', bufnr)
end
@@ -275,6 +295,7 @@ M._test = {
watching = watching,
opened = opened,
last_output = last_output,
+ debounce_timers = debounce_timers,
}
return M
From 277daa63cabbb05325341fde9e80cff98c8471db Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 13:42:59 -0500
Subject: [PATCH 10/49] feat(presets): add error parsers for built-in presets
(#9)
Problem: none of the four presets defined an error_parser, so the
diagnostic infrastructure went unused out of the box.
Solution: add parsers for typst (file:line:col short format), latexmk
(pdflatex file-line-error + summary), and pandoc (parse errors, YAML
exceptions, generic errors). Enable machine-parseable output flags in
typst and latex args. Pandoc parser is shared between markdown and
github presets.
---
lua/preview/presets.lua | 111 ++++++++++++++++++++++++++++++++++-
spec/presets_spec.lua | 126 +++++++++++++++++++++++++++++++++++++++-
2 files changed, 232 insertions(+), 5 deletions(-)
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index e04862b..196114b 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -1,15 +1,108 @@
local M = {}
+---@param stderr string
+---@return preview.Diagnostic[]
+local function parse_typst(stderr)
+ local diagnostics = {}
+ for line in stderr:gmatch('[^\r\n]+') do
+ local file, lnum, col, severity, msg = line:match('^(.+):(%d+):(%d+): (%w+): (.+)$')
+ 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 = tonumber(col) - 1,
+ message = msg,
+ severity = sev,
+ source = file,
+ })
+ end
+ end
+ return diagnostics
+end
+
+---@param stderr string
+---@return preview.Diagnostic[]
+local function parse_latexmk(stderr)
+ local diagnostics = {}
+ for line in stderr:gmatch('[^\r\n]+') do
+ local _, lnum, msg = line:match('^%.?/?(.+%.tex):(%d+): (.+)$')
+ if lnum then
+ table.insert(diagnostics, {
+ lnum = tonumber(lnum) - 1,
+ col = 0,
+ message = msg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ else
+ local rule_msg = line:match('^%s+(%S.+gave return code %d+)$')
+ if rule_msg then
+ table.insert(diagnostics, {
+ lnum = 0,
+ col = 0,
+ message = rule_msg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ end
+ end
+ end
+ return diagnostics
+end
+
+---@param stderr string
+---@return preview.Diagnostic[]
+local function parse_pandoc(stderr)
+ local diagnostics = {}
+ for line in stderr:gmatch('[^\r\n]+') do
+ local lnum, col, msg = line:match('Error at .+ %(line (%d+), column (%d+)%): (.+)$')
+ if lnum then
+ table.insert(diagnostics, {
+ lnum = tonumber(lnum) - 1,
+ col = tonumber(col) - 1,
+ message = msg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ else
+ local ylnum, ycol, ymsg =
+ line:match('YAML parse exception at line (%d+), column (%d+)[,:]%s*(.+)$')
+ if ylnum then
+ table.insert(diagnostics, {
+ lnum = tonumber(ylnum) - 1,
+ col = tonumber(ycol) - 1,
+ message = ymsg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ else
+ local errmsg = line:match('^pandoc: (.+)$')
+ if errmsg and not errmsg:match('^Error at') then
+ table.insert(diagnostics, {
+ lnum = 0,
+ col = 0,
+ message = errmsg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ end
+ end
+ end
+ end
+ return diagnostics
+end
+
---@type preview.ProviderConfig
M.typst = {
ft = 'typst',
cmd = { 'typst', 'compile' },
args = function(ctx)
- return { ctx.file }
+ return { '--diagnostic-format', 'short', ctx.file }
end,
output = function(ctx)
return (ctx.file:gsub('%.typ$', '.pdf'))
end,
+ error_parser = function(stderr)
+ return parse_typst(stderr)
+ end,
open = true,
}
@@ -18,11 +111,19 @@ M.latex = {
ft = 'tex',
cmd = { 'latexmk' },
args = function(ctx)
- return { '-pdf', '-interaction=nonstopmode', ctx.file }
+ return {
+ '-pdf',
+ '-interaction=nonstopmode',
+ '-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S',
+ ctx.file,
+ }
end,
output = function(ctx)
return (ctx.file:gsub('%.tex$', '.pdf'))
end,
+ error_parser = function(stderr)
+ return parse_latexmk(stderr)
+ end,
clean = function(ctx)
return { 'latexmk', '-c', ctx.file }
end,
@@ -40,6 +141,9 @@ M.markdown = {
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
end,
+ error_parser = function(stderr)
+ return parse_pandoc(stderr)
+ end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
end,
@@ -67,6 +171,9 @@ M.github = {
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
end,
+ error_parser = function(stderr)
+ return parse_pandoc(stderr)
+ end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
end,
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index d213389..33f3a91 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -21,10 +21,10 @@ describe('presets', function()
assert.are.same({ 'typst', 'compile' }, presets.typst.cmd)
end)
- it('returns args with file path', function()
+ it('returns args with diagnostic format and file path', function()
local args = presets.typst.args(ctx)
assert.is_table(args)
- assert.are.same({ '/tmp/document.typ' }, args)
+ assert.are.same({ '--diagnostic-format', 'short', '/tmp/document.typ' }, args)
end)
it('returns pdf output path', function()
@@ -36,6 +36,30 @@ describe('presets', function()
it('has open enabled', function()
assert.is_true(presets.typst.open)
end)
+
+ it('parses errors from stderr', function()
+ local stderr = table.concat({
+ 'main.typ:5:23: error: unexpected token',
+ 'main.typ:12:1: warning: unused variable',
+ }, '\n')
+ local diagnostics = presets.typst.error_parser(stderr, ctx)
+ assert.is_table(diagnostics)
+ assert.are.equal(2, #diagnostics)
+ assert.are.equal(4, diagnostics[1].lnum)
+ assert.are.equal(22, diagnostics[1].col)
+ assert.are.equal('unexpected token', diagnostics[1].message)
+ assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
+ assert.are.equal('main.typ', diagnostics[1].source)
+ assert.are.equal(11, diagnostics[2].lnum)
+ assert.are.equal(0, diagnostics[2].col)
+ assert.are.equal('unused variable', diagnostics[2].message)
+ assert.are.equal(vim.diagnostic.severity.WARN, diagnostics[2].severity)
+ end)
+
+ it('returns empty table for clean stderr', function()
+ local diagnostics = presets.typst.error_parser('', ctx)
+ assert.are.same({}, diagnostics)
+ end)
end)
describe('latex', function()
@@ -57,7 +81,12 @@ describe('presets', function()
it('returns args with pdf flag and file path', function()
local args = presets.latex.args(tex_ctx)
assert.is_table(args)
- assert.are.same({ '-pdf', '-interaction=nonstopmode', '/tmp/document.tex' }, args)
+ assert.are.same({
+ '-pdf',
+ '-interaction=nonstopmode',
+ '-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S',
+ '/tmp/document.tex',
+ }, args)
end)
it('returns pdf output path', function()
@@ -75,6 +104,44 @@ describe('presets', function()
it('has open enabled', function()
assert.is_true(presets.latex.open)
end)
+
+ it('parses file-line-error format from stderr', function()
+ local stderr = table.concat({
+ './document.tex:10: Undefined control sequence.',
+ 'l.10 \\badcommand',
+ 'Collected error summary (may duplicate other messages):',
+ " pdflatex: Command for 'pdflatex' gave return code 256",
+ }, '\n')
+ local diagnostics = presets.latex.error_parser(stderr, tex_ctx)
+ assert.is_table(diagnostics)
+ assert.is_true(#diagnostics > 0)
+ 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('parses collected error summary', function()
+ local stderr = table.concat({
+ 'Latexmk: Errors, so I did not complete making targets',
+ 'Collected error summary (may duplicate other messages):',
+ " pdflatex: Command for 'pdflatex' gave return code 256",
+ }, '\n')
+ local diagnostics = presets.latex.error_parser(stderr, tex_ctx)
+ assert.is_table(diagnostics)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(0, diagnostics[1].lnum)
+ assert.are.equal(0, diagnostics[1].col)
+ assert.are.equal(
+ "pdflatex: Command for 'pdflatex' gave return code 256",
+ diagnostics[1].message
+ )
+ end)
+
+ it('returns empty table for clean stderr', function()
+ local diagnostics = presets.latex.error_parser('', tex_ctx)
+ assert.are.same({}, diagnostics)
+ end)
end)
describe('markdown', function()
@@ -117,6 +184,43 @@ describe('presets', function()
it('has open enabled', function()
assert.is_true(presets.markdown.open)
end)
+
+ it('parses pandoc parse errors from stderr', function()
+ local stderr = 'Error at "source" (line 75, column 1): unexpected end of input'
+ local diagnostics = presets.markdown.error_parser(stderr, md_ctx)
+ assert.is_table(diagnostics)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(74, diagnostics[1].lnum)
+ assert.are.equal(0, diagnostics[1].col)
+ assert.are.equal('unexpected end of input', diagnostics[1].message)
+ assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
+ end)
+
+ it('parses YAML parse exceptions from stderr', function()
+ local stderr =
+ 'YAML parse exception at line 3, column 2, while scanning a block scalar: did not find expected comment or line break'
+ local diagnostics = presets.markdown.error_parser(stderr, md_ctx)
+ assert.is_table(diagnostics)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(2, diagnostics[1].lnum)
+ assert.are.equal(1, diagnostics[1].col)
+ assert.is_string(diagnostics[1].message)
+ end)
+
+ it('parses generic pandoc errors from stderr', function()
+ local stderr = 'pandoc: Could not find data file templates/default.html5'
+ local diagnostics = presets.markdown.error_parser(stderr, md_ctx)
+ assert.is_table(diagnostics)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(0, diagnostics[1].lnum)
+ assert.are.equal(0, diagnostics[1].col)
+ assert.are.equal('Could not find data file templates/default.html5', diagnostics[1].message)
+ end)
+
+ it('returns empty table for clean stderr', function()
+ local diagnostics = presets.markdown.error_parser('', md_ctx)
+ assert.are.same({}, diagnostics)
+ end)
end)
describe('github', function()
@@ -179,5 +283,21 @@ describe('presets', function()
it('has open enabled', function()
assert.is_true(presets.github.open)
end)
+
+ it('parses pandoc parse errors from stderr', function()
+ local stderr = 'Error at "document.md" (line 12, column 5): unexpected "}" expecting letter'
+ local diagnostics = presets.github.error_parser(stderr, md_ctx)
+ assert.is_table(diagnostics)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(11, diagnostics[1].lnum)
+ assert.are.equal(4, diagnostics[1].col)
+ assert.are.equal('unexpected "}" expecting letter', diagnostics[1].message)
+ assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
+ end)
+
+ it('returns empty table for clean stderr', function()
+ local diagnostics = presets.github.error_parser('', md_ctx)
+ assert.are.same({}, diagnostics)
+ end)
end)
end)
From 87fc00059c6867824b7757fb77fe45821c1d40e2 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Tue, 3 Mar 2026 13:44:43 -0500
Subject: [PATCH 11/49] doc: cleanup readme
---
README.md | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index da5f3cf..1d4446b 100644
--- a/README.md
+++ b/README.md
@@ -8,16 +8,14 @@ asynchronously with error diagnostics.
## Features
- Async compilation via `vim.system()`
+- Built-in presets for Typst, LaTeX, Markdown, and GitHub-flavored Markdown
- Compiler errors as native `vim.diagnostic`
- User events for extensibility (`PreviewCompileStarted`,
`PreviewCompileSuccess`, `PreviewCompileFailed`)
-- Built-in presets for Typst, LaTeX, Markdown, and GitHub-flavored Markdown
-- `:checkhealth` integration
-- Zero dependencies beyond Neovim 0.11.0+
## Requirements
-- Neovim 0.11.0+
+- Neovim 0.11+
## Installation
From 0f353446b6b0ff68415aedf33e24fb0006cfbe97 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 14:14:59 -0500
Subject: [PATCH 12/49] fix(presets): correct error parsers for real compiler
output (#11)
Problem: all three built-in error parsers were broken against real
compiler output. Typst set source to the relative file path, overriding
the provider name. LaTeX errors go to stdout but the parser only
received stderr. Pandoc's pattern matched "Error at" but not the real
"Error parsing YAML metadata at" format, and single-line parsing missed
multiline messages.
Solution: pass combined stdout+stderr to error_parser so LaTeX stdout
errors are visible. Remove source = file from the Typst parser so
diagnostic.lua defaults it to the provider name. Rewrite the Pandoc
parser with line-based lookahead: match (line N, column N) regardless
of prefix text, skip YAML parse exception lines when looking ahead for
the human-readable message. Rename stderr param to output throughout
diagnostic.lua, presets.lua, and init.lua annotations.
---
lua/preview/compiler.lua | 3 +-
lua/preview/diagnostic.lua | 8 ++--
lua/preview/init.lua | 2 +-
lua/preview/presets.lua | 93 ++++++++++++++++++++------------------
spec/presets_spec.lua | 74 ++++++++++++++++++------------
5 files changed, 101 insertions(+), 79 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 6e3f5cf..a247f2e 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -106,7 +106,8 @@ function M.compile(bufnr, name, provider, ctx)
else
log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code)
if provider.error_parser then
- diagnostic.set(bufnr, name, provider.error_parser, result.stderr or '', ctx)
+ local output = (result.stdout or '') .. (result.stderr or '')
+ diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
end
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
diff --git a/lua/preview/diagnostic.lua b/lua/preview/diagnostic.lua
index d81ec64..abd4105 100644
--- a/lua/preview/diagnostic.lua
+++ b/lua/preview/diagnostic.lua
@@ -12,11 +12,11 @@ end
---@param bufnr integer
---@param name string
----@param error_parser fun(stderr: string, ctx: preview.Context): preview.Diagnostic[]
----@param stderr string
+---@param error_parser fun(output: string, ctx: preview.Context): preview.Diagnostic[]
+---@param output string
---@param ctx preview.Context
-function M.set(bufnr, name, error_parser, stderr, ctx)
- local ok, diagnostics = pcall(error_parser, stderr, ctx)
+function M.set(bufnr, name, error_parser, output, ctx)
+ local ok, diagnostics = pcall(error_parser, output, ctx)
if not ok then
log.dbg('error_parser for "%s" failed: %s', name, diagnostics)
return
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index f6d0006..d122e61 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -5,7 +5,7 @@
---@field cwd? string|fun(ctx: preview.Context): string
---@field env? table
---@field output? string|fun(ctx: preview.Context): string
----@field error_parser? fun(stderr: string, ctx: preview.Context): preview.Diagnostic[]
+---@field error_parser? fun(output: string, ctx: preview.Context): preview.Diagnostic[]
---@field clean? string[]|fun(ctx: preview.Context): string[]
---@field open? boolean|string[]
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 196114b..8b9faab 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -1,11 +1,11 @@
local M = {}
----@param stderr string
+---@param output string
---@return preview.Diagnostic[]
-local function parse_typst(stderr)
+local function parse_typst(output)
local diagnostics = {}
- for line in stderr:gmatch('[^\r\n]+') do
- local file, lnum, col, severity, msg = line:match('^(.+):(%d+):(%d+): (%w+): (.+)$')
+ for line in output:gmatch('[^\r\n]+') do
+ local _, lnum, col, severity, msg = line:match('^(.+):(%d+):(%d+): (%w+): (.+)$')
if lnum then
local sev = vim.diagnostic.severity.ERROR
if severity == 'warning' then
@@ -16,18 +16,17 @@ local function parse_typst(stderr)
col = tonumber(col) - 1,
message = msg,
severity = sev,
- source = file,
})
end
end
return diagnostics
end
----@param stderr string
+---@param output string
---@return preview.Diagnostic[]
-local function parse_latexmk(stderr)
+local function parse_latexmk(output)
local diagnostics = {}
- for line in stderr:gmatch('[^\r\n]+') do
+ for line in output:gmatch('[^\r\n]+') do
local _, lnum, msg = line:match('^%.?/?(.+%.tex):(%d+): (.+)$')
if lnum then
table.insert(diagnostics, {
@@ -51,41 +50,45 @@ local function parse_latexmk(stderr)
return diagnostics
end
----@param stderr string
+---@param output string
---@return preview.Diagnostic[]
-local function parse_pandoc(stderr)
+local function parse_pandoc(output)
local diagnostics = {}
- for line in stderr:gmatch('[^\r\n]+') do
- local lnum, col, msg = line:match('Error at .+ %(line (%d+), column (%d+)%): (.+)$')
+ local lines = vim.split(output, '\n')
+ local i = 1
+ while i <= #lines do
+ local line = lines[i]
+ local lnum, col, msg = line:match('%(line (%d+), column (%d+)%):%s*(.*)$')
if lnum then
- table.insert(diagnostics, {
- lnum = tonumber(lnum) - 1,
- col = tonumber(col) - 1,
- message = msg,
- severity = vim.diagnostic.severity.ERROR,
- })
- else
- local ylnum, ycol, ymsg =
- line:match('YAML parse exception at line (%d+), column (%d+)[,:]%s*(.+)$')
- if ylnum then
- table.insert(diagnostics, {
- lnum = tonumber(ylnum) - 1,
- col = tonumber(ycol) - 1,
- message = ymsg,
- severity = vim.diagnostic.severity.ERROR,
- })
- else
- local errmsg = line:match('^pandoc: (.+)$')
- if errmsg and not errmsg:match('^Error at') then
- table.insert(diagnostics, {
- lnum = 0,
- col = 0,
- message = errmsg,
- severity = vim.diagnostic.severity.ERROR,
- })
+ if msg == '' then
+ for j = i + 1, math.min(i + 2, #lines) do
+ local next_line = lines[j]:match('^%s*(.+)$')
+ if next_line and not next_line:match('^YAML parse exception') then
+ msg = next_line
+ break
+ end
end
end
+ if msg ~= '' then
+ table.insert(diagnostics, {
+ lnum = tonumber(lnum) - 1,
+ col = tonumber(col) - 1,
+ message = msg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ end
+ else
+ local errmsg = line:match('^pandoc: (.+)$')
+ if errmsg then
+ table.insert(diagnostics, {
+ lnum = 0,
+ col = 0,
+ message = errmsg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ end
end
+ i = i + 1
end
return diagnostics
end
@@ -100,8 +103,8 @@ M.typst = {
output = function(ctx)
return (ctx.file:gsub('%.typ$', '.pdf'))
end,
- error_parser = function(stderr)
- return parse_typst(stderr)
+ error_parser = function(output)
+ return parse_typst(output)
end,
open = true,
}
@@ -121,8 +124,8 @@ M.latex = {
output = function(ctx)
return (ctx.file:gsub('%.tex$', '.pdf'))
end,
- error_parser = function(stderr)
- return parse_latexmk(stderr)
+ error_parser = function(output)
+ return parse_latexmk(output)
end,
clean = function(ctx)
return { 'latexmk', '-c', ctx.file }
@@ -141,8 +144,8 @@ M.markdown = {
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
end,
- error_parser = function(stderr)
- return parse_pandoc(stderr)
+ error_parser = function(output)
+ return parse_pandoc(output)
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
@@ -171,8 +174,8 @@ M.github = {
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
end,
- error_parser = function(stderr)
- return parse_pandoc(stderr)
+ error_parser = function(output)
+ return parse_pandoc(output)
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index 33f3a91..904a4f4 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -49,7 +49,7 @@ describe('presets', function()
assert.are.equal(22, diagnostics[1].col)
assert.are.equal('unexpected token', diagnostics[1].message)
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
- assert.are.equal('main.typ', diagnostics[1].source)
+ assert.is_nil(diagnostics[1].source)
assert.are.equal(11, diagnostics[2].lnum)
assert.are.equal(0, diagnostics[2].col)
assert.are.equal('unused variable', diagnostics[2].message)
@@ -105,14 +105,14 @@ describe('presets', function()
assert.is_true(presets.latex.open)
end)
- it('parses file-line-error format from stderr', function()
- local stderr = table.concat({
+ it('parses file-line-error format from output', function()
+ local output = table.concat({
'./document.tex:10: Undefined control sequence.',
'l.10 \\badcommand',
'Collected error summary (may duplicate other messages):',
" pdflatex: Command for 'pdflatex' gave return code 256",
}, '\n')
- local diagnostics = presets.latex.error_parser(stderr, tex_ctx)
+ local diagnostics = presets.latex.error_parser(output, tex_ctx)
assert.is_table(diagnostics)
assert.is_true(#diagnostics > 0)
assert.are.equal(9, diagnostics[1].lnum)
@@ -122,12 +122,12 @@ describe('presets', function()
end)
it('parses collected error summary', function()
- local stderr = table.concat({
+ local output = table.concat({
'Latexmk: Errors, so I did not complete making targets',
'Collected error summary (may duplicate other messages):',
" pdflatex: Command for 'pdflatex' gave return code 256",
}, '\n')
- local diagnostics = presets.latex.error_parser(stderr, tex_ctx)
+ local diagnostics = presets.latex.error_parser(output, tex_ctx)
assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics)
assert.are.equal(0, diagnostics[1].lnum)
@@ -185,9 +185,24 @@ describe('presets', function()
assert.is_true(presets.markdown.open)
end)
- it('parses pandoc parse errors from stderr', function()
- local stderr = 'Error at "source" (line 75, column 1): unexpected end of input'
- local diagnostics = presets.markdown.error_parser(stderr, md_ctx)
+ it('parses YAML metadata errors with multiline message', function()
+ local output = table.concat({
+ 'Error parsing YAML metadata at "/tmp/test.md" (line 1, column 1):',
+ 'YAML parse exception at line 1, column 9:',
+ 'mapping values are not allowed in this context',
+ }, '\n')
+ local diagnostics = presets.markdown.error_parser(output, md_ctx)
+ assert.is_table(diagnostics)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(0, diagnostics[1].lnum)
+ assert.are.equal(0, diagnostics[1].col)
+ assert.are.equal('mapping values are not allowed in this context', diagnostics[1].message)
+ assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
+ end)
+
+ it('parses Error at format', function()
+ local output = 'Error at "source" (line 75, column 1): unexpected end of input'
+ local diagnostics = presets.markdown.error_parser(output, md_ctx)
assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics)
assert.are.equal(74, diagnostics[1].lnum)
@@ -196,20 +211,9 @@ describe('presets', function()
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end)
- it('parses YAML parse exceptions from stderr', function()
- local stderr =
- 'YAML parse exception at line 3, column 2, while scanning a block scalar: did not find expected comment or line break'
- local diagnostics = presets.markdown.error_parser(stderr, md_ctx)
- assert.is_table(diagnostics)
- assert.are.equal(1, #diagnostics)
- assert.are.equal(2, diagnostics[1].lnum)
- assert.are.equal(1, diagnostics[1].col)
- assert.is_string(diagnostics[1].message)
- end)
-
- it('parses generic pandoc errors from stderr', function()
- local stderr = 'pandoc: Could not find data file templates/default.html5'
- local diagnostics = presets.markdown.error_parser(stderr, md_ctx)
+ it('parses generic pandoc errors', function()
+ local output = 'pandoc: Could not find data file templates/default.html5'
+ local diagnostics = presets.markdown.error_parser(output, md_ctx)
assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics)
assert.are.equal(0, diagnostics[1].lnum)
@@ -217,7 +221,7 @@ describe('presets', function()
assert.are.equal('Could not find data file templates/default.html5', diagnostics[1].message)
end)
- it('returns empty table for clean stderr', function()
+ it('returns empty table for clean output', function()
local diagnostics = presets.markdown.error_parser('', md_ctx)
assert.are.same({}, diagnostics)
end)
@@ -284,9 +288,23 @@ describe('presets', function()
assert.is_true(presets.github.open)
end)
- it('parses pandoc parse errors from stderr', function()
- local stderr = 'Error at "document.md" (line 12, column 5): unexpected "}" expecting letter'
- local diagnostics = presets.github.error_parser(stderr, md_ctx)
+ it('parses YAML metadata errors with multiline message', function()
+ local output = table.concat({
+ 'Error parsing YAML metadata at "/tmp/test.md" (line 1, column 1):',
+ 'YAML parse exception at line 1, column 9:',
+ 'mapping values are not allowed in this context',
+ }, '\n')
+ local diagnostics = presets.github.error_parser(output, md_ctx)
+ assert.is_table(diagnostics)
+ assert.are.equal(1, #diagnostics)
+ assert.are.equal(0, diagnostics[1].lnum)
+ assert.are.equal(0, diagnostics[1].col)
+ assert.are.equal('mapping values are not allowed in this context', diagnostics[1].message)
+ end)
+
+ it('parses Error at format', function()
+ local output = 'Error at "document.md" (line 12, column 5): unexpected "}" expecting letter'
+ local diagnostics = presets.github.error_parser(output, md_ctx)
assert.is_table(diagnostics)
assert.are.equal(1, #diagnostics)
assert.are.equal(11, diagnostics[1].lnum)
@@ -295,7 +313,7 @@ describe('presets', function()
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end)
- it('returns empty table for clean stderr', function()
+ it('returns empty table for clean output', function()
local diagnostics = presets.github.error_parser('', md_ctx)
assert.are.same({}, diagnostics)
end)
From 7995d6422d1fee18958a0fdce3699193a0af24f8 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 14:18:28 -0500
Subject: [PATCH 13/49] feat(compiler): notify on toggle watch start and stop
(#13)
Problem: :Preview toggle gave no feedback, leaving the user to guess
whether watching was enabled or disabled.
Solution: emit vim.notify messages when toggling on ("watching with
\"\"") and off ("watching stopped"). Also normalize the
[preview.nvim] prefix in commands.lua to include the colon.
---
lua/preview/commands.lua | 4 ++--
lua/preview/compiler.lua | 2 ++
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index ad63c97..f82c726 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -27,9 +27,9 @@ local function dispatch(args)
if s.watching then
table.insert(parts, 'watching')
end
- vim.notify('[preview.nvim] ' .. table.concat(parts, ', '), vim.log.levels.INFO)
+ vim.notify('[preview.nvim]: ' .. table.concat(parts, ', '), vim.log.levels.INFO)
else
- vim.notify('[preview.nvim] unknown subcommand: ' .. subcmd, vim.log.levels.ERROR)
+ vim.notify('[preview.nvim]: unknown subcommand: ' .. subcmd, vim.log.levels.ERROR)
end
end
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index a247f2e..052c4f0 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -178,6 +178,7 @@ end
function M.toggle(bufnr, name, provider, ctx_builder)
if watching[bufnr] then
M.unwatch(bufnr)
+ vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO)
return
end
@@ -202,6 +203,7 @@ function M.toggle(bufnr, name, provider, ctx_builder)
watching[bufnr] = au_id
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', {
buffer = bufnr,
From 253ca05da3b6a1d455328b2b74c1f7bb6c81fae5 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 14:57:44 -0500
Subject: [PATCH 14/49] feat(compiler): add configurable error output modes
(#14)
Problem: all parse errors went to vim.diagnostic with no way to silence
them or route them to the quickfix list. Users wanting quickfix-style
error navigation had no option.
Solution: add an errors field to ProviderConfig accepting false,
'diagnostic' (default), or 'quickfix'. false suppresses error handling
entirely. 'quickfix' converts parsed diagnostics to qflist items
(1-indexed), calls setqflist, and opens the window. On success,
'quickfix' mode clears the qflist the same way 'diagnostic' mode clears
vim.diagnostic.
---
lua/preview/compiler.lua | 33 +++++++++++--
lua/preview/init.lua | 1 +
spec/compiler_spec.lua | 102 +++++++++++++++++++++++++++++++++++++++
3 files changed, 133 insertions(+), 3 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 052c4f0..0643ccd 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -86,9 +86,18 @@ function M.compile(bufnr, name, provider, ctx)
return
end
+ local errors_mode = provider.errors
+ if errors_mode == nil then
+ errors_mode = 'diagnostic'
+ end
+
if result.code == 0 then
log.dbg('compilation succeeded for buffer %d', bufnr)
- diagnostic.clear(bufnr)
+ if errors_mode == 'diagnostic' then
+ diagnostic.clear(bufnr)
+ elseif errors_mode == 'quickfix' then
+ vim.fn.setqflist({}, 'r')
+ end
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file },
@@ -105,9 +114,27 @@ function M.compile(bufnr, name, provider, ctx)
end
else
log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code)
- if provider.error_parser then
+ if provider.error_parser and errors_mode then
local output = (result.stdout or '') .. (result.stderr or '')
- diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
+ if errors_mode == 'diagnostic' then
+ diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
+ elseif errors_mode == 'quickfix' then
+ local ok, diagnostics = pcall(provider.error_parser, output, ctx)
+ if ok and diagnostics and #diagnostics > 0 then
+ local items = {}
+ for _, d in ipairs(diagnostics) 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
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index d122e61..4a44a33 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -6,6 +6,7 @@
---@field env? table
---@field output? string|fun(ctx: preview.Context): string
---@field error_parser? fun(output: string, ctx: preview.Context): preview.Diagnostic[]
+---@field errors? false|'diagnostic'|'quickfix'
---@field clean? string[]|fun(ctx: preview.Context): string[]
---@field open? boolean|string[]
diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua
index 2046ef2..2189347 100644
--- a/spec/compiler_spec.lua
+++ b/spec/compiler_spec.lua
@@ -132,6 +132,108 @@ describe('compiler', function()
end)
end)
+ describe('errors mode', function()
+ it('errors = false suppresses error parser', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_false.txt')
+ vim.bo[bufnr].modified = false
+
+ local parser_called = false
+ local provider = {
+ cmd = { 'false' },
+ errors = false,
+ error_parser = function()
+ parser_called = true
+ return {}
+ end,
+ }
+ local ctx = {
+ bufnr = bufnr,
+ file = '/tmp/preview_test_errors_false.txt',
+ root = '/tmp',
+ ft = 'text',
+ }
+
+ compiler.compile(bufnr, 'falsecmd', provider, ctx)
+
+ vim.wait(2000, function()
+ return compiler._test.active[bufnr] == nil
+ end, 50)
+
+ assert.is_false(parser_called)
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('errors = quickfix populates quickfix list', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_qf.txt')
+ vim.bo[bufnr].modified = false
+
+ local provider = {
+ cmd = { 'sh', '-c', 'echo "line 1 error" >&2; exit 1' },
+ errors = 'quickfix',
+ error_parser = function()
+ return {
+ { lnum = 0, col = 0, message = 'test error', severity = vim.diagnostic.severity.ERROR },
+ }
+ end,
+ }
+ local ctx = {
+ bufnr = bufnr,
+ file = '/tmp/preview_test_errors_qf.txt',
+ root = '/tmp',
+ ft = 'text',
+ }
+
+ vim.fn.setqflist({}, 'r')
+ compiler.compile(bufnr, 'qfcmd', provider, ctx)
+
+ vim.wait(2000, function()
+ return compiler._test.active[bufnr] == nil
+ end, 50)
+
+ local qflist = vim.fn.getqflist()
+ assert.are.equal(1, #qflist)
+ assert.are.equal('test error', qflist[1].text)
+ assert.are.equal(1, qflist[1].lnum)
+
+ vim.fn.setqflist({}, 'r')
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('errors = quickfix clears quickfix on success', function()
+ local bufnr = helpers.create_buffer({ 'hello' }, 'text')
+ vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_qf_clear.txt')
+ vim.bo[bufnr].modified = false
+
+ vim.fn.setqflist({ { text = 'old error', lnum = 1 } }, 'r')
+ assert.are.equal(1, #vim.fn.getqflist())
+
+ local provider = {
+ cmd = { 'true' },
+ errors = 'quickfix',
+ error_parser = function()
+ return {}
+ end,
+ }
+ local ctx = {
+ bufnr = bufnr,
+ file = '/tmp/preview_test_errors_qf_clear.txt',
+ root = '/tmp',
+ ft = 'text',
+ }
+
+ compiler.compile(bufnr, 'truecmd', provider, ctx)
+
+ vim.wait(2000, function()
+ return compiler._test.active[bufnr] == nil
+ end, 50)
+
+ assert.are.equal(0, #vim.fn.getqflist())
+ helpers.delete_buffer(bufnr)
+ end)
+ end)
+
describe('stop', function()
it('does nothing when no process is active', function()
assert.has_no.errors(function()
From 7ed4b61c988dc1f400bebe634e3b212936f5c96d Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 15:03:48 -0500
Subject: [PATCH 15/49] refactor(commands): derive completion from dispatch
table (#15)
---
lua/preview/commands.lua | 35 ++++++++++++++++++++++-------------
1 file changed, 22 insertions(+), 13 deletions(-)
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
index f82c726..2c52c5a 100644
--- a/lua/preview/commands.lua
+++ b/lua/preview/commands.lua
@@ -1,22 +1,22 @@
local M = {}
-local subcommands = { 'compile', 'stop', 'clean', 'toggle', 'open', 'status' }
-
----@param args string
-local function dispatch(args)
- local subcmd = args ~= '' and args or 'compile'
-
- if subcmd == 'compile' then
+local handlers = {
+ compile = function()
require('preview').compile()
- elseif subcmd == 'stop' then
+ end,
+ stop = function()
require('preview').stop()
- elseif subcmd == 'clean' then
+ end,
+ clean = function()
require('preview').clean()
- elseif subcmd == 'toggle' then
+ end,
+ toggle = function()
require('preview').toggle()
- elseif subcmd == 'open' then
+ end,
+ open = function()
require('preview').open()
- elseif subcmd == 'status' then
+ end,
+ status = function()
local s = require('preview').status()
local parts = {}
if s.compiling then
@@ -28,6 +28,15 @@ local function dispatch(args)
table.insert(parts, 'watching')
end
vim.notify('[preview.nvim]: ' .. table.concat(parts, ', '), vim.log.levels.INFO)
+ end,
+}
+
+---@param args string
+local function dispatch(args)
+ local subcmd = args ~= '' and args or 'compile'
+ local handler = handlers[subcmd]
+ if handler then
+ handler()
else
vim.notify('[preview.nvim]: unknown subcommand: ' .. subcmd, vim.log.levels.ERROR)
end
@@ -38,7 +47,7 @@ end
local function complete(lead)
return vim.tbl_filter(function(s)
return s:find(lead, 1, true) == 1
- end, subcommands)
+ end, vim.tbl_keys(handlers))
end
function M.setup()
From 4c22f84b31ebfee1b8fd59dbb254917582f3985e Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 15:04:03 -0500
Subject: [PATCH 16/49] feat(init): validate provider config eagerly in setup
(#16)
---
lua/preview/init.lua | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index 4a44a33..f4f2831 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -85,6 +85,20 @@ function M.setup(opts)
end
end
+ for ft, provider in pairs(providers) do
+ local prefix = 'providers.' .. ft
+ vim.validate(prefix .. '.cmd', provider.cmd, 'table')
+ vim.validate(prefix .. '.cmd[1]', provider.cmd[1], 'string')
+ vim.validate(prefix .. '.args', provider.args, { 'table', 'function' }, true)
+ vim.validate(prefix .. '.cwd', provider.cwd, { 'string', 'function' }, true)
+ vim.validate(prefix .. '.output', provider.output, { 'string', 'function' }, true)
+ vim.validate(prefix .. '.error_parser', provider.error_parser, 'function', true)
+ vim.validate(prefix .. '.errors', provider.errors, function(x)
+ return x == nil or x == false or x == 'diagnostic' or x == 'quickfix'
+ end, 'false, "diagnostic", or "quickfix"')
+ vim.validate(prefix .. '.open', provider.open, { 'boolean', 'table' }, true)
+ end
+
config = vim.tbl_deep_extend('force', default_config, {
debug = debug,
providers = providers,
From 99263dec9f33712eacf001b703acff5166a9f6a6 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 15:12:14 -0500
Subject: [PATCH 17/49] refactor(compiler): resolve output before args (#17)
Problem: presets that need the output path in their args function
(markdown, github) had to recompute it inline, duplicating the same
gsub expression already in the output field.
Solution: resolve output_file first in M.compile, then extend ctx with
output = output_file into a resolved_ctx before evaluating args and cwd.
Presets can now reference ctx.output directly. Add output? to the
preview.Context type annotation.
---
lua/preview/compiler.lua | 16 +++++++++-------
lua/preview/init.lua | 1 +
lua/preview/presets.lua | 6 ++----
spec/presets_spec.lua | 2 ++
4 files changed, 14 insertions(+), 11 deletions(-)
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
index 0643ccd..eca9f4d 100644
--- a/lua/preview/compiler.lua
+++ b/lua/preview/compiler.lua
@@ -53,19 +53,21 @@ function M.compile(bufnr, name, provider, ctx)
M.stop(bufnr)
end
+ local output_file = ''
+ if provider.output then
+ output_file = eval_string(provider.output, ctx)
+ end
+
+ local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file })
+
local cmd = vim.list_extend({}, provider.cmd)
if provider.args then
- vim.list_extend(cmd, eval_list(provider.args, ctx))
+ vim.list_extend(cmd, eval_list(provider.args, resolved_ctx))
end
local cwd = ctx.root
if provider.cwd then
- cwd = eval_string(provider.cwd, ctx)
- end
-
- local output_file = ''
- if provider.output then
- output_file = eval_string(provider.output, ctx)
+ cwd = eval_string(provider.cwd, resolved_ctx)
end
if output_file ~= '' then
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
index f4f2831..2bee03a 100644
--- a/lua/preview/init.lua
+++ b/lua/preview/init.lua
@@ -19,6 +19,7 @@
---@field file string
---@field root string
---@field ft string
+---@field output? string
---@class preview.Diagnostic
---@field lnum integer
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
index 8b9faab..c1de0df 100644
--- a/lua/preview/presets.lua
+++ b/lua/preview/presets.lua
@@ -138,8 +138,7 @@ M.markdown = {
ft = 'markdown',
cmd = { 'pandoc' },
args = function(ctx)
- local output = ctx.file:gsub('%.md$', '.html')
- return { ctx.file, '-s', '--embed-resources', '-o', output }
+ return { ctx.file, '-s', '--embed-resources', '-o', ctx.output }
end,
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
@@ -158,7 +157,6 @@ M.github = {
ft = 'markdown',
cmd = { 'pandoc' },
args = function(ctx)
- local output = ctx.file:gsub('%.md$', '.html')
return {
'-f',
'gfm',
@@ -168,7 +166,7 @@ M.github = {
'--css',
'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',
- output,
+ ctx.output,
}
end,
output = function(ctx)
diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua
index 904a4f4..6eaa613 100644
--- a/spec/presets_spec.lua
+++ b/spec/presets_spec.lua
@@ -150,6 +150,7 @@ describe('presets', function()
file = '/tmp/document.md',
root = '/tmp',
ft = 'markdown',
+ output = '/tmp/document.html',
}
it('has ft', function()
@@ -233,6 +234,7 @@ describe('presets', function()
file = '/tmp/document.md',
root = '/tmp',
ft = 'markdown',
+ output = '/tmp/document.html',
}
it('has ft', function()
From bce3cec0e66654eb7b13ad46c5dbe8f9209e72de Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 15:12:28 -0500
Subject: [PATCH 18/49] docs: update help file for recent additions (#18)
---
doc/preview.nvim.txt | 22 +++++++++++++++++++---
1 file changed, 19 insertions(+), 3 deletions(-)
diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt
index 0b44a91..c8bc708 100644
--- a/doc/preview.nvim.txt
+++ b/doc/preview.nvim.txt
@@ -74,9 +74,16 @@ Provider fields:~
`output` string|function Output file path. If a function,
receives a |preview.Context|.
- `error_parser` function Receives (stderr, |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|.
@@ -85,7 +92,6 @@ Provider fields:~
successful compilation. `true` uses
|vim.ui.open()|. A string[] is run as
a command with the output path appended.
- Presets default to `{ 'xdg-open' }`.
*preview.Context*
Context fields:~
@@ -94,6 +100,8 @@ Context fields:~
`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
@@ -156,7 +164,8 @@ COMMANDS *preview.nvim-commands*
`stop` Kill active compilation for the current buffer.
`clean` Run the provider's clean command.
`toggle` Toggle auto-compile on save for the current buffer.
- `status` Echo compilation status (idle, compiling, toggled).
+ `open` Open the last compiled output without recompiling.
+ `status` Echo compilation status (idle, compiling, watching).
==============================================================================
API *preview.nvim-api*
@@ -175,9 +184,16 @@ preview.toggle({bufnr?}) *preview.toggle()*
immediately compiled and automatically recompiled on each save
(`BufWritePost`). Call again to stop.
+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:~
From 62961c854168cec9ae30e52c3e1b2e914248f1f2 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Tue, 3 Mar 2026 16:41:47 -0500
Subject: [PATCH 19/49] feat: unified reload field for live-preview (SSE +
long-running watch) (#19)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(reload): add SSE live-reload server module
Problem: HTML output from pandoc has no live-reload; the browser must
be refreshed manually after each compile.
Solution: add lua/preview/reload.lua — a minimal SSE-only TCP server.
start() binds 127.0.0.1:5554 and keeps EventSource connections alive;
broadcast() pushes a reload event to all clients; inject() appends an
EventSource script before
hello