ci: format

This commit is contained in:
Barrett Ruth 2026-03-01 17:22:59 -05:00
commit e49a664d48
No known key found for this signature in database
GPG key ID: A6C96C9349D2FC81
30 changed files with 1612 additions and 0 deletions

9
.busted Normal file
View file

@ -0,0 +1,9 @@
return {
_all = {
lua = 'nvim -l',
ROOT = { './spec/' },
},
default = {
verbose = true,
},
}

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
insert_final_newline = true
charset = utf-8
[*.lua]
indent_style = space
indent_size = 2

21
.github/workflows/luarocks.yaml vendored Normal file
View file

@ -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 }}

73
.github/workflows/quality.yaml vendored Normal file
View file

@ -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 .

22
.github/workflows/test.yaml vendored Normal file
View file

@ -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 }}

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
doc/tags
*.log
.*cache*
CLAUDE.md
.claude/
node_modules/
result
result-*
.direnv/
.envrc

8
.luarc.json Normal file
View file

@ -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"
}

9
.prettierrc Normal file
View file

@ -0,0 +1,9 @@
{
"proseWrap": "always",
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "none",
"semi": false,
"singleQuote": true
}

21
LICENSE Normal file
View file

@ -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.

64
README.md Normal file
View file

@ -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.

198
doc/render.nvim.txt Normal file
View file

@ -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:

43
flake.lock generated Normal file
View file

@ -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
}

36
flake.nix Normal file
View file

@ -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
];
};
});
};
}

47
lua/render/commands.lua Normal file
View file

@ -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

184
lua/render/compiler.lua Normal file
View file

@ -0,0 +1,184 @@
local M = {}
local diagnostic = require('render.diagnostic')
local log = require('render.log')
---@type table<integer, render.Process>
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

40
lua/render/diagnostic.lua Normal file
View file

@ -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

42
lua/render/health.lua Normal file
View file

@ -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

168
lua/render/init.lua Normal file
View file

@ -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<string, string>
---@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<string, render.ProviderConfig>
---@field providers_by_ft table<string, string>
---@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

35
lua/render/log.lua Normal file
View file

@ -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

12
plugin/render.lua Normal file
View file

@ -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,
})

View file

@ -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',
}

5
selene.toml Normal file
View file

@ -0,0 +1,5 @@
std = 'vim'
exclude = ['.direnv', 'result', 'result-*', 'node_modules']
[lints]
bad_string_escape = 'allow'

38
spec/commands_spec.lua Normal file
View file

@ -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)

176
spec/compiler_spec.lua Normal file
View file

@ -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)

128
spec/diagnostic_spec.lua Normal file
View file

@ -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)

27
spec/helpers.lua Normal file
View file

@ -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

116
spec/init_spec.lua Normal file
View file

@ -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)

4
spec/minimal_init.lua Normal file
View file

@ -0,0 +1,4 @@
vim.cmd([[set runtimepath=$VIMRUNTIME]])
vim.opt.runtimepath:append('.')
vim.opt.packpath = {}
vim.opt.loadplugins = false

8
stylua.toml Normal file
View file

@ -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

26
vim.yaml Normal file
View file

@ -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