Compare commits

..

1 commit

Author SHA1 Message Date
2888c5bb09
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.
2026-03-03 17:41:53 -05:00
16 changed files with 342 additions and 1105 deletions

View file

@ -4,6 +4,5 @@
"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"
} }

View file

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

View file

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

244
doc/preview.nvim.txt Normal file
View file

@ -0,0 +1,244 @@
*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:

View file

@ -1,276 +0,0 @@
*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,15 +13,12 @@
... ...
}: }:
let let
forEachSystem = forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
in in
{ {
formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
devShells = forEachSystem (pkgs: packages = [
let
devTools = [
(pkgs.luajit.withPackages ( (pkgs.luajit.withPackages (
ps: with ps; [ ps: with ps; [
busted busted
@ -33,23 +30,7 @@
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,14 +1,17 @@
local M = {} local M = {}
local handlers = { local handlers = {
compile = function() build = function()
require('preview').compile() require('preview').build()
end,
stop = function()
require('preview').stop()
end, end,
clean = function() clean = function()
require('preview').clean() require('preview').clean()
end, end,
toggle = function() watch = function()
require('preview').toggle() require('preview').watch()
end, end,
open = function() open = function()
require('preview').open() require('preview').open()
@ -30,7 +33,7 @@ local handlers = {
---@param args string ---@param args string
local function dispatch(args) local function dispatch(args)
local subcmd = args ~= '' and args or 'toggle' local subcmd = args ~= '' and args or 'build'
local handler = handlers[subcmd] local handler = handlers[subcmd]
if handler then if handler then
handler() handler()
@ -55,13 +58,7 @@ function M.setup()
complete = function(lead) complete = function(lead)
return complete(lead) return complete(lead)
end, end,
desc = 'Toggle, compile, clean, open, or check status of document preview', desc = 'Build, stop, clean, watch, 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,49 +15,10 @@ 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[]
@ -94,17 +55,7 @@ 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, opts) function M.compile(bufnr, name, provider, ctx)
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
@ -130,10 +81,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
last_output[bufnr] = output_file last_output[bufnr] = output_file
end end
local reload_cmd local reload_cmd = resolve_reload_cmd(provider, resolved_ctx)
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(
@ -143,52 +91,14 @@ function M.compile(bufnr, name, provider, ctx, opts)
table.concat(reload_cmd, ' ') table.concat(reload_cmd, ' ')
) )
local stderr_acc = {} local obj = vim.system(
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)
if active[bufnr] and active[bufnr].obj == obj then active[bufnr] = nil
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
@ -217,9 +127,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
}) })
end end
vim.fn.setqflist(items, 'r') vim.fn.setqflist(items, 'r')
local win = vim.fn.win_getid() vim.cmd('copen')
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
end end
end end
end end
@ -236,64 +144,24 @@ function M.compile(bufnr, name, provider, ctx, opts)
end) end)
) )
if provider.open and not opts.oneshot and not opened[bufnr] and output_file ~= '' then if provider.open and not opened[bufnr] and output_file ~= '' then
local pre_stat = vim.uv.fs_stat(output_file) if provider.open == true then
local pre_mtime = pre_stat and pre_stat.mtime.sec or 0 vim.ui.open(output_file)
local out_dir = vim.fn.fnamemodify(output_file, ':h') elseif type(provider.open) == 'table' then
local out_name = vim.fn.fnamemodify(output_file, ':t') local open_cmd = vim.list_extend({}, provider.open)
stop_open_watcher(bufnr) table.insert(open_cmd, output_file)
local watcher = vim.uv.new_fs_event() vim.system(open_cmd)
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('BufUnload', { vim.api.nvim_create_autocmd('BufWipeout', {
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,
}) })
@ -312,17 +180,14 @@ function M.compile(bufnr, name, provider, ctx, opts)
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 local obj = vim.system(
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)
if active[bufnr] and active[bufnr].obj == obj then active[bufnr] = nil
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
@ -338,7 +203,6 @@ function M.compile(bufnr, name, provider, ctx, opts)
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',
@ -350,14 +214,14 @@ function M.compile(bufnr, name, provider, ctx, opts)
r.inject(output_file) r.inject(output_file)
r.broadcast() r.broadcast()
end end
if if provider.open and not opened[bufnr] and output_file ~= '' then
provider.open if provider.open == true then
and not opts.oneshot vim.ui.open(output_file)
and not opened[bufnr] elseif type(provider.open) == 'table' then
and output_file ~= '' local open_cmd = vim.list_extend({}, provider.open)
and vim.uv.fs_stat(output_file) table.insert(open_cmd, output_file)
then vim.system(open_cmd)
do_open(bufnr, output_file, provider.open) end
opened[bufnr] = true opened[bufnr] = true
end end
else else
@ -380,9 +244,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
}) })
end end
vim.fn.setqflist(items, 'r') vim.fn.setqflist(items, 'r')
local win = vim.fn.win_getid() vim.cmd('copen')
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
end end
end end
end end
@ -401,14 +263,11 @@ function M.compile(bufnr, name, provider, ctx, opts)
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('BufUnload', { vim.api.nvim_create_autocmd('BufWipeout', {
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,
}) })
@ -449,12 +308,6 @@ 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
@ -505,15 +358,11 @@ 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('BufUnload', { vim.api.nvim_create_autocmd('BufWipeout', {
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,
}) })
@ -543,23 +392,14 @@ 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( vim.notify('[preview.nvim] provider "' .. name .. '" has no clean command', vim.log.levels.WARN)
'[preview.nvim]: provider "' .. name .. '" has no clean command',
vim.log.levels.WARN
)
return return
end end
local output_file = '' local cmd = eval_list(provider.clean, ctx)
if provider.output then local cwd = ctx.root
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, resolved_ctx) cwd = eval_string(provider.cwd, 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, ' '))
@ -570,10 +410,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)
) )
@ -581,17 +421,13 @@ end
---@param bufnr integer ---@param bufnr integer
---@return boolean ---@return boolean
function M.open(bufnr, open_config) function M.open(bufnr)
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
if not vim.uv.fs_stat(output) then vim.ui.open(output)
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
@ -616,8 +452,6 @@ 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,7 +10,6 @@
---@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
@ -40,10 +39,10 @@
---@class preview ---@class preview
---@field setup fun(opts?: table) ---@field setup fun(opts?: table)
---@field compile fun(bufnr?: integer) ---@field build fun(bufnr?: integer)
---@field stop fun(bufnr?: integer) ---@field stop fun(bufnr?: integer)
---@field clean fun(bufnr?: integer) ---@field clean fun(bufnr?: integer)
---@field toggle fun(bufnr?: integer) ---@field watch 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
@ -102,13 +101,6 @@ 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, {
@ -152,20 +144,16 @@ function M.build_context(bufnr)
end end
---@param bufnr? integer ---@param bufnr? integer
function M.compile(bufnr) function M.build(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, { oneshot = true }) compiler.compile(bufnr, name, provider, ctx)
end end
---@param bufnr? integer ---@param bufnr? integer
@ -177,13 +165,9 @@ 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)
@ -192,15 +176,11 @@ function M.clean(bufnr)
end end
---@param bufnr? integer ---@param bufnr? integer
function M.toggle(bufnr) function M.watch(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]
@ -210,14 +190,8 @@ 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 vim.api.nvim_buf_get_name(bufnr) == '' then if not compiler.open(bufnr) then
vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN) vim.notify('[preview.nvim] no output file available for this buffer', 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
@ -254,8 +228,4 @@ M._test = {
end, end,
} }
if vim.g.preview then
M.setup(vim.g.preview)
end
return M return M

