diff --git a/.luarc.json b/.luarc.json index d44eb7c..23646d3 100644 --- a/.luarc.json +++ b/.luarc.json @@ -4,6 +4,5 @@ "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 deleted file mode 100644 index 9b42106..0000000 --- a/.styluaignore +++ /dev/null @@ -1 +0,0 @@ -.direnv/ diff --git a/README.md b/README.md index 80a2650..6c2ca46 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,64 @@ -# preview.nvim +# render.nvim -**Universal document previewer for Neovim** +Async document compilation for Neovim. -An extensible framework for compiling and previewing _any_ documents (LaTeX, -Typst, Markdown, etc.)—diagnostics included. - - +A framework for compiling documents (LaTeX, Typst, Markdown, etc.) +asynchronously with error diagnostics. Ships with zero defaults — you configure +your own providers. ## Features - Async compilation via `vim.system()` -- Built-in presets for Typst, LaTeX (latexmk, pdflatex, tectonic), Markdown, - GitHub-flavored Markdown, AsciiDoc, PlantUML, Mermaid, and Quarto -- Compiler errors via `vim.diagnostic` or quickfix -- Previewer auto-close on buffer deletion +- Compiler errors as native `vim.diagnostic` +- User events for extensibility (`RenderCompileStarted`, `RenderCompileSuccess`, + `RenderCompileFailed`) +- `:checkhealth` integration +- Zero dependencies beyond Neovim 0.10.0+ ## Requirements -- Neovim 0.11+ +- Neovim >= 0.10.0 +- A compiler binary for each provider you configure ## Installation -With lazy.nvim: - ```lua -{ - 'barrettruth/preview.nvim', - init = function() - vim.g.preview = { typst = true, latex = true } - end, -} +-- lazy.nvim +{ 'barrettruth/render.nvim' } ``` -Or via [luarocks](https://luarocks.org/modules/barrettruth/preview.nvim): - -``` -luarocks install preview.nvim -``` - -## Documentation - ```vim -:help preview.nvim +" luarocks +:Rocks install render.nvim ``` -## FAQ - -**Q: How do I define a custom provider?** +## Configuration ```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, +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', }, } ``` -**Q: How do I override a preset?** +## Documentation -```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' } }, -} -``` +See `:help render.nvim` for full documentation. diff --git a/doc/preview.txt b/doc/preview.txt deleted file mode 100644 index 383a8f5..0000000 --- a/doc/preview.txt +++ /dev/null @@ -1,276 +0,0 @@ -*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 new file mode 100644 index 0000000..48dacfb --- /dev/null +++ b/doc/render.nvim.txt @@ -0,0 +1,198 @@ +*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 636f4d0..90c489e 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "preview.nvim — async document compilation for Neovim"; + description = "render.nvim — async document compilation for Neovim"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; @@ -13,15 +13,12 @@ ... }: let - forEachSystem = - f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); in { - formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); - - devShells = forEachSystem (pkgs: - let - devTools = [ + devShells = forEachSystem (pkgs: { + default = pkgs.mkShell { + packages = [ (pkgs.luajit.withPackages ( ps: with ps; [ busted @@ -33,23 +30,7 @@ pkgs.selene pkgs.lua-language-server ]; - in - { - default = pkgs.mkShell { - packages = devTools; - }; - presets = pkgs.mkShell { - packages = devTools ++ [ - pkgs.typst - pkgs.texliveMedium - pkgs.tectonic - pkgs.pandoc - pkgs.asciidoctor - pkgs.quarto - pkgs.plantuml - pkgs.mermaid-cli - ]; - }; - }); + }; + }); }; } diff --git a/lua/preview/commands.lua b/lua/preview/commands.lua deleted file mode 100644 index 97a13f7..0000000 --- a/lua/preview/commands.lua +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index a193ad2..0000000 --- a/lua/preview/compiler.lua +++ /dev/null @@ -1,623 +0,0 @@ -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/preview/health.lua b/lua/preview/health.lua deleted file mode 100644 index 196100d..0000000 --- a/lua/preview/health.lua +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 7cb982b..0000000 --- a/lua/preview/init.lua +++ /dev/null @@ -1,261 +0,0 @@ ----@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/preview/presets.lua b/lua/preview/presets.lua deleted file mode 100644 index 1b5333e..0000000 --- a/lua/preview/presets.lua +++ /dev/null @@ -1,354 +0,0 @@ -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 deleted file mode 100644 index d2c1de9..0000000 --- a/lua/preview/reload.lua +++ /dev/null @@ -1,113 +0,0 @@ -local M = {} - -local PORT = 5554 -local server_handle = nil -local actual_port = nil -local clients = {} - -local function make_script(port) - return '' -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 new file mode 100644 index 0000000..925c204 --- /dev/null +++ b/lua/render/commands.lua @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..7d2a1d4 --- /dev/null +++ b/lua/render/compiler.lua @@ -0,0 +1,186 @@ +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/preview/diagnostic.lua b/lua/render/diagnostic.lua similarity index 67% rename from lua/preview/diagnostic.lua rename to lua/render/diagnostic.lua index abd4105..a526219 100644 --- a/lua/preview/diagnostic.lua +++ b/lua/render/diagnostic.lua @@ -1,8 +1,8 @@ local M = {} -local log = require('preview.log') +local log = require('render.log') -local ns = vim.api.nvim_create_namespace('preview') +local ns = vim.api.nvim_create_namespace('render') ---@param bufnr integer function M.clear(bufnr) @@ -12,11 +12,11 @@ end ---@param bufnr integer ---@param name string ----@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) +---@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) if not ok then log.dbg('error_parser for "%s" failed: %s', name, diagnostics) return diff --git a/lua/render/health.lua b/lua/render/health.lua new file mode 100644 index 0000000..25623a5 --- /dev/null +++ b/lua/render/health.lua @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..73f3057 --- /dev/null +++ b/lua/render/init.lua @@ -0,0 +1,177 @@ +---@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/lua/preview/log.lua b/lua/render/log.lua similarity index 90% rename from lua/preview/log.lua rename to lua/render/log.lua index 6b6d5c9..3896978 100644 --- a/lua/preview/log.lua +++ b/lua/render/log.lua @@ -20,7 +20,7 @@ function M.dbg(msg, ...) if not enabled then return end - local formatted = '[preview.nvim]: ' .. string.format(msg, ...) + local formatted = '[render.nvim]: ' .. string.format(msg, ...) if log_file then local f = io.open(log_file, 'a') if f then diff --git a/plugin/preview.lua b/plugin/preview.lua deleted file mode 100644 index eefacf8..0000000 --- a/plugin/preview.lua +++ /dev/null @@ -1,12 +0,0 @@ -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 new file mode 100644 index 0000000..6bcb837 --- /dev/null +++ b/plugin/render.lua @@ -0,0 +1,12 @@ +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/preview.nvim-scm-1.rockspec b/render.nvim-scm-1.rockspec similarity index 67% rename from preview.nvim-scm-1.rockspec rename to render.nvim-scm-1.rockspec index 44064e8..0233c56 100644 --- a/preview.nvim-scm-1.rockspec +++ b/render.nvim-scm-1.rockspec @@ -1,14 +1,14 @@ rockspec_format = '3.0' -package = 'preview.nvim' +package = 'render.nvim' version = 'scm-1' source = { - url = 'git+https://github.com/barrettruth/preview.nvim.git', + url = 'git+https://github.com/barrettruth/render.nvim.git', } description = { summary = 'Async document compilation for Neovim', - homepage = 'https://github.com/barrettruth/preview.nvim', + homepage = 'https://github.com/barrettruth/render.nvim', license = 'MIT', } diff --git a/scripts/ci.sh b/scripts/ci.sh deleted file mode 100755 index 37b1267..0000000 --- a/scripts/ci.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -set -eu - -nix develop --command stylua --check . -git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet -nix develop --command prettier --check . -nix fmt -git diff --exit-code -- '*.nix' -nix develop --command lua-language-server --check lua/ --configpath "$(pwd)/.luarc.json" --checklevel=Warning -nix develop --command busted diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index 4e12e5d..87b251c 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -6,52 +6,32 @@ describe('commands', function() end) describe('setup', function() - it('creates the :Preview command', function() - require('preview.commands').setup() + it('creates the :Render command', function() + require('render.commands').setup() local cmds = vim.api.nvim_get_commands({}) - assert.is_not_nil(cmds.Preview) - end) - - it('registers VimLeavePre autocmd', function() - require('preview.commands').setup() - local aus = vim.api.nvim_get_autocmds({ event = 'VimLeavePre' }) - local found = false - for _, au in ipairs(aus) do - if au.callback then - found = true - break - end - end - assert.is_true(found) + assert.is_not_nil(cmds.Render) end) end) describe('dispatch', function() - it('does not error on :Preview compile with no provider', function() - require('preview.commands').setup() + it('does not error on :Render with no provider', function() + require('render.commands').setup() assert.has_no.errors(function() - vim.cmd('Preview compile') + vim.cmd('Render compile') end) end) - it('does not error on :Preview status', function() - require('preview.commands').setup() + it('does not error on :Render stop', function() + require('render.commands').setup() assert.has_no.errors(function() - vim.cmd('Preview status') + vim.cmd('Render stop') end) end) - it('does not error on :Preview open', function() - require('preview.commands').setup() + it('does not error on :Render status', function() + require('render.commands').setup() assert.has_no.errors(function() - 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') + vim.cmd('Render status') end) end) end) diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua index cd1dd9f..19a701c 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('preview.compiler') + compiler = require('render.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/preview_test.txt') + vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test.txt') vim.bo[bufnr].modified = false local provider = { cmd = { 'echo', 'ok' } } local ctx = { bufnr = bufnr, - file = '/tmp/preview_test.txt', + file = '/tmp/render_test.txt', root = '/tmp', ft = 'text', } @@ -35,14 +35,14 @@ describe('compiler', function() helpers.delete_buffer(bufnr) end) - it('fires PreviewCompileStarted event', function() + it('fires RenderCompileStarted event', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') - vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_event.txt') + vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_event.txt') vim.bo[bufnr].modified = false local fired = false vim.api.nvim_create_autocmd('User', { - pattern = 'PreviewCompileStarted', + pattern = 'RenderCompileStarted', once = true, callback = function() fired = true @@ -52,7 +52,7 @@ describe('compiler', function() local provider = { cmd = { 'echo', 'ok' } } local ctx = { bufnr = bufnr, - file = '/tmp/preview_test_event.txt', + file = '/tmp/render_test_event.txt', root = '/tmp', ft = 'text', } @@ -67,14 +67,14 @@ describe('compiler', function() helpers.delete_buffer(bufnr) end) - it('fires PreviewCompileSuccess on exit code 0', function() + it('fires RenderCompileSuccess on exit code 0', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') - vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_success.txt') + vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_success.txt') vim.bo[bufnr].modified = false local succeeded = false vim.api.nvim_create_autocmd('User', { - pattern = 'PreviewCompileSuccess', + pattern = 'RenderCompileSuccess', once = true, callback = function() succeeded = true @@ -84,7 +84,7 @@ describe('compiler', function() local provider = { cmd = { 'true' } } local ctx = { bufnr = bufnr, - file = '/tmp/preview_test_success.txt', + file = '/tmp/render_test_success.txt', root = '/tmp', ft = 'text', } @@ -99,43 +99,14 @@ describe('compiler', function() helpers.delete_buffer(bufnr) end) - it('notifies and returns when binary is not executable', function() + it('fires RenderCompileFailed on non-zero exit', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') - vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_nobin.txt') - vim.bo[bufnr].modified = false - - local notified = false - local orig = vim.notify - vim.notify = function(msg) - if msg:find('not executable') then - notified = true - end - end - - local provider = { cmd = { 'totally_nonexistent_binary_xyz_preview' } } - local ctx = { - bufnr = bufnr, - file = '/tmp/preview_test_nobin.txt', - root = '/tmp', - ft = 'text', - } - - compiler.compile(bufnr, 'nobin', provider, ctx) - vim.notify = orig - - assert.is_true(notified) - assert.is_nil(compiler._test.active[bufnr]) - helpers.delete_buffer(bufnr) - end) - - it('fires PreviewCompileFailed on non-zero exit', function() - local bufnr = helpers.create_buffer({ 'hello' }, 'text') - vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_fail.txt') + vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_fail.txt') vim.bo[bufnr].modified = false local failed = false vim.api.nvim_create_autocmd('User', { - pattern = 'PreviewCompileFailed', + pattern = 'RenderCompileFailed', once = true, callback = function() failed = true @@ -145,7 +116,7 @@ describe('compiler', function() local provider = { cmd = { 'false' } } local ctx = { bufnr = bufnr, - file = '/tmp/preview_test_fail.txt', + file = '/tmp/render_test_fail.txt', root = '/tmp', ft = 'text', } @@ -161,108 +132,6 @@ 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() @@ -279,13 +148,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/preview_test_status.txt') + vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_status.txt') vim.bo[bufnr].modified = false local provider = { cmd = { 'sleep', '10' } } local ctx = { bufnr = bufnr, - file = '/tmp/preview_test_status.txt', + file = '/tmp/render_test_status.txt', root = '/tmp', ft = 'text', } @@ -304,117 +173,4 @@ 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 05ccc6d..fba0a01 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('preview.diagnostic') + diagnostic = require('render.diagnostic') end) describe('clear', function() diff --git a/spec/helpers.lua b/spec/helpers.lua index dd1ed7e..605f3b5 100644 --- a/spec/helpers.lua +++ b/spec/helpers.lua @@ -20,11 +20,8 @@ function M.delete_buffer(bufnr) end function M.reset_config(opts) - local preview = require('preview') - preview._test.reset() - if opts then - preview.setup(opts) - end + vim.g.render = opts + require('render')._test.reset() end return M diff --git a/spec/init_spec.lua b/spec/init_spec.lua index f68438c..1005e16 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -1,79 +1,86 @@ local helpers = require('spec.helpers') -describe('preview', function() - local preview +describe('render', function() + local render before_each(function() helpers.reset_config() - preview = require('preview') + render = require('render') end) describe('config', function() - it('returns defaults before setup is called', function() - local config = preview.get_config() + 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() 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('preview').get_config() + local config = require('render').get_config() assert.is_true(config.debug) assert.are.same({}, config.providers) end) - it('merges override table with matching preset', function() + it('accepts full provider config', function() helpers.reset_config({ - typst = { - cmd = { 'typst', 'compile' }, - args = { '%s' }, + providers = { + typst = { + cmd = { 'typst', 'compile' }, + args = { '%s' }, + }, + }, + providers_by_ft = { + typst = 'typst', }, }) - local config = require('preview').get_config() + local config = require('render').get_config() assert.is_not_nil(config.providers.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) + assert.are.equal('typst', config.providers_by_ft.typst) end) end) describe('resolve_provider', function() before_each(function() helpers.reset_config({ - typst = true, + providers = { + typst = { cmd = { 'typst', 'compile' } }, + }, + providers_by_ft = { + typst = 'typst', + }, }) - preview = require('preview') + render = require('render') end) - it('returns filetype when provider exists', function() + it('returns provider name for mapped filetype', function() local bufnr = helpers.create_buffer({}, 'typst') - local name = preview.resolve_provider(bufnr) + local name = render.resolve_provider(bufnr) assert.are.equal('typst', name) helpers.delete_buffer(bufnr) end) - it('returns nil for unconfigured filetype', function() + it('returns nil for unmapped filetype', function() local bufnr = helpers.create_buffer({}, 'lua') - local name = preview.resolve_provider(bufnr) + 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) assert.is_nil(name) helpers.delete_buffer(bufnr) end) @@ -82,7 +89,7 @@ describe('preview', function() describe('build_context', function() it('builds context from buffer', function() local bufnr = helpers.create_buffer({}, 'typst') - local ctx = preview.build_context(bufnr) + local ctx = render.build_context(bufnr) assert.are.equal(bufnr, ctx.bufnr) assert.are.equal('typst', ctx.ft) assert.is_string(ctx.file) @@ -94,76 +101,10 @@ describe('preview', function() describe('status', function() it('returns idle when nothing is compiling', function() local bufnr = helpers.create_buffer({}) - local s = preview.status(bufnr) + local s = render.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 deleted file mode 100644 index 2160dfa..0000000 --- a/spec/presets_spec.lua +++ /dev/null @@ -1,576 +0,0 @@ -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 deleted file mode 100644 index 68b2851..0000000 --- a/spec/reload_spec.lua +++ /dev/null @@ -1,58 +0,0 @@ -describe('reload', function() - local reload - - before_each(function() - package.loaded['preview.reload'] = nil - reload = require('preview.reload') - end) - - after_each(function() - reload.stop() - end) - - describe('inject', function() - it('injects script before ', 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)