diff --git a/.luarc.json b/.luarc.json
index 23646d3..d44eb7c 100644
--- a/.luarc.json
+++ b/.luarc.json
@@ -4,5 +4,6 @@
"diagnostics.globals": ["vim", "jit"],
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
"workspace.checkThirdParty": false,
+ "workspace.ignoreDir": [".direnv"],
"completion.callSnippet": "Replace"
}
diff --git a/.styluaignore b/.styluaignore
new file mode 100644
index 0000000..9b42106
--- /dev/null
+++ b/.styluaignore
@@ -0,0 +1 @@
+.direnv/
diff --git a/README.md b/README.md
index 6c2ca46..80a2650 100644
--- a/README.md
+++ b/README.md
@@ -1,64 +1,83 @@
-# render.nvim
+# preview.nvim
-Async document compilation for Neovim.
+**Universal document previewer for Neovim**
-A framework for compiling documents (LaTeX, Typst, Markdown, etc.)
-asynchronously with error diagnostics. Ships with zero defaults — you configure
-your own providers.
+An extensible framework for compiling and previewing _any_ documents (LaTeX,
+Typst, Markdown, etc.)—diagnostics included.
+
+
## Features
- Async compilation via `vim.system()`
-- Compiler errors as native `vim.diagnostic`
-- User events for extensibility (`RenderCompileStarted`, `RenderCompileSuccess`,
- `RenderCompileFailed`)
-- `:checkhealth` integration
-- Zero dependencies beyond Neovim 0.10.0+
+- Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), Markdown,
+ GitHub-flavored Markdown, AsciiDoc, PlantUML, Mermaid, and Quarto
+- Compiler errors via `vim.diagnostic` or quickfix
+- Previewer auto-close on buffer deletion
## Requirements
-- Neovim >= 0.10.0
-- A compiler binary for each provider you configure
+- Neovim 0.11+
## Installation
-```lua
--- lazy.nvim
-{ 'barrettruth/render.nvim' }
-```
-
-```vim
-" luarocks
-:Rocks install render.nvim
-```
-
-## Configuration
+With lazy.nvim:
```lua
-vim.g.render = {
- providers = {
- typst = {
- cmd = { 'typst', 'compile' },
- args = function(ctx)
- return { ctx.file }
- end,
- output = function(ctx)
- return ctx.file:gsub('%.typ$', '.pdf')
- end,
- },
- latexmk = {
- cmd = { 'latexmk' },
- args = { '-pdf', '-interaction=nonstopmode' },
- clean = { 'latexmk', '-c' },
- },
- },
- providers_by_ft = {
- typst = 'typst',
- tex = 'latexmk',
- },
+{
+ '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
+```
+
## Documentation
-See `:help render.nvim` for full documentation.
+```vim
+:help preview.nvim
+```
+
+## FAQ
+
+**Q: How do I define a custom provider?**
+
+```lua
+vim.g.preview = {
+ rst = {
+ cmd = { 'rst2html' },
+ args = function(ctx)
+ return { ctx.file, ctx.output }
+ end,
+ output = function(ctx)
+ return ctx.file:gsub('%.rst$', '.html')
+ end,
+ },
+}
+```
+
+**Q: How do I override a preset?**
+
+```lua
+vim.g.preview = {
+ typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } },
+}
+```
+
+**Q: How do I automatically open the output file?**
+
+Set `open = true` on your provider (all built-in presets have this enabled) to
+open the output with `vim.ui.open()` after the first successful compilation in
+toggle/watch mode. For a specific application, pass a command table:
+
+```lua
+vim.g.preview = {
+ typst = { open = { 'sioyek', '--new-instance' } },
+}
+```
diff --git a/doc/preview.txt b/doc/preview.txt
new file mode 100644
index 0000000..383a8f5
--- /dev/null
+++ b/doc/preview.txt
@@ -0,0 +1,276 @@
+*preview.txt* Async document compilation for Neovim
+
+Author: Barrett Ruth
+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:
diff --git a/doc/render.nvim.txt b/doc/render.nvim.txt
deleted file mode 100644
index 48dacfb..0000000
--- a/doc/render.nvim.txt
+++ /dev/null
@@ -1,198 +0,0 @@
-*render.nvim.txt* Async document compilation for Neovim
-
-Author: Raphael
-License: MIT
-
-==============================================================================
-INTRODUCTION *render.nvim*
-
-render.nvim is an extensible framework for compiling documents asynchronously
-in Neovim. It provides a unified interface for any compilation workflow —
-LaTeX, Typst, Markdown, or anything else with a CLI compiler.
-
-The plugin ships with zero provider defaults. Users must explicitly configure
-their compiler commands. render.nvim is purely an orchestration framework.
-
-==============================================================================
-REQUIREMENTS *render.nvim-requirements*
-
-- Neovim >= 0.10.0
-- A compiler binary for each configured provider (e.g. `typst`, `latexmk`)
-
-==============================================================================
-INSTALLATION *render.nvim-installation*
-
-With luarocks (recommended):
->
- :Rocks install render.nvim
-<
-
-With lazy.nvim:
->lua
- {
- 'barrettruth/render.nvim',
- }
-<
-
-==============================================================================
-CONFIGURATION *render.nvim-configuration*
-
-Configure via the `vim.g.render` global table before the plugin loads.
-
- *render.Config*
-Fields:~
-
- `debug` boolean|string Enable debug logging. A string value
- is treated as a log file path.
- Default: `false`
-
- `providers` table Provider configurations keyed by name.
- Default: `{}`
-
- `providers_by_ft` table Maps filetypes to provider names.
- Default: `{}`
-
- *render.ProviderConfig*
-Provider fields:~
-
- `cmd` string[] The compiler command (required).
-
- `args` string[]|function Additional arguments. If a function,
- receives a |render.Context| and returns
- a string[].
-
- `cwd` string|function Working directory. If a function,
- receives a |render.Context|. Default:
- git root or file directory.
-
- `env` table Environment variables.
-
- `output` string|function Output file path. If a function,
- receives a |render.Context|.
-
- `error_parser` function Receives (stderr, |render.Context|)
- and returns vim.Diagnostic[].
-
- `clean` string[]|function Command to remove build artifacts.
- If a function, receives a
- |render.Context|.
-
- *render.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:~
->lua
- vim.g.render = {
- providers = {
- typst = {
- cmd = { 'typst', 'compile' },
- args = function(ctx)
- return { ctx.file }
- end,
- output = function(ctx)
- return ctx.file:gsub('%.typ$', '.pdf')
- end,
- error_parser = function(stderr, ctx)
- local diagnostics = {}
- for line, col, msg in stderr:gmatch('error:.-(%d+):(%d+):%s*(.-)%\n') do
- table.insert(diagnostics, {
- lnum = tonumber(line) - 1,
- col = tonumber(col) - 1,
- message = msg,
- severity = vim.diagnostic.severity.ERROR,
- })
- end
- return diagnostics
- end,
- },
- latexmk = {
- cmd = { 'latexmk' },
- args = { '-pdf', '-interaction=nonstopmode' },
- clean = { 'latexmk', '-c' },
- },
- },
- providers_by_ft = {
- typst = 'typst',
- tex = 'latexmk',
- },
- }
-<
-
-==============================================================================
-COMMANDS *render.nvim-commands*
-
-:Render [subcommand] *:Render*
-
- Subcommands:~
-
- `compile` Compile the current buffer (default if omitted).
- `stop` Kill active compilation for the current buffer.
- `clean` Run the provider's clean command.
- `status` Echo compilation status (idle or compiling).
-
-==============================================================================
-API *render.nvim-api*
-
-render.compile({bufnr?}) *render.compile()*
- Compile the document in the given buffer (default: current).
-
-render.stop({bufnr?}) *render.stop()*
- Kill the active compilation process for the buffer.
-
-render.clean({bufnr?}) *render.clean()*
- Run the provider's clean command for the buffer.
-
-render.status({bufnr?}) *render.status()*
- Returns a |render.Status| table.
-
- *render.Status*
-Status fields:~
-
- `compiling` boolean Whether compilation is active.
- `provider` string? Name of the active provider.
- `output_file` string? Path to the output file.
-
-render.get_config() *render.get_config()*
- Returns the resolved |render.Config|.
-
-==============================================================================
-EVENTS *render.nvim-events*
-
-render.nvim fires User autocmds with structured data:
-
-`RenderCompileStarted` Compilation began.
- data: `{ bufnr, provider }`
-
-`RenderCompileSuccess` Compilation succeeded (exit code 0).
- data: `{ bufnr, provider, output }`
-
-`RenderCompileFailed` Compilation failed (non-zero exit).
- data: `{ bufnr, provider, code, stderr }`
-
-Example:~
->lua
- vim.api.nvim_create_autocmd('User', {
- pattern = 'RenderCompileSuccess',
- callback = function(args)
- local data = args.data
- vim.notify('Compiled ' .. data.output .. ' with ' .. data.provider)
- end,
- })
-<
-
-==============================================================================
-HEALTH *render.nvim-health*
-
-Run `:checkhealth render` to verify:
-
-- Neovim version >= 0.10.0
-- Each configured provider's binary is executable
-- Filetype-to-provider mappings are valid
-
-==============================================================================
- vim:tw=78:ts=8:ft=help:norl:
diff --git a/flake.nix b/flake.nix
index 90c489e..636f4d0 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,5 +1,5 @@
{
- description = "render.nvim — async document compilation for Neovim";
+ description = "preview.nvim — async document compilation for Neovim";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
@@ -13,12 +13,15 @@
...
}:
let
- forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
+ forEachSystem =
+ f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
in
{
- devShells = forEachSystem (pkgs: {
- default = pkgs.mkShell {
- packages = [
+ formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
+
+ devShells = forEachSystem (pkgs:
+ let
+ devTools = [
(pkgs.luajit.withPackages (
ps: with ps; [
busted
@@ -30,7 +33,23 @@
pkgs.selene
pkgs.lua-language-server
];
- };
- });
+ in
+ {
+ default = pkgs.mkShell {
+ packages = devTools;
+ };
+ presets = pkgs.mkShell {
+ packages = devTools ++ [
+ pkgs.typst
+ pkgs.texliveMedium
+ pkgs.tectonic
+ pkgs.pandoc
+ pkgs.asciidoctor
+ pkgs.quarto
+ pkgs.plantuml
+ pkgs.mermaid-cli
+ ];
+ };
+ });
};
}
diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua
new file mode 100644
index 0000000..97a13f7
--- /dev/null
+++ b/lua/preview/commands.lua
@@ -0,0 +1,68 @@
+local M = {}
+
+local handlers = {
+ compile = function()
+ require('preview').compile()
+ end,
+ clean = function()
+ require('preview').clean()
+ end,
+ toggle = function()
+ require('preview').toggle()
+ end,
+ open = function()
+ require('preview').open()
+ end,
+ status = function()
+ local s = require('preview').status()
+ local parts = {}
+ if s.compiling then
+ table.insert(parts, 'compiling with "' .. s.provider .. '"')
+ else
+ table.insert(parts, 'idle')
+ end
+ if s.watching then
+ table.insert(parts, 'watching')
+ end
+ vim.notify('[preview.nvim]: ' .. table.concat(parts, ', '), vim.log.levels.INFO)
+ end,
+}
+
+---@param args string
+local function dispatch(args)
+ local subcmd = args ~= '' and args or 'toggle'
+ local handler = handlers[subcmd]
+ if handler then
+ handler()
+ else
+ vim.notify('[preview.nvim]: unknown subcommand: ' .. subcmd, vim.log.levels.ERROR)
+ end
+end
+
+---@param lead string
+---@return string[]
+local function complete(lead)
+ return vim.tbl_filter(function(s)
+ return s:find(lead, 1, true) == 1
+ end, vim.tbl_keys(handlers))
+end
+
+function M.setup()
+ vim.api.nvim_create_user_command('Preview', function(opts)
+ dispatch(opts.args)
+ end, {
+ nargs = '?',
+ complete = function(lead)
+ return complete(lead)
+ end,
+ desc = 'Toggle, compile, clean, open, or check status of document preview',
+ })
+
+ vim.api.nvim_create_autocmd('VimLeavePre', {
+ callback = function()
+ require('preview.compiler').stop_all()
+ end,
+ })
+end
+
+return M
diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua
new file mode 100644
index 0000000..a193ad2
--- /dev/null
+++ b/lua/preview/compiler.lua
@@ -0,0 +1,623 @@
+local M = {}
+
+local diagnostic = require('preview.diagnostic')
+local log = require('preview.log')
+
+---@type table
+local active = {}
+
+---@type table
+local watching = {}
+
+---@type table
+local opened = {}
+
+---@type table
+local last_output = {}
+
+---@type table
+local viewer_procs = {}
+
+---@type table
+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[]
+local function eval_list(val, ctx)
+ if type(val) == 'function' then
+ return val(ctx)
+ end
+ return val
+end
+
+---@param val string|fun(ctx: preview.Context): string
+---@param ctx preview.Context
+---@return string
+local function eval_string(val, ctx)
+ if type(val) == 'function' then
+ return val(ctx)
+ end
+ 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
+
+ if vim.bo[bufnr].modified then
+ vim.cmd('silent! update')
+ end
+
+ if active[bufnr] then
+ log.dbg('killing existing process for buffer %d before recompile', bufnr)
+ M.stop(bufnr)
+ end
+
+ local output_file = ''
+ if provider.output then
+ output_file = eval_string(provider.output, ctx)
+ end
+
+ local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file })
+
+ local cwd = ctx.root
+ if provider.cwd then
+ cwd = eval_string(provider.cwd, resolved_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(
+ cmd,
+ {
+ cwd = cwd,
+ env = provider.env,
+ },
+ 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
+
+ local errors_mode = provider.errors
+ if errors_mode == nil then
+ errors_mode = 'diagnostic'
+ end
+
+ if result.code == 0 then
+ log.dbg('compilation succeeded for buffer %d', bufnr)
+ if errors_mode == 'diagnostic' then
+ 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)
+ opened[bufnr] = true
+ end
+ else
+ log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code)
+ 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)
+ )
+
+ active[bufnr] = { obj = obj, provider = name, output_file = output_file }
+
+ vim.api.nvim_create_autocmd('BufUnload', {
+ buffer = bufnr,
+ once = true,
+ callback = function()
+ M.stop(bufnr)
+ if not provider.detach then
+ close_viewer(bufnr)
+ end
+ last_output[bufnr] = nil
+ end,
+ })
+
+ vim.api.nvim_exec_autocmds('User', {
+ pattern = 'PreviewCompileStarted',
+ data = { bufnr = bufnr, provider = name },
+ })
+end
+
+---@param bufnr integer
+function M.stop(bufnr)
+ local proc = active[bufnr]
+ if not proc then
+ return
+ end
+ log.dbg('stopping process for buffer %d', bufnr)
+ ---@type fun(self: table, signal: string|integer)
+ local kill = proc.obj.kill
+ kill(proc.obj, 'sigterm')
+
+ local timer = vim.uv.new_timer()
+ if timer then
+ timer:start(5000, 0, function()
+ timer:close()
+ if active[bufnr] and active[bufnr].obj == proc.obj then
+ kill(proc.obj, 'sigkill')
+ active[bufnr] = nil
+ end
+ end)
+ end
+end
+
+function M.stop_all()
+ for bufnr, _ in pairs(active) do
+ M.stop(bufnr)
+ end
+ 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
+---@param name string
+---@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
+
+ local au_id = vim.api.nvim_create_autocmd('BufWritePost', {
+ buffer = bufnr,
+ callback = function()
+ if debounce_timers[bufnr] then
+ debounce_timers[bufnr]:stop()
+ else
+ debounce_timers[bufnr] = vim.uv.new_timer()
+ end
+ debounce_timers[bufnr]:start(
+ DEBOUNCE_MS,
+ 0,
+ vim.schedule_wrap(function()
+ local ctx = ctx_builder(bufnr)
+ M.compile(bufnr, name, provider, ctx)
+ end)
+ )
+ end,
+ })
+
+ 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', {
+ 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,
+ })
+
+ M.compile(bufnr, name, provider, ctx_builder(bufnr))
+end
+
+---@param bufnr integer
+function M.unwatch(bufnr)
+ local au_id = watching[bufnr]
+ if not au_id then
+ return
+ end
+ vim.api.nvim_del_autocmd(au_id)
+ if debounce_timers[bufnr] then
+ debounce_timers[bufnr]:stop()
+ debounce_timers[bufnr]:close()
+ debounce_timers[bufnr] = nil
+ end
+ watching[bufnr] = nil
+ log.dbg('unwatched buffer %d', bufnr)
+end
+
+---@param bufnr integer
+---@param name string
+---@param provider preview.ProviderConfig
+---@param ctx preview.Context
+function M.clean(bufnr, name, provider, ctx)
+ if not provider.clean then
+ vim.notify(
+ '[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
+ if provider.cwd then
+ cwd = eval_string(provider.cwd, resolved_ctx)
+ end
+
+ log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
+
+ vim.system(
+ cmd,
+ { cwd = cwd },
+ 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)
+ 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)
+ end
+ end)
+ )
+end
+
+---@param bufnr integer
+---@return boolean
+function M.open(bufnr, open_config)
+ 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)
+ return true
+end
+
+---@param bufnr integer
+---@return preview.Status
+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,
+ provider = proc.provider,
+ output_file = proc.output_file,
+ }
+ end
+ return { compiling = false, watching = watching[bufnr] ~= nil }
+end
+
+M._test = {
+ active = active,
+ watching = watching,
+ opened = opened,
+ last_output = last_output,
+ debounce_timers = debounce_timers,
+ viewer_procs = viewer_procs,
+ open_watchers = open_watchers,
+}
+
+return M
diff --git a/lua/render/diagnostic.lua b/lua/preview/diagnostic.lua
similarity index 67%
rename from lua/render/diagnostic.lua
rename to lua/preview/diagnostic.lua
index a526219..abd4105 100644
--- a/lua/render/diagnostic.lua
+++ b/lua/preview/diagnostic.lua
@@ -1,8 +1,8 @@
local M = {}
-local log = require('render.log')
+local log = require('preview.log')
-local ns = vim.api.nvim_create_namespace('render')
+local ns = vim.api.nvim_create_namespace('preview')
---@param bufnr integer
function M.clear(bufnr)
@@ -12,11 +12,11 @@ end
---@param bufnr integer
---@param name string
----@param error_parser fun(stderr: string, ctx: render.Context): render.Diagnostic[]
----@param stderr string
----@param ctx render.Context
-function M.set(bufnr, name, error_parser, stderr, ctx)
- local ok, diagnostics = pcall(error_parser, stderr, ctx)
+---@param error_parser fun(output: string, ctx: preview.Context): preview.Diagnostic[]
+---@param output string
+---@param ctx preview.Context
+function M.set(bufnr, name, error_parser, output, ctx)
+ local ok, diagnostics = pcall(error_parser, output, ctx)
if not ok then
log.dbg('error_parser for "%s" failed: %s', name, diagnostics)
return
diff --git a/lua/preview/health.lua b/lua/preview/health.lua
new file mode 100644
index 0000000..196100d
--- /dev/null
+++ b/lua/preview/health.lua
@@ -0,0 +1,39 @@
+local M = {}
+
+function M.check()
+ vim.health.start('preview.nvim')
+
+ if vim.fn.has('nvim-0.11.0') == 1 then
+ vim.health.ok('Neovim 0.11.0+ detected')
+ else
+ vim.health.error('preview.nvim requires Neovim 0.11.0+')
+ end
+
+ local config = require('preview').get_config()
+
+ local provider_count = vim.tbl_count(config.providers)
+ if provider_count == 0 then
+ vim.health.warn('no providers configured')
+ else
+ vim.health.ok(provider_count .. ' provider(s) configured')
+ end
+
+ for ft, provider in pairs(config.providers) do
+ local bin = provider.cmd[1]
+ if vim.fn.executable(bin) == 1 then
+ vim.health.ok('filetype "' .. ft .. '": ' .. bin .. ' found')
+ else
+ vim.health.error('filetype "' .. ft .. '": ' .. bin .. ' not found')
+ end
+ if type(provider.open) == 'table' then
+ local opener = provider.open[1]
+ if vim.fn.executable(opener) == 1 then
+ vim.health.ok('filetype "' .. ft .. '": opener ' .. opener .. ' found')
+ else
+ vim.health.error('filetype "' .. ft .. '": opener ' .. opener .. ' not found')
+ end
+ end
+ end
+end
+
+return M
diff --git a/lua/preview/init.lua b/lua/preview/init.lua
new file mode 100644
index 0000000..7cb982b
--- /dev/null
+++ b/lua/preview/init.lua
@@ -0,0 +1,261 @@
+---@class preview.ProviderConfig
+---@field ft? string
+---@field cmd string[]
+---@field args? string[]|fun(ctx: preview.Context): string[]
+---@field cwd? string|fun(ctx: preview.Context): string
+---@field env? table
+---@field output? string|fun(ctx: preview.Context): string
+---@field error_parser? fun(output: string, ctx: preview.Context): preview.Diagnostic[]
+---@field errors? false|'diagnostic'|'quickfix'
+---@field clean? string[]|fun(ctx: preview.Context): string[]
+---@field open? boolean|string[]
+---@field reload? boolean|string[]|fun(ctx: preview.Context): string[]
+---@field detach? boolean
+
+---@class preview.Config
+---@field debug boolean|string
+---@field providers table
+
+---@class preview.Context
+---@field bufnr integer
+---@field file string
+---@field root string
+---@field ft string
+---@field output? string
+
+---@class preview.Diagnostic
+---@field lnum integer
+---@field col integer
+---@field message string
+---@field severity? integer
+---@field end_lnum? integer
+---@field end_col? integer
+---@field source? string
+
+---@class preview.Process
+---@field obj table
+---@field provider string
+---@field output_file string
+---@field is_reload? boolean
+
+---@class preview
+---@field setup fun(opts?: table)
+---@field compile fun(bufnr?: integer)
+---@field stop fun(bufnr?: integer)
+---@field clean fun(bufnr?: integer)
+---@field toggle fun(bufnr?: integer)
+---@field open fun(bufnr?: integer)
+---@field status fun(bufnr?: integer): preview.Status
+---@field statusline fun(bufnr?: integer): string
+---@field get_config fun(): preview.Config
+local M = {}
+
+local compiler = require('preview.compiler')
+local log = require('preview.log')
+
+---@type preview.Config
+local default_config = {
+ debug = false,
+ providers = {},
+}
+
+---@type preview.Config
+local config = vim.deepcopy(default_config)
+
+---@param opts? table
+function M.setup(opts)
+ opts = opts or {}
+ vim.validate('preview.setup opts', opts, 'table')
+
+ local presets = require('preview.presets')
+ local providers = {}
+ local debug = false
+
+ for k, v in pairs(opts) do
+ if k == 'debug' then
+ vim.validate('preview.setup opts.debug', v, { 'boolean', 'string' })
+ debug = v
+ elseif type(k) ~= 'number' then
+ local preset = presets[k]
+ if preset then
+ if v == true then
+ providers[preset.ft] = preset
+ elseif type(v) == 'table' then
+ providers[preset.ft] = vim.tbl_deep_extend('force', preset, v)
+ end
+ elseif type(v) == 'table' then
+ providers[k] = v
+ end
+ 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,
+ })
+
+ log.set_enabled(config.debug)
+ log.dbg('initialized with %d providers', vim.tbl_count(config.providers))
+end
+
+---@return preview.Config
+function M.get_config()
+ return config
+end
+
+---@param bufnr? integer
+---@return string?
+function M.resolve_provider(bufnr)
+ bufnr = bufnr or vim.api.nvim_get_current_buf()
+ local ft = vim.bo[bufnr].filetype
+ if not config.providers[ft] then
+ log.dbg('no provider configured for filetype: %s', ft)
+ return nil
+ end
+ return ft
+end
+
+---@param bufnr? integer
+---@return preview.Context
+function M.build_context(bufnr)
+ bufnr = bufnr or vim.api.nvim_get_current_buf()
+ local file = vim.api.nvim_buf_get_name(bufnr)
+ local root = vim.fs.root(bufnr, { '.git' }) or vim.fn.fnamemodify(file, ':h')
+ return {
+ bufnr = bufnr,
+ file = file,
+ root = root,
+ ft = vim.bo[bufnr].filetype,
+ }
+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)
+ return
+ end
+ local ctx = M.build_context(bufnr)
+ local provider = config.providers[name]
+ compiler.compile(bufnr, name, provider, ctx, { oneshot = true })
+end
+
+---@param bufnr? integer
+function M.stop(bufnr)
+ bufnr = bufnr or vim.api.nvim_get_current_buf()
+ compiler.stop(bufnr)
+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)
+ return
+ end
+ local ctx = M.build_context(bufnr)
+ local provider = config.providers[name]
+ compiler.clean(bufnr, name, provider, ctx)
+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)
+ return
+ end
+ local provider = config.providers[name]
+ compiler.toggle(bufnr, name, provider, M.build_context)
+end
+
+---@param bufnr? integer
+function M.open(bufnr)
+ bufnr = bufnr or vim.api.nvim_get_current_buf()
+ if 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)
+ end
+end
+
+---@class preview.Status
+---@field compiling boolean
+---@field watching boolean
+---@field provider? string
+---@field output_file? string
+
+---@param bufnr? integer
+---@return preview.Status
+function M.status(bufnr)
+ bufnr = bufnr or vim.api.nvim_get_current_buf()
+ return compiler.status(bufnr)
+end
+
+---@param bufnr? integer
+---@return string
+function M.statusline(bufnr)
+ bufnr = bufnr or vim.api.nvim_get_current_buf()
+ local s = compiler.status(bufnr)
+ if s.compiling then
+ return 'compiling'
+ elseif s.watching then
+ return 'watching'
+ end
+ return ''
+end
+
+M._test = {
+ ---@diagnostic disable-next-line: assign-type-mismatch
+ reset = function()
+ config = vim.deepcopy(default_config)
+ end,
+}
+
+if vim.g.preview then
+ M.setup(vim.g.preview)
+end
+
+return M
diff --git a/lua/render/log.lua b/lua/preview/log.lua
similarity index 90%
rename from lua/render/log.lua
rename to lua/preview/log.lua
index 3896978..6b6d5c9 100644
--- a/lua/render/log.lua
+++ b/lua/preview/log.lua
@@ -20,7 +20,7 @@ function M.dbg(msg, ...)
if not enabled then
return
end
- local formatted = '[render.nvim]: ' .. string.format(msg, ...)
+ local formatted = '[preview.nvim]: ' .. string.format(msg, ...)
if log_file then
local f = io.open(log_file, 'a')
if f then
diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua
new file mode 100644
index 0000000..1b5333e
--- /dev/null
+++ b/lua/preview/presets.lua
@@ -0,0 +1,354 @@
+local M = {}
+
+---@param output string
+---@return preview.Diagnostic[]
+local function parse_typst(output)
+ local diagnostics = {}
+ for line in output:gmatch('[^\r\n]+') do
+ local _, lnum, col, severity, msg = line:match('^(.+):(%d+):(%d+): (%w+): (.+)$')
+ if lnum then
+ local sev = vim.diagnostic.severity.ERROR
+ if severity == 'warning' then
+ sev = vim.diagnostic.severity.WARN
+ end
+ table.insert(diagnostics, {
+ lnum = tonumber(lnum) - 1,
+ col = tonumber(col) - 1,
+ message = msg,
+ severity = sev,
+ })
+ end
+ end
+ return diagnostics
+end
+
+---@param output string
+---@return preview.Diagnostic[]
+local function parse_latexmk(output)
+ local diagnostics = {}
+ for line in output:gmatch('[^\r\n]+') do
+ local _, lnum, msg = line:match('^%.?/?(.+%.tex):(%d+): (.+)$')
+ if lnum then
+ table.insert(diagnostics, {
+ lnum = tonumber(lnum) - 1,
+ col = 0,
+ message = msg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ else
+ local rule_msg = line:match('^%s+(%S.+gave return code %d+)$')
+ if rule_msg then
+ table.insert(diagnostics, {
+ lnum = 0,
+ col = 0,
+ message = rule_msg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ end
+ end
+ end
+ return diagnostics
+end
+
+---@param output string
+---@return preview.Diagnostic[]
+local function parse_pandoc(output)
+ local diagnostics = {}
+ local lines = vim.split(output, '\n')
+ local i = 1
+ while i <= #lines do
+ local line = lines[i]
+ local lnum, col, msg = line:match('%(line (%d+), column (%d+)%):%s*(.*)$')
+ if lnum then
+ if msg == '' then
+ for j = i + 1, math.min(i + 2, #lines) do
+ local next_line = lines[j]:match('^%s*(.+)$')
+ if next_line and not next_line:match('^YAML parse exception') then
+ msg = next_line
+ break
+ end
+ end
+ end
+ if msg ~= '' then
+ table.insert(diagnostics, {
+ lnum = tonumber(lnum) - 1,
+ col = tonumber(col) - 1,
+ message = msg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ end
+ else
+ local errmsg = line:match('^pandoc: (.+)$')
+ if errmsg then
+ table.insert(diagnostics, {
+ lnum = 0,
+ col = 0,
+ message = errmsg,
+ severity = vim.diagnostic.severity.ERROR,
+ })
+ end
+ end
+ i = i + 1
+ end
+ return diagnostics
+end
+
+---@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',
+ cmd = { 'typst', 'compile' },
+ args = function(ctx)
+ return { '--diagnostic-format', 'short', ctx.file }
+ end,
+ output = function(ctx)
+ return (ctx.file:gsub('%.typ$', '.pdf'))
+ end,
+ 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
+M.latex = {
+ ft = 'tex',
+ cmd = { 'latexmk' },
+ args = function(ctx)
+ return {
+ '-pdf',
+ '-interaction=nonstopmode',
+ '-synctex=1',
+ '-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S',
+ ctx.file,
+ }
+ end,
+ output = function(ctx)
+ return (ctx.file:gsub('%.tex$', '.pdf'))
+ end,
+ error_parser = function(output)
+ return parse_latexmk(output)
+ end,
+ clean = function(ctx)
+ return { 'latexmk', '-c', ctx.file }
+ end,
+ 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 }
+ end,
+ output = function(ctx)
+ return (ctx.file:gsub('%.md$', '.html'))
+ end,
+ error_parser = function(output)
+ return parse_pandoc(output)
+ end,
+ clean = function(ctx)
+ return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) }
+ end,
+ open = true,
+ reload = true,
+}
+
+---@type preview.ProviderConfig
+M.github = {
+ ft = 'markdown',
+ cmd = { 'pandoc' },
+ args = function(ctx)
+ return {
+ '-f',
+ 'gfm',
+ ctx.file,
+ '-s',
+ '--embed-resources',
+ '--css',
+ 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css',
+ '-o',
+ ctx.output,
+ }
+ end,
+ output = function(ctx)
+ return (ctx.file:gsub('%.md$', '.html'))
+ end,
+ error_parser = function(output)
+ return parse_pandoc(output)
+ end,
+ clean = function(ctx)
+ 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
diff --git a/lua/preview/reload.lua b/lua/preview/reload.lua
new file mode 100644
index 0000000..d2c1de9
--- /dev/null
+++ b/lua/preview/reload.lua
@@ -0,0 +1,113 @@
+local M = {}
+
+local PORT = 5554
+local server_handle = nil
+local actual_port = nil
+local clients = {}
+
+local function make_script(port)
+ return ''
+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('