Compare commits

..

2 commits

Author SHA1 Message Date
b2e89dcf8b
feat(compiler): add configurable error output modes
Problem: all parse errors went to vim.diagnostic with no way to silence
them or route them to the quickfix list. Users wanting quickfix-style
error navigation had no option.

Solution: add an errors field to ProviderConfig accepting false,
'diagnostic' (default), or 'quickfix'. false suppresses error handling
entirely. 'quickfix' converts parsed diagnostics to qflist items
(1-indexed), calls setqflist, and opens the window. On success,
'quickfix' mode clears the qflist the same way 'diagnostic' mode clears
vim.diagnostic.
2026-03-03 14:07:28 -05:00
56d110a74e
fix(presets): correct error parsers for real compiler output
Problem: all three built-in error parsers were broken against real
compiler output. Typst set source to the relative file path, overriding
the provider name. LaTeX errors go to stdout but the parser only
received stderr. Pandoc's pattern matched "Error at" but not the real
"Error parsing YAML metadata at" format, and single-line parsing missed
multiline messages.

Solution: pass combined stdout+stderr to error_parser so LaTeX stdout
errors are visible. Remove source = file from the Typst parser so
diagnostic.lua defaults it to the provider name. Rewrite the Pandoc
parser with line-based lookahead: match (line N, column N) regardless
of prefix text, skip YAML parse exception lines when looking ahead for
the human-readable message. Rename stderr param to output throughout
diagnostic.lua, presets.lua, and init.lua annotations.
2026-03-03 14:07:00 -05:00
17 changed files with 323 additions and 1455 deletions

View file

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

View file

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

View file

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

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

@ -0,0 +1,228 @@
*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 (stderr, |preview.Context|)
and returns vim.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.
Presets default to `{ 'xdg-open' }`.
*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.
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.
`status` Echo compilation status (idle, compiling, toggled).
==============================================================================
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.status({bufnr?}) *preview.status()*
Returns a |preview.Status| table.
*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
forEachSystem =
f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
in
{
formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
devShells = forEachSystem (pkgs:
let
devTools = [
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
packages = [
(pkgs.luajit.withPackages (
ps: with ps; [
busted
@ -33,23 +30,7 @@
pkgs.selene
pkgs.lua-language-server
];
in
{
default = pkgs.mkShell {
packages = devTools;
};
presets = pkgs.mkShell {
packages = devTools ++ [
pkgs.typst
pkgs.texliveMedium
pkgs.tectonic
pkgs.pandoc
pkgs.asciidoctor
pkgs.quarto
pkgs.plantuml
pkgs.mermaid-cli
];
};
});
};
});
};
}

View file

@ -1,19 +1,22 @@
local M = {}
local handlers = {
compile = function()
local subcommands = { 'compile', 'stop', 'clean', 'toggle', 'open', 'status' }
---@param args string
local function dispatch(args)
local subcmd = args ~= '' and args or 'compile'
if subcmd == 'compile' then
require('preview').compile()
end,
clean = function()
elseif subcmd == 'stop' then
require('preview').stop()
elseif subcmd == 'clean' then
require('preview').clean()
end,
toggle = function()
elseif subcmd == 'toggle' then
require('preview').toggle()
end,
open = function()
elseif subcmd == 'open' then
require('preview').open()
end,
status = function()
elseif subcmd == 'status' then
local s = require('preview').status()
local parts = {}
if s.compiling then
@ -24,18 +27,9 @@ local handlers = {
if s.watching then
table.insert(parts, 'watching')
end
vim.notify('[preview.nvim]: ' .. table.concat(parts, ', '), vim.log.levels.INFO)
end,
}
---@param args string
local function dispatch(args)
local subcmd = args ~= '' and args or 'toggle'
local handler = handlers[subcmd]
if handler then
handler()
vim.notify('[preview.nvim] ' .. table.concat(parts, ', '), vim.log.levels.INFO)
else
vim.notify('[preview.nvim]: unknown subcommand: ' .. subcmd, vim.log.levels.ERROR)
vim.notify('[preview.nvim] unknown subcommand: ' .. subcmd, vim.log.levels.ERROR)
end
end
@ -44,7 +38,7 @@ end
local function complete(lead)
return vim.tbl_filter(function(s)
return s:find(lead, 1, true) == 1
end, vim.tbl_keys(handlers))
end, subcommands)
end
function M.setup()
@ -55,13 +49,7 @@ function M.setup()
complete = function(lead)
return complete(lead)
end,
desc = 'Toggle, compile, clean, open, or check status of document preview',
})
vim.api.nvim_create_autocmd('VimLeavePre', {
callback = function()
require('preview.compiler').stop_all()
end,
desc = 'Compile, stop, clean, toggle, open, or check status of document preview',
})
end

