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 1d4446b..80a2650 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # preview.nvim -**Async document compilation for Neovim** +**Universal document previewer for Neovim** -An extensible framework for compiling documents (LaTeX, Typst, Markdown, etc.) -asynchronously with error diagnostics. +An extensible framework for compiling and previewing _any_ documents (LaTeX, +Typst, Markdown, etc.)—diagnostics included. + + ## Features - Async compilation via `vim.system()` -- Built-in presets for Typst, LaTeX, Markdown, and GitHub-flavored Markdown -- Compiler errors as native `vim.diagnostic` -- User events for extensibility (`PreviewCompileStarted`, - `PreviewCompileSuccess`, `PreviewCompileFailed`) +- Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), Markdown, + GitHub-flavored Markdown, AsciiDoc, PlantUML, Mermaid, and Quarto +- Compiler errors via `vim.diagnostic` or quickfix +- Previewer auto-close on buffer deletion ## Requirements @@ -19,8 +21,18 @@ asynchronously with error diagnostics. ## Installation -Install with your package manager of choice or via -[luarocks](https://luarocks.org/modules/barrettruth/preview.nvim): +With lazy.nvim: + +```lua +{ + 'barrettruth/preview.nvim', + init = function() + vim.g.preview = { typst = true, latex = true } + end, +} +``` + +Or via [luarocks](https://luarocks.org/modules/barrettruth/preview.nvim): ``` luarocks install preview.nvim @@ -37,35 +49,35 @@ luarocks install preview.nvim **Q: How do I define a custom provider?** ```lua -require('preview').setup({ - typst = { - cmd = { 'typst', 'compile' }, +vim.g.preview = { + rst = { + cmd = { 'rst2html' }, args = function(ctx) - return { ctx.file } + return { ctx.file, ctx.output } end, output = function(ctx) - return ctx.file:gsub('%.typ$', '.pdf') + return ctx.file:gsub('%.rst$', '.html') end, }, -}) +} ``` **Q: How do I override a preset?** ```lua -require('preview').setup({ +vim.g.preview = { typst = { env = { TYPST_FONT_PATHS = '/usr/share/fonts' } }, -}) +} ``` **Q: How do I automatically open the output file?** Set `open = true` on your provider (all built-in presets have this enabled) to -open the output with `vim.ui.open()` after the first successful compilation. For -a specific application, pass a command table: +open the output with `vim.ui.open()` after the first successful compilation in +toggle/watch mode. For a specific application, pass a command table: ```lua -require('preview').setup({ +vim.g.preview = { typst = { open = { 'sioyek', '--new-instance' } }, -}) +} ``` diff --git a/doc/preview.nvim.txt b/doc/preview.nvim.txt deleted file mode 100644 index 0b44a91..0000000 --- a/doc/preview.nvim.txt +++ /dev/null @@ -1,228 +0,0 @@ -*preview.nvim.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 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: 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/flake.nix b/flake.nix index 7413113..636f4d0 100644 --- a/flake.nix +++ b/flake.nix @@ -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 index 2c52c5a..97a13f7 100644 --- a/lua/preview/commands.lua +++ b/lua/preview/commands.lua @@ -4,9 +4,6 @@ local handlers = { compile = function() require('preview').compile() end, - stop = function() - require('preview').stop() - end, clean = function() require('preview').clean() end, @@ -33,7 +30,7 @@ local handlers = { ---@param args string local function dispatch(args) - local subcmd = args ~= '' and args or 'compile' + local subcmd = args ~= '' and args or 'toggle' local handler = handlers[subcmd] if handler then handler() @@ -58,7 +55,13 @@ function M.setup() complete = function(lead) return complete(lead) end, - desc = 'Compile, stop, clean, toggle, open, or check status of document preview', + desc = 'Toggle, compile, clean, open, or check status of document preview', + }) + + vim.api.nvim_create_autocmd('VimLeavePre', { + callback = function() + require('preview.compiler').stop_all() + end, }) end diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 0643ccd..a193ad2 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -15,10 +15,49 @@ 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[] @@ -39,11 +78,33 @@ 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) +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 @@ -53,35 +114,215 @@ function M.compile(bufnr, name, provider, ctx) M.stop(bufnr) end - local cmd = vim.list_extend({}, provider.cmd) - if provider.args then - vim.list_extend(cmd, eval_list(provider.args, ctx)) - end - - local cwd = ctx.root - if provider.cwd then - cwd = eval_string(provider.cwd, ctx) - 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 = vim.system( + local obj + obj = vim.system( cmd, { cwd = cwd, env = provider.env, }, vim.schedule_wrap(function(result) - active[bufnr] = nil + if active[bufnr] and active[bufnr].obj == obj then + active[bufnr] = nil + end if not vim.api.nvim_buf_is_valid(bufnr) then return end @@ -97,19 +338,26 @@ function M.compile(bufnr, name, provider, ctx) 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.open and not opened[bufnr] and output_file ~= '' then - if provider.open == true then - vim.ui.open(output_file) - elseif type(provider.open) == 'table' then - local open_cmd = vim.list_extend({}, provider.open) - table.insert(open_cmd, output_file) - vim.system(open_cmd) - end + if provider.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 @@ -132,7 +380,9 @@ function M.compile(bufnr, name, provider, ctx) }) end vim.fn.setqflist(items, 'r') - vim.cmd('copen') + local win = vim.fn.win_getid() + vim.cmd.cwindow() + vim.fn.win_gotoid(win) end end end @@ -151,11 +401,14 @@ function M.compile(bufnr, name, provider, ctx) active[bufnr] = { obj = obj, provider = name, output_file = output_file } - vim.api.nvim_create_autocmd('BufWipeout', { + vim.api.nvim_create_autocmd('BufUnload', { buffer = bufnr, once = true, callback = function() M.stop(bufnr) + if not provider.detach then + close_viewer(bufnr) + end last_output[bufnr] = nil end, }) @@ -196,6 +449,13 @@ 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 @@ -203,6 +463,19 @@ 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) @@ -232,11 +505,15 @@ function M.toggle(bufnr, name, provider, ctx_builder) log.dbg('watching buffer %d with provider "%s"', bufnr, name) vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO) - vim.api.nvim_create_autocmd('BufWipeout', { + vim.api.nvim_create_autocmd('BufUnload', { buffer = bufnr, once = true, callback = function() M.unwatch(bufnr) + stop_open_watcher(bufnr) + if not provider.detach then + close_viewer(bufnr) + end opened[bufnr] = nil end, }) @@ -266,14 +543,23 @@ 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 cmd = eval_list(provider.clean, ctx) - local cwd = ctx.root + 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, ctx) + cwd = eval_string(provider.cwd, resolved_ctx) end log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' ')) @@ -284,10 +570,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) ) @@ -295,13 +581,17 @@ end ---@param bufnr integer ---@return boolean -function M.open(bufnr) +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 - vim.ui.open(output) + if not vim.uv.fs_stat(output) then + log.dbg('output file no longer exists for buffer %d: %s', bufnr, output) + return false + end + do_open(bufnr, output, open_config) return true end @@ -311,8 +601,8 @@ function M.status(bufnr) local proc = active[bufnr] if proc then return { - compiling = true, - watching = watching[bufnr] ~= nil, + compiling = not proc.is_reload, + watching = watching[bufnr] ~= nil or proc.is_reload == true, provider = proc.provider, output_file = proc.output_file, } @@ -326,6 +616,8 @@ M._test = { opened = opened, last_output = last_output, debounce_timers = debounce_timers, + viewer_procs = viewer_procs, + open_watchers = open_watchers, } return M diff --git a/lua/preview/init.lua b/lua/preview/init.lua index 4a44a33..7cb982b 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -9,6 +9,8 @@ ---@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 @@ -19,6 +21,7 @@ ---@field file string ---@field root string ---@field ft string +---@field output? string ---@class preview.Diagnostic ---@field lnum integer @@ -33,6 +36,7 @@ ---@field obj table ---@field provider string ---@field output_file string +---@field is_reload? boolean ---@class preview ---@field setup fun(opts?: table) @@ -85,6 +89,28 @@ 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, @@ -128,14 +154,18 @@ 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) + compiler.compile(bufnr, name, provider, ctx, { oneshot = true }) end ---@param bufnr? integer @@ -147,9 +177,13 @@ 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) @@ -160,9 +194,13 @@ 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] @@ -172,8 +210,14 @@ end ---@param bufnr? integer function M.open(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() - if not compiler.open(bufnr) then - vim.notify('[preview.nvim] no output file available for this buffer', vim.log.levels.WARN) + 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 @@ -210,4 +254,8 @@ M._test = { end, } +if vim.g.preview then + M.setup(vim.g.preview) +end + return M diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index 8b9faab..1b5333e 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -93,6 +93,28 @@ local function parse_pandoc(output) return diagnostics end +---@param output string +---@return preview.Diagnostic[] +local function parse_asciidoctor(output) + local diagnostics = {} + for line in output:gmatch('[^\r\n]+') do + local severity, _, lnum, msg = line:match('^asciidoctor: (%u+): (.+): line (%d+): (.+)$') + if lnum then + local sev = vim.diagnostic.severity.ERROR + if severity == 'WARNING' then + sev = vim.diagnostic.severity.WARN + end + table.insert(diagnostics, { + lnum = tonumber(lnum) - 1, + col = 0, + message = msg, + severity = sev, + }) + end + end + return diagnostics +end + ---@type preview.ProviderConfig M.typst = { ft = 'typst', @@ -106,7 +128,13 @@ 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 @@ -117,6 +145,7 @@ M.latex = { return { '-pdf', '-interaction=nonstopmode', + '-synctex=1', '-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S', ctx.file, } @@ -133,13 +162,51 @@ 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) - local output = ctx.file:gsub('%.md$', '.html') - return { ctx.file, '-s', '--embed-resources', '-o', output } + return { ctx.file, '-s', '--embed-resources', '-o', ctx.output } end, output = function(ctx) return (ctx.file:gsub('%.md$', '.html')) @@ -151,6 +218,7 @@ M.markdown = { return { 'rm', '-f', (ctx.file:gsub('%.md$', '.html')) } end, open = true, + reload = true, } ---@type preview.ProviderConfig @@ -158,7 +226,6 @@ M.github = { ft = 'markdown', cmd = { 'pandoc' }, args = function(ctx) - local output = ctx.file:gsub('%.md$', '.html') return { '-f', 'gfm', @@ -168,7 +235,7 @@ M.github = { '--css', 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css', '-o', - output, + ctx.output, } end, output = function(ctx) @@ -181,6 +248,107 @@ 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 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('', script .. '\n', 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 diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..37b1267 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +nix develop --command stylua --check . +git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet +nix develop --command prettier --check . +nix fmt +git diff --exit-code -- '*.nix' +nix develop --command lua-language-server --check lua/ --configpath "$(pwd)/.luarc.json" --checklevel=Warning +nix develop --command busted diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index 931174f..4e12e5d 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -11,23 +11,29 @@ 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 with no provider', function() + it('does not error on :Preview compile 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() diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua index 2189347..cd1dd9f 100644 --- a/spec/compiler_spec.lua +++ b/spec/compiler_spec.lua @@ -99,6 +99,35 @@ describe('compiler', function() helpers.delete_buffer(bufnr) end) + it('notifies and returns when binary is not executable', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_nobin.txt') + vim.bo[bufnr].modified = false + + local notified = false + local orig = vim.notify + vim.notify = function(msg) + if msg:find('not executable') then + notified = true + end + end + + local provider = { cmd = { 'totally_nonexistent_binary_xyz_preview' } } + local ctx = { + bufnr = bufnr, + file = '/tmp/preview_test_nobin.txt', + root = '/tmp', + ft = 'text', + } + + compiler.compile(bufnr, 'nobin', provider, ctx) + vim.notify = orig + + assert.is_true(notified) + assert.is_nil(compiler._test.active[bufnr]) + helpers.delete_buffer(bufnr) + end) + it('fires PreviewCompileFailed on non-zero exit', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_fail.txt') diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 5c49276..f68438c 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -108,4 +108,62 @@ describe('preview', function() helpers.delete_buffer(bufnr) end) end) + + describe('unnamed buffer guard', function() + before_each(function() + helpers.reset_config({ typst = true }) + preview = require('preview') + end) + + local function capture_notify(fn) + local msg = nil + local orig = vim.notify + vim.notify = function(m) + msg = m + end + fn() + vim.notify = orig + return msg + end + + it('compile warns on unnamed buffer', function() + local bufnr = helpers.create_buffer({}, 'typst') + local msg = capture_notify(function() + preview.compile(bufnr) + end) + assert.is_not_nil(msg) + assert.is_truthy(msg:find('no file name')) + helpers.delete_buffer(bufnr) + end) + + it('toggle warns on unnamed buffer', function() + local bufnr = helpers.create_buffer({}, 'typst') + local msg = capture_notify(function() + preview.toggle(bufnr) + end) + assert.is_not_nil(msg) + assert.is_truthy(msg:find('no file name')) + helpers.delete_buffer(bufnr) + end) + + it('clean warns on unnamed buffer', function() + local bufnr = helpers.create_buffer({}, 'typst') + local msg = capture_notify(function() + preview.clean(bufnr) + end) + assert.is_not_nil(msg) + assert.is_truthy(msg:find('no file name')) + helpers.delete_buffer(bufnr) + end) + + it('open warns on unnamed buffer', function() + local bufnr = helpers.create_buffer({}, 'typst') + local msg = capture_notify(function() + preview.open(bufnr) + end) + assert.is_not_nil(msg) + assert.is_truthy(msg:find('no file name')) + helpers.delete_buffer(bufnr) + end) + end) end) diff --git a/spec/presets_spec.lua b/spec/presets_spec.lua index 904a4f4..2160dfa 100644 --- a/spec/presets_spec.lua +++ b/spec/presets_spec.lua @@ -33,10 +33,28 @@ 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', @@ -84,6 +102,7 @@ describe('presets', function() assert.are.same({ '-pdf', '-interaction=nonstopmode', + '-synctex=1', '-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S', '/tmp/document.tex', }, args) @@ -144,12 +163,127 @@ 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() @@ -185,6 +319,10 @@ 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):', @@ -233,6 +371,7 @@ describe('presets', function() file = '/tmp/document.md', root = '/tmp', ft = 'markdown', + output = '/tmp/document.html', } it('has ft', function() @@ -288,6 +427,10 @@ 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):', @@ -318,4 +461,116 @@ describe('presets', function() assert.are.same({}, diagnostics) end) end) + + describe('asciidoctor', function() + local adoc_ctx = { + bufnr = 1, + file = '/tmp/document.adoc', + root = '/tmp', + ft = 'asciidoc', + output = '/tmp/document.html', + } + + it('has ft', function() + assert.are.equal('asciidoc', presets.asciidoctor.ft) + end) + + it('has cmd', function() + assert.are.same({ 'asciidoctor' }, presets.asciidoctor.cmd) + end) + + it('returns args with file and output', function() + assert.are.same( + { '--failure-level', 'ERROR', '/tmp/document.adoc', '-o', '/tmp/document.html' }, + presets.asciidoctor.args(adoc_ctx) + ) + end) + + it('returns html output path', function() + assert.are.equal('/tmp/document.html', presets.asciidoctor.output(adoc_ctx)) + end) + + it('returns clean command', function() + assert.are.same({ 'rm', '-f', '/tmp/document.html' }, presets.asciidoctor.clean(adoc_ctx)) + end) + + it('has open enabled', function() + assert.is_true(presets.asciidoctor.open) + end) + + it('has reload enabled for SSE', function() + assert.is_true(presets.asciidoctor.reload) + end) + + it('parses error messages', function() + local output = + 'asciidoctor: ERROR: document.adoc: line 8: invalid part, must have at least one section' + local diagnostics = presets.asciidoctor.error_parser(output, adoc_ctx) + assert.are.equal(1, #diagnostics) + assert.are.equal(7, diagnostics[1].lnum) + assert.are.equal(0, diagnostics[1].col) + assert.are.equal('invalid part, must have at least one section', diagnostics[1].message) + assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity) + end) + + it('parses warning messages', function() + local output = 'asciidoctor: WARNING: document.adoc: line 52: section title out of sequence' + local diagnostics = presets.asciidoctor.error_parser(output, adoc_ctx) + assert.are.equal(1, #diagnostics) + assert.are.equal(51, diagnostics[1].lnum) + assert.are.equal(vim.diagnostic.severity.WARN, diagnostics[1].severity) + end) + + it('returns empty table for clean output', function() + assert.are.same({}, presets.asciidoctor.error_parser('', adoc_ctx)) + end) + end) + + describe('quarto', function() + local qmd_ctx = { + bufnr = 1, + file = '/tmp/document.qmd', + root = '/tmp', + ft = 'quarto', + output = '/tmp/document.html', + } + + it('has ft', function() + assert.are.equal('quarto', presets.quarto.ft) + end) + + it('has cmd', function() + assert.are.same({ 'quarto' }, presets.quarto.cmd) + end) + + it('returns args with render subcommand and html format', function() + assert.are.same( + { 'render', '/tmp/document.qmd', '--to', 'html', '--embed-resources' }, + presets.quarto.args(qmd_ctx) + ) + end) + + it('returns html output path', function() + assert.are.equal('/tmp/document.html', presets.quarto.output(qmd_ctx)) + end) + + it('returns clean command removing html and _files directory', function() + assert.are.same( + { 'rm', '-rf', '/tmp/document.html', '/tmp/document_files' }, + presets.quarto.clean(qmd_ctx) + ) + end) + + it('has open enabled', function() + assert.is_true(presets.quarto.open) + end) + + it('has reload enabled for SSE', function() + assert.is_true(presets.quarto.reload) + end) + + it('has no error_parser', function() + assert.is_nil(presets.quarto.error_parser) + end) + end) end) diff --git a/spec/reload_spec.lua b/spec/reload_spec.lua new file mode 100644 index 0000000..68b2851 --- /dev/null +++ b/spec/reload_spec.lua @@ -0,0 +1,58 @@ +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 ', function() + local path = os.tmpname() + local f = assert(io.open(path, 'w')) + f:write('

hello

') + 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('', 1, true) + assert.is_truthy(body_pos) + assert.is_true(script_pos < body_pos) + end) + + it('appends script when no ', function() + local path = os.tmpname() + local f = assert(io.open(path, 'w')) + f:write('

hello

') + 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)