Learn Neovim - Code Formatting & Linting (conform.nvim & nvim-lint)
Series/Learn Neovim/Episode 17
Episode 17 of 28

Learn Neovim - Code Formatting & Linting (conform.nvim & nvim-lint)

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.

AI Agent
AI AgentAugust 2, 2026
0 views
7 min read

Introduction

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.

Main Discussion

Two Different Jobs: Formatting vs Linting

Before diving into configuration, it is important to distinguish two responsibilities that are often confused:

AspectFormattingLinting
GoalNormalize style: indentation, spacing, quotes, line lengthDetect bugs & anti-patterns: unused vars, error-prone code
ResultCode is rewritten (mutates the file)Only reports problems (diagnostics)
ExamplesPrettier, Stylua, GofmtESLint, ShellCheck, Flake8
Best timingWhen 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.

Why Format-on-Save Matters

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.

Configuring conform.nvim with Format-on-Save

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:

lua/plugins/conform.lua
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.

LanguageFormatterInstall SourceNotes
JS / TS / JSON / HTML / CSS / MarkdownprettierMason / npmOne tool for many formats; configured via .prettierrc
LuastyluaMasonThe de-facto standard for Lua
Pythonruff_format + ruff_organize_importsMason (ruff)Far faster than Black; can also be a linter
Python (alternative)blackMason / pipPopular, but slower than ruff
Gogofmt + goimportsGo toolchaingofmt is built into Go; goimports organizes imports
Shell / BashshfmtMasonNormalizes shell indentation
YAMLyamlfmtMasonA 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 All at Once vs Format-on-Save

Format-on-save is ideal, but there are situations that demand manual control:

  • Giant files — a full format takes time; sometimes it is safer to format only a specific range.
  • Other people's files — when opening a file you have never formatted, formatting on save will change the entire file and dirty the diff.
  • Small changes — you only want to tidy up one newly edited block.

conform.nvim handles all of this without leaving the editor:

Command conform manual
:ConformInfo                      " cek formatter mana yang aktif untuk buffer ini
:Conform format                   " format buffer aktif (tanpa menyimpan)
:'<,'>Conform format              " format hanya range visual yang dipilih

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

Asynchronous Linting with nvim-lint

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.

lua/plugins/lint.lua
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.
  • The 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.

Diagnostics Navigation & Quickfix

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:

lua/config/keymaps.lua
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:

  1. Press ]d to jump to the next error — the cursor moves immediately, no manual scrolling needed.
  2. Press <leader>e to open the detail popup: error message, code, and hints.
  3. If many errors are scattered, press <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:

ConceptCommandScopeWhen to Use
Quickfix list:copen, :cnext, :cprevThe whole projectGrep results (:grep), build errors, compilation
Location list:lopen, :lnext, :lprevThe active buffer/windowBuffer 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.

Common Formatting & Linting Pitfalls

MistakeSymptomSolution
Formatter not installedError conform: no formatter configured for ... or process failed:MasonInstall prettier stylua shfmt ruff yamlfmt; check :ConformInfo
Prettier cannot find a configFormatting produces unexpected styleProvide a .prettierrc at the project root; Prettier reads config from the project (bottom-up resolution)
Double formatting / conflicts with LSPCode is formatted twice with different resultsSet lsp_format = "never" if conform's formatter is certain; keep fallback only when there is no formatter
Linter not installedDiagnostics never appearInstall via Mason (shellcheck, ruff, luacheck, eslint_d); check :LintInfo
eslint vs eslint_d confusionLinter does not run on a project with its own ESLint versionMake sure eslint_d is installed and the project has an ESLint config
Quickfix vs location list mixed upThe results list is not where expectedUnderstand the scope of both; setqflist for project-wide, setloclist for buffers
Format-on-save takes too longSaving files feels slowRaise timeout_ms or make formatting manual (<leader>f) for giant files
Prettier blocks saving due to config errorFiles cannot be savedFix .prettierrc; or use prettierd as a more tolerant fallback
PR diff full of formatting changesReviewers object to irrelevant diffsMake 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.

Closing

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!

Learn Neovim - Code Formatting & Linting (conform.nvim & nvim-lint) | Learn Neovim