Compare commits

..

26 commits

Author SHA1 Message Date
e76ace674f merge: resolve conflict with origin/main 2026-03-05 11:04:53 -05:00
Barrett Ruth
9fe68dd159
docs: document plantuml and mermaid presets (#47)
Problem: the README and vimdoc presets list omitted `plantuml` and
`mermaid` after both were added.

Solution: add both presets to the vimdoc table and the README features
blurb.
2026-03-05 11:03:43 -05:00
6f090fdcf3
build: split nix dev shell into default and presets
Problem: the single dev shell mixed dev tooling (linters, test runner)
with preset compiler tools, causing heavy rebuilds (e.g. Chromium for
`mermaid-cli`) for contributors who only need the dev tools.

Solution: extract dev tooling into a shared `devTools` list and expose
two shells — `default` for development and `presets` for running all
built-in preset compilers (`typst`, `texliveMedium`, `tectonic`,
`pandoc`, `asciidoctor`, `quarto`, `plantuml`, `mermaid-cli`).
2026-03-05 10:55:03 -05:00
Barrett Ruth
31dcf9c91f
feat: add mermaid preset (#46)
Problem: no built-in support for compiling mermaid diagrams via `mmdc`.

Solution: add a `mermaid` preset that compiles `.mmd` files to SVG and
parses `Parse error on line N` diagnostics from stderr. Add
`mermaid-cli` to the nix dev shell.
2026-03-05 10:44:33 -05:00
Barrett Ruth
23aa8acc55
feat: add plantuml preset (#45)
Problem: PlantUML (`.puml`) diagrams have no built-in preview support,
and Neovim lacks filetype detection for PlantUML files.

Solution: Add a `plantuml` preset that compiles to SVG via `plantuml
-tsvg`, with an error parser for `Error line N` diagnostics. Register
`.puml` and `.pu` extensions via `vim.filetype.add` when the preset is
configured. Add `plantuml` to the nix dev shell.
2026-03-05 09:32:33 -05:00
Barrett Ruth
837c97cd09
docs: rewrite vimdoc to match pending.txt conventions (#44)
Problem: The vimdoc used `preview.nvim.txt` filename and
`*preview.nvim-xyz*` tags, inconsistent with other plugins.

Solution: Rename to `preview.txt`, normalize tags to `*preview-xyz*`,
add contents/install sections, and use `{field} (type)` formatting.
2026-03-05 01:38:29 -05:00
Barrett Ruth
7895b67c21
docs: replace all setup() references with vim.g.preview (#43) 2026-03-04 19:39:00 -05:00
Barrett Ruth
f1aed82f42
feat: add detach provider field and vim.g.preview config support (#42)
Problem: viewer processes launched via a string[] `open` command were
always killed on buffer deletion with no way to opt out. Configuring
the plugin also required an explicit `setup()` call in a `config`
hook, preventing config from being declared before the plugin loads.

Solution: add a `detach` boolean to `ProviderConfig` that skips
SIGTERM on buffer unload. Auto-call `setup()` from `vim.g.preview`
at module load time, enabling config via lazy.nvim's `init` hook.
Update vimdoc and README accordingly.
2026-03-04 19:30:56 -05:00
Barrett Ruth
bb9ca987e1
Add note about previewer auto-close feature
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
2026-03-04 19:12:33 -05:00
8107f8c0ac
doc: improve error phrasing, remove redundant feautre
Some checks are pending
quality / changes (push) Waiting to run
quality / Lua Format Check (push) Blocked by required conditions
quality / Lua Lint Check (push) Blocked by required conditions
quality / Lua Type Check (push) Blocked by required conditions
quality / Markdown Format Check (push) Blocked by required conditions
test / Test (Neovim nightly) (push) Waiting to run
test / Test (Neovim stable) (push) Waiting to run
2026-03-04 17:28:07 -05:00
Barrett Ruth
cf8fd02e6d
Add video demonstration to README
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
Added a video demonstration to the README.
2026-03-04 17:23:57 -05:00
Barrett Ruth
3e6ba580e4
fix: quickfix support for long-running providers (#41)
* fix(compiler): open quickfix in background, retain focus on source buffer

* fix(compiler): use cwindow and win_gotoid for quickfix focus management

* fix: unused var warning and update typst reload test for short format

* fix: remove testing files
2026-03-04 17:23:06 -05:00
Barrett Ruth
c8e3a88434
fix: stream stderr for long-running providers, clear errors on success (#40)
Problem: Long-running providers (e.g. `typst watch`) never exit on
compile error, so the exit callback never fired and diagnostics/quickfix
were never populated. The `typst watch` `reload` command also lacked
`--diagnostic-format short`, producing unparseable verbose output.

Solution: Add a `stderr` streaming callback to the long-running
`vim.system` call that accumulates chunks and re-parses on each new
chunk, populating diagnostics or quickfix in real time. When the
fs_event fires (successful compile), clear `stderr_acc` and the
reported errors. Add `--diagnostic-format short` to the typst `reload`
command to match the one-shot `args` format.
2026-03-04 16:33:58 -05:00
Barrett Ruth
239f8a4769
fix(compiler): defer open until successful compile, close viewer on :bd (#38)
* fix(compiler): defer open until successful compile, close viewer on :bd

Problem: For long-running providers (e.g. `typst watch`), the viewer
was opened immediately on toggle start by checking if the output file
existed on disk. A stale PDF from a prior session satisfied that check,
so a failed compile still opened the viewer. Additionally, viewer
processes spawned via a table `open` command were untracked, so `:bd`
killed the compiler but left the viewer running.

Solution: Replace the immediate open with a `vim.uv.new_fs_event`
directory watcher that fires only when the output file's `mtime`
advances past its pre-compile value, proving the current session wrote
it. Add `viewer_procs` and `open_watchers` tables with `close_viewer`
and `stop_open_watcher` helpers; all `BufUnload` paths and `stop_all`
now tear down both. Extract `do_open` to deduplicate the open branching
logic across three call sites.

* docs: document viewer auto-close behaviour and limitations in `open` field

* ci: format
2026-03-04 15:48:30 -05:00
df0765a27f
doc: cleanup 2026-03-04 15:11:44 -05:00
934ef03728
doc: cleanup readme 2026-03-04 15:10:40 -05:00
e8f93fb47e
doc: readme typo 2026-03-04 15:09:45 -05:00
Barrett Ruth
7a11f39341
docs: pre-release polish (#36)
* docs: pre-release polish

Update README preset list to include pdflatex, tectonic, asciidoctor,
and quarto. Fix custom provider FAQ example to use a non-preset key.
Clarify open field fires on toggle/watch mode only, not :Preview compile.
Expand intro to mention AsciiDoc and Quarto alongside existing tools.

* docs: update slogan to universal document previewer

* ci: format
2026-03-04 15:09:07 -05:00
Barrett Ruth
68e2e82232
fix(presets): add --failure-level ERROR to asciidoctor, add clean to typst/pdflatex/tectonic, skip auto-open on one-shot compile (#35)
Problem: asciidoctor exits 0 on errors so error_parser never ran.
typst, pdflatex, and tectonic had no clean subcommand. auto-open
fired on :Preview compile, surprising users who just want a build.

Solution: pass --failure-level ERROR in asciidoctor args. Add clean
commands to typst (rm pdf), pdflatex (rm pdf/aux/log/synctex.gz),
and tectonic (rm pdf). Gate auto-open on not opts.oneshot so it
only fires during toggle/watch mode.
2026-03-04 14:57:36 -05:00
Barrett Ruth
d4e7d8c2fd
fix(ci): use absolute path for lua-language-server --configpath (#34)
Problem: --configpath is resolved relative to the workspace root passed
to --check (lua/), not CWD. So .luarc.json was looked up at lua/.luarc.json
and not found, leaving vim and jit as undefined globals.

Solution: expand to an absolute path with $(pwd) at shell invocation
time, matching what the GitHub CI action already does.
2026-03-04 14:33:25 -05:00
Barrett Ruth
ec00648f7a
fix(ci): pass --configpath to lua-language-server (#33)
Problem: lua-language-server --check lua/ treats lua/ as its workspace
root and fails to find .luarc.json in the project root, so diagnostics
globals (vim, jit) are not loaded and every vim.* reference is flagged
as undefined-global.

Solution: pass --configpath .luarc.json explicitly, matching what the
GitHub CI action already does.
2026-03-04 14:31:17 -05:00
Barrett Ruth
dd27374833
fix(ci): resolve lua-language-server warnings (#32)
Problem: reload_spec.lua called io.open() without nil checks, causing
need-check-nil warnings. Adding ${3rd}/busted and ${3rd}/luassert to
workspace.library caused lua-language-server 3.7.4 to run diagnostics
on its own bundled meta files, surfacing pre-existing cast-local-type
bugs in luassert's annotations that are not ours to fix.

Solution: use assert(io.open(...)) in reload_spec.lua to satisfy the
nil check. Remove busted/luassert library paths from .luarc.json since
they only benefit spec/ which is not type-checked in CI. Narrow the
lua-language-server check in scripts/ci.sh to lua/ to match CI.
2026-03-04 14:28:52 -05:00
Barrett Ruth
50a21a787d
ci: scripts (#31) 2026-03-04 14:23:38 -05:00
Barrett Ruth
180c672983
feat(presets): add pdflatex, tectonic, asciidoctor, and quarto presets (#30)
* feat(presets): add pdflatex preset

Adds a direct pdflatex preset for users who want single-pass
compilation without latexmk orchestration. Uses -file-line-error
for parseable diagnostics and reuses the existing parse_latexmk
error parser since both emit the same file:line: message format.

* feat(presets): add tectonic preset

Adds a tectonic preset for the modern Rust-based LaTeX engine, which
auto-downloads packages and requires no TeX installation. Reuses
parse_latexmk since tectonic emits the same file:line: message
diagnostic format.

* feat(presets): add asciidoctor preset

Adds an asciidoctor preset for AsciiDoc → HTML compilation with SSE
live-reload. Includes a parse_asciidoctor error parser handling the
"asciidoctor: SEVERITY: file: line N: message" format for both
ERROR and WARNING diagnostics.

* feat(presets): add quarto preset

Adds a quarto preset for .qmd scientific documents rendering to
self-contained HTML with SSE live-reload. Uses --embed-resources
to avoid a _files directory in the common case. No error_parser
since quarto errors are heterogeneous (mixed R/Python/pandoc output).

* refactor: apply stylua formatting to new preset code
2026-03-04 14:02:30 -05:00
Barrett Ruth
c94df7c5d0
fix: lifecycle cleanup and defensive runtime checks (#29)
* fix(commands): register VimLeavePre to call stop_all

Problem: spawned compiler processes and watching autocmds were never
cleaned up when Neovim exited, leaving orphaned processes running.

Solution: register a VimLeavePre autocmd in commands setup that calls
compiler.stop_all(), which kills active processes, unwatches all
buffers, and stops the reload server.

* fix(compiler): replace BufWipeout with BufUnload

Problem: cleanup autocmds used BufWipeout, which only fires for
:bwipeout. The common :bdelete path (used by most buffer managers
and nvim_buf_delete) fires BufUnload but not BufWipeout, so processes
and watches leaked on normal buffer deletion.

Solution: switch all three cleanup autocmds from BufWipeout to
BufUnload, which fires for both :bdelete and :bwipeout.

* fix(init): guard against unnamed buffer in public API

Problem: calling compile/toggle/clean/open on an unsaved scratch
buffer passed an empty string as ctx.file, producing nonsensical
output paths like ".pdf" and silently passing empty strings to
compiler binaries.

Solution: add an early return with a WARN notification in compile,
toggle, clean, and open when the buffer has no file name.

* fix(compiler): add fs_stat check to one-shot open path

Problem: the long-running process path already guarded opens with
vim.uv.fs_stat(), but the one-shot compile path and M.open() did not.
Compilation can exit 0 and produce no output, and output files can be
externally deleted between compile and open.

Solution: add the same fs_stat guard to the one-shot open branch and
to M.open() before attempting to launch the viewer.

* fix(compiler): check executable before spawning process

Problem: if a configured binary was missing or not in PATH, vim.system
would fail silently or with a cryptic OS error. The user had no
actionable feedback without running :checkhealth.

Solution: check vim.fn.executable() at the start of M.compile() and
notify with an ERROR-level message pointing to :checkhealth preview
if the binary is not found.

* fix(compiler): reformat one-shot open condition for line length

Problem: the added fs_stat condition exceeded stylua's line length
limit on the one-shot open guard.

Solution: split the boolean condition across multiple lines to match
the project's stylua formatting rules.
2026-03-04 14:02:16 -05:00
Barrett Ruth
75b855438a
refactor: simplify command surface (#28)
* refactor: rename build to compile and watch to toggle in public API

Problem: the code used build/watch while the help file already
documented compile/toggle, creating a confusing mismatch.

Solution: rename M.build() to M.compile() and M.watch() to M.toggle()
in init.lua, update handler keys in commands.lua, and update the test
file to match.

* refactor(commands): make toggle the default subcommand

Problem: bare :Preview ran a one-shot compile, but users reaching for a
"preview" plugin expect it to start previewing (i.e. watch mode).

Solution: change the fallback subcommand from compile to toggle so
:Preview starts/stops auto-compile on save.

* refactor(commands): remove stop subcommand

Problem: :Preview stop had a subtle distinction from toggle-off (kill
process but keep autocmd) that nobody reaches for deliberately from
the command line.

Solution: remove stop from the command dispatch table. The Lua API
require('preview').stop() remains as a programmatic escape hatch.

* docs: update help file for new command surface and document reload

Problem: the help file listed compile as the default subcommand, still
included the stop subcommand, omitted the reload provider field, and
had a misleading claim about shipping with zero defaults.

Solution: make toggle the default in the commands section, remove stop
from subcommands, add reload to provider fields, fix the introduction
text, reorder API entries to match new primacy, and add an output path
override example addressing #26/#27.
2026-03-04 13:16:01 -05:00
15 changed files with 1037 additions and 335 deletions

View file

@ -2,12 +2,7 @@
"runtime.version": "LuaJIT",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim", "jit"],
"workspace.library": [
"$VIMRUNTIME/lua",
"${3rd}/luv/library",
"${3rd}/busted/library",
"${3rd}/luassert/library"
],
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
"workspace.checkThirdParty": false,
"workspace.ignoreDir": [".direnv"],
"completion.callSnippet": "Replace"

View file

@ -1,17 +1,19 @@
# preview.nvim
**Async document compilation for Neovim**
**Universal document previewer for Neovim**
An extensible framework for compiling documents (LaTeX, Typst, Markdown, etc.)
asynchronously with error diagnostics.
An extensible framework for compiling and previewing _any_ documents (LaTeX,
Typst, Markdown, etc.)—diagnostics included.
<video src="https://github.com/user-attachments/assets/3b4fbc31-c1c4-4429-a9dc-a68d6185ab2e" width="100%" controls></video>
## 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 (latexmk, pdflatex, tectonic), Markdown,
GitHub-flavored Markdown, AsciiDoc, PlantUML, Mermaid, and Quarto
- Compiler errors via `vim.diagnostic` or quickfix
- Previewer auto-close on buffer deletion
## Requirements
@ -19,8 +21,18 @@ asynchronously with error diagnostics.
## Installation
Install with your package manager of choice or via
[luarocks](https://luarocks.org/modules/barrettruth/preview.nvim):
With lazy.nvim:
```lua
{
'barrettruth/preview.nvim',
init = function()
vim.g.preview = { typst = true, latex = true }
end,
}
```
Or via [luarocks](https://luarocks.org/modules/barrettruth/preview.nvim):
```
luarocks install preview.nvim
@ -37,35 +49,35 @@ luarocks install preview.nvim
**Q: How do I define a custom provider?**
```lua
require('preview').setup({
typst = {
cmd = { 'typst', 'compile' },
vim.g.preview = {
rst = {
cmd = { 'rst2html' },
args = function(ctx)
return { ctx.file }
return { ctx.file, ctx.output }
end,
output = function(ctx)
return ctx.file:gsub('%.typ$', '.pdf')
return ctx.file:gsub('%.rst$', '.html')
end,
},
})
}
```
**Q: How do I override a preset?**
```lua
require('preview').setup({
vim.g.preview = {
typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } },
})
}
```
**Q: How do I automatically open the output file?**
Set `open = true` on your provider (all built-in presets have this enabled) to
open the output with `vim.ui.open()` after the first successful compilation. For
a specific application, pass a command table:
open the output with `vim.ui.open()` after the first successful compilation in
toggle/watch mode. For a specific application, pass a command table:
```lua
require('preview').setup({
vim.g.preview = {
typst = { open = { 'sioyek', '--new-instance' } },
})
}
```

View file

@ -1,263 +0,0 @@
*preview.nvim.txt* Async document compilation for Neovim
Author: Barrett Ruth <br.barrettruth@gmail.com>
License: MIT
==============================================================================
INTRODUCTION *preview.nvim*
preview.nvim is an extensible framework for compiling documents asynchronously
in Neovim. It provides a unified interface for any compilation workflow —
LaTeX, Typst, Markdown, or anything else with a CLI compiler.
The plugin ships with opt-in presets for common tools (Typst, LaTeX, Pandoc)
and supports fully custom providers. See |preview.nvim-presets|.
==============================================================================
REQUIREMENTS *preview.nvim-requirements*
- Neovim >= 0.11.0
- A compiler binary for each configured provider (e.g. `typst`, `latexmk`)
==============================================================================
SETUP *preview.nvim-setup*
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*
Configure via `require('preview').setup()`.
*preview.setup()*
setup({opts?})
`opts` is a table where keys are preset names or filetypes. For each
key `k` with value `v` (excluding `debug`):
- If `k` is a preset name and `v` is `true`, the preset is registered
as-is under its filetype.
- If `k` is a preset name and `v` is a table, it is deep-merged with
the preset and registered under the preset's filetype.
- If `k` is not a preset name and `v` is a table, it is registered
directly as a custom provider keyed by filetype `k`.
- If `v` is `false`, the entry is skipped (no-op).
See |preview.nvim-presets| for available preset names.
Fields:~
`debug` boolean|string Enable debug logging. A string value
is treated as a log file path.
Default: `false`
*preview.ProviderConfig*
Provider fields:~
`cmd` string[] The compiler command (required).
`args` string[]|function Additional arguments. If a function,
receives a |preview.Context| and returns
a string[].
`cwd` string|function Working directory. If a function,
receives a |preview.Context|. Default:
git root or file directory.
`env` table Environment variables.
`output` string|function Output file path. If a function,
receives a |preview.Context|.
`error_parser` function Receives (output, |preview.Context|)
and returns vim.Diagnostic[].
`errors` false|'diagnostic'|'quickfix'
How parse errors are reported.
`false` suppresses error handling.
`'quickfix'` populates the quickfix
list and opens it. Default:
`'diagnostic'`.
`clean` string[]|function Command to remove build artifacts.
If a function, receives a
|preview.Context|.
`open` boolean|string[] Open the output file after the first
successful compilation. `true` uses
|vim.ui.open()|. A string[] is run as
a command with the output path appended.
`reload` boolean|string[]|function
Reload the output after recompilation.
`true` uses a built-in SSE server for
HTML files. A string[] is run as a
command. If a function, receives a
|preview.Context| and returns a
string[].
*preview.Context*
Context fields:~
`bufnr` integer Buffer number.
`file` string Absolute file path.
`root` string Project root (git root or file directory).
`ft` string Filetype.
`output` string? Resolved output file path (set after `output`
is evaluated, available to `args` functions).
Example enabling presets:~
>lua
require('preview').setup({ typst = true, latex = true, github = true })
<
Example overriding a preset field:~
>lua
require('preview').setup({
typst = { open = { 'sioyek', '--new-instance' } },
})
<
Example overriding the output path (e.g. latexmk `$out_dir`):~
>lua
require('preview').setup({
latex = {
output = function(ctx)
return 'build/' .. vim.fn.fnamemodify(ctx.file, ':t:r') .. '.pdf'
end,
},
})
<
Example with a fully custom provider (key is not a preset name):~
>lua
require('preview').setup({
rst = {
cmd = { 'rst2html' },
args = function(ctx)
return { ctx.file }
end,
output = function(ctx)
return ctx.file:gsub('%.rst$', '.html')
end,
},
})
<
==============================================================================
PRESETS *preview.nvim-presets*
preview.nvim ships with pre-built provider configurations for common tools.
Import them from `preview.presets`:
`presets.typst` typst compile → PDF
`presets.latex` latexmk -pdf → PDF (with clean support)
`presets.markdown` pandoc → HTML (standalone, embedded)
`presets.github` pandoc → HTML (GitHub-styled, `-f gfm` input)
Enable presets with `preset_name = true`:
>lua
require('preview').setup({ typst = true, latex = true, github = true })
<
Override individual fields by passing a table instead of `true`:
>lua
require('preview').setup({
typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } },
})
<
==============================================================================
COMMANDS *preview.nvim-commands*
:Preview [subcommand] *:Preview*
Subcommands:~
`toggle` Toggle auto-compile on save (default if omitted).
`compile` One-shot compile of the current buffer.
`clean` Run the provider's clean command.
`open` Open the last compiled output without recompiling.
`status` Echo compilation status (idle, compiling, watching).
==============================================================================
API *preview.nvim-api*
preview.toggle({bufnr?}) *preview.toggle()*
Toggle auto-compile for the buffer. When enabled, the buffer is
immediately compiled and automatically recompiled on each save
(`BufWritePost`). Call again to stop.
preview.compile({bufnr?}) *preview.compile()*
One-shot compile the document in the given buffer (default: current).
preview.stop({bufnr?}) *preview.stop()*
Kill the active compilation process for the buffer. Programmatic
escape hatch — not exposed as a subcommand.
preview.clean({bufnr?}) *preview.clean()*
Run the provider's clean command for the buffer.
preview.open({bufnr?}) *preview.open()*
Open the last compiled output for the buffer without recompiling.
preview.status({bufnr?}) *preview.status()*
Returns a |preview.Status| table.
preview.statusline({bufnr?}) *preview.statusline()*
Returns a short status string for statusline integration:
`'compiling'`, `'watching'`, or `''` (idle).
*preview.Status*
Status fields:~
`compiling` boolean Whether compilation is active.
`watching` boolean Whether auto-compile is active.
`provider` string? Name of the active provider.
`output_file` string? Path to the output file.
preview.get_config() *preview.get_config()*
Returns the resolved |preview.Config|.
==============================================================================
EVENTS *preview.nvim-events*
preview.nvim fires User autocmds with structured data:
`PreviewCompileStarted` Compilation began.
data: `{ bufnr, provider }`
`PreviewCompileSuccess` Compilation succeeded (exit code 0).
data: `{ bufnr, provider, output }`
`PreviewCompileFailed` Compilation failed (non-zero exit).
data: `{ bufnr, provider, code, stderr }`
Example:~
>lua
vim.api.nvim_create_autocmd('User', {
pattern = 'PreviewCompileSuccess',
callback = function(args)
local data = args.data
vim.notify('Compiled ' .. data.output .. ' with ' .. data.provider)
end,
})
<
==============================================================================
HEALTH *preview.nvim-health*
Run `:checkhealth preview` to verify:
- Neovim version >= 0.11.0
- Each configured provider's binary is executable
- Each configured provider's opener binary (if any) is executable
- Each configured provider's filetype mapping is valid
==============================================================================
vim:tw=78:ts=8:ft=help:norl:

276
doc/preview.txt Normal file
View file

@ -0,0 +1,276 @@
*preview.txt* Async document compilation for Neovim
Author: Barrett Ruth <br.barrettruth@gmail.com>
License: MIT
==============================================================================
INTRODUCTION *preview.nvim*
preview.nvim is an extensible framework for compiling documents asynchronously
in Neovim. It provides a unified interface for any compilation workflow —
LaTeX, Typst, Markdown, or anything else with a CLI compiler.
The plugin ships with opt-in presets for common tools (Typst, LaTeX, Pandoc,
AsciiDoc, PlantUML, Mermaid, Quarto) and supports fully custom providers.
See |preview-presets|.
==============================================================================
CONTENTS *preview-contents*
1. Introduction ............................................. |preview.nvim|
2. Requirements ..................................... |preview-requirements|
3. Install ............................................... |preview-install|
4. Configuration ........................................... |preview-config|
5. Presets ............................................... |preview-presets|
6. Commands ............................................. |preview-commands|
7. Lua API ................................................... |preview-api|
8. Events ............................................... |preview-events|
9. Health ............................................... |preview-health|
==============================================================================
REQUIREMENTS *preview-requirements*
- Neovim >= 0.11.0
- A compiler binary for each configured provider (e.g. `typst`, `latexmk`)
==============================================================================
INSTALL *preview-install*
Install with lazy.nvim: >lua
{ 'barrettruth/preview.nvim' }
<
No `setup()` call is needed. The plugin loads automatically when
|vim.g.preview| is set. See |preview-config|.
==============================================================================
CONFIGURATION *preview-config*
Configure by setting |vim.g.preview| to a table where keys are preset names
or filetypes. For each key `k` with value `v` (excluding `debug`):
- If `k` is a preset name and `v` is `true`, the preset is registered
as-is under its filetype.
- If `k` is a preset name and `v` is a table, it is deep-merged with
the preset and registered under the preset's filetype.
- If `k` is not a preset name and `v` is a table, it is registered
directly as a custom provider keyed by filetype `k`.
- If `v` is `false`, the entry is skipped (no-op).
See |preview-presets| for available preset names.
*preview.ProviderConfig*
Provider fields: ~
{cmd} (string[]) Compiler command (required).
{args} (string[]|function) Additional arguments. If a function,
receives a |preview.Context| and
returns a string[].
{cwd} (string|function) Working directory. If a function,
receives a |preview.Context|.
Default: git root or file directory.
{env} (table) Environment variables.
{output} (string|function) Output file path. If a function,
receives a |preview.Context|.
{error_parser} (function) Receives (output, |preview.Context|)
and returns vim.Diagnostic[].
{errors} (false|'diagnostic'|'quickfix')
How parse errors are reported.
`false` suppresses error handling.
`'quickfix'` populates the quickfix
list and opens it.
Default: `'diagnostic'`.
{clean} (string[]|function) Command to remove build artifacts.
If a function, receives a
|preview.Context|.
{open} (boolean|string[]) Open the output file after the first
successful compilation in toggle/watch
mode. `true` uses |vim.ui.open()|. A
string[] is run as a command with the
output path appended. When a string[]
is used the viewer process is tracked
and sent SIGTERM when the buffer is
deleted. `true` and single-instance
apps (e.g. Chrome) do not support
auto-close.
{reload} (boolean|string[]|function)
Reload the output after recompilation.
`true` uses a built-in SSE server for
HTML files. A string[] is run as a
command. If a function, receives a
|preview.Context| and returns a
string[].
{detach} (boolean) When `true`, the viewer process opened
via a string[] `open` command is not
sent SIGTERM when the buffer is
deleted. Has no effect when `open` is
`true`. Default: `false`.
*preview.Context*
Context fields: ~
{bufnr} (integer) Buffer number.
{file} (string) Absolute file path.
{root} (string) Project root (git root or file directory).
{ft} (string) Filetype.
{output} (string?) Resolved output file path (set after `output`
is evaluated, available to `args` functions).
Global options: ~
{debug} (boolean|string) Enable debug logging. A string value is treated
as a log file path. Default: `false`.
Example enabling presets: >lua
vim.g.preview = { typst = true, latex = true, github = true }
<
Example overriding a preset field: >lua
vim.g.preview = {
typst = { open = { 'sioyek', '--new-instance' } },
}
<
Example overriding the output path (e.g. latexmk `$out_dir`): >lua
vim.g.preview = {
latex = {
output = function(ctx)
return 'build/' .. vim.fn.fnamemodify(ctx.file, ':t:r') .. '.pdf'
end,
},
}
<
Example with a fully custom provider (key is not a preset name): >lua
vim.g.preview = {
rst = {
cmd = { 'rst2html' },
args = function(ctx)
return { ctx.file }
end,
output = function(ctx)
return ctx.file:gsub('%.rst$', '.html')
end,
},
}
<
==============================================================================
PRESETS *preview-presets*
Built-in provider configurations. Enable with `preset_name = true` or
override individual fields by passing a table instead: >lua
vim.g.preview = { typst = true, latex = true, github = true }
<
`typst` typst compile → PDF
`latex` latexmk -pdf → PDF (with clean support)
`pdflatex` pdflatex → PDF (single pass, no latexmk)
`tectonic` tectonic → PDF (Rust-based LaTeX engine)
`markdown` pandoc → HTML (standalone, embedded)
`github` pandoc → HTML (GitHub-styled, `-f gfm` input)
`asciidoctor` asciidoctor → HTML (AsciiDoc with SSE reload)
`plantuml` plantuml → SVG (UML diagrams, `.puml`)
`mermaid` mmdc → SVG (Mermaid diagrams, `.mmd`)
`quarto` quarto render → HTML (scientific publishing)
==============================================================================
COMMANDS *preview-commands*
:Preview [subcommand] *:Preview*
Subcommands: ~
`toggle` Toggle auto-compile on save (default if omitted).
`compile` One-shot compile of the current buffer.
`clean` Run the provider's clean command.
`open` Open the last compiled output without recompiling.
`status` Echo compilation status (idle, compiling, watching).
==============================================================================
LUA API *preview-api*
preview.toggle({bufnr?}) *preview.toggle()*
Toggle auto-compile for the buffer. When enabled, the buffer is
immediately compiled and automatically recompiled on each save
(`BufWritePost`). Call again to stop.
preview.compile({bufnr?}) *preview.compile()*
One-shot compile the document in the given buffer (default: current).
preview.stop({bufnr?}) *preview.stop()*
Kill the active compilation process for the buffer. Programmatic
escape hatch — not exposed as a subcommand.
preview.clean({bufnr?}) *preview.clean()*
Run the provider's clean command for the buffer.
preview.open({bufnr?}) *preview.open()*
Open the last compiled output for the buffer without recompiling.
preview.status({bufnr?}) *preview.status()*
Returns a |preview.Status| table.
preview.statusline({bufnr?}) *preview.statusline()*
Returns a short status string for statusline integration:
`'compiling'`, `'watching'`, or `''` (idle).
preview.get_config() *preview.get_config()*
Returns the resolved |preview.Config|.
*preview.Status*
Status fields: ~
{compiling} (boolean) Whether compilation is active.
{watching} (boolean) Whether auto-compile is active.
{provider} (string?) Name of the active provider.
{output_file} (string?) Path to the output file.
==============================================================================
EVENTS *preview-events*
preview.nvim fires User autocmds with structured data:
`PreviewCompileStarted` Compilation began.
data: `{ bufnr, provider }`
`PreviewCompileSuccess` Compilation succeeded (exit code 0).
data: `{ bufnr, provider, output }`
`PreviewCompileFailed` Compilation failed (non-zero exit).
data: `{ bufnr, provider, code, stderr }`
Example: >lua
vim.api.nvim_create_autocmd('User', {
pattern = 'PreviewCompileSuccess',
callback = function(args)
local data = args.data
vim.notify('Compiled ' .. data.output .. ' with ' .. data.provider)
end,
})
<
==============================================================================
HEALTH *preview-health*
Run |:checkhealth| preview to verify your setup: >vim
:checkhealth preview
<
Checks: ~
- Neovim version >= 0.11.0
- Each configured provider's binary is executable
- Each configured provider's opener binary (if any) is executable
==============================================================================
vim:tw=78:ts=8:ft=help:norl:

View file

@ -13,14 +13,15 @@
...
}:
let
forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
forEachSystem =
f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
in
{
formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
packages = [
devShells = forEachSystem (pkgs:
let
devTools = [
(pkgs.luajit.withPackages (
ps: with ps; [
busted
@ -32,7 +33,23 @@
pkgs.selene
pkgs.lua-language-server
];
};
});
in
{
default = pkgs.mkShell {
packages = devTools;
};
presets = pkgs.mkShell {
packages = devTools ++ [
pkgs.typst
pkgs.texliveMedium
pkgs.tectonic
pkgs.pandoc
pkgs.asciidoctor
pkgs.quarto
pkgs.plantuml
pkgs.mermaid-cli
];
};
});
};
}

View file

@ -57,6 +57,12 @@ function M.setup()
end,
desc = 'Toggle, compile, clean, open, or check status of document preview',
})
vim.api.nvim_create_autocmd('VimLeavePre', {
callback = function()
require('preview.compiler').stop_all()
end,
})
end
return M

View file

@ -15,10 +15,49 @@ local opened = {}
---@type table<integer, string>
local last_output = {}
---@type table<integer, table>
local viewer_procs = {}
---@type table<integer, uv.uv_fs_event_t>
local open_watchers = {}
local debounce_timers = {}
local DEBOUNCE_MS = 500
---@param bufnr integer
local function stop_open_watcher(bufnr)
local w = open_watchers[bufnr]
if w then
w:stop()
w:close()
open_watchers[bufnr] = nil
end
end
---@param bufnr integer
local function close_viewer(bufnr)
local obj = viewer_procs[bufnr]
if obj then
local kill = obj.kill
kill(obj, 'sigterm')
viewer_procs[bufnr] = nil
end
end
---@param bufnr integer
---@param output_file string
---@param open_config boolean|string[]
local function do_open(bufnr, output_file, open_config)
if open_config == true then
vim.ui.open(output_file)
elseif type(open_config) == 'table' then
local open_cmd = vim.list_extend({}, open_config)
table.insert(open_cmd, output_file)
viewer_procs[bufnr] = vim.system(open_cmd)
end
end
---@param val string[]|fun(ctx: preview.Context): string[]
---@param ctx preview.Context
---@return string[]
@ -58,6 +97,14 @@ end
function M.compile(bufnr, name, provider, ctx, opts)
opts = opts or {}
if vim.fn.executable(provider.cmd[1]) ~= 1 then
vim.notify(
'[preview.nvim]: "' .. provider.cmd[1] .. '" is not executable (run :checkhealth preview)',
vim.log.levels.ERROR
)
return
end
if vim.bo[bufnr].modified then
vim.cmd('silent! update')
end
@ -96,12 +143,47 @@ function M.compile(bufnr, name, provider, ctx, opts)
table.concat(reload_cmd, ' ')
)
local stderr_acc = {}
local obj
obj = vim.system(
reload_cmd,
{
cwd = cwd,
env = provider.env,
stderr = vim.schedule_wrap(function(_err, data)
if not data or not vim.api.nvim_buf_is_valid(bufnr) then
return
end
stderr_acc[#stderr_acc + 1] = data
local errors_mode = provider.errors
if errors_mode == nil then
errors_mode = 'diagnostic'
end
if provider.error_parser and errors_mode then
local output = table.concat(stderr_acc)
if errors_mode == 'diagnostic' then
diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
elseif errors_mode == 'quickfix' then
local ok, diags = pcall(provider.error_parser, output, ctx)
if ok and diags and #diags > 0 then
local items = {}
for _, d in ipairs(diags) do
table.insert(items, {
bufnr = bufnr,
lnum = d.lnum + 1,
col = d.col + 1,
text = d.message,
type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E',
})
end
vim.fn.setqflist(items, 'r')
local win = vim.fn.win_getid()
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
end
end
end
end),
},
vim.schedule_wrap(function(result)
if active[bufnr] and active[bufnr].obj == obj then
@ -135,7 +217,9 @@ function M.compile(bufnr, name, provider, ctx, opts)
})
end
vim.fn.setqflist(items, 'r')
vim.cmd('copen')
local win = vim.fn.win_getid()
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
end
end
end
@ -152,29 +236,64 @@ function M.compile(bufnr, name, provider, ctx, opts)
end)
)
if
provider.open
and not opened[bufnr]
and output_file ~= ''
and vim.uv.fs_stat(output_file)
then
if provider.open == true then
vim.ui.open(output_file)
elseif type(provider.open) == 'table' then
local open_cmd = vim.list_extend({}, provider.open)
table.insert(open_cmd, output_file)
vim.system(open_cmd)
if provider.open and not opts.oneshot and not opened[bufnr] and output_file ~= '' then
local pre_stat = vim.uv.fs_stat(output_file)
local pre_mtime = pre_stat and pre_stat.mtime.sec or 0
local out_dir = vim.fn.fnamemodify(output_file, ':h')
local out_name = vim.fn.fnamemodify(output_file, ':t')
stop_open_watcher(bufnr)
local watcher = vim.uv.new_fs_event()
if watcher then
open_watchers[bufnr] = watcher
watcher:start(
out_dir,
{},
vim.schedule_wrap(function(err, filename, _events)
if err or vim.fn.fnamemodify(filename or '', ':t') ~= out_name then
return
end
if opened[bufnr] then
stop_open_watcher(bufnr)
return
end
if not vim.api.nvim_buf_is_valid(bufnr) then
stop_open_watcher(bufnr)
return
end
local new_stat = vim.uv.fs_stat(output_file)
if not (new_stat and new_stat.mtime.sec > pre_mtime) then
return
end
stop_open_watcher(bufnr)
stderr_acc = {}
local errors_mode = provider.errors
if errors_mode == nil then
errors_mode = 'diagnostic'
end
if errors_mode == 'diagnostic' then
diagnostic.clear(bufnr)
elseif errors_mode == 'quickfix' then
vim.fn.setqflist({}, 'r')
vim.cmd.cwindow()
end
do_open(bufnr, output_file, provider.open)
opened[bufnr] = true
end)
)
end
opened[bufnr] = true
end
active[bufnr] = { obj = obj, provider = name, output_file = output_file, is_reload = true }
vim.api.nvim_create_autocmd('BufWipeout', {
vim.api.nvim_create_autocmd('BufUnload', {
buffer = bufnr,
once = true,
callback = function()
M.stop(bufnr)
stop_open_watcher(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
last_output[bufnr] = nil
end,
})
@ -219,6 +338,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
diagnostic.clear(bufnr)
elseif errors_mode == 'quickfix' then
vim.fn.setqflist({}, 'r')
vim.cmd.cwindow()
end
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
@ -230,14 +350,14 @@ function M.compile(bufnr, name, provider, ctx, opts)
r.inject(output_file)
r.broadcast()
end
if provider.open and not opened[bufnr] and output_file ~= '' then
if provider.open == 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
if
provider.open
and not opts.oneshot
and not opened[bufnr]
and output_file ~= ''
and vim.uv.fs_stat(output_file)
then
do_open(bufnr, output_file, provider.open)
opened[bufnr] = true
end
else
@ -260,7 +380,9 @@ function M.compile(bufnr, name, provider, ctx, opts)
})
end
vim.fn.setqflist(items, 'r')
vim.cmd('copen')
local win = vim.fn.win_getid()
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
end
end
end
@ -279,11 +401,14 @@ function M.compile(bufnr, name, provider, ctx, opts)
active[bufnr] = { obj = obj, provider = name, output_file = output_file }
vim.api.nvim_create_autocmd('BufWipeout', {
vim.api.nvim_create_autocmd('BufUnload', {
buffer = bufnr,
once = true,
callback = function()
M.stop(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
last_output[bufnr] = nil
end,
})
@ -324,6 +449,12 @@ function M.stop_all()
for bufnr, _ in pairs(watching) do
M.unwatch(bufnr)
end
for bufnr, _ in pairs(open_watchers) do
stop_open_watcher(bufnr)
end
for bufnr, _ in pairs(viewer_procs) do
close_viewer(bufnr)
end
require('preview.reload').stop()
end
@ -374,11 +505,15 @@ function M.toggle(bufnr, name, provider, ctx_builder)
log.dbg('watching buffer %d with provider "%s"', bufnr, name)
vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO)
vim.api.nvim_create_autocmd('BufWipeout', {
vim.api.nvim_create_autocmd('BufUnload', {
buffer = bufnr,
once = true,
callback = function()
M.unwatch(bufnr)
stop_open_watcher(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
opened[bufnr] = nil
end,
})
@ -452,13 +587,11 @@ function M.open(bufnr, open_config)
log.dbg('no last output file for buffer %d', bufnr)
return false
end
if type(open_config) == 'table' then
local open_cmd = vim.list_extend({}, open_config)
table.insert(open_cmd, output)
vim.system(open_cmd)
else
vim.ui.open(output)
if not vim.uv.fs_stat(output) then
log.dbg('output file no longer exists for buffer %d: %s', bufnr, output)
return false
end
do_open(bufnr, output, open_config)
return true
end
@ -483,6 +616,8 @@ M._test = {
opened = opened,
last_output = last_output,
debounce_timers = debounce_timers,
viewer_procs = viewer_procs,
open_watchers = open_watchers,
}
return M

View file

@ -10,6 +10,7 @@
---@field clean? string[]|fun(ctx: preview.Context): string[]
---@field open? boolean|string[]
---@field reload? boolean|string[]|fun(ctx: preview.Context): string[]
---@field detach? boolean
---@class preview.Config
---@field debug boolean|string
@ -101,6 +102,13 @@ function M.setup(opts)
end, 'false, "diagnostic", or "quickfix"')
vim.validate(prefix .. '.open', provider.open, { 'boolean', 'table' }, true)
vim.validate(prefix .. '.reload', provider.reload, { 'boolean', 'table', 'function' }, true)
vim.validate(prefix .. '.detach', provider.detach, 'boolean', true)
end
if providers['plantuml'] then
vim.filetype.add({
extension = { puml = 'plantuml', pu = 'plantuml' },
})
end
config = vim.tbl_deep_extend('force', default_config, {
@ -146,6 +154,10 @@ end
---@param bufnr? integer
function M.compile(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if vim.api.nvim_buf_get_name(bufnr) == '' then
vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
return
end
local name = M.resolve_provider(bufnr)
if not name then
vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
@ -165,6 +177,10 @@ end
---@param bufnr? integer
function M.clean(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if vim.api.nvim_buf_get_name(bufnr) == '' then
vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
return
end
local name = M.resolve_provider(bufnr)
if not name then
vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
@ -178,6 +194,10 @@ end
---@param bufnr? integer
function M.toggle(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if vim.api.nvim_buf_get_name(bufnr) == '' then
vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
return
end
local name = M.resolve_provider(bufnr)
if not name then
vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
@ -190,6 +210,10 @@ end
---@param bufnr? integer
function M.open(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if vim.api.nvim_buf_get_name(bufnr) == '' then
vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
return
end
local name = M.resolve_provider(bufnr)
local open_config = name and config.providers[name] and config.providers[name].open
if not compiler.open(bufnr, open_config) then
@ -230,4 +254,8 @@ M._test = {
end,
}
if vim.g.preview then
M.setup(vim.g.preview)
end
return M

View file

@ -93,6 +93,28 @@ local function parse_pandoc(output)
return diagnostics
end
---@param output string
---@return preview.Diagnostic[]
local function parse_asciidoctor(output)
local diagnostics = {}
for line in output:gmatch('[^\r\n]+') do
local severity, _, lnum, msg = line:match('^asciidoctor: (%u+): (.+): line (%d+): (.+)$')
if lnum then
local sev = vim.diagnostic.severity.ERROR
if severity == 'WARNING' then
sev = vim.diagnostic.severity.WARN
end
table.insert(diagnostics, {
lnum = tonumber(lnum) - 1,
col = 0,
message = msg,
severity = sev,
})
end
end
return diagnostics
end
---@type preview.ProviderConfig
M.typst = {
ft = 'typst',
@ -106,9 +128,12 @@ M.typst = {
error_parser = function(output)
return parse_typst(output)
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.typ$', '.pdf')) }
end,
open = true,
reload = function(ctx)
return { 'typst', 'watch', ctx.file }
return { 'typst', 'watch', '--diagnostic-format', 'short', ctx.file }
end,
}
@ -137,6 +162,45 @@ M.latex = {
open = true,
}
---@type preview.ProviderConfig
M.pdflatex = {
ft = 'tex',
cmd = { 'pdflatex' },
args = function(ctx)
return { '-interaction=nonstopmode', '-file-line-error', '-synctex=1', ctx.file }
end,
output = function(ctx)
return (ctx.file:gsub('%.tex$', '.pdf'))
end,
error_parser = function(output)
return parse_latexmk(output)
end,
clean = function(ctx)
local base = ctx.file:gsub('%.tex$', '')
return { 'rm', '-f', base .. '.pdf', base .. '.aux', base .. '.log', base .. '.synctex.gz' }
end,
open = true,
}
---@type preview.ProviderConfig
M.tectonic = {
ft = 'tex',
cmd = { 'tectonic' },
args = function(ctx)
return { ctx.file }
end,
output = function(ctx)
return (ctx.file:gsub('%.tex$', '.pdf'))
end,
error_parser = function(output)
return parse_latexmk(output)
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.tex$', '.pdf')) }
end,
open = true,
}
---@type preview.ProviderConfig
M.markdown = {
ft = 'markdown',
@ -187,4 +251,104 @@ M.github = {
reload = true,
}
---@type preview.ProviderConfig
M.asciidoctor = {
ft = 'asciidoc',
cmd = { 'asciidoctor' },
args = function(ctx)
return { '--failure-level', 'ERROR', ctx.file, '-o', ctx.output }
end,
output = function(ctx)
return (ctx.file:gsub('%.adoc$', '.html'))
end,
error_parser = function(output)
return parse_asciidoctor(output)
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.adoc$', '.html')) }
end,
open = true,
reload = true,
}
---@type preview.ProviderConfig
M.plantuml = {
ft = 'plantuml',
cmd = { 'plantuml' },
args = function(ctx)
return { '-tsvg', ctx.file }
end,
output = function(ctx)
return (ctx.file:gsub('%.puml$', '.svg'))
end,
error_parser = function(output)
local diagnostics = {}
for line in output:gmatch('[^\r\n]+') do
local lnum = line:match('^Error line (%d+) in file:')
if lnum then
table.insert(diagnostics, {
lnum = tonumber(lnum) - 1,
col = 0,
message = line,
severity = vim.diagnostic.severity.ERROR,
})
end
end
return diagnostics
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.puml$', '.svg')) }
end,
open = true,
}
---@type preview.ProviderConfig
M.mermaid = {
ft = 'mermaid',
cmd = { 'mmdc' },
args = function(ctx)
return { '-i', ctx.file, '-o', ctx.output }
end,
output = function(ctx)
return (ctx.file:gsub('%.mmd$', '.svg'))
end,
error_parser = function(output)
local diagnostics = {}
for line in output:gmatch('[^\r\n]+') do
local lnum = line:match('^%s*Parse error on line (%d+)')
if lnum then
table.insert(diagnostics, {
lnum = tonumber(lnum) - 1,
col = 0,
message = line,
severity = vim.diagnostic.severity.ERROR,
})
end
end
return diagnostics
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.mmd$', '.svg')) }
end,
open = true,
}
---@type preview.ProviderConfig
M.quarto = {
ft = 'quarto',
cmd = { 'quarto' },
args = function(ctx)
return { 'render', ctx.file, '--to', 'html', '--embed-resources' }
end,
output = function(ctx)
return (ctx.file:gsub('%.qmd$', '.html'))
end,
clean = function(ctx)
local base = ctx.file:gsub('%.qmd$', '')
return { 'rm', '-rf', base .. '.html', base .. '_files' }
end,
open = true,
reload = true,
}
return M

View file

@ -6,5 +6,5 @@ git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet
nix develop --command prettier --check .
nix fmt
git diff --exit-code -- '*.nix'
nix develop --command lua-language-server --check . --checklevel=Warning
nix develop --command lua-language-server --check lua/ --configpath "$(pwd)/.luarc.json" --checklevel=Warning
nix develop --command busted

View file

@ -11,6 +11,19 @@ describe('commands', function()
local cmds = vim.api.nvim_get_commands({})
assert.is_not_nil(cmds.Preview)
end)
it('registers VimLeavePre autocmd', function()
require('preview.commands').setup()
local aus = vim.api.nvim_get_autocmds({ event = 'VimLeavePre' })
local found = false
for _, au in ipairs(aus) do
if au.callback then
found = true
break
end
end
assert.is_true(found)
end)
end)
describe('dispatch', function()

View file

@ -99,6 +99,35 @@ describe('compiler', function()
helpers.delete_buffer(bufnr)
end)
it('notifies and returns when binary is not executable', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_nobin.txt')
vim.bo[bufnr].modified = false
local notified = false
local orig = vim.notify
vim.notify = function(msg)
if msg:find('not executable') then
notified = true
end
end
local provider = { cmd = { 'totally_nonexistent_binary_xyz_preview' } }
local ctx = {
bufnr = bufnr,
file = '/tmp/preview_test_nobin.txt',
root = '/tmp',
ft = 'text',
}
compiler.compile(bufnr, 'nobin', provider, ctx)
vim.notify = orig
assert.is_true(notified)
assert.is_nil(compiler._test.active[bufnr])
helpers.delete_buffer(bufnr)
end)
it('fires PreviewCompileFailed on non-zero exit', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_fail.txt')

View file

@ -108,4 +108,62 @@ describe('preview', function()
helpers.delete_buffer(bufnr)
end)
end)
describe('unnamed buffer guard', function()
before_each(function()
helpers.reset_config({ typst = true })
preview = require('preview')
end)
local function capture_notify(fn)
local msg = nil
local orig = vim.notify
vim.notify = function(m)
msg = m
end
fn()
vim.notify = orig
return msg
end
it('compile warns on unnamed buffer', function()
local bufnr = helpers.create_buffer({}, 'typst')
local msg = capture_notify(function()
preview.compile(bufnr)
end)
assert.is_not_nil(msg)
assert.is_truthy(msg:find('no file name'))
helpers.delete_buffer(bufnr)
end)
it('toggle warns on unnamed buffer', function()
local bufnr = helpers.create_buffer({}, 'typst')
local msg = capture_notify(function()
preview.toggle(bufnr)
end)
assert.is_not_nil(msg)
assert.is_truthy(msg:find('no file name'))
helpers.delete_buffer(bufnr)
end)
it('clean warns on unnamed buffer', function()
local bufnr = helpers.create_buffer({}, 'typst')
local msg = capture_notify(function()
preview.clean(bufnr)
end)
assert.is_not_nil(msg)
assert.is_truthy(msg:find('no file name'))
helpers.delete_buffer(bufnr)
end)
it('open warns on unnamed buffer', function()
local bufnr = helpers.create_buffer({}, 'typst')
local msg = capture_notify(function()
preview.open(bufnr)
end)
assert.is_not_nil(msg)
assert.is_truthy(msg:find('no file name'))
helpers.delete_buffer(bufnr)
end)
end)
end)

View file

@ -33,6 +33,10 @@ describe('presets', function()
assert.are.equal('/tmp/document.pdf', output)
end)
it('returns clean command', function()
assert.are.same({ 'rm', '-f', '/tmp/document.pdf' }, presets.typst.clean(ctx))
end)
it('has open enabled', function()
assert.is_true(presets.typst.open)
end)
@ -46,7 +50,9 @@ describe('presets', function()
assert.is_table(result)
assert.are.equal('typst', result[1])
assert.are.equal('watch', result[2])
assert.are.equal(ctx.file, result[3])
assert.are.equal('--diagnostic-format', result[3])
assert.are.equal('short', result[4])
assert.are.equal(ctx.file, result[5])
end)
it('parses errors from stderr', function()
@ -157,6 +163,120 @@ describe('presets', function()
end)
end)
describe('pdflatex', function()
local tex_ctx = {
bufnr = 1,
file = '/tmp/document.tex',
root = '/tmp',
ft = 'tex',
}
it('has ft', function()
assert.are.equal('tex', presets.pdflatex.ft)
end)
it('has cmd', function()
assert.are.same({ 'pdflatex' }, presets.pdflatex.cmd)
end)
it('returns args with flags and file path', function()
local args = presets.pdflatex.args(tex_ctx)
assert.are.same(
{ '-interaction=nonstopmode', '-file-line-error', '-synctex=1', '/tmp/document.tex' },
args
)
end)
it('returns pdf output path', function()
assert.are.equal('/tmp/document.pdf', presets.pdflatex.output(tex_ctx))
end)
it('has open enabled', function()
assert.is_true(presets.pdflatex.open)
end)
it('returns clean command removing pdf and aux files', function()
local clean = presets.pdflatex.clean(tex_ctx)
assert.are.same({
'rm',
'-f',
'/tmp/document.pdf',
'/tmp/document.aux',
'/tmp/document.log',
'/tmp/document.synctex.gz',
}, clean)
end)
it('has no reload', function()
assert.is_nil(presets.pdflatex.reload)
end)
it('parses file-line-error format', function()
local output = './document.tex:10: Undefined control sequence.'
local diagnostics = presets.pdflatex.error_parser(output, tex_ctx)
assert.are.equal(1, #diagnostics)
assert.are.equal(9, diagnostics[1].lnum)
assert.are.equal(0, diagnostics[1].col)
assert.are.equal('Undefined control sequence.', diagnostics[1].message)
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end)
it('returns empty table for clean output', function()
assert.are.same({}, presets.pdflatex.error_parser('', tex_ctx))
end)
end)
describe('tectonic', function()
local tex_ctx = {
bufnr = 1,
file = '/tmp/document.tex',
root = '/tmp',
ft = 'tex',
}
it('has ft', function()
assert.are.equal('tex', presets.tectonic.ft)
end)
it('has cmd', function()
assert.are.same({ 'tectonic' }, presets.tectonic.cmd)
end)
it('returns args with file path', function()
assert.are.same({ '/tmp/document.tex' }, presets.tectonic.args(tex_ctx))
end)
it('returns pdf output path', function()
assert.are.equal('/tmp/document.pdf', presets.tectonic.output(tex_ctx))
end)
it('has open enabled', function()
assert.is_true(presets.tectonic.open)
end)
it('returns clean command removing pdf', function()
assert.are.same({ 'rm', '-f', '/tmp/document.pdf' }, presets.tectonic.clean(tex_ctx))
end)
it('has no reload', function()
assert.is_nil(presets.tectonic.reload)
end)
it('parses file-line-error format', function()
local output = './document.tex:5: Missing $ inserted.'
local diagnostics = presets.tectonic.error_parser(output, tex_ctx)
assert.are.equal(1, #diagnostics)
assert.are.equal(4, diagnostics[1].lnum)
assert.are.equal(0, diagnostics[1].col)
assert.are.equal('Missing $ inserted.', diagnostics[1].message)
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end)
it('returns empty table for clean output', function()
assert.are.same({}, presets.tectonic.error_parser('', tex_ctx))
end)
end)
describe('markdown', function()
local md_ctx = {
bufnr = 1,
@ -341,4 +461,116 @@ describe('presets', function()
assert.are.same({}, diagnostics)
end)
end)
describe('asciidoctor', function()
local adoc_ctx = {
bufnr = 1,
file = '/tmp/document.adoc',
root = '/tmp',
ft = 'asciidoc',
output = '/tmp/document.html',
}
it('has ft', function()
assert.are.equal('asciidoc', presets.asciidoctor.ft)
end)
it('has cmd', function()
assert.are.same({ 'asciidoctor' }, presets.asciidoctor.cmd)
end)
it('returns args with file and output', function()
assert.are.same(
{ '--failure-level', 'ERROR', '/tmp/document.adoc', '-o', '/tmp/document.html' },
presets.asciidoctor.args(adoc_ctx)
)
end)
it('returns html output path', function()
assert.are.equal('/tmp/document.html', presets.asciidoctor.output(adoc_ctx))
end)
it('returns clean command', function()
assert.are.same({ 'rm', '-f', '/tmp/document.html' }, presets.asciidoctor.clean(adoc_ctx))
end)
it('has open enabled', function()
assert.is_true(presets.asciidoctor.open)
end)
it('has reload enabled for SSE', function()
assert.is_true(presets.asciidoctor.reload)
end)
it('parses error messages', function()
local output =
'asciidoctor: ERROR: document.adoc: line 8: invalid part, must have at least one section'
local diagnostics = presets.asciidoctor.error_parser(output, adoc_ctx)
assert.are.equal(1, #diagnostics)
assert.are.equal(7, diagnostics[1].lnum)
assert.are.equal(0, diagnostics[1].col)
assert.are.equal('invalid part, must have at least one section', diagnostics[1].message)
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end)
it('parses warning messages', function()
local output = 'asciidoctor: WARNING: document.adoc: line 52: section title out of sequence'
local diagnostics = presets.asciidoctor.error_parser(output, adoc_ctx)
assert.are.equal(1, #diagnostics)
assert.are.equal(51, diagnostics[1].lnum)
assert.are.equal(vim.diagnostic.severity.WARN, diagnostics[1].severity)
end)
it('returns empty table for clean output', function()
assert.are.same({}, presets.asciidoctor.error_parser('', adoc_ctx))
end)
end)
describe('quarto', function()
local qmd_ctx = {
bufnr = 1,
file = '/tmp/document.qmd',
root = '/tmp',
ft = 'quarto',
output = '/tmp/document.html',
}
it('has ft', function()
assert.are.equal('quarto', presets.quarto.ft)
end)
it('has cmd', function()
assert.are.same({ 'quarto' }, presets.quarto.cmd)
end)
it('returns args with render subcommand and html format', function()
assert.are.same(
{ 'render', '/tmp/document.qmd', '--to', 'html', '--embed-resources' },
presets.quarto.args(qmd_ctx)
)
end)
it('returns html output path', function()
assert.are.equal('/tmp/document.html', presets.quarto.output(qmd_ctx))
end)
it('returns clean command removing html and _files directory', function()
assert.are.same(
{ 'rm', '-rf', '/tmp/document.html', '/tmp/document_files' },
presets.quarto.clean(qmd_ctx)
)
end)
it('has open enabled', function()
assert.is_true(presets.quarto.open)
end)
it('has reload enabled for SSE', function()
assert.is_true(presets.quarto.reload)
end)
it('has no error_parser', function()
assert.is_nil(presets.quarto.error_parser)
end)
end)
end)

View file

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