View file

@ -93,28 +93,6 @@ 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',
@ -128,12 +106,9 @@ 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', '--diagnostic-format', 'short', ctx.file } return { 'typst', 'watch', ctx.file }
end, end,
} }
@ -162,45 +137,6 @@ 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',
@ -251,104 +187,4 @@ 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

@ -1,10 +0,0 @@
#!/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,26 +11,20 @@ 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 compile with no provider', function() it('does not error on :Preview 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 compile') vim.cmd('Preview build')
end)
end)
it('does not error on :Preview stop', function()
require('preview.commands').setup()
assert.has_no.errors(function()
vim.cmd('Preview stop')
end) end)
end) end)
@ -48,10 +42,10 @@ describe('commands', function()
end) end)
end) end)
it('does not error on :Preview toggle with no provider', function() it('does not error on :Preview watch 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 toggle') vim.cmd('Preview watch')
end) end)
end) end)
end) end)

View file

@ -99,35 +99,6 @@ 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,62 +108,4 @@ 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,10 +33,6 @@ 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)
@ -50,9 +46,7 @@ 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('--diagnostic-format', result[3]) assert.are.equal(ctx.file, 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()
@ -163,120 +157,6 @@ 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,
@ -461,116 +341,4 @@ 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 = assert(io.open(path, 'w')) local f = 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 = assert(io.open(path, 'r')) local fr = 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 = assert(io.open(path, 'w')) local f = 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 = assert(io.open(path, 'r')) local fr = io.open(path, 'r')
local content = fr:read('*a') local content = fr:read('*a')
fr:close() fr:close()
os.remove(path) os.remove(path)