View file

@ -15,49 +15,10 @@ local opened = {}
---@type table<integer, string>
local last_output = {}
---@type table<integer, table>
local viewer_procs = {}
---@type table<integer, uv.uv_fs_event_t>
local open_watchers = {}
local debounce_timers = {}
local DEBOUNCE_MS = 500
---@param bufnr integer
local function stop_open_watcher(bufnr)
local w = open_watchers[bufnr]
if w then
w:stop()
w:close()
open_watchers[bufnr] = nil
end
end
---@param bufnr integer
local function close_viewer(bufnr)
local obj = viewer_procs[bufnr]
if obj then
local kill = obj.kill
kill(obj, 'sigterm')
viewer_procs[bufnr] = nil
end
end
---@param bufnr integer
---@param output_file string
---@param open_config boolean|string[]
local function do_open(bufnr, output_file, open_config)
if open_config == true then
vim.ui.open(output_file)
elseif type(open_config) == 'table' then
local open_cmd = vim.list_extend({}, open_config)
table.insert(open_cmd, output_file)
viewer_procs[bufnr] = vim.system(open_cmd)
end
end
---@param val string[]|fun(ctx: preview.Context): string[]
---@param ctx preview.Context
---@return string[]
@ -78,33 +39,11 @@ local function eval_string(val, ctx)
return val
end
---@param provider preview.ProviderConfig
---@param ctx preview.Context
---@return string[]?
local function resolve_reload_cmd(provider, ctx)
if type(provider.reload) == 'function' then
return provider.reload(ctx)
elseif type(provider.reload) == 'table' then
return vim.list_extend({}, provider.reload)
end
return nil
end
---@param bufnr integer
---@param name string
---@param provider preview.ProviderConfig
---@param ctx preview.Context
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
function M.compile(bufnr, name, provider, ctx)
if vim.bo[bufnr].modified then
vim.cmd('silent! update')
end
@ -114,215 +53,35 @@ function M.compile(bufnr, name, provider, ctx, opts)
M.stop(bufnr)
end
local output_file = ''
if provider.output then
output_file = eval_string(provider.output, ctx)
local cmd = vim.list_extend({}, provider.cmd)
if provider.args then
vim.list_extend(cmd, eval_list(provider.args, ctx))
end
local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file })
local cwd = ctx.root
if provider.cwd then
cwd = eval_string(provider.cwd, resolved_ctx)
cwd = eval_string(provider.cwd, ctx)
end
local output_file = ''
if provider.output then
output_file = eval_string(provider.output, ctx)
end
if output_file ~= '' then
last_output[bufnr] = output_file
end
local reload_cmd
if not opts.oneshot then
reload_cmd = resolve_reload_cmd(provider, resolved_ctx)
end
if reload_cmd then
log.dbg(
'starting long-running process for buffer %d with provider "%s": %s',
bufnr,
name,
table.concat(reload_cmd, ' ')
)
local stderr_acc = {}
local obj
obj = vim.system(
reload_cmd,
{
cwd = cwd,
env = provider.env,
stderr = vim.schedule_wrap(function(_err, data)
if not data or not vim.api.nvim_buf_is_valid(bufnr) then
return
end
stderr_acc[#stderr_acc + 1] = data
local errors_mode = provider.errors
if errors_mode == nil then
errors_mode = 'diagnostic'
end
if provider.error_parser and errors_mode then
local output = table.concat(stderr_acc)
if errors_mode == 'diagnostic' then
diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
elseif errors_mode == 'quickfix' then
local ok, diags = pcall(provider.error_parser, output, ctx)
if ok and diags and #diags > 0 then
local items = {}
for _, d in ipairs(diags) do
table.insert(items, {
bufnr = bufnr,
lnum = d.lnum + 1,
col = d.col + 1,
text = d.message,
type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E',
})
end
vim.fn.setqflist(items, 'r')
local win = vim.fn.win_getid()
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
end
end
end
end),
},
vim.schedule_wrap(function(result)
if active[bufnr] and active[bufnr].obj == obj then
active[bufnr] = nil
end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
if result.code ~= 0 then
log.dbg('long-running process failed for buffer %d (exit code %d)', bufnr, result.code)
local errors_mode = provider.errors
if errors_mode == nil then
errors_mode = 'diagnostic'
end
if provider.error_parser and errors_mode then
local output = (result.stdout or '') .. (result.stderr or '')
if errors_mode == 'diagnostic' then
diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
elseif errors_mode == 'quickfix' then
local ok, diagnostics = pcall(provider.error_parser, output, ctx)
if ok and diagnostics and #diagnostics > 0 then
local items = {}
for _, d in ipairs(diagnostics) do
table.insert(items, {
bufnr = bufnr,
lnum = d.lnum + 1,
col = d.col + 1,
text = d.message,
type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E',
})
end
vim.fn.setqflist(items, 'r')
local win = vim.fn.win_getid()
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
end
end
end
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
data = {
bufnr = bufnr,
provider = name,
code = result.code,
stderr = result.stderr or '',
},
})
end
end)
)
if provider.open and not opts.oneshot and not opened[bufnr] and output_file ~= '' then
local pre_stat = vim.uv.fs_stat(output_file)
local pre_mtime = pre_stat and pre_stat.mtime.sec or 0
local out_dir = vim.fn.fnamemodify(output_file, ':h')
local out_name = vim.fn.fnamemodify(output_file, ':t')
stop_open_watcher(bufnr)
local watcher = vim.uv.new_fs_event()
if watcher then
open_watchers[bufnr] = watcher
watcher:start(
out_dir,
{},
vim.schedule_wrap(function(err, filename, _events)
if err or vim.fn.fnamemodify(filename or '', ':t') ~= out_name then
return
end
if opened[bufnr] then
stop_open_watcher(bufnr)
return
end
if not vim.api.nvim_buf_is_valid(bufnr) then
stop_open_watcher(bufnr)
return
end
local new_stat = vim.uv.fs_stat(output_file)
if not (new_stat and new_stat.mtime.sec > pre_mtime) then
return
end
stop_open_watcher(bufnr)
stderr_acc = {}
local errors_mode = provider.errors
if errors_mode == nil then
errors_mode = 'diagnostic'
end
if errors_mode == 'diagnostic' then
diagnostic.clear(bufnr)
elseif errors_mode == 'quickfix' then
vim.fn.setqflist({}, 'r')
vim.cmd.cwindow()
end
do_open(bufnr, output_file, provider.open)
opened[bufnr] = true
end)
)
end
end
active[bufnr] = { obj = obj, provider = name, output_file = output_file, is_reload = true }
vim.api.nvim_create_autocmd('BufUnload', {
buffer = bufnr,
once = true,
callback = function()
M.stop(bufnr)
stop_open_watcher(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
last_output[bufnr] = nil
end,
})
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileStarted',
data = { bufnr = bufnr, provider = name },
})
return
end
local cmd = vim.list_extend({}, provider.cmd)
if provider.args then
vim.list_extend(cmd, eval_list(provider.args, resolved_ctx))
end
log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
local obj
obj = vim.system(
local obj = vim.system(
cmd,
{
cwd = cwd,
env = provider.env,
},
vim.schedule_wrap(function(result)
if active[bufnr] and active[bufnr].obj == obj then
active[bufnr] = nil
end
active[bufnr] = nil
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
@ -338,26 +97,19 @@ function M.compile(bufnr, name, provider, ctx, opts)
diagnostic.clear(bufnr)
elseif errors_mode == 'quickfix' then
vim.fn.setqflist({}, 'r')
vim.cmd.cwindow()
end
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file },
})
if provider.reload == true and output_file:match('%.html$') then
local r = require('preview.reload')
r.start()
r.inject(output_file)
r.broadcast()
end
if
provider.open
and not opts.oneshot
and not opened[bufnr]
and output_file ~= ''
and vim.uv.fs_stat(output_file)
then
do_open(bufnr, output_file, provider.open)
if provider.open and not opened[bufnr] and output_file ~= '' then
if provider.open == true then
vim.ui.open(output_file)
elseif type(provider.open) == 'table' then
local open_cmd = vim.list_extend({}, provider.open)
table.insert(open_cmd, output_file)
vim.system(open_cmd)
end
opened[bufnr] = true
end
else
@ -380,9 +132,7 @@ function M.compile(bufnr, name, provider, ctx, opts)
})
end
vim.fn.setqflist(items, 'r')
local win = vim.fn.win_getid()
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
vim.cmd('copen')
end
end
end
@ -401,14 +151,11 @@ function M.compile(bufnr, name, provider, ctx, opts)
active[bufnr] = { obj = obj, provider = name, output_file = output_file }
vim.api.nvim_create_autocmd('BufUnload', {
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
once = true,
callback = function()
M.stop(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
last_output[bufnr] = nil
end,
})
@ -449,13 +196,6 @@ function M.stop_all()
for bufnr, _ in pairs(watching) do
M.unwatch(bufnr)
end
for bufnr, _ in pairs(open_watchers) do
stop_open_watcher(bufnr)
end
for bufnr, _ in pairs(viewer_procs) do
close_viewer(bufnr)
end
require('preview.reload').stop()
end
---@param bufnr integer
@ -463,22 +203,8 @@ end
---@param provider preview.ProviderConfig
---@param ctx_builder fun(bufnr: integer): preview.Context
function M.toggle(bufnr, name, provider, ctx_builder)
local is_longrunning = type(provider.reload) == 'table' or type(provider.reload) == 'function'
if is_longrunning then
if active[bufnr] then
M.stop(bufnr)
vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO)
else
M.compile(bufnr, name, provider, ctx_builder(bufnr))
vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO)
end
return
end
if watching[bufnr] then
M.unwatch(bufnr)
vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO)
return
end
@ -503,17 +229,12 @@ function M.toggle(bufnr, name, provider, ctx_builder)
watching[bufnr] = au_id
log.dbg('watching buffer %d with provider "%s"', bufnr, name)
vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO)
vim.api.nvim_create_autocmd('BufUnload', {
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
once = true,
callback = function()
M.unwatch(bufnr)
stop_open_watcher(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
opened[bufnr] = nil
end,
})
@ -543,23 +264,14 @@ end
---@param ctx preview.Context
function M.clean(bufnr, name, provider, ctx)
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
end
local output_file = ''
if provider.output then
output_file = eval_string(provider.output, ctx)
end
local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file })
local cmd = eval_list(provider.clean, resolved_ctx)
local cwd = resolved_ctx.root
local cmd = eval_list(provider.clean, ctx)
local cwd = ctx.root
if provider.cwd then
cwd = eval_string(provider.cwd, resolved_ctx)
cwd = eval_string(provider.cwd, ctx)
end
log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
@ -570,10 +282,10 @@ function M.clean(bufnr, name, provider, ctx)
vim.schedule_wrap(function(result)
if result.code == 0 then
log.dbg('clean succeeded for buffer %d', bufnr)
vim.notify('[preview.nvim]: clean complete', vim.log.levels.INFO)
vim.notify('[preview.nvim] clean complete', vim.log.levels.INFO)
else
log.dbg('clean failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[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)
)
@ -581,17 +293,13 @@ end
---@param bufnr integer
---@return boolean
function M.open(bufnr, open_config)
function M.open(bufnr)
local output = last_output[bufnr]
if not output then
log.dbg('no last output file for buffer %d', bufnr)
return false
end
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)
vim.ui.open(output)
return true
end
@ -601,8 +309,8 @@ function M.status(bufnr)
local proc = active[bufnr]
if proc then
return {
compiling = not proc.is_reload,
watching = watching[bufnr] ~= nil or proc.is_reload == true,
compiling = true,
watching = watching[bufnr] ~= nil,
provider = proc.provider,
output_file = proc.output_file,
}
@ -616,8 +324,6 @@ M._test = {
opened = opened,
last_output = last_output,
debounce_timers = debounce_timers,
viewer_procs = viewer_procs,
open_watchers = open_watchers,
}
return M

