# nvim-lint: an async linter runner for Neovim that stays out of the LSP's way

> nvim-lint spawns external linters, parses their output and feeds it into vim.diagnostic. It is for Neovim users who want linting in filetypes where no language server exists, and who accept that it executes whatever binary sits in the project tree.

**mfussenegger/nvim-lint** — Mirror of: https://codeberg.org/mfussenegger/nvim-lint An asynchronous linter plugin for Neovim complementary to the built-in Language Server Protocol support.

- Repository: https://github.com/mfussenegger/nvim-lint
- Website: https://codeberg.org/mfussenegger/nvim-lint
- Stars: 2,786 · Forks: 310
- Language: Lua
- License: GPL-3.0
- Published: 2026-09-28 · Updated: 2026-09-28 · Language: en
- Canonical page: https://hysenlabs.com/projects/mfussenegger-nvim-lint

## The gap nvim-lint fills next to the built-in LSP client

Neovim ships a language server client. What it does not ship is a way to run a plain command-line linter and turn its stdout into diagnostics. For some languages that is fine, because the language server already reports warnings. For others it is not: there is no server at all, or the standalone tool is simply stricter than the server. The README states the project's scope in one sentence: it spawns linters, parses their output, and reports the results via the vim.diagnostic module. That narrowness is the design decision. The README contrasts it with ale, which also includes its own language server client, and says nvim-lint complements the built-in client instead of replacing it. If you already run an LSP, nvim-lint adds a second, independent source of diagnostics rather than a competing protocol implementation.

## How try_lint spawns a linter and where the diagnostics land

The plugin has no daemon and no background server. Configuration is a table that maps a filetype to a list of linter names, and nothing runs until you call try_lint. When called without arguments, it looks up the current buffer's filetype in that table and runs the matching linters; when called with a name or a list of names, it runs those regardless of the table. Each linter is a record with a command, arguments, and a way to parse the tool's output, and the parsed results are pushed into vim.diagnostic, which means display, navigation and severity filtering are whatever vim.diagnostic.config gives you. The README points at :help vim.diagnostic.config for customising how diagnostics appear, rather than offering its own display settings. The filetype key can be compound. A buffer with filetype yaml.ghaction can be matched by ghaction, by yaml, or by the full yaml.ghaction, which the README presents as useful for actionlint together with vim.filetype patterns such as [".*/.github/workflows/.*%.yml"] = "yaml.ghaction". That is a small feature with real consequences: you can scope a linter to GitHub Actions workflows without touching every YAML file in the repository.

## Installing nvim-lint and running a first lint on save

The README requires Neovim >= 0.9.5 and describes nvim-lint as a regular plugin installable through the :h packages mechanism or a plugin manager. The packages route clones the repository into a start directory:

```bash
git clone \
    https://codeberg.org/mfussenegger/nvim-lint.git
    ~/.config/nvim/pack/plugins/start/nvim-lint
```

Plugin manager users get two one-line options from the README: Plug 'mfussenegger/nvim-lint' for vim-plug, and use 'mfussenegger/nvim-lint' for packer.nvim. The linter binary itself is not installed by the plugin. You install vale, eslint or whatever tool you configure, separately.

Configuration is a Lua table keyed by filetype. The README's example pairs markdown with vale:

```lua
require('lint').linters_by_ft = {
  markdown = {'vale'},
}
```

To find the filetype of the buffer you are in, the README gives the command := vim.bo.filetype. Nothing lints until you trigger it. The README shows a Vimscript autocmd, au BufWritePost * lua require('lint').try_lint(), and the equivalent in Lua:

```lua
vim.api.nvim_create_autocmd({ "BufWritePost" }, {
  callback = function()
    require("lint").try_lint()
    require("lint").try_lint("cspell")
  end,
})
```

The first call runs the linters listed for the current filetype. The second always runs cspell, independent of the table. After saving a file that vale or cspell has something to say about, the messages should appear through vim.diagnostic. If nothing appears, check the filetype key first: a linter registered under markdown never fires in a buffer whose filetype is something else.

Some linters need the file on disk, while others accept stdin input. The README notes that for stdin-capable linters you can attach a more aggressive autocmd, for example on InsertLeave or TextChanged, instead of waiting for a write.

## The security boundary: try_lint executes project-local binaries

This is the part worth reading twice. The README states that some linters prioritise an executable relative to the current working directory over the one in $PATH, and gives eslint as the example: it will use ./node_modules/.bin/eslint if that file exists. The executable runs with your user's permissions. The README's instruction follows directly: you must not call try_lint() in untrusted repositories. A BufWritePost autocmd, which is the most common setup, means opening and saving a file in a cloned repository is enough to trigger it. The suggested mitigation is the wrap_linter option, which lets you rewrite a linter's command before it runs. The README's example wraps linters with systemd-run, setting --user, --collect, --same-dir, --quiet and --pipe, then applying PrivateUsers=true, ProtectSystem=true and PrivateNetwork=true, binding the working directory read-only, and forwarding PATH. The wrapper is passed as the second argument to try_lint:

```lua
lint.try_lint(nil, {
    wrap_linter = systemd_run
})
```

The README also mentions bubblewrap as an alternative sandbox. Either way, wrapping is opt-in and per-call, not a global default. If you write a plain BufWritePost autocmd and never pass wrap_linter, you get the unwrapped behaviour.

## The built-in linter table and the compiler fallback

