In this episode we automate code formatting on file save with conform.nvim and run asynchronous static analysis with nvim-lint, finishing with efficient diagnostics navigation.

After building responsive autocompletion with nvim-cmp and LuaSnip in episode 16, in this episode we will complete the code quality puzzle: automatic formatting and asynchronous linting with conform.nvim and nvim-lint.
In the real world, format-on-save and linting are part of a team's code culture. Imagine joining a large codebase with 10 contributors: without a formatter, each person writes 2-space or 4-space indentation, single or double quotes, and pull request diffs are filled with irrelevant changes. Mature teams use a formatter to remove style arguments from code review discussions — debate is focused on logic, not spacing. Meanwhile, a linter works as a silent reviewer: catching unused variables, conditions that can never be true, or dangerous patterns before the code reaches CI. Making both run automatically in the editor is the standard of modern engineering — and all of it can live inside Neovim.
Before diving into configuration, it is important to distinguish two responsibilities that are often confused:
| Aspect | Formatting | Linting |
|---|---|---|
| Goal | Normalize style: indentation, spacing, quotes, line length | Detect bugs & anti-patterns: unused vars, error-prone code |
| Result | Code is rewritten (mutates the file) | Only reports problems (diagnostics) |
| Examples | Prettier, Stylua, Gofmt | ESLint, ShellCheck, Flake8 |
| Best timing | When saving a file (BufWritePre) | Continuously, without blocking typing |
Formatting changes code, linting only flags it. The two complement each other: code can pass formatting but still have problems, and vice versa.
This seemingly trivial feature solves a consistency problem that is actually complicated socially: when is formatting done? If the formatter only runs on a specific command, humans will forget — and the diff stays messy. With format-on-save, the rule is deterministic: every saved file will be formatted. No more "oh, this PR adds 40 lines of formatting changes". This is automation over negotiation — policy enforced by machines, not expected from human discipline.
conform.nvim by stevearc is a modern formatter runner: it runs external formatter binaries (Prettier, Stylua, and others) asynchronously and writes their results into the buffer. Here is the complete spec:
return {
{
"stevearc/conform.nvim",
event = { "BufWritePre" },
cmd = { "ConformInfo" },
keys = {
{
"<leader>f",
function()
require("conform").format({ async = true })
end,
desc = "Format file",
},
},
opts = {
formatters_by_ft = {
lua = { "stylua" },
javascript = { "prettier" },
typescript = { "prettier" },
javascriptreact = { "prettier" },
typescriptreact = { "prettier" },
json = { "prettier" },
html = { "prettier" },
css = { "prettier" },
scss = { "prettier" },
python = { "ruff_format", "ruff_organize_imports" },
go = { "goimports", "gofmt" },
sh = { "shfmt" },
yaml = { "yamlfmt" },
markdown = { "prettier" },
},
format_on_save = {
timeout_ms = 2000,
lsp_format = "fallback",
},
},
},
}Dissecting the key points:
formatters_by_ft — a filetype → formatter list map. The list is executed in order from left to right: for Python, ruff_format runs formatting first, then ruff_organize_imports tidies up imports. For Go, goimports organizes imports first, then gofmt normalizes the format.format_on_save — triggers formatting on every BufWritePre. timeout_ms = 2000 gives a maximum of 2 seconds before notifying the user, and lsp_format = "fallback" means: if no formatter matches the filetype, try using the language server's (LSP) formatting. This is the safety net that keeps new file types formatted without extra configuration.event = { "BufWritePre" } — the plugin is only loaded when you save a file, because format-on-save is the only reason this plugin is needed. Startup time stays minimal.Note
Note that formatters run as external processes. conform.nvim does not write its own formatting code — it calls binaries like prettier or stylua that must already exist on the system. This is where Mason (episode 15) plays its role: :MasonInstall prettier stylua shfmt yamlfmt ruff installs everything from one marketplace. Make sure formatters are installed before expecting formatting to work.
| Language | Formatter | Install Source | Notes |
|---|---|---|---|
| JS / TS / JSON / HTML / CSS / Markdown | prettier | Mason / npm | One tool for many formats; configured via .prettierrc |
| Lua | stylua | Mason | The de-facto standard for Lua |
| Python | ruff_format + ruff_organize_imports | Mason (ruff) | Far faster than Black; can also be a linter |
| Python (alternative) | black | Mason / pip | Popular, but slower than ruff |
| Go | gofmt + goimports | Go toolchain | gofmt is built into Go; goimports organizes imports |
| Shell / Bash | shfmt | Mason | Normalizes shell indentation |
| YAML | yamlfmt | Mason | A faster alternative to prettier for YAML |
Note
Notice the difference between two big formatter categories. "Universal" formatters like Prettier are configured via files at the project root (.prettierrc, package.json) — they follow the project standard, not the editor standard. Meanwhile, per-language formatters like Stylua and Gofmt are deterministic (Gofmt is even famous for having no configuration options at all — that is the "one way to format Go" philosophy). When you move between projects, it is the universal formatters that make your editor adapt, not the other way around.
Format-on-save is ideal, but there are situations that demand manual control:
conform.nvim handles all of this without leaving the editor:
:ConformInfo " cek formatter mana yang aktif untuk buffer ini
:Conform format " format buffer aktif (tanpa menyimpan)
:'<,'>Conform format " format hanya range visual yang dipilihCombined with the <leader>f keymap we defined in the spec, you have two modes: automatic on save for routine work, and manual when precision is needed. This flexible policy is usually preferred by teams over forcing format-on-save without exception.
Tip
For teams applying "don't format other people's code", a widely used pattern: disable format-on-save globally, enable it only for certain filetypes via an autocmd, or leave manual <leader>f as the only trigger. Discuss this policy with your team and put it into a shared configuration (dotfiles) so all developers stay consistent.
If a formatter rewrites code, a linter reads and reports. nvim-lint is a lightweight asynchronous linter runner: it does not build its own UI, but instead routes linter results into Neovim's built-in diagnostics system — the same window used by LSP in episode 15.
return {
{
"mfussenegger/nvim-lint",
event = { "BufReadPost", "BufWritePost" },
config = function()
local lint = require("lint")
lint.linters_by_ft = {
javascript = { "eslint_d" },
typescript = { "eslint_d" },
javascriptreact = { "eslint_d" },
typescriptreact = { "eslint_d" },
sh = { "shellcheck" },
python = { "ruff" },
lua = { "luacheck" },
}
vim.api.nvim_create_autocmd({ "BufWritePost", "BufEnter" }, {
callback = function()
lint.try_lint()
end,
})
end,
},
}Dissecting the logic:
linters_by_ft — a filetype → linter map. eslint_d is the daemon version of ESLint, far faster for repeated re-runs; shellcheck for shell; ruff for Python (two roles: formatter and linter); luacheck for Lua.autocmd with try_lint() — runs linting when the file is saved (BufWritePost) and when the buffer is entered (BufEnter). try_lint() is an idempotent function: if the linter is unavailable or already running, it will not duplicate work.Important
nvim-lint's main strength is asynchronicity. Linters run outside Neovim's event loop, so typing never stutters — results appear as colored lines in the gutter (the signcolumn we set up in episode 7) without freezing the editor. This contrasts with running linters synchronously in the terminal, which forces you to wait for the process to finish.
Now there are two diagnostic sources: LSP (episode 15) and nvim-lint — both feed into the same system. Because of that, all diagnostic navigation keymaps use the universal vim.diagnostic API:
local map = vim.keymap.set
map("n", "[d", vim.diagnostic.goto_prev, { desc = "Diagnostic sebelumnya" })
map("n", "]d", vim.diagnostic.goto_next, { desc = "Diagnostic berikutnya" })
map("n", "<leader>e", vim.diagnostic.open_float, { desc = "Detail diagnostic" })
map("n", "<leader>q", vim.diagnostic.setloclist, { desc = "Semua diagnostic ke list" })A productive diagnostics workflow:
]d to jump to the next error — the cursor moves immediately, no manual scrolling needed.<leader>e to open the detail popup: error message, code, and hints.<leader>q to send them all to the location list and view them as a filtered list below.The difference between two "lists" in Vim/Neovim that often confuses:
| Concept | Command | Scope | When to Use |
|---|---|---|---|
| Quickfix list | :copen, :cnext, :cprev | The whole project | Grep results (:grep), build errors, compilation |
| Location list | :lopen, :lnext, :lprev | The active buffer/window | Buffer diagnostics (our case), per-window results |
Tip
Because setloclist fills the location list (scoped per-window), the diagnostics list does not mix with the quickfix used by other processes like grep. If you want everything to go into the global quickfix, replace it with vim.diagnostic.setqflist — both choices are valid, just adapt to your team's workflow.
| Mistake | Symptom | Solution |
|---|---|---|
| Formatter not installed | Error conform: no formatter configured for ... or process failed | :MasonInstall prettier stylua shfmt ruff yamlfmt; check :ConformInfo |
| Prettier cannot find a config | Formatting produces unexpected style | Provide a .prettierrc at the project root; Prettier reads config from the project (bottom-up resolution) |
| Double formatting / conflicts with LSP | Code is formatted twice with different results | Set lsp_format = "never" if conform's formatter is certain; keep fallback only when there is no formatter |
| Linter not installed | Diagnostics never appear | Install via Mason (shellcheck, ruff, luacheck, eslint_d); check :LintInfo |
eslint vs eslint_d confusion | Linter does not run on a project with its own ESLint version | Make sure eslint_d is installed and the project has an ESLint config |
| Quickfix vs location list mixed up | The results list is not where expected | Understand the scope of both; setqflist for project-wide, setloclist for buffers |
| Format-on-save takes too long | Saving files feels slow | Raise timeout_ms or make formatting manual (<leader>f) for giant files |
| Prettier blocks saving due to config error | Files cannot be saved | Fix .prettierrc; or use prettierd as a more tolerant fallback |
| PR diff full of formatting changes | Reviewers object to irrelevant diffs | Make sure format-on-save is active before writing code; do not reformat old files without team coordination |
Warning
One trap that often frustrates developers: Prettier and ESLint sometimes recommend conflicting styles (for example regarding semicolons or trailing commas). If both are enabled at once, diffs can fluctuate. The standard modern team solution: let Prettier (the formatter) handle style, and disable all format rules in ESLint (eslint-plugin-prettier or setting format rules to off). The principle: one tool for one responsibility.
In episode 17 we built two pillars of code quality: conform.nvim for deterministic format-on-save with a per-language formatter map (Prettier, Stylua, Ruff, Gofmt, shfmt), and nvim-lint for asynchronous static analysis (ESLint, ShellCheck, Ruff, Luacheck) that merges into Neovim's diagnostics system. We also mastered diagnostics navigation ([d, ]d, <leader>e, <leader>q) and the difference between quickfix vs location lists.
Your code is now not only complete and semantically correct, but also stylistically consistent and clean in quality — three standards at once that would normally require three separate tools in a commercial IDE.
There is still one dimension we have not touched on this journey: collaboration. All the work in our editor so far has been individual. In the real world, code is a team product living in version control.
In episode 18, we will cover Git Integration Inside Neovim with gitsigns.nvim and diffview.nvim — seeing code changes directly in the gutter, inline blame, staging/resetting hunks, all the way to resolving merge conflicts without leaving the editor. Stay motivated!