How I Configure Formatting in Neovim
How I wire formatting in Neovim with conform.nvim, mapping filetypes to command-line formatters on save. A closer look at StyLua and the committed stylua.toml explains the formatting rules for Lua.
How I wire formatting in Neovim with conform.nvim, mapping filetypes to command-line formatters on save. A closer look at StyLua and the committed stylua.toml explains the formatting rules for Lua.
Formatting in this configuration runs through one plugin,
lua/plugins/conform.lua, and the language servers that could also format those
buffers are kept off that path. The only formatter-specific configuration file
in this Neovim configuration is stylua.toml, so the Lua half of the setup has
rules of its own. That makes Lua the natural example: one filetype, lua, from
the entry point to the tool to the output, then the rest of the table in one
pass.
Formatting runs through conform.nvim: one save hook, one filetype table, and
command-line formatters. The table maps twenty filetypes to tools, and lua
maps to stylua. StyLua reads a committed stylua.toml that fixes tabs, an
eighty-column target, single quotes where they do not need escaping, parentheses
on the calls that can omit them, and sorted require runs. Formatters run on the
write, and the configuration defines no other way to format.
lua/plugins/conform.lua is thirty-three lines: one plugin, one setup() call,
and two tables.
vim.pack.add({
{ src = 'https://github.com/stevearc/conform.nvim' },
})
require('conform').setup({
format_on_save = {
lsp_format = 'never',
timeout_ms = 5000,
},
formatters_by_ft = {
astro = { 'prettier' },
css = { 'prettier' },
graphql = { 'prettier' },
html = { 'prettier' },
javascript = { 'prettier' },
javascriptreact = { 'prettier' },
json = { 'prettier' },
jsonc = { 'prettier' },
markdown = { 'prettier' },
rust = { 'rustfmt' },
toml = { 'taplo' },
typescript = { 'prettier' },
typescriptreact = { 'prettier' },
yaml = { 'yamlfmt' },
go = { 'goimports', 'gofmt' },
lua = { 'stylua' },
python = { 'isort', 'black' },
sh = { 'shfmt' },
sql = { 'sqlfmt' },
},
})
The format_on_save table turns formatting on. Without it, conform would format
only when something calls conform.format(), and nothing else in this
configuration does. The two keys set how the automatic path behaves.
lsp_format = 'never' tells conform not to hand a write to a language server,
and timeout_ms = 5000 bounds how long a write can wait on a formatter. The
ceiling is five seconds because conform’s own default is one.
never is also conform’s default for lsp_format, so the line pins behavior
that already holds. It matters because language servers are enabled for many of
these filetypes, and the option is what keeps a write from falling through to
one.
The second table is the policy, each key a Neovim filetype, each value the
formatters to run for it. This post follows lua = { 'stylua' }.
A write to a Lua buffer makes its way to stylua through two decisions that
keep anything else out. One is conform’s lsp_format = 'never', which keeps the
attached server from getting a turn, whatever it advertises. The other is the
server’s own. lsp/lua_ls.lua, the spec for the one server that covers lua
files, disables the formatter lua_ls carries. Its settings table puts
format = { enable = false } under Lua, so the server would not format even
if it were asked.
StyLua itself is a binary outside Neovim, and it brings its own configuration
lookup. It searches for a file named stylua.toml or .stylua.toml starting at
the file’s directory and walking upward, so every Lua file inside this
configuration resolves to the single stylua.toml at the configuration root.
stylua.toml is nine lines and holds every formatting decision for this
configuration’s Lua.
column_width = 80
line_endings = "Unix"
indent_type = "Tabs"
indent_width = 2
quote_style = "AutoPreferSingle"
call_parentheses = "Always"
[sort_requires]
enabled = true
Every example below was produced or checked with the installed stylua, version 2.5.2, under this file.
column_width = 80 is the wrapping target, and it is a soft one. StyLua treats
it as a guide and will exceed it rather than break a line where the break would
not help. What happens at the edge shows in a long concatenation, which comes
back with the operator leading each continuation, one tab of indentation deep:
local joined = 'aaaa'
.. 'bbbb'
.. 'cccc'
.. 'dddd'
line_endings = "Unix" forces the LF newline. A file handed to stylua with CRLF
endings came back with LF, so the setting overrides whatever the input used.
indent_type = "Tabs" makes the indentation characters tabs, as the wrapped
example shows. indent_width = 2 does not set the width of a tab. It is
stylua’s count of one level of indentation when it decides where lines wrap, so
it reads alongside column_width rather than alongside the editor. The editor
happens to agree anyway. lua/config/options.lua sets tabstop to 2, so a tab
renders two columns wide and what stylua writes matches what the buffer shows.
quote_style = "AutoPreferSingle" prefers single quotes and falls back to
double quotes when they avoid an escape. local s = "double" comes back as
local s = 'double', and a string that contains a single quote keeps its double
quotes rather than escaping into 'it\'s'. The preference is decided per
string, not per file.
call_parentheses = "Always" sets the rule for Lua’s shorthand call syntax,
where a single string or single table argument can appear without parentheses.
Always writes the parentheses in, so print "hi" becomes print('hi') and
require "mod" becomes require('mod'), the shape the plugin files in this
configuration already use. Calls that already carry parentheses are left alone.
It is also stylua’s default, so the line makes the intended style explicit
rather than overriding the default.
The last block sorts. With [sort_requires] enabled = true, stylua reorders
runs of consecutive local assignments to a require() call, lexicographically
by the assigned name. Two requires written out of order:
local b = require('b')
local a = require('a')
come back reordered:
local a = require('a')
local b = require('b')
The sort applies only to that shape, and only within a run. Any line that is not
a local require assignment ends the run, so a run on the far side of a break
sorts on its own. A bare require() call is that other shape, and stylua leaves
it where it is regardless.
The configuration has both shapes. lua/plugins/nvim-cmp.lua carries a
consecutive pair, local cmp = require('cmp') and
local luasnip = require('luasnip'), already in the order sorting produces. On
the other side, the autocmds module makes two bare calls back to back:
local M = {}
function M.setup()
require('config.autocmds.highlight_yank').setup()
require('config.autocmds.text_wrap_spell').setup()
end
return M
They are consecutive, and sorting leaves them alone, because neither is a
local assignment. A --check run of stylua 2.5.2 over this file and the
others quoted here reports them all as already formatted.
Everything else in formatters_by_ft is the same mechanism under other tools.
Twelve of the twenty entries name prettier, covering astro, css, graphql,
html, javascript, json, jsonc, markdown, svelte, typescript, and both React
variants. Eight more name a program of their own: rustfmt, taplo, yamlfmt,
stylua, shfmt, sqlfmt, and the two pairs for Go and Python.
Two filetypes run more than one tool. Go maps to goimports then gofmt, and
Python to isort then black. conform runs the list in order from first to
last, and with stop_after_first unset, both entries run. A filetype the table
does not mention gets nothing. The configuration supplies no formatter for it,
and with lsp_format = 'never' Conform does not fall back to a language server.
Where the tools come from is lua/plugins/mason.lua. mason-tool-installer is
configured to install the command-line programs, thirteen in its list, and nine
of those are names this table uses: prettier, stylua, shfmt, sqlfmt,
taplo, yamlfmt, isort, black, and goimports. Two more are not in the
list. gofmt ships with the Go toolchain and rustfmt with Rust, so the table
expects them on the path from outside Mason. conform resolves each name against
what is available before a run, so a missing binary drops that entry rather than
failing the write.
The wiring ends at the write. No mapping in lua/config/keymaps/ formats a
buffer, nothing in the tree calls conform.format() by hand, and there is no
binding for vim.lsp.buf.format(), so the save hook is the only trigger the
configuration installs. One option shares the word without sharing the
mechanism. formatoptions carries an 'r' flag from lua/config/options.lua
that continues comment leaders while typing, and conform does not touch it.
The evidence here is of two kinds. The output blocks ran stylua 2.5.2 directly
on small inputs under this stylua.toml, and the --check results point the
same version at copies of real files. I did not save a file through Neovim and
watch conform run, so the save path rests on the configuration and conform’s
documented behavior, not on an observed run.
Formatting is configured through one save hook and one filetype table, with
StyLua as the detailed example. The formatter outputs and the --check results
were produced with the installed stylua and the committed config, leaving only
the integrated Neovim save path inferred from the configuration and Conform’s
behavior.