From e49a664d48e927e11db3ff8192f800a2e343c23b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 1 Mar 2026 17:22:59 -0500 Subject: [PATCH] ci: format --- .busted | 9 ++ .editorconfig | 9 ++ .github/workflows/luarocks.yaml | 21 ++++ .github/workflows/quality.yaml | 73 ++++++++++++ .github/workflows/test.yaml | 22 ++++ .gitignore | 13 +++ .luarc.json | 8 ++ .prettierrc | 9 ++ LICENSE | 21 ++++ README.md | 64 +++++++++++ doc/render.nvim.txt | 198 ++++++++++++++++++++++++++++++++ flake.lock | 43 +++++++ flake.nix | 36 ++++++ lua/render/commands.lua | 47 ++++++++ lua/render/compiler.lua | 184 +++++++++++++++++++++++++++++ lua/render/diagnostic.lua | 40 +++++++ lua/render/health.lua | 42 +++++++ lua/render/init.lua | 168 +++++++++++++++++++++++++++ lua/render/log.lua | 35 ++++++ plugin/render.lua | 12 ++ render.nvim-scm-1.rockspec | 30 +++++ selene.toml | 5 + spec/commands_spec.lua | 38 ++++++ spec/compiler_spec.lua | 176 ++++++++++++++++++++++++++++ spec/diagnostic_spec.lua | 128 +++++++++++++++++++++ spec/helpers.lua | 27 +++++ spec/init_spec.lua | 116 +++++++++++++++++++ spec/minimal_init.lua | 4 + stylua.toml | 8 ++ vim.yaml | 26 +++++ 30 files changed, 1612 insertions(+) create mode 100644 .busted create mode 100644 .editorconfig create mode 100644 .github/workflows/luarocks.yaml create mode 100644 .github/workflows/quality.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 .luarc.json create mode 100644 .prettierrc create mode 100644 LICENSE create mode 100644 README.md create mode 100644 doc/render.nvim.txt create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 lua/render/commands.lua create mode 100644 lua/render/compiler.lua create mode 100644 lua/render/diagnostic.lua create mode 100644 lua/render/health.lua create mode 100644 lua/render/init.lua create mode 100644 lua/render/log.lua create mode 100644 plugin/render.lua create mode 100644 render.nvim-scm-1.rockspec create mode 100644 selene.toml create mode 100644 spec/commands_spec.lua create mode 100644 spec/compiler_spec.lua create mode 100644 spec/diagnostic_spec.lua create mode 100644 spec/helpers.lua create mode 100644 spec/init_spec.lua create mode 100644 spec/minimal_init.lua create mode 100644 stylua.toml create mode 100644 vim.yaml diff --git a/.busted b/.busted new file mode 100644 index 0000000..53513b8 --- /dev/null +++ b/.busted @@ -0,0 +1,9 @@ +return { + _all = { + lua = 'nvim -l', + ROOT = { './spec/' }, + }, + default = { + verbose = true, + }, +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b9de190 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +insert_final_newline = true +charset = utf-8 + +[*.lua] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/luarocks.yaml b/.github/workflows/luarocks.yaml new file mode 100644 index 0000000..9b6664e --- /dev/null +++ b/.github/workflows/luarocks.yaml @@ -0,0 +1,21 @@ +name: luarocks + +on: + push: + tags: + - 'v*' + +jobs: + quality: + uses: ./.github/workflows/quality.yaml + + publish: + needs: quality + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: nvim-neorocks/luarocks-tag-release@v7 + env: + LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml new file mode 100644 index 0000000..ee1f094 --- /dev/null +++ b/.github/workflows/quality.yaml @@ -0,0 +1,73 @@ +name: quality + +on: + workflow_call: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + lua: ${{ steps.changes.outputs.lua }} + markdown: ${{ steps.changes.outputs.markdown }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + lua: + - 'lua/**' + - 'plugin/**' + - '*.lua' + - '.luarc.json' + - '*.toml' + markdown: + - '*.md' + + lua-format: + name: Lua Format Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v31 + - run: nix develop --command stylua --check . + + lua-lint: + name: Lua Lint Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v31 + - run: nix develop --command selene --display-style quiet . + + lua-typecheck: + name: Lua Type Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.lua == 'true' }} + steps: + - uses: actions/checkout@v4 + - name: Run Lua LS Type Check + uses: mrcjkb/lua-typecheck-action@v0 + with: + checklevel: Warning + directories: lua + configpath: .luarc.json + + markdown-format: + name: Markdown Format Check + runs-on: ubuntu-latest + needs: changes + if: ${{ needs.changes.outputs.markdown == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v31 + - run: nix develop --command prettier --check . diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..6910389 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,22 @@ +name: test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + nvim: [stable, nightly] + name: Test (Neovim ${{ matrix.nvim }}) + steps: + - uses: actions/checkout@v4 + + - uses: nvim-neorocks/nvim-busted-action@v1 + with: + nvim_version: ${{ matrix.nvim }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93ac2c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +doc/tags +*.log + +.*cache* +CLAUDE.md +.claude/ + +node_modules/ + +result +result-* +.direnv/ +.envrc diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..23646d3 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,8 @@ +{ + "runtime.version": "LuaJIT", + "runtime.path": ["lua/?.lua", "lua/?/init.lua"], + "diagnostics.globals": ["vim", "jit"], + "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], + "workspace.checkThirdParty": false, + "completion.callSnippet": "Replace" +} diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0663621 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "proseWrap": "always", + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "none", + "semi": false, + "singleQuote": true +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fee363a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Raphael + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5dc1795 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# render.nvim + +Async document compilation for Neovim. + +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()` +- 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.10.0 +- A compiler binary for each provider you configure + +## Installation + +```lua +-- lazy.nvim +{ 'barrettruth/render.nvim' } +``` + +```vim +" luarocks +:Rocks install render.nvim +``` + +## Configuration + +```lua +vim.g.render = { + providers = { + typst = { + cmd = { 'typst', 'compile' }, + args = function(ctx) + return { ctx.file } + end, + output = function(ctx) + return ctx.file:gsub('%.typ$', '.pdf') + end, + }, + latexmk = { + cmd = { 'latexmk' }, + args = { '-pdf', '-interaction=nonstopmode' }, + clean = { 'latexmk', '-c' }, + }, + }, + providers_by_ft = { + typst = 'typst', + tex = 'latexmk', + }, +} +``` + +## Documentation + +See `:help render.nvim` for full documentation. 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.lock b/flake.lock new file mode 100644 index 0000000..c9a3796 --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1772173633, + "narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..90c489e --- /dev/null +++ b/flake.nix @@ -0,0 +1,36 @@ +{ + description = "render.nvim — async document compilation for Neovim"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + systems.url = "github:nix-systems/default"; + }; + + outputs = + { + nixpkgs, + systems, + ... + }: + let + forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + in + { + devShells = forEachSystem (pkgs: { + default = pkgs.mkShell { + packages = [ + (pkgs.luajit.withPackages ( + ps: with ps; [ + busted + nlua + ] + )) + pkgs.prettier + pkgs.stylua + pkgs.selene + pkgs.lua-language-server + ]; + }; + }); + }; +} 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..78b2aec --- /dev/null +++ b/lua/render/compiler.lua @@ -0,0 +1,184 @@ +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) + proc.obj:kill('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 + proc.obj:kill('sigkill') + active[bufnr] = nil + end + end) + end +end + +function M.stop_all() + for bufnr, _ in pairs(active) do + M.stop(bufnr) + end +end + +---@param bufnr integer +---@param name string +---@param provider render.ProviderConfig +---@param ctx render.Context +function M.clean(bufnr, name, provider, ctx) + if not provider.clean then + vim.notify('[render.nvim] provider "' .. name .. '" has no clean command', vim.log.levels.WARN) + return + end + + local cmd = eval_list(provider.clean, ctx) + local cwd = ctx.root + if provider.cwd then + cwd = eval_string(provider.cwd, ctx) + end + + log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' ')) + + vim.system( + cmd, + { cwd = cwd }, + vim.schedule_wrap(function(result) + if result.code == 0 then + log.dbg('clean succeeded for buffer %d', bufnr) + vim.notify('[render.nvim] clean complete', vim.log.levels.INFO) + else + log.dbg('clean failed for buffer %d (exit code %d)', bufnr, result.code) + vim.notify('[render.nvim] clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR) + end + end) + ) +end + +---@param bufnr integer +---@return render.Status +function M.status(bufnr) + local proc = active[bufnr] + if proc then + return { compiling = true, provider = proc.provider, output_file = proc.output_file } + end + return { compiling = false } +end + +M._test = { + active = active, +} + +return M diff --git a/lua/render/diagnostic.lua b/lua/render/diagnostic.lua new file mode 100644 index 0000000..8e9658f --- /dev/null +++ b/lua/render/diagnostic.lua @@ -0,0 +1,40 @@ +local M = {} + +local log = require('render.log') + +local ns = vim.api.nvim_create_namespace('render') + +---@param bufnr integer +function M.clear(bufnr) + vim.diagnostic.set(ns, bufnr, {}) + log.dbg('cleared diagnostics for buffer %d', bufnr) +end + +---@param bufnr integer +---@param name string +---@param error_parser fun(stderr: string, ctx: render.Context): vim.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 + end + if not diagnostics or #diagnostics == 0 then + log.dbg('error_parser for "%s" returned no diagnostics', name) + return + end + for _, d in ipairs(diagnostics) do + d.source = d.source or name + end + vim.diagnostic.set(ns, bufnr, diagnostics) + log.dbg('set %d diagnostics for buffer %d from provider "%s"', #diagnostics, bufnr, name) +end + +---@return integer +function M.get_namespace() + return ns +end + +return M 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..a04192b --- /dev/null +++ b/lua/render/init.lua @@ -0,0 +1,168 @@ +---@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): vim.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.Process +---@field obj vim.SystemObj +---@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/render/log.lua b/lua/render/log.lua new file mode 100644 index 0000000..3896978 --- /dev/null +++ b/lua/render/log.lua @@ -0,0 +1,35 @@ +local M = {} + +local enabled = false +local log_file = nil + +---@param val boolean|string +function M.set_enabled(val) + if type(val) == 'string' then + enabled = true + log_file = val + else + enabled = val + log_file = nil + end +end + +---@param msg string +---@param ... any +function M.dbg(msg, ...) + if not enabled then + return + end + local formatted = '[render.nvim]: ' .. string.format(msg, ...) + if log_file then + local f = io.open(log_file, 'a') + if f then + f:write(string.format('%.6fs', vim.uv.hrtime() / 1e9) .. ' ' .. formatted .. '\n') + f:close() + end + else + vim.notify(formatted, vim.log.levels.DEBUG) + end +end + +return M 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/render.nvim-scm-1.rockspec b/render.nvim-scm-1.rockspec new file mode 100644 index 0000000..0233c56 --- /dev/null +++ b/render.nvim-scm-1.rockspec @@ -0,0 +1,30 @@ +rockspec_format = '3.0' +package = 'render.nvim' +version = 'scm-1' + +source = { + url = 'git+https://github.com/barrettruth/render.nvim.git', +} + +description = { + summary = 'Async document compilation for Neovim', + homepage = 'https://github.com/barrettruth/render.nvim', + license = 'MIT', +} + +dependencies = { + 'lua >= 5.1', +} + +test_dependencies = { + 'nlua', + 'busted >= 2.1.1', +} + +test = { + type = 'busted', +} + +build = { + type = 'builtin', +} diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..83d196e --- /dev/null +++ b/selene.toml @@ -0,0 +1,5 @@ +std = 'vim' +exclude = ['.direnv', 'result', 'result-*', 'node_modules'] + +[lints] +bad_string_escape = 'allow' diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua new file mode 100644 index 0000000..87b251c --- /dev/null +++ b/spec/commands_spec.lua @@ -0,0 +1,38 @@ +local helpers = require('spec.helpers') + +describe('commands', function() + before_each(function() + helpers.reset_config() + end) + + describe('setup', function() + it('creates the :Render command', function() + require('render.commands').setup() + local cmds = vim.api.nvim_get_commands({}) + assert.is_not_nil(cmds.Render) + end) + end) + + describe('dispatch', function() + it('does not error on :Render with no provider', function() + require('render.commands').setup() + assert.has_no.errors(function() + vim.cmd('Render compile') + end) + end) + + it('does not error on :Render stop', function() + require('render.commands').setup() + assert.has_no.errors(function() + vim.cmd('Render stop') + end) + end) + + it('does not error on :Render status', function() + require('render.commands').setup() + assert.has_no.errors(function() + vim.cmd('Render status') + end) + end) + end) +end) diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua new file mode 100644 index 0000000..19a701c --- /dev/null +++ b/spec/compiler_spec.lua @@ -0,0 +1,176 @@ +local helpers = require('spec.helpers') + +describe('compiler', function() + local compiler + + before_each(function() + helpers.reset_config() + 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/render_test.txt') + vim.bo[bufnr].modified = false + + local provider = { cmd = { 'echo', 'ok' } } + local ctx = { + bufnr = bufnr, + file = '/tmp/render_test.txt', + root = '/tmp', + ft = 'text', + } + + compiler.compile(bufnr, 'echo', provider, ctx) + local active = compiler._test.active + assert.is_not_nil(active[bufnr]) + assert.are.equal('echo', active[bufnr].provider) + + vim.wait(2000, function() + return active[bufnr] == nil + end, 50) + + assert.is_nil(active[bufnr]) + helpers.delete_buffer(bufnr) + end) + + it('fires RenderCompileStarted event', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + 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 = 'RenderCompileStarted', + once = true, + callback = function() + fired = true + end, + }) + + local provider = { cmd = { 'echo', 'ok' } } + local ctx = { + bufnr = bufnr, + file = '/tmp/render_test_event.txt', + root = '/tmp', + ft = 'text', + } + + compiler.compile(bufnr, 'echo', provider, ctx) + assert.is_true(fired) + + vim.wait(2000, function() + return compiler._test.active[bufnr] == nil + end, 50) + + helpers.delete_buffer(bufnr) + end) + + it('fires RenderCompileSuccess on exit code 0', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_success.txt') + vim.bo[bufnr].modified = false + + local succeeded = false + vim.api.nvim_create_autocmd('User', { + pattern = 'RenderCompileSuccess', + once = true, + callback = function() + succeeded = true + end, + }) + + local provider = { cmd = { 'true' } } + local ctx = { + bufnr = bufnr, + file = '/tmp/render_test_success.txt', + root = '/tmp', + ft = 'text', + } + + compiler.compile(bufnr, 'truecmd', provider, ctx) + + vim.wait(2000, function() + return succeeded + end, 50) + + assert.is_true(succeeded) + helpers.delete_buffer(bufnr) + end) + + it('fires RenderCompileFailed on non-zero exit', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + 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 = 'RenderCompileFailed', + once = true, + callback = function() + failed = true + end, + }) + + local provider = { cmd = { 'false' } } + local ctx = { + bufnr = bufnr, + file = '/tmp/render_test_fail.txt', + root = '/tmp', + ft = 'text', + } + + compiler.compile(bufnr, 'falsecmd', provider, ctx) + + vim.wait(2000, function() + return failed + end, 50) + + assert.is_true(failed) + helpers.delete_buffer(bufnr) + end) + end) + + describe('stop', function() + it('does nothing when no process is active', function() + assert.has_no.errors(function() + compiler.stop(999) + end) + end) + end) + + describe('status', function() + it('returns idle for buffer with no process', function() + local s = compiler.status(42) + assert.is_false(s.compiling) + end) + + it('returns compiling during active process', function() + local bufnr = helpers.create_buffer({ 'hello' }, 'text') + vim.api.nvim_buf_set_name(bufnr, '/tmp/render_test_status.txt') + vim.bo[bufnr].modified = false + + local provider = { cmd = { 'sleep', '10' } } + local ctx = { + bufnr = bufnr, + file = '/tmp/render_test_status.txt', + root = '/tmp', + ft = 'text', + } + + compiler.compile(bufnr, 'sleepcmd', provider, ctx) + local s = compiler.status(bufnr) + assert.is_true(s.compiling) + assert.are.equal('sleepcmd', s.provider) + + compiler.stop(bufnr) + + vim.wait(2000, function() + return compiler._test.active[bufnr] == nil + end, 50) + + helpers.delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/diagnostic_spec.lua b/spec/diagnostic_spec.lua new file mode 100644 index 0000000..fba0a01 --- /dev/null +++ b/spec/diagnostic_spec.lua @@ -0,0 +1,128 @@ +local helpers = require('spec.helpers') + +describe('diagnostic', function() + local diagnostic + + before_each(function() + helpers.reset_config() + diagnostic = require('render.diagnostic') + end) + + describe('clear', function() + it('clears diagnostics for a buffer', function() + local bufnr = helpers.create_buffer({ 'line1', 'line2' }) + local ns = diagnostic.get_namespace() + vim.diagnostic.set(ns, bufnr, { + { lnum = 0, col = 0, message = 'test error', severity = vim.diagnostic.severity.ERROR }, + }) + + local diags = vim.diagnostic.get(bufnr, { namespace = ns }) + assert.are.equal(1, #diags) + + diagnostic.clear(bufnr) + + diags = vim.diagnostic.get(bufnr, { namespace = ns }) + assert.are.equal(0, #diags) + helpers.delete_buffer(bufnr) + end) + end) + + describe('set', function() + it('sets diagnostics from error_parser output', function() + local bufnr = helpers.create_buffer({ 'line1', 'line2' }) + local ns = diagnostic.get_namespace() + + local parser = function() + return { + { lnum = 0, col = 0, message = 'syntax error', severity = vim.diagnostic.severity.ERROR }, + } + end + + local ctx = { bufnr = bufnr, file = '/tmp/test.typ', root = '/tmp', ft = 'typst' } + diagnostic.set(bufnr, 'typst', parser, 'error on line 1', ctx) + + local diags = vim.diagnostic.get(bufnr, { namespace = ns }) + assert.are.equal(1, #diags) + assert.are.equal('syntax error', diags[1].message) + assert.are.equal('typst', diags[1].source) + helpers.delete_buffer(bufnr) + end) + + it('sets source to provider name when not specified', function() + local bufnr = helpers.create_buffer({ 'line1' }) + local ns = diagnostic.get_namespace() + + local parser = function() + return { + { lnum = 0, col = 0, message = 'err', severity = vim.diagnostic.severity.ERROR }, + } + end + + local ctx = { bufnr = bufnr, file = '/tmp/test.tex', root = '/tmp', ft = 'tex' } + diagnostic.set(bufnr, 'latexmk', parser, 'error', ctx) + + local diags = vim.diagnostic.get(bufnr, { namespace = ns }) + assert.are.equal('latexmk', diags[1].source) + helpers.delete_buffer(bufnr) + end) + + it('preserves existing source from parser', function() + local bufnr = helpers.create_buffer({ 'line1' }) + local ns = diagnostic.get_namespace() + + local parser = function() + return { + { + lnum = 0, + col = 0, + message = 'err', + severity = vim.diagnostic.severity.ERROR, + source = 'custom', + }, + } + end + + local ctx = { bufnr = bufnr, file = '/tmp/test.tex', root = '/tmp', ft = 'tex' } + diagnostic.set(bufnr, 'latexmk', parser, 'error', ctx) + + local diags = vim.diagnostic.get(bufnr, { namespace = ns }) + assert.are.equal('custom', diags[1].source) + helpers.delete_buffer(bufnr) + end) + + it('handles parser failure gracefully', function() + local bufnr = helpers.create_buffer({ 'line1' }) + local ns = diagnostic.get_namespace() + + local parser = function() + error('parser exploded') + end + + local ctx = { bufnr = bufnr, file = '/tmp/test.tex', root = '/tmp', ft = 'tex' } + + assert.has_no.errors(function() + diagnostic.set(bufnr, 'latexmk', parser, 'error', ctx) + end) + + local diags = vim.diagnostic.get(bufnr, { namespace = ns }) + assert.are.equal(0, #diags) + helpers.delete_buffer(bufnr) + end) + + it('does nothing when parser returns empty list', function() + local bufnr = helpers.create_buffer({ 'line1' }) + local ns = diagnostic.get_namespace() + + local parser = function() + return {} + end + + local ctx = { bufnr = bufnr, file = '/tmp/test.tex', root = '/tmp', ft = 'tex' } + diagnostic.set(bufnr, 'latexmk', parser, 'error', ctx) + + local diags = vim.diagnostic.get(bufnr, { namespace = ns }) + assert.are.equal(0, #diags) + helpers.delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/helpers.lua b/spec/helpers.lua new file mode 100644 index 0000000..f337dd2 --- /dev/null +++ b/spec/helpers.lua @@ -0,0 +1,27 @@ +local plugin_dir = vim.fn.getcwd() +vim.opt.runtimepath:prepend(plugin_dir) +vim.opt.packpath = {} + +local M = {} + +function M.create_buffer(lines, ft) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + if ft then + vim.bo[bufnr].filetype = ft + end + return bufnr +end + +function M.delete_buffer(bufnr) + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end +end + +function M.reset_config() + vim.g.render = nil + require('render')._test.reset() +end + +return M diff --git a/spec/init_spec.lua b/spec/init_spec.lua new file mode 100644 index 0000000..1a2d82a --- /dev/null +++ b/spec/init_spec.lua @@ -0,0 +1,116 @@ +local helpers = require('spec.helpers') + +describe('render', function() + local render + + before_each(function() + helpers.reset_config() + render = require('render') + end) + + describe('config', function() + it('accepts nil config', function() + vim.g.render = nil + assert.has_no.errors(function() + render.get_config() + end) + end) + + it('applies default values', function() + vim.g.render = nil + 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() + vim.g.render = { debug = true } + helpers.reset_config() + local config = require('render').get_config() + assert.is_true(config.debug) + assert.are.same({}, config.providers) + end) + + it('accepts full provider config', function() + vim.g.render = { + providers = { + typst = { + cmd = { 'typst', 'compile' }, + args = { '%s' }, + }, + }, + providers_by_ft = { + typst = 'typst', + }, + } + helpers.reset_config() + local config = require('render').get_config() + assert.is_not_nil(config.providers.typst) + assert.are.equal('typst', config.providers_by_ft.typst) + end) + end) + + describe('resolve_provider', function() + before_each(function() + vim.g.render = { + providers = { + typst = { cmd = { 'typst', 'compile' } }, + }, + providers_by_ft = { + typst = 'typst', + }, + } + helpers.reset_config() + render = require('render') + end) + + it('returns provider name for mapped filetype', function() + local bufnr = helpers.create_buffer({}, 'typst') + local name = render.resolve_provider(bufnr) + assert.are.equal('typst', name) + helpers.delete_buffer(bufnr) + end) + + it('returns nil for unmapped filetype', function() + local bufnr = helpers.create_buffer({}, 'lua') + local name = render.resolve_provider(bufnr) + assert.is_nil(name) + helpers.delete_buffer(bufnr) + end) + + it('returns nil when provider name maps to missing config', function() + vim.g.render = { + providers = {}, + providers_by_ft = { typst = 'typst' }, + } + helpers.reset_config() + local bufnr = helpers.create_buffer({}, 'typst') + local name = require('render').resolve_provider(bufnr) + assert.is_nil(name) + helpers.delete_buffer(bufnr) + end) + end) + + describe('build_context', function() + it('builds context from buffer', function() + local bufnr = helpers.create_buffer({}, 'typst') + local ctx = render.build_context(bufnr) + assert.are.equal(bufnr, ctx.bufnr) + assert.are.equal('typst', ctx.ft) + assert.is_string(ctx.file) + assert.is_string(ctx.root) + helpers.delete_buffer(bufnr) + end) + end) + + describe('status', function() + it('returns idle when nothing is compiling', function() + local bufnr = helpers.create_buffer({}) + local s = render.status(bufnr) + assert.is_false(s.compiling) + assert.is_nil(s.provider) + helpers.delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/minimal_init.lua b/spec/minimal_init.lua new file mode 100644 index 0000000..313d2a3 --- /dev/null +++ b/spec/minimal_init.lua @@ -0,0 +1,4 @@ +vim.cmd([[set runtimepath=$VIMRUNTIME]]) +vim.opt.runtimepath:append('.') +vim.opt.packpath = {} +vim.opt.loadplugins = false diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..01ded03 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,8 @@ +column_width = 100 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferSingle" +call_parentheses = "Always" +[sort_requires] +enabled = true diff --git a/vim.yaml b/vim.yaml new file mode 100644 index 0000000..3821d25 --- /dev/null +++ b/vim.yaml @@ -0,0 +1,26 @@ +--- +base: lua51 +name: vim +lua_versions: + - luajit +globals: + vim: + any: true + jit: + any: true + assert: + any: true + describe: + any: true + it: + any: true + before_each: + any: true + after_each: + any: true + spy: + any: true + stub: + any: true + bit: + any: true