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('', 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/lua/render/commands.lua b/lua/render/commands.lua deleted file mode 100644 index 925c204..0000000 --- a/lua/render/commands.lua +++ /dev/null @@ -1,47 +0,0 @@ -local M = {} - -local subcommands = { 'compile', 'stop', 'clean', 'status' } - ----@param args string -local function dispatch(args) - local subcmd = args ~= '' and args or 'compile' - - if subcmd == 'compile' then - require('render').compile() - elseif subcmd == 'stop' then - require('render').stop() - elseif subcmd == 'clean' then - require('render').clean() - elseif subcmd == 'status' then - local s = require('render').status() - if s.compiling then - vim.notify('[render.nvim] compiling with "' .. s.provider .. '"', vim.log.levels.INFO) - else - vim.notify('[render.nvim] idle', vim.log.levels.INFO) - end - else - vim.notify('[render.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, subcommands) -end - -function M.setup() - vim.api.nvim_create_user_command('Render', function(opts) - dispatch(opts.args) - end, { - nargs = '?', - complete = function(lead) - return complete(lead) - end, - desc = 'Compile, stop, clean, or check status of document rendering', - }) -end - -return M diff --git a/lua/render/compiler.lua b/lua/render/compiler.lua deleted file mode 100644 index 7d2a1d4..0000000 --- a/lua/render/compiler.lua +++ /dev/null @@ -1,186 +0,0 @@ -local M = {} - -local diagnostic = require('render.diagnostic') -local log = require('render.log') - ----@type table -local active = {} - ----@param val string[]|fun(ctx: render.Context): string[] ----@param ctx render.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: render.Context): string ----@param ctx render.Context ----@return string -local function eval_string(val, ctx) - if type(val) == 'function' then - return val(ctx) - end - return val -end - ----@param bufnr integer ----@param name string ----@param provider render.ProviderConfig ----@param ctx render.Context -function M.compile(bufnr, name, provider, ctx) - 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 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 - - log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' ')) - - local obj = vim.system( - cmd, - { - cwd = cwd, - env = provider.env, - }, - vim.schedule_wrap(function(result) - active[bufnr] = nil - - if result.code == 0 then - log.dbg('compilation succeeded for buffer %d', bufnr) - diagnostic.clear(bufnr) - vim.api.nvim_exec_autocmds('User', { - pattern = 'RenderCompileSuccess', - data = { bufnr = bufnr, provider = name, output = output_file }, - }) - else - log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code) - if provider.error_parser then - diagnostic.set(bufnr, name, provider.error_parser, result.stderr or '', ctx) - end - vim.api.nvim_exec_autocmds('User', { - pattern = 'RenderCompileFailed', - 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('BufWipeout', { - buffer = bufnr, - once = true, - callback = function() - M.stop(bufnr) - end, - }) - - vim.api.nvim_exec_autocmds('User', { - pattern = 'RenderCompileStarted', - 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 -end - ----@param bufnr integer ----@param name string ----@param provider render.ProviderConfig ----@param ctx render.Context -function M.clean(bufnr, name, provider, ctx) - if not provider.clean then - vim.notify('[render.nvim] provider "' .. name .. '" has no clean command', vim.log.levels.WARN) - return - end - - local cmd = eval_list(provider.clean, ctx) - local cwd = ctx.root - if provider.cwd then - cwd = eval_string(provider.cwd, 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('[render.nvim] clean complete', vim.log.levels.INFO) - else - log.dbg('clean failed for buffer %d (exit code %d)', bufnr, result.code) - vim.notify('[render.nvim] clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR) - end - end) - ) -end - ----@param bufnr integer ----@return render.Status -function M.status(bufnr) - local proc = active[bufnr] - if proc then - return { compiling = true, provider = proc.provider, output_file = proc.output_file } - end - return { compiling = false } -end - -M._test = { - active = active, -} - -return M diff --git a/lua/render/health.lua b/lua/render/health.lua deleted file mode 100644 index 25623a5..0000000 --- a/lua/render/health.lua +++ /dev/null @@ -1,42 +0,0 @@ -local M = {} - -function M.check() - vim.health.start('render.nvim') - - if vim.fn.has('nvim-0.10.0') == 1 then - vim.health.ok('Neovim 0.10.0+ detected') - else - vim.health.error('render.nvim requires Neovim 0.10.0+') - end - - local config = require('render').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 name, provider in pairs(config.providers) do - local bin = provider.cmd[1] - if vim.fn.executable(bin) == 1 then - vim.health.ok('provider "' .. name .. '": ' .. bin .. ' found') - else - vim.health.error('provider "' .. name .. '": ' .. bin .. ' not found') - end - end - - local ft_count = vim.tbl_count(config.providers_by_ft) - if ft_count > 0 then - for ft, name in pairs(config.providers_by_ft) do - if config.providers[name] then - vim.health.ok('filetype "' .. ft .. '" -> provider "' .. name .. '"') - else - vim.health.error('filetype "' .. ft .. '" maps to unknown provider "' .. name .. '"') - end - end - end -end - -return M diff --git a/lua/render/init.lua b/lua/render/init.lua deleted file mode 100644 index 73f3057..0000000 --- a/lua/render/init.lua +++ /dev/null @@ -1,177 +0,0 @@ ----@class render.ProviderConfig ----@field cmd string[] ----@field args? string[]|fun(ctx: render.Context): string[] ----@field cwd? string|fun(ctx: render.Context): string ----@field env? table ----@field output? string|fun(ctx: render.Context): string ----@field error_parser? fun(stderr: string, ctx: render.Context): render.Diagnostic[] ----@field clean? string[]|fun(ctx: render.Context): string[] - ----@class render.Config ----@field debug boolean|string ----@field providers table ----@field providers_by_ft table - ----@class render.Context ----@field bufnr integer ----@field file string ----@field root string ----@field ft string - ----@class render.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 render.Process ----@field obj table ----@field provider string ----@field output_file string - ----@class render ----@field compile fun(bufnr?: integer) ----@field stop fun(bufnr?: integer) ----@field clean fun(bufnr?: integer) ----@field status fun(bufnr?: integer): render.Status ----@field get_config fun(): render.Config -local M = {} - -local compiler = require('render.compiler') -local log = require('render.log') - ----@type render.Config -local default_config = { - debug = false, - providers = {}, - providers_by_ft = {}, -} - ----@type render.Config -local config = vim.deepcopy(default_config) - -local initialized = false - -local function init() - if initialized then - return - end - initialized = true - - local opts = vim.g.render or {} - - vim.validate('render config', opts, 'table') - if opts.debug ~= nil then - vim.validate('render config.debug', opts.debug, { 'boolean', 'string' }) - end - if opts.providers ~= nil then - vim.validate('render config.providers', opts.providers, 'table') - end - if opts.providers_by_ft ~= nil then - vim.validate('render config.providers_by_ft', opts.providers_by_ft, 'table') - end - - config = vim.tbl_deep_extend('force', default_config, opts) - log.set_enabled(config.debug) - log.dbg('initialized with %d providers', vim.tbl_count(config.providers)) -end - ----@return render.Config -function M.get_config() - init() - return config -end - ----@param bufnr? integer ----@return string? -function M.resolve_provider(bufnr) - init() - bufnr = bufnr or vim.api.nvim_get_current_buf() - local ft = vim.bo[bufnr].filetype - local name = config.providers_by_ft[ft] - if not name then - log.dbg('no provider mapped for filetype: %s', ft) - return nil - end - if not config.providers[name] then - log.dbg('provider "%s" mapped for ft "%s" but not configured', name, ft) - return nil - end - return name -end - ----@param bufnr? integer ----@return render.Context -function M.build_context(bufnr) - init() - 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) - init() - bufnr = bufnr or vim.api.nvim_get_current_buf() - local name = M.resolve_provider(bufnr) - if not name then - vim.notify('[render.nvim] no provider configured for this filetype', vim.log.levels.WARN) - return - end - local provider = config.providers[name] - local ctx = M.build_context(bufnr) - compiler.compile(bufnr, name, provider, ctx) -end - ----@param bufnr? integer -function M.stop(bufnr) - init() - bufnr = bufnr or vim.api.nvim_get_current_buf() - compiler.stop(bufnr) -end - ----@param bufnr? integer -function M.clean(bufnr) - init() - bufnr = bufnr or vim.api.nvim_get_current_buf() - local name = M.resolve_provider(bufnr) - if not name then - vim.notify('[render.nvim] no provider configured for this filetype', vim.log.levels.WARN) - return - end - local provider = config.providers[name] - local ctx = M.build_context(bufnr) - compiler.clean(bufnr, name, provider, ctx) -end - ----@class render.Status ----@field compiling boolean ----@field provider? string ----@field output_file? string - ----@param bufnr? integer ----@return render.Status -function M.status(bufnr) - init() - bufnr = bufnr or vim.api.nvim_get_current_buf() - return compiler.status(bufnr) -end - -M._test = { - ---@diagnostic disable-next-line: assign-type-mismatch - reset = function() - initialized = false - config = vim.deepcopy(default_config) - end, -} - -return M diff --git a/plugin/preview.lua b/plugin/preview.lua new file mode 100644 index 0000000..eefacf8 --- /dev/null +++ b/plugin/preview.lua @@ -0,0 +1,12 @@ +if vim.g.loaded_preview then + return +end +vim.g.loaded_preview = 1 + +require('preview.commands').setup() + +vim.api.nvim_create_autocmd('VimLeavePre', { + callback = function() + require('preview.compiler').stop_all() + end, +}) diff --git a/plugin/render.lua b/plugin/render.lua deleted file mode 100644 index 6bcb837..0000000 --- a/plugin/render.lua +++ /dev/null @@ -1,12 +0,0 @@ -if vim.g.loaded_render then - return -end -vim.g.loaded_render = 1 - -require('render.commands').setup() - -vim.api.nvim_create_autocmd('VimLeavePre', { - callback = function() - require('render.compiler').stop_all() - end, -}) diff --git a/render.nvim-scm-1.rockspec b/preview.nvim-scm-1.rockspec similarity index 67% rename from render.nvim-scm-1.rockspec rename to preview.nvim-scm-1.rockspec index 0233c56..44064e8 100644 --- a/render.nvim-scm-1.rockspec +++ b/preview.nvim-scm-1.rockspec @@ -1,14 +1,14 @@ rockspec_format = '3.0' -package = 'render.nvim' +package = 'preview.nvim' version = 'scm-1' source = { - url = 'git+https://github.com/barrettruth/render.nvim.git', + url = 'git+https://github.com/barrettruth/preview.nvim.git', } description = { summary = 'Async document compilation for Neovim', - homepage = 'https://github.com/barrettruth/render.nvim', + homepage = 'https://github.com/barrettruth/preview.nvim', license = 'MIT', } 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 87b251c..4e12e5d 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -6,32 +6,52 @@ describe('commands', function() end) describe('setup', function() - it('creates the :Render command', function() - require('render.commands').setup() + it('creates the :Preview command', function() + require('preview.commands').setup() local cmds = vim.api.nvim_get_commands({}) - assert.is_not_nil(cmds.Render) + 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 :Render with no provider', function() - require('render.commands').setup() + it('does not error on :Preview compile with no provider', function() + require('preview.commands').setup() assert.has_no.errors(function() - vim.cmd('Render compile') + vim.cmd('Preview compile') end) end) - it('does not error on :Render stop', function() - require('render.commands').setup() + it('does not error on :Preview status', function() + require('preview.commands').setup() assert.has_no.errors(function() - vim.cmd('Render stop') + vim.cmd('Preview status') end) end) - it('does not error on :Render status', function() - require('render.commands').setup() + it('does not error on :Preview open', function() + require('preview.commands').setup() assert.has_no.errors(function() - vim.cmd('Render status') + vim.cmd('Preview open') + end) + end) + + it('does not error on :Preview toggle with no provider', function() + require('preview.commands').setup() + assert.has_no.errors(function() + vim.cmd('Preview toggle') end) end) end) diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua index 19a701c..cd1dd9f 100644 --- a/spec/compiler_spec.lua +++ b/spec/compiler_spec.lua @@ -5,19 +5,19 @@ describe('compiler', function() before_each(function() helpers.reset_config() - compiler = require('render.compiler') + compiler = require('preview.compiler') end) describe('compile', function() it('spawns a process and tracks it in active table', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') - vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test.txt') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test.txt') vim.bo[bufnr].modified = false local provider = { cmd = { 'echo', 'ok' } } local ctx = { bufnr = bufnr, - file = '/tmp/render_test.txt', + file = '/tmp/preview_test.txt', root = '/tmp', ft = 'text', } @@ -35,14 +35,14 @@ describe('compiler', function() helpers.delete_buffer(bufnr) end) - it('fires RenderCompileStarted event', function() + it('fires PreviewCompileStarted event', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') - vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_event.txt') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_event.txt') vim.bo[bufnr].modified = false local fired = false vim.api.nvim_create_autocmd('User', { - pattern = 'RenderCompileStarted', + pattern = 'PreviewCompileStarted', once = true, callback = function() fired = true @@ -52,7 +52,7 @@ describe('compiler', function() local provider = { cmd = { 'echo', 'ok' } } local ctx = { bufnr = bufnr, - file = '/tmp/render_test_event.txt', + file = '/tmp/preview_test_event.txt', root = '/tmp', ft = 'text', } @@ -67,14 +67,14 @@ describe('compiler', function() helpers.delete_buffer(bufnr) end) - it('fires RenderCompileSuccess on exit code 0', function() + it('fires PreviewCompileSuccess on exit code 0', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') - vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_success.txt') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_success.txt') vim.bo[bufnr].modified = false local succeeded = false vim.api.nvim_create_autocmd('User', { - pattern = 'RenderCompileSuccess', + pattern = 'PreviewCompileSuccess', once = true, callback = function() succeeded = true @@ -84,7 +84,7 @@ describe('compiler', function() local provider = { cmd = { 'true' } } local ctx = { bufnr = bufnr, - file = '/tmp/render_test_success.txt', + file = '/tmp/preview_test_success.txt', root = '/tmp', ft = 'text', } @@ -99,14 +99,43 @@ describe('compiler', function() helpers.delete_buffer(bufnr) end) - it('fires RenderCompileFailed on non-zero exit', function() + 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/render_test_fail.txt') + 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') vim.bo[bufnr].modified = false local failed = false vim.api.nvim_create_autocmd('User', { - pattern = 'RenderCompileFailed', + pattern = 'PreviewCompileFailed', once = true, callback = function() failed = true @@ -116,7 +145,7 @@ describe('compiler', function() local provider = { cmd = { 'false' } } local ctx = { bufnr = bufnr, - file = '/tmp/render_test_fail.txt', + file = '/tmp/preview_test_fail.txt', root = '/tmp', ft = 'text', } @@ -132,6 +161,108 @@ describe('compiler', function() end) end) + describe('errors mode', function() + it('errors = false suppresses error parser', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_false.txt') + vim.bo[bufnr].modified = false + + local parser_called = false + local provider = { + cmd = { 'false' }, + errors = false, + error_parser = function() + parser_called = true + return {} + end, + } + local ctx = { + bufnr = bufnr, + file = '/tmp/preview_test_errors_false.txt', + root = '/tmp', + ft = 'text', + } + + compiler.compile(bufnr, 'falsecmd', provider, ctx) + + vim.wait(2000, function() + return compiler._test.active[bufnr] == nil + end, 50) + + assert.is_false(parser_called) + helpers.delete_buffer(bufnr) + end) + + it('errors = quickfix populates quickfix list', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_qf.txt') + vim.bo[bufnr].modified = false + + local provider = { + cmd = { 'sh', '-c', 'echo "line 1 error" >&2; exit 1' }, + errors = 'quickfix', + error_parser = function() + return { + { lnum = 0, col = 0, message = 'test error', severity = vim.diagnostic.severity.ERROR }, + } + end, + } + local ctx = { + bufnr = bufnr, + file = '/tmp/preview_test_errors_qf.txt', + root = '/tmp', + ft = 'text', + } + + vim.fn.setqflist({}, 'r') + compiler.compile(bufnr, 'qfcmd', provider, ctx) + + vim.wait(2000, function() + return compiler._test.active[bufnr] == nil + end, 50) + + local qflist = vim.fn.getqflist() + assert.are.equal(1, #qflist) + assert.are.equal('test error', qflist[1].text) + assert.are.equal(1, qflist[1].lnum) + + vim.fn.setqflist({}, 'r') + helpers.delete_buffer(bufnr) + end) + + it('errors = quickfix clears quickfix on success', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_errors_qf_clear.txt') + vim.bo[bufnr].modified = false + + vim.fn.setqflist({ { text = 'old error', lnum = 1 } }, 'r') + assert.are.equal(1, #vim.fn.getqflist()) + + local provider = { + cmd = { 'true' }, + errors = 'quickfix', + error_parser = function() + return {} + end, + } + local ctx = { + bufnr = bufnr, + file = '/tmp/preview_test_errors_qf_clear.txt', + root = '/tmp', + ft = 'text', + } + + compiler.compile(bufnr, 'truecmd', provider, ctx) + + vim.wait(2000, function() + return compiler._test.active[bufnr] == nil + end, 50) + + assert.are.equal(0, #vim.fn.getqflist()) + helpers.delete_buffer(bufnr) + end) + end) + describe('stop', function() it('does nothing when no process is active', function() assert.has_no.errors(function() @@ -148,13 +279,13 @@ describe('compiler', function() it('returns compiling during active process', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') - vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_status.txt') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_status.txt') vim.bo[bufnr].modified = false local provider = { cmd = { 'sleep', '10' } } local ctx = { bufnr = bufnr, - file = '/tmp/render_test_status.txt', + file = '/tmp/preview_test_status.txt', root = '/tmp', ft = 'text', } @@ -173,4 +304,117 @@ describe('compiler', function() helpers.delete_buffer(bufnr) end) end) + + describe('open', function() + it('returns false when no output exists', function() + assert.is_false(compiler.open(999)) + end) + + it('returns true after compilation stores output', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_open.txt') + vim.bo[bufnr].modified = false + + local provider = { + cmd = { 'true' }, + output = function() + return '/tmp/preview_test_open.pdf' + end, + } + local ctx = { + bufnr = bufnr, + file = '/tmp/preview_test_open.txt', + root = '/tmp', + ft = 'text', + } + + compiler.compile(bufnr, 'testprov', provider, ctx) + assert.is_not_nil(compiler._test.last_output[bufnr]) + assert.are.equal('/tmp/preview_test_open.pdf', compiler._test.last_output[bufnr]) + + vim.wait(2000, function() + return compiler._test.active[bufnr] == nil + end, 50) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('toggle', function() + it('registers autocmd and tracks in watching table', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch.txt') + + local provider = { cmd = { 'echo', 'ok' } } + local ctx_builder = function(b) + return { bufnr = b, file = '/tmp/preview_test_watch.txt', root = '/tmp', ft = 'text' } + end + + compiler.toggle(bufnr, 'echo', provider, ctx_builder) + assert.is_not_nil(compiler._test.watching[bufnr]) + + helpers.delete_buffer(bufnr) + end) + + it('toggles off when called again', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_toggle.txt') + + local provider = { cmd = { 'echo', 'ok' } } + local ctx_builder = function(b) + return { bufnr = b, file = '/tmp/preview_test_watch_toggle.txt', root = '/tmp', ft = 'text' } + end + + compiler.toggle(bufnr, 'echo', provider, ctx_builder) + assert.is_not_nil(compiler._test.watching[bufnr]) + + compiler.toggle(bufnr, 'echo', provider, ctx_builder) + assert.is_nil(compiler._test.watching[bufnr]) + + helpers.delete_buffer(bufnr) + end) + + it('stop_all clears watches', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_stopall.txt') + + local provider = { cmd = { 'echo', 'ok' } } + local ctx_builder = function(b) + return { + bufnr = b, + file = '/tmp/preview_test_watch_stopall.txt', + root = '/tmp', + ft = 'text', + } + end + + compiler.toggle(bufnr, 'echo', provider, ctx_builder) + assert.is_not_nil(compiler._test.watching[bufnr]) + + compiler.stop_all() + assert.is_nil(compiler._test.watching[bufnr]) + + helpers.delete_buffer(bufnr) + end) + + it('status includes watching state', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch_status.txt') + + local s = compiler.status(bufnr) + assert.is_false(s.watching) + + local provider = { cmd = { 'echo', 'ok' } } + local ctx_builder = function(b) + return { bufnr = b, file = '/tmp/preview_test_watch_status.txt', root = '/tmp', ft = 'text' } + end + + compiler.toggle(bufnr, 'echo', provider, ctx_builder) + s = compiler.status(bufnr) + assert.is_true(s.watching) + + compiler.unwatch(bufnr) + helpers.delete_buffer(bufnr) + end) + end) end) diff --git a/spec/diagnostic_spec.lua b/spec/diagnostic_spec.lua index fba0a01..05ccc6d 100644 --- a/spec/diagnostic_spec.lua +++ b/spec/diagnostic_spec.lua @@ -5,7 +5,7 @@ describe('diagnostic', function() before_each(function() helpers.reset_config() - diagnostic = require('render.diagnostic') + diagnostic = require('preview.diagnostic') end) describe('clear', function() diff --git a/spec/helpers.lua b/spec/helpers.lua index 605f3b5..dd1ed7e 100644 --- a/spec/helpers.lua +++ b/spec/helpers.lua @@ -20,8 +20,11 @@ function M.delete_buffer(bufnr) end function M.reset_config(opts) - vim.g.render = opts - require('render')._test.reset() + local preview = require('preview') + preview._test.reset() + if opts then + preview.setup(opts) + end end return M diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 1005e16..f68438c 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -1,86 +1,79 @@ local helpers = require('spec.helpers') -describe('render', function() - local render +describe('preview', function() + local preview before_each(function() helpers.reset_config() - render = require('render') + preview = require('preview') end) describe('config', function() - it('accepts nil config', function() - assert.has_no.errors(function() - render.get_config() - end) - end) - - it('applies default values', function() - local config = render.get_config() + it('returns defaults before setup is called', function() + local config = preview.get_config() assert.is_false(config.debug) assert.are.same({}, config.providers) - assert.are.same({}, config.providers_by_ft) end) it('merges user config with defaults', function() helpers.reset_config({ debug = true }) - local config = require('render').get_config() + local config = require('preview').get_config() assert.is_true(config.debug) assert.are.same({}, config.providers) end) - it('accepts full provider config', function() + it('merges override table with matching preset', function() helpers.reset_config({ - providers = { - typst = { - cmd = { 'typst', 'compile' }, - args = { '%s' }, - }, - }, - providers_by_ft = { - typst = 'typst', + typst = { + cmd = { 'typst', 'compile' }, + args = { '%s' }, }, }) - local config = require('render').get_config() + local config = require('preview').get_config() assert.is_not_nil(config.providers.typst) - assert.are.equal('typst', config.providers_by_ft.typst) + end) + + it('resolves preset = true to provider config', function() + helpers.reset_config({ typst = true, markdown = true }) + local config = require('preview').get_config() + local presets = require('preview.presets') + assert.are.same(presets.typst, config.providers.typst) + assert.are.same(presets.markdown, config.providers.markdown) + end) + + it('resolves latex preset under tex filetype', function() + helpers.reset_config({ latex = true }) + local config = require('preview').get_config() + local presets = require('preview.presets') + assert.are.same(presets.latex, config.providers.tex) + end) + + it('resolves github preset under markdown filetype', function() + helpers.reset_config({ github = true }) + local config = require('preview').get_config() + local presets = require('preview.presets') + assert.are.same(presets.github, config.providers.markdown) end) end) describe('resolve_provider', function() before_each(function() helpers.reset_config({ - providers = { - typst = { cmd = { 'typst', 'compile' } }, - }, - providers_by_ft = { - typst = 'typst', - }, + typst = true, }) - render = require('render') + preview = require('preview') end) - it('returns provider name for mapped filetype', function() + it('returns filetype when provider exists', function() local bufnr = helpers.create_buffer({}, 'typst') - local name = render.resolve_provider(bufnr) + local name = preview.resolve_provider(bufnr) assert.are.equal('typst', name) helpers.delete_buffer(bufnr) end) - it('returns nil for unmapped filetype', function() + it('returns nil for unconfigured filetype', function() local bufnr = helpers.create_buffer({}, 'lua') - local name = render.resolve_provider(bufnr) - assert.is_nil(name) - helpers.delete_buffer(bufnr) - end) - - it('returns nil when provider name maps to missing config', function() - helpers.reset_config({ - providers = {}, - providers_by_ft = { typst = 'typst' }, - }) - local bufnr = helpers.create_buffer({}, 'typst') - local name = require('render').resolve_provider(bufnr) + local name = preview.resolve_provider(bufnr) assert.is_nil(name) helpers.delete_buffer(bufnr) end) @@ -89,7 +82,7 @@ describe('render', function() describe('build_context', function() it('builds context from buffer', function() local bufnr = helpers.create_buffer({}, 'typst') - local ctx = render.build_context(bufnr) + local ctx = preview.build_context(bufnr) assert.are.equal(bufnr, ctx.bufnr) assert.are.equal('typst', ctx.ft) assert.is_string(ctx.file) @@ -101,10 +94,76 @@ describe('render', function() describe('status', function() it('returns idle when nothing is compiling', function() local bufnr = helpers.create_buffer({}) - local s = render.status(bufnr) + local s = preview.status(bufnr) assert.is_false(s.compiling) assert.is_nil(s.provider) helpers.delete_buffer(bufnr) end) end) + + describe('statusline', function() + it('returns empty string when idle', function() + local bufnr = helpers.create_buffer({}) + assert.are.equal('', preview.statusline(bufnr)) + 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 new file mode 100644 index 0000000..2160dfa --- /dev/null +++ b/spec/presets_spec.lua @@ -0,0 +1,576 @@ +describe('presets', function() + local presets + + before_each(function() + presets = require('preview.presets') + end) + + local ctx = { + bufnr = 1, + file = '/tmp/document.typ', + root = '/tmp', + ft = 'typst', + } + + describe('typst', function() + it('has ft', function() + assert.are.equal('typst', presets.typst.ft) + end) + + it('has cmd', function() + assert.are.same({ 'typst', 'compile' }, presets.typst.cmd) + end) + + it('returns args with diagnostic format and file path', function() + local args = presets.typst.args(ctx) + assert.is_table(args) + assert.are.same({ '--diagnostic-format', 'short', '/tmp/document.typ' }, args) + end) + + it('returns pdf output path', function() + local output = presets.typst.output(ctx) + assert.is_string(output) + 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', + 'main.typ:12:1: warning: unused variable', + }, '\n') + local diagnostics = presets.typst.error_parser(stderr, ctx) + assert.is_table(diagnostics) + assert.are.equal(2, #diagnostics) + assert.are.equal(4, diagnostics[1].lnum) + assert.are.equal(22, diagnostics[1].col) + assert.are.equal('unexpected token', diagnostics[1].message) + assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity) + assert.is_nil(diagnostics[1].source) + assert.are.equal(11, diagnostics[2].lnum) + assert.are.equal(0, diagnostics[2].col) + assert.are.equal('unused variable', diagnostics[2].message) + assert.are.equal(vim.diagnostic.severity.WARN, diagnostics[2].severity) + end) + + it('returns empty table for clean stderr', function() + local diagnostics = presets.typst.error_parser('', ctx) + assert.are.same({}, diagnostics) + end) + end) + + describe('latex', function() + local tex_ctx = { + bufnr = 1, + file = '/tmp/document.tex', + root = '/tmp', + ft = 'tex', + } + + it('has ft', function() + assert.are.equal('tex', presets.latex.ft) + end) + + it('has cmd', function() + assert.are.same({ 'latexmk' }, presets.latex.cmd) + end) + + it('returns args with pdf flag and file path', function() + local args = presets.latex.args(tex_ctx) + assert.is_table(args) + assert.are.same({ + '-pdf', + '-interaction=nonstopmode', + '-synctex=1', + '-pdflatex=pdflatex -file-line-error -interaction=nonstopmode %O %S', + '/tmp/document.tex', + }, args) + end) + + it('returns pdf output path', function() + local output = presets.latex.output(tex_ctx) + assert.is_string(output) + assert.are.equal('/tmp/document.pdf', output) + end) + + it('returns clean command', function() + local clean = presets.latex.clean(tex_ctx) + assert.is_table(clean) + assert.are.same({ 'latexmk', '-c', '/tmp/document.tex' }, clean) + end) + + it('has open enabled', function() + assert.is_true(presets.latex.open) + end) + + it('parses file-line-error format from output', function() + local output = table.concat({ + './document.tex:10: Undefined control sequence.', + 'l.10 \\badcommand', + 'Collected error summary (may duplicate other messages):', + " pdflatex: Command for 'pdflatex' gave return code 256", + }, '\n') + local diagnostics = presets.latex.error_parser(output, tex_ctx) + assert.is_table(diagnostics) + assert.is_true(#diagnostics > 0) + 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('parses collected error summary', function() + local output = table.concat({ + 'Latexmk: Errors, so I did not complete making targets', + 'Collected error summary (may duplicate other messages):', + " pdflatex: Command for 'pdflatex' gave return code 256", + }, '\n') + local diagnostics = presets.latex.error_parser(output, tex_ctx) + assert.is_table(diagnostics) + assert.are.equal(1, #diagnostics) + assert.are.equal(0, diagnostics[1].lnum) + assert.are.equal(0, diagnostics[1].col) + assert.are.equal( + "pdflatex: Command for 'pdflatex' gave return code 256", + diagnostics[1].message + ) + end) + + it('returns empty table for clean stderr', function() + local diagnostics = presets.latex.error_parser('', tex_ctx) + assert.are.same({}, diagnostics) + 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() + assert.are.equal('markdown', presets.markdown.ft) + end) + + it('has cmd', function() + assert.are.same({ 'pandoc' }, presets.markdown.cmd) + end) + + it('returns args with standalone and embed-resources flags', function() + local args = presets.markdown.args(md_ctx) + assert.is_table(args) + assert.are.same( + { '/tmp/document.md', '-s', '--embed-resources', '-o', '/tmp/document.html' }, + args + ) + end) + + it('returns html output path', function() + local output = presets.markdown.output(md_ctx) + assert.is_string(output) + assert.are.equal('/tmp/document.html', output) + end) + + it('returns clean command', function() + local clean = presets.markdown.clean(md_ctx) + assert.is_table(clean) + assert.are.same({ 'rm', '-f', '/tmp/document.html' }, clean) + end) + + it('has open enabled', 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):', + 'YAML parse exception at line 1, column 9:', + 'mapping values are not allowed in this context', + }, '\n') + local diagnostics = presets.markdown.error_parser(output, md_ctx) + assert.is_table(diagnostics) + assert.are.equal(1, #diagnostics) + assert.are.equal(0, diagnostics[1].lnum) + assert.are.equal(0, diagnostics[1].col) + assert.are.equal('mapping values are not allowed in this context', diagnostics[1].message) + assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity) + end) + + it('parses Error at format', function() + local output = 'Error at "source" (line 75, column 1): unexpected end of input' + local diagnostics = presets.markdown.error_parser(output, md_ctx) + assert.is_table(diagnostics) + assert.are.equal(1, #diagnostics) + assert.are.equal(74, diagnostics[1].lnum) + assert.are.equal(0, diagnostics[1].col) + assert.are.equal('unexpected end of input', diagnostics[1].message) + assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity) + end) + + it('parses generic pandoc errors', function() + local output = 'pandoc: Could not find data file templates/default.html5' + local diagnostics = presets.markdown.error_parser(output, md_ctx) + assert.is_table(diagnostics) + assert.are.equal(1, #diagnostics) + assert.are.equal(0, diagnostics[1].lnum) + assert.are.equal(0, diagnostics[1].col) + assert.are.equal('Could not find data file templates/default.html5', diagnostics[1].message) + end) + + it('returns empty table for clean output', function() + local diagnostics = presets.markdown.error_parser('', md_ctx) + assert.are.same({}, diagnostics) + end) + end) + + describe('github', function() + local md_ctx = { + bufnr = 1, + file = '/tmp/document.md', + root = '/tmp', + ft = 'markdown', + output = '/tmp/document.html', + } + + it('has ft', function() + assert.are.equal('markdown', presets.github.ft) + end) + + it('has cmd', function() + assert.are.same({ 'pandoc' }, presets.github.cmd) + end) + + it('returns args with standalone, embed-resources, and css flags', function() + local args = presets.github.args(md_ctx) + assert.is_table(args) + assert.are.same({ + '-f', + 'gfm', + '/tmp/document.md', + '-s', + '--embed-resources', + '--css', + 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css', + '-o', + '/tmp/document.html', + }, args) + end) + + it('args include -f and gfm flags', function() + local args = presets.github.args(md_ctx) + local idx = nil + for i, v in ipairs(args) do + if v == '-f' then + idx = i + break + end + end + assert.is_not_nil(idx) + assert.are.equal('gfm', args[idx + 1]) + end) + + it('returns html output path', function() + local output = presets.github.output(md_ctx) + assert.is_string(output) + assert.are.equal('/tmp/document.html', output) + end) + + it('returns clean command', function() + local clean = presets.github.clean(md_ctx) + assert.is_table(clean) + assert.are.same({ 'rm', '-f', '/tmp/document.html' }, clean) + end) + + it('has open enabled', 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):', + 'YAML parse exception at line 1, column 9:', + 'mapping values are not allowed in this context', + }, '\n') + local diagnostics = presets.github.error_parser(output, md_ctx) + assert.is_table(diagnostics) + assert.are.equal(1, #diagnostics) + assert.are.equal(0, diagnostics[1].lnum) + assert.are.equal(0, diagnostics[1].col) + assert.are.equal('mapping values are not allowed in this context', diagnostics[1].message) + end) + + it('parses Error at format', function() + local output = 'Error at "document.md" (line 12, column 5): unexpected "}" expecting letter' + local diagnostics = presets.github.error_parser(output, md_ctx) + assert.is_table(diagnostics) + assert.are.equal(1, #diagnostics) + assert.are.equal(11, diagnostics[1].lnum) + assert.are.equal(4, diagnostics[1].col) + assert.are.equal('unexpected "}" expecting letter', diagnostics[1].message) + assert.are.equal(vim.diagnostic.severity.ERROR, diagnostics[1].severity) + end) + + it('returns empty table for clean output', function() + local diagnostics = presets.github.error_parser('', md_ctx) + 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)