Compare commits

..

32 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
Barrett Ruth
4732696a62
fix(compiler): one-shot builds and :Preview open provider config (#25)
* fix(compiler): skip reload command for one-shot builds

Problem: compile() unconditionally resolved the reload command, so
:Preview build on a provider with reload = function/table (e.g. typst)
would start a long-running process (typst watch) instead of a one-shot
compile (typst compile). Error diagnostics were also lost because
typst watch does not exit non-zero on input errors.

Solution: add an opts parameter to compile() with a oneshot flag.
M.build() passes { oneshot = true } so resolve_reload_cmd() is
skipped and the one-shot path is always taken. toggle() continues
to call compile() without the flag, preserving long-running behavior
for watch mode.

* fix(compiler): respect provider open config in M.open()

Problem: :Preview open always called vim.ui.open regardless of the
provider's open field. Providers configured with a custom opener
(e.g. open = { 'sioyek', '--new-instance' }) were ignored, so the
first build opened with sioyek but :Preview open fell back to the
system default.

Solution: init.lua resolves the provider's open config and passes it
to compiler.open(). If open_config is a table, the custom command is
spawned with the output path appended. Otherwise vim.ui.open is used
as before.

* fix: normalize notify prefix to [preview.nvim]: everywhere

Problem: some vim.notify calls used [preview.nvim] (no colon) while
others used [preview.nvim]: — inconsistent with the log module format.

Solution: add the missing colon and space to all notify calls in
init.lua and compiler.lua so every user-facing message matches the
[preview.nvim]: prefix used by the log module.

* ci: format
2026-03-04 02:18:28 -05:00
Barrett Ruth
da3e3e4249
fix(compiler): check output exists before opening from long-running process (#24)
* fix(compiler): check output exists before opening from long-running process

Problem: for long-running processes (typst watch), M.compile() calls
the opener immediately after vim.system() returns, before the process
has produced any output. On first run the output file does not exist
yet, so the opener is called on a nonexistent path.

Solution: guard the open block with vim.uv.fs_stat so it only fires
if the output file already exists at spawn time.

* style(compiler): reformat long condition for stylua
2026-03-04 00:20:41 -05:00
Barrett Ruth
54ef0c3c99
fix(compiler): guard active entry before clearing in process callback (#22)
* fix(compiler): guard active entry before clearing in process callback

Problem: when M.compile() is called while a previous process is still
running, the old process's vim.schedule_wrap callback unconditionally
sets active[bufnr] = nil, wiping the new process from the tracking
table. status() incorrectly returns idle and stop() becomes a no-op
against the still-running process.

Solution: capture obj as an upvalue in each callback and only clear
active[bufnr] if it still points to the same process object.

* fix(compiler): hoist obj declaration before vim.system closure

Problem: lua-language-server flagged obj as an undefined global in
both vim.schedule_wrap callbacks because local obj = vim.system(...)
puts the variable out of scope inside the closure at declaration time.
At runtime the guard active[bufnr].obj == obj evaluated obj as nil,
so the clear was always skipped and the process remained tracked
indefinitely.

Solution: split into local obj / obj = vim.system(...) so the
upvalue is in scope when the closure is defined.
2026-03-04 00:17:10 -05:00
Barrett Ruth
ec922a3564
fix(compiler): respect provider open config in M.open() (#23)
* fix(reload): bind SSE server to port 0 for OS-assigned port

Problem: the SSE reload server hardcoded port 5554, causing silent
failure when that port was already in use. bind() would fail but its
return value was never checked; listen() would also error and silently
drop via the if err then return end guard. inject() still wrote the
dead EventSource URL into the HTML, so the browser would connect to
whatever was on 5554 — or nothing — and live reload would silently
stop working.

Solution: bind to port or 0 so the OS assigns a free port, then call
getsockname() after bind to capture the actual port into actual_port.
inject() reads actual_port in preference to the hardcoded constant,
and stop() resets it. PORT = 5554 is kept only as a last-resort
fallback in inject() if actual_port is unset.

* fix(compiler): resolve output into ctx before evaluating clean command

Problem: M.clean() passes the raw ctx (no output field) to the
provider's clean function. Built-in presets work around this by
recomputing the output path inline, but custom providers using
ctx.output in their clean function receive nil.

Solution: resolve output_file from provider.output before eval, extend
ctx into resolved_ctx with the output field, and use resolved_ctx when
evaluating clean and cwd — consistent with how M.compile() handles args.
2026-03-03 17:59:58 -05:00
Barrett Ruth
e661ea78e8
fix(reload): bind SSE server to port 0 for OS-assigned port (#21)
Problem: the SSE reload server hardcoded port 5554, causing silent
failure when that port was already in use. bind() would fail but its
return value was never checked; listen() would also error and silently
drop via the if err then return end guard. inject() still wrote the
dead EventSource URL into the HTML, so the browser would connect to
whatever was on 5554 — or nothing — and live reload would silently
stop working.

Solution: bind to port or 0 so the OS assigns a free port, then call
getsockname() after bind to capture the actual port into actual_port.
inject() reads actual_port in preference to the hardcoded constant,
and stop() resets it. PORT = 5554 is kept only as a last-resort
fallback in inject() if actual_port is unset.
2026-03-03 17:46:04 -05:00
Barrett Ruth
62961c8541
feat: unified reload field for live-preview (SSE + long-running watch) (#19)
* 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 </body> (or at EOF) on every compile so
pandoc overwrites do not lose the tag.

* refactor(presets): add reload field, remove synctex field

Problem: the synctex field only handled PDF forward search and left
HTML live-preview and typst watch mode unsupported.

Solution: add reload = function(ctx) returning { 'typst', 'watch',
ctx.file } to typst (long-running watch mode), reload = true to
markdown and github (SSE push after each pandoc compile), and remove
synctex = true from latex (the -synctex=1 arg in latex.args remains
for .synctex.gz generation).

* refactor(init): replace synctex field and validation with reload

Problem: ProviderConfig still declared synctex and validated it, but
the field is being dropped in favour of the general-purpose reload.

Solution: replace the synctex annotation and vim.validate call with the
reload field, accepting boolean | string[] | function.

* feat(compiler): support long-running watch processes and SSE reload

Problem: compile() only supports one-shot invocations, requiring a
BufWritePost autocmd for watch mode and leaving HTML without live-
reload.

Solution: resolve_reload_cmd() maps provider.reload (function or table)
to a command; when present, compile() spawns it as a long-running
process instead of building a one-shot cmd from provider.cmd + args.
toggle() detects long-running providers and toggles the process
directly instead of registering a BufWritePost autocmd. When
reload = true and output is .html, the SSE server is invoked after
each successful compile. status() reports is_reload processes as
watching, not compiling. stop_all() also stops the SSE server.

* fix(compiler): format is_longrunning and annotate is_reload field

Problem: stylua required is_longrunning to be on one line; lua-ls
warned about undefined field is_reload on preview.Process.

Solution: inline the boolean expression and add is_reload? to the
preview.Process annotation.

* refactor: rename compile/toggle commands to build/watch

Problem: `compile` and `toggle` are accurate but unintuitive — `compile`
sounds academic and `toggle` says nothing about what it toggles.

Solution: rename the public API and `:Preview` subcommands to `build`
(one-shot) and `watch` (live preview). Internal compiler functions are
unchanged. No aliases for old names — clean break.
2026-03-03 16:41:47 -05:00
17 changed files with 1113 additions and 345 deletions

View file

@ -4,5 +4,6 @@
"diagnostics.globals": ["vim", "jit"], "diagnostics.globals": ["vim", "jit"],
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
"workspace.checkThirdParty": false, "workspace.checkThirdParty": false,
"workspace.ignoreDir": [".direnv"],
"completion.callSnippet": "Replace" "completion.callSnippet": "Replace"
} }

1
.styluaignore Normal file
View file

@ -0,0 +1 @@
.direnv/

View file

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

View file

@ -1,244 +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 zero provider defaults. Users must explicitly configure
their compiler commands. preview.nvim is purely an orchestration framework.
==============================================================================
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.
*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 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:~
`compile` Compile the current buffer (default if omitted).
`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.
`open` Open the last compiled output without recompiling.
`status` Echo compilation status (idle, compiling, watching).
==============================================================================
API *preview.nvim-api*
preview.compile({bufnr?}) *preview.compile()*
Compile the document in the given buffer (default: current).
preview.stop({bufnr?}) *preview.stop()*
Kill the active compilation process for the buffer.
preview.clean({bufnr?}) *preview.clean()*
Run the provider's clean command for the buffer.
preview.toggle({bufnr?}) *preview.toggle()*
Toggle auto-compile for the buffer. When enabled, the buffer is
immediately compiled and automatically recompiled on each save
(`BufWritePost`). Call again to stop.
preview.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,12 +13,15 @@
... ...
}: }:
let 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 in
{ {
devShells = forEachSystem (pkgs: { formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
default = pkgs.mkShell {
packages = [ devShells = forEachSystem (pkgs:
let
devTools = [
(pkgs.luajit.withPackages ( (pkgs.luajit.withPackages (
ps: with ps; [ ps: with ps; [
busted busted
@ -30,7 +33,23 @@
pkgs.selene pkgs.selene
pkgs.lua-language-server 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

@ -1,17 +1,14 @@
local M = {} local M = {}
local handlers = { local handlers = {
build = function() compile = function()
require('preview').build() require('preview').compile()
end,
stop = function()
require('preview').stop()
end, end,
clean = function() clean = function()
require('preview').clean() require('preview').clean()
end, end,
watch = function() toggle = function()
require('preview').watch() require('preview').toggle()
end, end,
open = function() open = function()
require('preview').open() require('preview').open()
@ -33,7 +30,7 @@ local handlers = {
---@param args string ---@param args string
local function dispatch(args) local function dispatch(args)
local subcmd = args ~= '' and args or 'build' local subcmd = args ~= '' and args or 'toggle'
local handler = handlers[subcmd] local handler = handlers[subcmd]
if handler then if handler then
handler() handler()
@ -58,7 +55,13 @@ function M.setup()
complete = function(lead) complete = function(lead)
return complete(lead) return complete(lead)
end, end,
desc = 'Build, stop, clean, watch, open, or check status of document preview', 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 end

View file

@ -15,10 +15,49 @@ local opened = {}
---@type table<integer, string> ---@type table<integer, string>
local last_output = {} 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_timers = {}
local DEBOUNCE_MS = 500 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 val string[]|fun(ctx: preview.Context): string[]
---@param ctx preview.Context ---@param ctx preview.Context
---@return string[] ---@return string[]
@ -55,7 +94,17 @@ end
---@param name string ---@param name string
---@param provider preview.ProviderConfig ---@param provider preview.ProviderConfig
---@param ctx preview.Context ---@param ctx preview.Context
function M.compile(bufnr, name, provider, ctx) 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 if vim.bo[bufnr].modified then
vim.cmd('silent! update') vim.cmd('silent! update')
end end
@ -81,7 +130,10 @@ function M.compile(bufnr, name, provider, ctx)
last_output[bufnr] = output_file last_output[bufnr] = output_file
end end
local reload_cmd = resolve_reload_cmd(provider, resolved_ctx) local reload_cmd
if not opts.oneshot then
reload_cmd = resolve_reload_cmd(provider, resolved_ctx)
end
if reload_cmd then if reload_cmd then
log.dbg( log.dbg(
@ -91,14 +143,52 @@ function M.compile(bufnr, name, provider, ctx)
table.concat(reload_cmd, ' ') table.concat(reload_cmd, ' ')
) )
local obj = vim.system( local stderr_acc = {}
local obj
obj = vim.system(
reload_cmd, reload_cmd,
{ {
cwd = cwd, cwd = cwd,
env = provider.env, 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) vim.schedule_wrap(function(result)
active[bufnr] = nil if active[bufnr] and active[bufnr].obj == obj then
active[bufnr] = nil
end
if not vim.api.nvim_buf_is_valid(bufnr) then if not vim.api.nvim_buf_is_valid(bufnr) then
return return
end end
@ -127,7 +217,9 @@ function M.compile(bufnr, name, provider, ctx)
}) })
end end
vim.fn.setqflist(items, 'r') 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 end
end end
@ -144,24 +236,64 @@ function M.compile(bufnr, name, provider, ctx)
end) end)
) )
if provider.open and not opened[bufnr] and output_file ~= '' then if provider.open and not opts.oneshot and not opened[bufnr] and output_file ~= '' then
if provider.open == true then local pre_stat = vim.uv.fs_stat(output_file)
vim.ui.open(output_file) local pre_mtime = pre_stat and pre_stat.mtime.sec or 0
elseif type(provider.open) == 'table' then local out_dir = vim.fn.fnamemodify(output_file, ':h')
local open_cmd = vim.list_extend({}, provider.open) local out_name = vim.fn.fnamemodify(output_file, ':t')
table.insert(open_cmd, output_file) stop_open_watcher(bufnr)
vim.system(open_cmd) 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 end
opened[bufnr] = true
end end
active[bufnr] = { obj = obj, provider = name, output_file = output_file, is_reload = true } 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, buffer = bufnr,
once = true, once = true,
callback = function() callback = function()
M.stop(bufnr) M.stop(bufnr)
stop_open_watcher(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
last_output[bufnr] = nil last_output[bufnr] = nil
end, end,
}) })
@ -180,14 +312,17 @@ function M.compile(bufnr, name, provider, ctx)
log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' ')) log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
local obj = vim.system( local obj
obj = vim.system(
cmd, cmd,
{ {
cwd = cwd, cwd = cwd,
env = provider.env, env = provider.env,
}, },
vim.schedule_wrap(function(result) vim.schedule_wrap(function(result)
active[bufnr] = nil if active[bufnr] and active[bufnr].obj == obj then
active[bufnr] = nil
end
if not vim.api.nvim_buf_is_valid(bufnr) then if not vim.api.nvim_buf_is_valid(bufnr) then
return return
end end
@ -203,6 +338,7 @@ function M.compile(bufnr, name, provider, ctx)
diagnostic.clear(bufnr) diagnostic.clear(bufnr)
elseif errors_mode == 'quickfix' then elseif errors_mode == 'quickfix' then
vim.fn.setqflist({}, 'r') vim.fn.setqflist({}, 'r')
vim.cmd.cwindow()
end end
vim.api.nvim_exec_autocmds('User', { vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess', pattern = 'PreviewCompileSuccess',
@ -214,14 +350,14 @@ function M.compile(bufnr, name, provider, ctx)
r.inject(output_file) r.inject(output_file)
r.broadcast() r.broadcast()
end end
if provider.open and not opened[bufnr] and output_file ~= '' then if
if provider.open == true then provider.open
vim.ui.open(output_file) and not opts.oneshot
elseif type(provider.open) == 'table' then and not opened[bufnr]
local open_cmd = vim.list_extend({}, provider.open) and output_file ~= ''
table.insert(open_cmd, output_file) and vim.uv.fs_stat(output_file)
vim.system(open_cmd) then
end do_open(bufnr, output_file, provider.open)
opened[bufnr] = true opened[bufnr] = true
end end
else else
@ -244,7 +380,9 @@ function M.compile(bufnr, name, provider, ctx)
}) })
end end
vim.fn.setqflist(items, 'r') 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 end
end end
@ -263,11 +401,14 @@ function M.compile(bufnr, name, provider, ctx)
active[bufnr] = { obj = obj, provider = name, output_file = output_file } active[bufnr] = { obj = obj, provider = name, output_file = output_file }
vim.api.nvim_create_autocmd('BufWipeout', { vim.api.nvim_create_autocmd('BufUnload', {
buffer = bufnr, buffer = bufnr,
once = true, once = true,
callback = function() callback = function()
M.stop(bufnr) M.stop(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
last_output[bufnr] = nil last_output[bufnr] = nil
end, end,
}) })
@ -308,6 +449,12 @@ function M.stop_all()
for bufnr, _ in pairs(watching) do for bufnr, _ in pairs(watching) do
M.unwatch(bufnr) M.unwatch(bufnr)
end 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() require('preview.reload').stop()
end end
@ -358,11 +505,15 @@ function M.toggle(bufnr, name, provider, ctx_builder)
log.dbg('watching buffer %d with provider "%s"', bufnr, name) log.dbg('watching buffer %d with provider "%s"', bufnr, name)
vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO) 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, buffer = bufnr,
once = true, once = true,
callback = function() callback = function()
M.unwatch(bufnr) M.unwatch(bufnr)
stop_open_watcher(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
opened[bufnr] = nil opened[bufnr] = nil
end, end,
}) })
@ -392,14 +543,23 @@ end
---@param ctx preview.Context ---@param ctx preview.Context
function M.clean(bufnr, name, provider, ctx) function M.clean(bufnr, name, provider, ctx)
if not provider.clean then if not provider.clean then
vim.notify('[preview.nvim] provider "' .. name .. '" has no clean command', vim.log.levels.WARN) vim.notify(
'[preview.nvim]: provider "' .. name .. '" has no clean command',
vim.log.levels.WARN
)
return return
end end
local cmd = eval_list(provider.clean, ctx) local output_file = ''
local cwd = ctx.root if provider.output then
output_file = eval_string(provider.output, ctx)
end
local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file })
local cmd = eval_list(provider.clean, resolved_ctx)
local cwd = resolved_ctx.root
if provider.cwd then if provider.cwd then
cwd = eval_string(provider.cwd, ctx) cwd = eval_string(provider.cwd, resolved_ctx)
end end
log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' ')) log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
@ -410,10 +570,10 @@ function M.clean(bufnr, name, provider, ctx)
vim.schedule_wrap(function(result) vim.schedule_wrap(function(result)
if result.code == 0 then if result.code == 0 then
log.dbg('clean succeeded for buffer %d', bufnr) log.dbg('clean succeeded for buffer %d', bufnr)
vim.notify('[preview.nvim] clean complete', vim.log.levels.INFO) vim.notify('[preview.nvim]: clean complete', vim.log.levels.INFO)
else else
log.dbg('clean failed for buffer %d (exit code %d)', bufnr, result.code) log.dbg('clean failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[preview.nvim] clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR) vim.notify('[preview.nvim]: clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR)
end end
end) end)
) )
@ -421,13 +581,17 @@ end
---@param bufnr integer ---@param bufnr integer
---@return boolean ---@return boolean
function M.open(bufnr) function M.open(bufnr, open_config)
local output = last_output[bufnr] local output = last_output[bufnr]
if not output then if not output then
log.dbg('no last output file for buffer %d', bufnr) log.dbg('no last output file for buffer %d', bufnr)
return false return false
end end
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 return true
end end
@ -452,6 +616,8 @@ M._test = {
opened = opened, opened = opened,
last_output = last_output, last_output = last_output,
debounce_timers = debounce_timers, debounce_timers = debounce_timers,
viewer_procs = viewer_procs,
open_watchers = open_watchers,
} }
return M return M

View file

@ -10,6 +10,7 @@
---@field clean? string[]|fun(ctx: preview.Context): string[] ---@field clean? string[]|fun(ctx: preview.Context): string[]
---@field open? boolean|string[] ---@field open? boolean|string[]
---@field reload? boolean|string[]|fun(ctx: preview.Context): string[] ---@field reload? boolean|string[]|fun(ctx: preview.Context): string[]
---@field detach? boolean
---@class preview.Config ---@class preview.Config
---@field debug boolean|string ---@field debug boolean|string
@ -39,10 +40,10 @@
---@class preview ---@class preview
---@field setup fun(opts?: table) ---@field setup fun(opts?: table)
---@field build fun(bufnr?: integer) ---@field compile fun(bufnr?: integer)
---@field stop fun(bufnr?: integer) ---@field stop fun(bufnr?: integer)
---@field clean fun(bufnr?: integer) ---@field clean fun(bufnr?: integer)
---@field watch fun(bufnr?: integer) ---@field toggle fun(bufnr?: integer)
---@field open fun(bufnr?: integer) ---@field open fun(bufnr?: integer)
---@field status fun(bufnr?: integer): preview.Status ---@field status fun(bufnr?: integer): preview.Status
---@field statusline fun(bufnr?: integer): string ---@field statusline fun(bufnr?: integer): string
@ -101,6 +102,13 @@ function M.setup(opts)
end, 'false, "diagnostic", or "quickfix"') end, 'false, "diagnostic", or "quickfix"')
vim.validate(prefix .. '.open', provider.open, { 'boolean', 'table' }, true) vim.validate(prefix .. '.open', provider.open, { 'boolean', 'table' }, true)
vim.validate(prefix .. '.reload', provider.reload, { 'boolean', 'table', 'function' }, 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 end
config = vim.tbl_deep_extend('force', default_config, { config = vim.tbl_deep_extend('force', default_config, {
@ -144,16 +152,20 @@ function M.build_context(bufnr)
end end
---@param bufnr? integer ---@param bufnr? integer
function M.build(bufnr) function M.compile(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf() 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 name = M.resolve_provider(bufnr)
if not name then if not name then
vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN) vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
return return
end end
local ctx = M.build_context(bufnr) local ctx = M.build_context(bufnr)
local provider = config.providers[name] local provider = config.providers[name]
compiler.compile(bufnr, name, provider, ctx) compiler.compile(bufnr, name, provider, ctx, { oneshot = true })
end end
---@param bufnr? integer ---@param bufnr? integer
@ -165,9 +177,13 @@ end
---@param bufnr? integer ---@param bufnr? integer
function M.clean(bufnr) function M.clean(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf() 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 name = M.resolve_provider(bufnr)
if not name then if not name then
vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN) vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
return return
end end
local ctx = M.build_context(bufnr) local ctx = M.build_context(bufnr)
@ -176,11 +192,15 @@ function M.clean(bufnr)
end end
---@param bufnr? integer ---@param bufnr? integer
function M.watch(bufnr) function M.toggle(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf() 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 name = M.resolve_provider(bufnr)
if not name then if not name then
vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN) vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
return return
end end
local provider = config.providers[name] local provider = config.providers[name]
@ -190,8 +210,14 @@ end
---@param bufnr? integer ---@param bufnr? integer
function M.open(bufnr) function M.open(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf() bufnr = bufnr or vim.api.nvim_get_current_buf()
if not compiler.open(bufnr) then if vim.api.nvim_buf_get_name(bufnr) == '' then
vim.notify('[preview.nvim] no output file available for this buffer', vim.log.levels.WARN) 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
vim.notify('[preview.nvim]: no output file available for this buffer', vim.log.levels.WARN)
end end
end end
@ -228,4 +254,8 @@ M._test = {
end, end,
} }
if vim.g.preview then
M.setup(vim.g.preview)
end
return M return M

View file

@ -93,6 +93,28 @@ local function parse_pandoc(output)
return diagnostics return diagnostics
end 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 ---@type preview.ProviderConfig
M.typst = { M.typst = {
ft = 'typst', ft = 'typst',
@ -106,9 +128,12 @@ M.typst = {
error_parser = function(output) error_parser = function(output)
return parse_typst(output) return parse_typst(output)
end, end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.typ$', '.pdf')) }
end,
open = true, open = true,
reload = function(ctx) reload = function(ctx)
return { 'typst', 'watch', ctx.file } return { 'typst', 'watch', '--diagnostic-format', 'short', ctx.file }
end, end,
} }
@ -137,6 +162,45 @@ M.latex = {
open = true, 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 ---@type preview.ProviderConfig
M.markdown = { M.markdown = {
ft = 'markdown', ft = 'markdown',
@ -187,4 +251,104 @@ M.github = {
reload = true, 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 return M

View file

@ -2,6 +2,7 @@ local M = {}
local PORT = 5554 local PORT = 5554
local server_handle = nil local server_handle = nil
local actual_port = nil
local clients = {} local clients = {}
local function make_script(port) local function make_script(port)
@ -14,12 +15,15 @@ local function make_script(port)
end end
function M.start(port) function M.start(port)
port = port or PORT
if server_handle then if server_handle then
return return
end end
local server = vim.uv.new_tcp() local server = vim.uv.new_tcp()
server:bind('127.0.0.1', port) server:bind('127.0.0.1', port or 0)
local sockname = server:getsockname()
if sockname then
actual_port = sockname.port
end
server:listen(128, function(err) server:listen(128, function(err)
if err then if err then
return return
@ -66,6 +70,7 @@ function M.stop()
server_handle:close() server_handle:close()
server_handle = nil server_handle = nil
end end
actual_port = nil
end end
function M.broadcast() function M.broadcast()
@ -85,7 +90,7 @@ function M.broadcast()
end end
function M.inject(path, port) function M.inject(path, port)
port = port or PORT port = actual_port or port or PORT
local f = io.open(path, 'r') local f = io.open(path, 'r')
if not f then if not f then
return return

10
scripts/ci.sh Executable file
View file

@ -0,0 +1,10 @@
#!/bin/sh
set -eu
nix develop --command stylua --check .
git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet
nix develop --command prettier --check .
nix fmt
git diff --exit-code -- '*.nix'
nix develop --command lua-language-server --check lua/ --configpath "$(pwd)/.luarc.json" --checklevel=Warning
nix develop --command busted

View file

@ -11,20 +11,26 @@ describe('commands', function()
local cmds = vim.api.nvim_get_commands({}) local cmds = vim.api.nvim_get_commands({})
assert.is_not_nil(cmds.Preview) assert.is_not_nil(cmds.Preview)
end) 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) end)
describe('dispatch', function() describe('dispatch', function()
it('does not error on :Preview with no provider', function() it('does not error on :Preview compile with no provider', function()
require('preview.commands').setup() require('preview.commands').setup()
assert.has_no.errors(function() assert.has_no.errors(function()
vim.cmd('Preview build') vim.cmd('Preview compile')
end)
end)
it('does not error on :Preview stop', function()
require('preview.commands').setup()
assert.has_no.errors(function()
vim.cmd('Preview stop')
end) end)
end) end)
@ -42,10 +48,10 @@ describe('commands', function()
end) end)
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() require('preview.commands').setup()
assert.has_no.errors(function() assert.has_no.errors(function()
vim.cmd('Preview watch') vim.cmd('Preview toggle')
end) end)
end) end)
end) end)

View file

@ -99,6 +99,35 @@ describe('compiler', function()
helpers.delete_buffer(bufnr) helpers.delete_buffer(bufnr)
end) 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() it('fires PreviewCompileFailed on non-zero exit', function()
local bufnr = helpers.create_buffer({ 'hello' }, 'text') local bufnr = helpers.create_buffer({ 'hello' }, 'text')
vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_fail.txt') 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) helpers.delete_buffer(bufnr)
end) end)
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) end)

View file

@ -33,6 +33,10 @@ describe('presets', function()
assert.are.equal('/tmp/document.pdf', output) assert.are.equal('/tmp/document.pdf', output)
end) end)
it('returns clean command', function()
assert.are.same({ 'rm', '-f', '/tmp/document.pdf' }, presets.typst.clean(ctx))
end)
it('has open enabled', function() it('has open enabled', function()
assert.is_true(presets.typst.open) assert.is_true(presets.typst.open)
end) end)
@ -46,7 +50,9 @@ describe('presets', function()
assert.is_table(result) assert.is_table(result)
assert.are.equal('typst', result[1]) assert.are.equal('typst', result[1])
assert.are.equal('watch', result[2]) 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) end)
it('parses errors from stderr', function() it('parses errors from stderr', function()
@ -157,6 +163,120 @@ describe('presets', function()
end) end)
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() describe('markdown', function()
local md_ctx = { local md_ctx = {
bufnr = 1, bufnr = 1,
@ -341,4 +461,116 @@ describe('presets', function()
assert.are.same({}, diagnostics) assert.are.same({}, diagnostics)
end) end)
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) end)

View file

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