View file

@ -9,8 +9,6 @@
---@field errors? false|'diagnostic'|'quickfix'
---@field clean? string[]|fun(ctx: preview.Context): string[]
---@field open? boolean|string[]
---@field reload? boolean|string[]|fun(ctx: preview.Context): string[]
---@field detach? boolean
---@class preview.Config
---@field debug boolean|string
@ -21,7 +19,6 @@
---@field file string
---@field root string
---@field ft string
---@field output? string
---@class preview.Diagnostic
---@field lnum integer
@ -36,7 +33,6 @@
---@field obj table
---@field provider string
---@field output_file string
---@field is_reload? boolean
---@class preview
---@field setup fun(opts?: table)
@ -89,28 +85,6 @@ function M.setup(opts)
end
end
for ft, provider in pairs(providers) do
local prefix = 'providers.' .. ft
vim.validate(prefix .. '.cmd', provider.cmd, 'table')
vim.validate(prefix .. '.cmd[1]', provider.cmd[1], 'string')
vim.validate(prefix .. '.args', provider.args, { 'table', 'function' }, true)
vim.validate(prefix .. '.cwd', provider.cwd, { 'string', 'function' }, true)
vim.validate(prefix .. '.output', provider.output, { 'string', 'function' }, true)
vim.validate(prefix .. '.error_parser', provider.error_parser, 'function', true)
vim.validate(prefix .. '.errors', provider.errors, function(x)
return x == nil or x == false or x == 'diagnostic' or x == 'quickfix'
end, 'false, "diagnostic", or "quickfix"')
vim.validate(prefix .. '.open', provider.open, { 'boolean', 'table' }, true)
vim.validate(prefix .. '.reload', provider.reload, { 'boolean', 'table', 'function' }, true)
vim.validate(prefix .. '.detach', provider.detach, 'boolean', true)
end
if providers['plantuml'] then
vim.filetype.add({
extension = { puml = 'plantuml', pu = 'plantuml' },
})
end
config = vim.tbl_deep_extend('force', default_config, {
debug = debug,
providers = providers,
@ -154,18 +128,14 @@ end
---@param bufnr? integer
function M.compile(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if vim.api.nvim_buf_get_name(bufnr) == '' then
vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
return
end
local name = M.resolve_provider(bufnr)
if not name then
vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN)
return
end
local ctx = M.build_context(bufnr)
local provider = config.providers[name]
compiler.compile(bufnr, name, provider, ctx, { oneshot = true })
compiler.compile(bufnr, name, provider, ctx)
end
---@param bufnr? integer
@ -177,13 +147,9 @@ end
---@param bufnr? integer
function M.clean(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if vim.api.nvim_buf_get_name(bufnr) == '' then
vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
return
end
local name = M.resolve_provider(bufnr)
if not name then
vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN)
return
end
local ctx = M.build_context(bufnr)
@ -194,13 +160,9 @@ end
---@param bufnr? integer
function M.toggle(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if vim.api.nvim_buf_get_name(bufnr) == '' then
vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
return
end
local name = M.resolve_provider(bufnr)
if not name then
vim.notify('[preview.nvim]: no provider configured for this filetype', vim.log.levels.WARN)
vim.notify('[preview.nvim] no provider configured for this filetype', vim.log.levels.WARN)
return
end
local provider = config.providers[name]
@ -210,14 +172,8 @@ end
---@param bufnr? integer
function M.open(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
if vim.api.nvim_buf_get_name(bufnr) == '' then
vim.notify('[preview.nvim]: buffer has no file name', vim.log.levels.WARN)
return
end
local name = M.resolve_provider(bufnr)
local open_config = name and config.providers[name] and config.providers[name].open
if not compiler.open(bufnr, open_config) then
vim.notify('[preview.nvim]: no output file available for this buffer', vim.log.levels.WARN)
if not compiler.open(bufnr) then
vim.notify('[preview.nvim] no output file available for this buffer', vim.log.levels.WARN)
end
end
@ -254,8 +210,4 @@ M._test = {
end,
}
if vim.g.preview then
M.setup(vim.g.preview)
end
return M

View file

@ -93,28 +93,6 @@ local function parse_pandoc(output)
return diagnostics
end
---@param output string
---@return preview.Diagnostic[]
local function parse_asciidoctor(output)
local diagnostics = {}
for line in output:gmatch('[^\r\n]+') do
local severity, _, lnum, msg = line:match('^asciidoctor: (%u+): (.+): line (%d+): (.+)$')
if lnum then
local sev = vim.diagnostic.severity.ERROR
if severity == 'WARNING' then
sev = vim.diagnostic.severity.WARN
end
table.insert(diagnostics, {
lnum = tonumber(lnum) - 1,
col = 0,
message = msg,
severity = sev,
})
end
end
return diagnostics
end
---@type preview.ProviderConfig
M.typst = {
ft = 'typst',
@ -128,13 +106,7 @@ M.typst = {
error_parser = function(output)
return parse_typst(output)
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.typ$', '.pdf')) }
end,
open = true,
reload = function(ctx)
return { 'typst', 'watch', '--diagnostic-format', 'short', ctx.file }
end,
}
---@type preview.ProviderConfig
@ -145,7 +117,6 @@ M.latex = {
return {
'-pdf',
'-interaction=nonstopmode',
'-synctex=1',
'-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S',
ctx.file,
}
@ -162,51 +133,13 @@ M.latex = {
open = true,
}
---@type preview.ProviderConfig
M.pdflatex = {
ft = 'tex',
cmd = { 'pdflatex' },
args = function(ctx)
return { '-interaction=nonstopmode', '-file-line-error', '-synctex=1', ctx.file }
end,
output = function(ctx)
return (ctx.file:gsub('%.tex$', '.pdf'))
end,
error_parser = function(output)
return parse_latexmk(output)
end,
clean = function(ctx)
local base = ctx.file:gsub('%.tex$', '')
return { 'rm', '-f', base .. '.pdf', base .. '.aux', base .. '.log', base .. '.synctex.gz' }
end,
open = true,
}
---@type preview.ProviderConfig
M.tectonic = {
ft = 'tex',
cmd = { 'tectonic' },
args = function(ctx)
return { ctx.file }
end,
output = function(ctx)
return (ctx.file:gsub('%.tex$', '.pdf'))
end,
error_parser = function(output)
return parse_latexmk(output)
end,
clean = function(ctx)
return { 'rm', '-f', (ctx.file:gsub('%.tex$', '.pdf')) }
end,
open = true,
}
---@type preview.ProviderConfig
M.markdown = {
ft = 'markdown',
cmd = { 'pandoc' },
args = function(ctx)
return { ctx.file, '-s', '--embed-resources', '-o', ctx.output }
local output = ctx.file:gsub('%.md$', '.html')
return { ctx.file, '-s', '--embed-resources', '-o', output }
end,
output = function(ctx)
return (ctx.file:gsub('%.md$', '.html'))
@ -218,7 +151,6 @@ M.markdown = {
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
end,
open = true,
reload = true,
}
---@type preview.ProviderConfig
@ -226,6 +158,7 @@ M.github = {
ft = 'markdown',
cmd = { 'pandoc' },
args = function(ctx)
local output = ctx.file:gsub('%.md$', '.html')
return {
'-f',
'gfm',
@ -235,7 +168,7 @@ M.github = {
'--css',
'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
'-o',
ctx.output,
output,
}
end,
output = function(ctx)
@ -248,107 +181,6 @@ M.github = {
return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
end,
open = 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

View file

@ -1,113 +0,0 @@
local M = {}
local PORT = 5554
local server_handle = nil
local actual_port = nil
local clients = {}
local function make_script(port)
return '<script>(function(){'
.. 'var es=new EventSource("http://localhost:'
.. tostring(port)
.. '/__live/events");'
.. 'es.addEventListener("reload",function(){location.reload();});'
.. '})()</script>'
end
function M.start(port)
if server_handle then
return
end
local server = vim.uv.new_tcp()
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)
if err then
return
end
local client = vim.uv.new_tcp()
server:accept(client)
local buf = ''
client:read_start(function(read_err, data)
if read_err or not data then
if not client:is_closing() then
client:close()
end
return
end
buf = buf .. data
if buf:find('\r\n\r\n') or buf:find('\n\n') then
client:read_stop()
local first_line = buf:match('^([^\r\n]+)')
if first_line and first_line:find('/__live/events', 1, true) then
local response = 'HTTP/1.1 200 OK\r\n'
.. 'Content-Type: text/event-stream\r\n'
.. 'Cache-Control: no-cache\r\n'
.. 'Access-Control-Allow-Origin: *\r\n'
.. '\r\n'
client:write(response)
table.insert(clients, client)
else
client:close()
end
end
end)
end)
server_handle = server
end
function M.stop()
for _, c in ipairs(clients) do
if not c:is_closing() then
c:close()
end
end
clients = {}
if server_handle then
server_handle:close()
server_handle = nil
end
actual_port = nil
end
function M.broadcast()
local event = 'event: reload\ndata: {}\n\n'
local alive = {}
for _, c in ipairs(clients) do
if not c:is_closing() then
local ok = pcall(function()
c:write(event)
end)
if ok then
table.insert(alive, c)
end
end
end
clients = alive
end
function M.inject(path, port)
port = actual_port or port or PORT
local f = io.open(path, 'r')
if not f then
return
end
local content = f:read('*a')
f:close()
local script = make_script(port)
local new_content, n = content:gsub('</body>', script .. '\n</body>', 1)
if n == 0 then
new_content = content .. '\n' .. script
end
local fw = io.open(path, 'w')
if not fw then
return
end
fw:write(new_content)
fw:close()
end
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,29 +11,23 @@ describe('commands', function()
local cmds = vim.api.nvim_get_commands({})
assert.is_not_nil(cmds.Preview)
end)
it('registers VimLeavePre autocmd', function()
require('preview.commands').setup()
local aus = vim.api.nvim_get_autocmds({ event = 'VimLeavePre' })
local found = false
for _, au in ipairs(aus) do
if au.callback then
found = true
break
end
end
assert.is_true(found)
end)
end)
describe('dispatch', function()
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()
assert.has_no.errors(function()
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)
it('does not error on :Preview status', function()
require('preview.commands').setup()
assert.has_no.errors(function()

View file

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

View file

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

View file

@ -33,28 +33,10 @@ describe('presets', function()
assert.are.equal('/tmp/document.pdf', output)
end)
it('returns clean command', function()
assert.are.same({ 'rm', '-f', '/tmp/document.pdf' }, presets.typst.clean(ctx))
end)
it('has open enabled', function()
assert.is_true(presets.typst.open)
end)
it('has reload as a function', function()
assert.is_function(presets.typst.reload)
end)
it('reload returns typst watch command', function()
local result = presets.typst.reload(ctx)
assert.is_table(result)
assert.are.equal('typst', result[1])
assert.are.equal('watch', result[2])
assert.are.equal('--diagnostic-format', result[3])
assert.are.equal('short', result[4])
assert.are.equal(ctx.file, result[5])
end)
it('parses errors from stderr', function()
local stderr = table.concat({
'main.typ:5:23: error: unexpected token',
@ -102,7 +84,6 @@ describe('presets', function()
assert.are.same({
'-pdf',
'-interaction=nonstopmode',
'-synctex=1',
'-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S',
'/tmp/document.tex',
}, args)
@ -163,127 +144,12 @@ describe('presets', function()
end)
end)
describe('pdflatex', function()
local tex_ctx = {
bufnr = 1,
file = '/tmp/document.tex',
root = '/tmp',
ft = 'tex',
}
it('has ft', function()
assert.are.equal('tex', presets.pdflatex.ft)
end)
it('has cmd', function()
assert.are.same({ 'pdflatex' }, presets.pdflatex.cmd)
end)
it('returns args with flags and file path', function()
local args = presets.pdflatex.args(tex_ctx)
assert.are.same(
{ '-interaction=nonstopmode', '-file-line-error', '-synctex=1', '/tmp/document.tex' },
args
)
end)
it('returns pdf output path', function()
assert.are.equal('/tmp/document.pdf', presets.pdflatex.output(tex_ctx))
end)
it('has open enabled', function()
assert.is_true(presets.pdflatex.open)
end)
it('returns clean command removing pdf and aux files', function()
local clean = presets.pdflatex.clean(tex_ctx)
assert.are.same({
'rm',
'-f',
'/tmp/document.pdf',
'/tmp/document.aux',
'/tmp/document.log',
'/tmp/document.synctex.gz',
}, clean)
end)
it('has no reload', function()
assert.is_nil(presets.pdflatex.reload)
end)
it('parses file-line-error format', function()
local output = './document.tex:10: Undefined control sequence.'
local diagnostics = presets.pdflatex.error_parser(output, tex_ctx)
assert.are.equal(1, #diagnostics)
assert.are.equal(9, diagnostics[1].lnum)
assert.are.equal(0, diagnostics[1].col)
assert.are.equal('Undefined control sequence.', diagnostics[1].message)
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end)
it('returns empty table for clean output', function()
assert.are.same({}, presets.pdflatex.error_parser('', tex_ctx))
end)
end)
describe('tectonic', function()
local tex_ctx = {
bufnr = 1,
file = '/tmp/document.tex',
root = '/tmp',
ft = 'tex',
}
it('has ft', function()
assert.are.equal('tex', presets.tectonic.ft)
end)
it('has cmd', function()
assert.are.same({ 'tectonic' }, presets.tectonic.cmd)
end)
it('returns args with file path', function()
assert.are.same({ '/tmp/document.tex' }, presets.tectonic.args(tex_ctx))
end)
it('returns pdf output path', function()
assert.are.equal('/tmp/document.pdf', presets.tectonic.output(tex_ctx))
end)
it('has open enabled', function()
assert.is_true(presets.tectonic.open)
end)
it('returns clean command removing pdf', function()
assert.are.same({ 'rm', '-f', '/tmp/document.pdf' }, presets.tectonic.clean(tex_ctx))
end)
it('has no reload', function()
assert.is_nil(presets.tectonic.reload)
end)
it('parses file-line-error format', function()
local output = './document.tex:5: Missing $ inserted.'
local diagnostics = presets.tectonic.error_parser(output, tex_ctx)
assert.are.equal(1, #diagnostics)
assert.are.equal(4, diagnostics[1].lnum)
assert.are.equal(0, diagnostics[1].col)
assert.are.equal('Missing $ inserted.', diagnostics[1].message)
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end)
it('returns empty table for clean output', function()
assert.are.same({}, presets.tectonic.error_parser('', tex_ctx))
end)
end)
describe('markdown', function()
local md_ctx = {
bufnr = 1,
file = '/tmp/document.md',
root = '/tmp',
ft = 'markdown',
output = '/tmp/document.html',
}
it('has ft', function()
@ -319,10 +185,6 @@ describe('presets', function()
assert.is_true(presets.markdown.open)
end)
it('has reload enabled for SSE', function()
assert.is_true(presets.markdown.reload)
end)
it('parses YAML metadata errors with multiline message', function()
local output = table.concat({
'Error parsing YAML metadata at "/tmp/test.md" (line 1, column 1):',
@ -371,7 +233,6 @@ describe('presets', function()
file = '/tmp/document.md',
root = '/tmp',
ft = 'markdown',
output = '/tmp/document.html',
}
it('has ft', function()
@ -427,10 +288,6 @@ describe('presets', function()
assert.is_true(presets.github.open)
end)
it('has reload enabled for SSE', function()
assert.is_true(presets.github.reload)
end)
it('parses YAML metadata errors with multiline message', function()
local output = table.concat({
'Error parsing YAML metadata at "/tmp/test.md" (line 1, column 1):',
@ -461,116 +318,4 @@ describe('presets', function()
assert.are.same({}, diagnostics)
end)
end)
describe('asciidoctor', function()
local adoc_ctx = {
bufnr = 1,
file = '/tmp/document.adoc',
root = '/tmp',
ft = 'asciidoc',
output = '/tmp/document.html',
}
it('has ft', function()
assert.are.equal('asciidoc', presets.asciidoctor.ft)
end)
it('has cmd', function()
assert.are.same({ 'asciidoctor' }, presets.asciidoctor.cmd)
end)
it('returns args with file and output', function()
assert.are.same(
{ '--failure-level', 'ERROR', '/tmp/document.adoc', '-o', '/tmp/document.html' },
presets.asciidoctor.args(adoc_ctx)
)
end)
it('returns html output path', function()
assert.are.equal('/tmp/document.html', presets.asciidoctor.output(adoc_ctx))
end)
it('returns clean command', function()
assert.are.same({ 'rm', '-f', '/tmp/document.html' }, presets.asciidoctor.clean(adoc_ctx))
end)
it('has open enabled', function()
assert.is_true(presets.asciidoctor.open)
end)
it('has reload enabled for SSE', function()
assert.is_true(presets.asciidoctor.reload)
end)
it('parses error messages', function()
local output =
'asciidoctor: ERROR: document.adoc: line 8: invalid part, must have at least one section'
local diagnostics = presets.asciidoctor.error_parser(output, adoc_ctx)
assert.are.equal(1, #diagnostics)
assert.are.equal(7, diagnostics[1].lnum)
assert.are.equal(0, diagnostics[1].col)
assert.are.equal('invalid part, must have at least one section', diagnostics[1].message)
assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity)
end)
it('parses warning messages', function()
local output = 'asciidoctor: WARNING: document.adoc: line 52: section title out of sequence'
local diagnostics = presets.asciidoctor.error_parser(output, adoc_ctx)
assert.are.equal(1, #diagnostics)
assert.are.equal(51, diagnostics[1].lnum)
assert.are.equal(vim.diagnostic.severity.WARN, diagnostics[1].severity)
end)
it('returns empty table for clean output', function()
assert.are.same({}, presets.asciidoctor.error_parser('', adoc_ctx))
end)
end)
describe('quarto', function()
local qmd_ctx = {
bufnr = 1,
file = '/tmp/document.qmd',
root = '/tmp',
ft = 'quarto',
output = '/tmp/document.html',
}
it('has ft', function()
assert.are.equal('quarto', presets.quarto.ft)
end)
it('has cmd', function()
assert.are.same({ 'quarto' }, presets.quarto.cmd)
end)
it('returns args with render subcommand and html format', function()
assert.are.same(
{ 'render', '/tmp/document.qmd', '--to', 'html', '--embed-resources' },
presets.quarto.args(qmd_ctx)
)
end)
it('returns html output path', function()
assert.are.equal('/tmp/document.html', presets.quarto.output(qmd_ctx))
end)
it('returns clean command removing html and _files directory', function()
assert.are.same(
{ 'rm', '-rf', '/tmp/document.html', '/tmp/document_files' },
presets.quarto.clean(qmd_ctx)
)
end)
it('has open enabled', function()
assert.is_true(presets.quarto.open)
end)
it('has reload enabled for SSE', function()
assert.is_true(presets.quarto.reload)
end)
it('has no error_parser', function()
assert.is_nil(presets.quarto.error_parser)
end)
end)
end)

View file

@ -1,58 +0,0 @@
describe('reload', function()
local reload
before_each(function()
package.loaded['preview.reload'] = nil
reload = require('preview.reload')
end)
after_each(function()
reload.stop()
end)
describe('inject', function()
it('injects script before </body>', function()
local path = os.tmpname()
local f = assert(io.open(path, 'w'))
f:write('<html><body><p>hello</p></body></html>')
f:close()
reload.inject(path)
local fr = assert(io.open(path, 'r'))
local content = fr:read('*a')
fr:close()
os.remove(path)
assert.is_truthy(content:find('EventSource', 1, true))
local script_pos = content:find('EventSource', 1, true)
local body_pos = content:find('</body>', 1, true)
assert.is_truthy(body_pos)
assert.is_true(script_pos < body_pos)
end)
it('appends script when no </body>', function()
local path = os.tmpname()
local f = assert(io.open(path, 'w'))
f:write('<html><p>hello</p></html>')
f:close()
reload.inject(path)
local fr = assert(io.open(path, 'r'))
local content = fr:read('*a')
fr:close()
os.remove(path)
assert.is_truthy(content:find('EventSource', 1, true))
end)
end)
describe('broadcast', function()
it('does not error with no clients', function()
assert.has_no.errors(function()
reload.broadcast()
end)
end)
end)
end)