nvim-lint ships a long list of linter definitions, so for most tools you do not write a command and a parser yourself. The README's table includes actionlint, alex, ameba, ansible_lint, bandit, bash, bean_check, biomejs, blocklint, buf_lint, buildifier, cfn_lint, cfn_nag, checkbashisms, checkmake, checkpatch, checkstyle, chktex, clangtidy, clazy, clippy, clj-kondo, cmakelint, cmake_lint, codespell, commitlint, cppcheck, cpplint, credo, dialyxir, cspell, cue, curlylint, dash, dclint, deadnix, deno, detect-secrets, detekt, dmypy, dxc and djlint, among others. The names are not always the tool's name: ansible-lint is ansible_lint, cfn-lint is cfn_lint, clang-tidy is clangtidy. You configure the string that appears in the table, not the binary name. There is also a generic linter called compiler, which uses the makeprg and errorformat options of the current buffer. That is the escape hatch for any tool without a dedicated definition: set makeprg to your command, set errorformat to match its output, and register compiler. It is also the weakest option, because the quality of the diagnostics depends entirely on how well you write the errorformat pattern.

## Where nvim-lint is the wrong tool

The plugin deliberately does not manage linter installation. If you expect a single configuration to fetch eslint, vale and clippy for you, this is not that. Mason and similar tools cover installation; nvim-lint only runs what is already on the machine. The second limit is the absence of a scheduler. Linting happens when you call try_lint, and the README's examples are autocmds, so the frequency is your choice and your problem. A TextChanged autocmd on a slow linter will spawn processes on every keystroke pause, and the README does not offer debouncing or a queue. Third, the security model puts the burden on you: the README's warning is explicit, and the wrap_linter example is something you have to copy into your config. Finally, if you want formatting as well as linting, nvim-lint does not format anything. Its scope ends at spawning linters and reporting diagnostics, and the README never claims otherwise.

## nvim-lint versus conform.nvim and ale

The comparison that comes up most often is with conform.nvim, and the difference is the job. conform.nvim is a formatter runner: it invokes tools that rewrite buffers, and it can be configured to run them on save. nvim-lint is a linter runner: it invokes tools that report problems and never modifies the buffer. They overlap only in the plumbing of spawning a process and reading its output, which is why people run both, one for formatting and one for diagnostics. The comparison with ale is different. ale bundles its own language server client, so it can replace your LSP setup. nvim-lint does not implement a language server client at all, and the README frames it as complementary to the built-in one. That means nvim-lint is not a drop-in replacement for ale: if you rely on ale to start language servers, switching to nvim-lint means moving that responsibility to Neovim's built-in client or to a separate plugin. The payoff is a smaller surface: no server management, no protocol implementation, just linter definitions and a diagnostic bridge.

## Maintenance, licence and the cost of upgrading

The repository is not archived, and the most recent push recorded for it is 2026-08-25. The licence is GPL-3.0, which is a copyleft licence; if you redistribute nvim-lint or a modified version, the GPL's terms apply to that distribution, and this is a factual statement about the licence identifier rather than legal advice. For most users the plugin is loaded as-is, so the practical question is upgrade cost. Two things make upgrades cheap here. First, the interface you configure is a plain Lua table and a single function call, try_lint, so there is very little API to break. Second, linter definitions live in the plugin, so a linter whose command-line flags change upstream is fixed by updating the plugin rather than by editing your own command. The cost sits on the other side: because definitions are bundled, an upstream flag change can break your linting until you update, and the README does not document a deprecation policy or a rollback path for linter definitions. The repository ships a rockspec, nvim-lint-scm-1.rockspec, and a spec directory with .busted configuration, which indicates the project carries a test suite, though the README does not describe how to run it.

## Conclusion

Adopt nvim-lint if you work in filetypes that lack a language server, or if a standalone linter gives better output than the one bundled with your LSP. Skip it if you already have ale configured and do not care that ale also ships its own language server client, or if you need linting inside untrusted repositories and are not prepared to wrap the linter with bubblewrap or systemd-run. Before committing, verify three things: that your Neovim is at least 0.9.5, that the linter binary you plan to configure appears in the built-in linter table under the exact name, and whether that linter resolves its executable relative to the working directory, which decides whether try_lint is safe to call on BufWritePost in a cloned project.

## FAQ

### What is nvim-lint?

It is an asynchronous linter plugin for Neovim, version 0.9.5 or newer, that spawns external linters, parses their output and reports the results through the vim.diagnostic module. The README describes it as complementary to Neovim's built-in Language Server Protocol support rather than a replacement for it.

### How do I use nvim-lint?

Set require('lint').linters_by_ft to map filetypes to linter names, then call require('lint').try_lint() from an autocmd such as BufWritePost. The README's example maps markdown to vale and triggers linting on write.

### How does nvim-lint differ from ale?

The README states that ale also includes its own language server client, while nvim-lint has a narrower scope: it spawns linters, parses their output and reports through vim.diagnostic, complementing the built-in language server client.

### Does nvim-lint replace the LSP in Neovim?

No. The README presents it as complementary to the built-in Language Server Protocol support, for languages where there is no language server or where standalone linters give better results.

## Sources

- [Issues](https://github.com/mfussenegger/nvim-lint/issues)
- [License: GPL-3.0](https://github.com/mfussenegger/nvim-lint/blob/master/LICENSE)
- [mfussenegger/nvim-lint on GitHub](https://github.com/mfussenegger/nvim-lint)
- [Project website](https://codeberg.org/mfussenegger/nvim-lint)
- [README](https://github.com/mfussenegger/nvim-lint/blob/master/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/mfussenegger-nvim-lint
