How To Setup Linting & Formatting In Neovim And Replace null-ls
You can find my current Neovim config and the rest of my dotfiles here: dotfiles
Basic Folder Structure
I’m using the lazy.nvim plugin manager in my Neovim config and my folder structure looks something like the following:
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
Whenever I mention [name], replace it with your actual name (so ”[name]” => “josean” in my case)
If you’re on mac or linux the config should be in ~/.config/nvim
and on Windows it should be in ~/AppData/Local/nvim/
If you’d like to learn more about using lazy.nvim, checkout my youtube video on that: How To Use lazy.nvim
init.lua
The ~/.config/nvim/init.lua
file is the most important as this runs whenever Neovim starts:
~/.config/nvim
├── init.lua
In this file I load the ”[name].lazy” module like so:
Remember to replace [name] with your actual name
require("[name].core")
require("[name].lazy")
This module is located in ~/.config/nvim/lua/[name]/lazy.lua
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
In this file I bootstrap lazy.nvim and setup the lazy.nvim plugin.
Setting up lazy.nvim
The lazy.lua
file should first bootstrap lazy.nvim to
install it if not already installed whenever neovim starts up:
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
"git",
"clone",
"--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable", -- latest stable release
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
After that we should call the setup function passing lua modules that will contain all of our plugin specs/configurations.
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
"git",
"clone",
"--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable", -- latest stable release
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
require("lazy").setup({ { import = "[name].plugins" }, { import = "[name].plugins.lsp" } })
Replace [name] with your actual name (example: ”[name]” => “josean”)
These two modules [name].plugins
and [name].plugins.lsp
correspond to these two folders
that contain files with plugin configurations within them.
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
├── lsp/
│ ├── lspconfig.lua
├── alpha-nvim.lua
├── auto-session.lua
├── bufferline.lua
├── colorizer.lua
├── ....
Setup Mason
Let’s now setup the Mason plugin to be able to install the linters and formatters we need. You can also use this plugin to install language servers.
Add a mason.lua
file under ~/.config/nvim/lua/[name]/plugins/lsp/
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
├── lsp/
│ ├── lspconfig.lua
│ └── mason.lua
├── alpha-nvim.lua
├── auto-session.lua
├── bufferline.lua
├── colorizer.lua
├── ....
Add the following to mason.lua
to setup the mason plugin with lazy.nvim:
return {
"williamboman/mason.nvim",
config = function()
-- import mason
local mason = require("mason")
-- enable mason and configure icons
mason.setup({
ui = {
icons = {
package_installed = "✓",
package_pending = "➜",
package_uninstalled = "✗",
},
},
})
end,
}
Open the Lazy ui with :Lazy
and pressing enter. Then press i
to install the missing mason plugin.
Formatting With conform.nvim
Let’s setup formatting with conform.nvim
Add a formatting.lua
file under ~/.config/nvim/lua/[name]/plugins/
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
├── lsp/
│ ├── lspconfig.lua
│ └── mason.lua
├── alpha-nvim.lua
├── auto-session.lua
├── bufferline.lua
├── colorizer.lua
├── formatting.lua
├── ....
Return a table from this file with the short plugin url for conform.nvim:
return {
"stevearc/conform.nvim",
}
Then add an event
field with the BufReadPre
and BufNewFile
events.
return {
"stevearc/conform.nvim",
event = { "BufReadPre", "BufNewFile" },
}
This is so that we lazy load conform.nvim when we open a buffer either for an already existing file or for a new file as we only need formatting when working in a buffer.
Do :help events
to see a list of all available Neovim events
Now add a config function to setup the plugin once it loads and require the plugin.
return {
"stevearc/conform.nvim",
event = { "BufReadPre", "BufNewFile" },
config = function()
local conform = require("conform")
end,
}
After requiring the plugin we can call conform.setup
and configure the filetypes we want to setup formatting for like so:
return {
"stevearc/conform.nvim",
event = { "BufReadPre", "BufNewFile" },
config = function()
local conform = require("conform")
conform.setup({
formatters_by_ft = {
javascript = { "prettier" },
typescript = { "prettier" },
javascriptreact = { "prettier" },
typescriptreact = { "prettier" },
svelte = { "prettier" },
css = { "prettier" },
html = { "prettier" },
json = { "prettier" },
yaml = { "prettier" },
markdown = { "prettier" },
graphql = { "prettier" },
lua = { "stylua" },
python = { "isort", "black" },
},
})
end,
}
As you can see we’re configuring several different formatters and filetypes here:
- prettier: javascript, typescript, css, html, json, etc…
- stylua: lua
- isort & black: python
For a list of all available formatters see: Available Formatters
If you specify more than one formatter, they will be executed in the order you list them like we’re doing for python.
return {
"stevearc/conform.nvim",
event = { "BufReadPre", "BufNewFile" },
config = function()
local conform = require("conform")
conform.setup({
formatters_by_ft = {
javascript = { "prettier" },
typescript = { "prettier" },
javascriptreact = { "prettier" },
typescriptreact = { "prettier" },
svelte = { "prettier" },
css = { "prettier" },
html = { "prettier" },
json = { "prettier" },
yaml = { "prettier" },
markdown = { "prettier" },
graphql = { "prettier" },
lua = { "stylua" },
python = { "isort", "black" },
},
})
end,
}
If you use a subtable like so: python = {{ "isort", "black" }}
then
the first available formatter is the only one that will execute.
Next, add a format_on_save
field to apply formatting when saving a file.
return {
"stevearc/conform.nvim",
event = { "BufReadPre", "BufNewFile" },
config = function()
local conform = require("conform")
conform.setup({
formatters_by_ft = {
javascript = { "prettier" },
typescript = { "prettier" },
javascriptreact = { "prettier" },
typescriptreact = { "prettier" },
svelte = { "prettier" },
css = { "prettier" },
html = { "prettier" },
json = { "prettier" },
yaml = { "prettier" },
markdown = { "prettier" },
graphql = { "prettier" },
lua = { "stylua" },
python = { "isort", "black" },
},
format_on_save = {
lsp_fallback = true,
async = false,
timeout_ms = 500,
},
})
end,
}
lsp_fallback = true
tells conform.nvim to use the lsp if no formatter is available.async = false
means to not do asynchronous formatting.timeout_ms = 500
means to timeout after 500ms if formatting isn’t finished
Some formatters may take longer than 500ms, you could change this to 1000ms which is the default
Now add a keymap to execute formatting with the same options as formatting on save.
return { "stevearc/conform.nvim",
event = { "BufReadPre", "BufNewFile" },
config = function()
local conform = require("conform")
conform.setup({
formatters_by_ft = {
javascript = { "prettier" },
typescript = { "prettier" },
javascriptreact = { "prettier" },
typescriptreact = { "prettier" },
svelte = { "prettier" },
css = { "prettier" },
html = { "prettier" },
json = { "prettier" },
yaml = { "prettier" },
markdown = { "prettier" },
graphql = { "prettier" },
lua = { "stylua" },
python = { "isort", "black" },
},
format_on_save = {
lsp_fallback = true,
async = false,
timeout_ms = 500,
},
})
vim.keymap.set({ "n", "v" }, "<leader>mp", function()
conform.format({
lsp_fallback = true,
async = false,
timeout_ms = 500,
})
end, { desc = "Format file or range (in visual mode)" })
end,
}
Now you can execute formatting by doing <leader>mp
.
In normal mode it will apply to the whole file, in visual mode it will apply to the current selection.
If formatting on save doesn’t seem to be working, try <leader>mp
and make sure that the timeout isn’t being reached.
There are other possible reasons like the formatter not being installed.
Install the formatters
Open the Mason UI with :Mason
and press 5
on your keyboard to go to the formatters section.
Hover a formatter with the cursor and press i
to install it.
Once installed, to uninstall, hover over it and press X
In my case I need:
- prettier
- stylua
- isort
- black
One really nice feature of conform.nvim is that with formatters like prettier
,
it will check if you have it in your node_modules
and use that instead. This
comes in handy when you need to use a specific version of prettier in your project.
Using conform.nvim will also respect any formatter config files
Install & Load conform.nvim
This is optional, you can also exit and reenter Neovim.
Open the Lazy ui with :Lazy
and press I
to install the missing conform.nvim plugin.
Close the ui with q
After closing run :Lazy load conform.nvim
to load the plugin.
Now you can see the formatting at work whenever you save your lua files or do <leader>mp
.
Setup linting with nvim-lint
Let’s setup linting with nvim-lint
Add a linting.lua
file under ~/.config/nvim/lua/[name]/plugins/
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
├── lsp/
│ ├── lspconfig.lua
│ └── mason.lua
├── alpha-nvim.lua
├── auto-session.lua
├── bufferline.lua
├── colorizer.lua
├── formatting.lua
├── linting.lua
├── ....
Return a table from this file with the short plugin url for conform.nvim:
return {
"mfussenegger/nvim-lint",
}
Then add an event
field with the BufReadPre
and BufNewFile
events.
return {
"mfussenegger/nvim-lint",
event = { "BufReadPre", "BufNewFile" },
}
Now add a config function and require the nvim-lint
plugin.
return {
"mfussenegger/nvim-lint",
event = {
"BufReadPre",
"BufNewFile",
},
config = function()
local lint = require("lint")
end,
}
Next set lint.linters_by_ft
to a list of file types with the linters you want to use for them.
return {
"mfussenegger/nvim-lint",
event = {
"BufReadPre",
"BufNewFile",
},
config = function()
local lint = require("lint")
lint.linters_by_ft = {
javascript = { "eslint_d" },
typescript = { "eslint_d" },
javascriptreact = { "eslint_d" },
typescriptreact = { "eslint_d" },
svelte = { "eslint_d" },
python = { "pylint" },
}
end,
}
In this case I’m using eslint_d
for web dev related file types in my projects and pylint
for python files.
To look at a list of available linters see: Available Linters
Now add a Neovim autocommand group and autocommand to execute linting on a set of Neovim events.
return {
"mfussenegger/nvim-lint",
event = {
"BufReadPre",
"BufNewFile",
},
config = function()
local lint = require("lint")
lint.linters_by_ft = {
javascript = { "eslint_d" },
typescript = { "eslint_d" },
javascriptreact = { "eslint_d" },
typescriptreact = { "eslint_d" },
svelte = { "eslint_d" },
python = { "pylint" },
}
local lint_augroup = vim.api.nvim_create_augroup("lint", { clear = true })
vim.api.nvim_create_autocmd({ "BufEnter", "BufWritePost", "InsertLeave" }, {
group = lint_augroup,
callback = function()
lint.try_lint()
end,
})
end,
}
This means that whenever the BufEnter
, BufWritePost
, and InsertLeave
events happen,
linting will execute with lint.try_lint()
.
To see a list of available Neovim events do: :help events
Only some linters support linting from stdin
and don’t require you to save to the file. Only for these linters
will events like InsertLeave
or TextChanged
work properly. For example, these events work for eslint_d
, but not for pylint
.
To check for this take a look at the linter config
here: Linter Configs and check
that stdin=true
for your particular linter
Now let’s add a keymap to execute linting.
return {
"mfussenegger/nvim-lint",
event = {
"BufReadPre",
"BufNewFile",
},
config = function()
local lint = require("lint")
lint.linters_by_ft = {
javascript = { "eslint_d" },
typescript = { "eslint_d" },
javascriptreact = { "eslint_d" },
typescriptreact = { "eslint_d" },
svelte = { "eslint_d" },
python = { "pylint" },
}
local lint_augroup = vim.api.nvim_create_augroup("lint", { clear = true })
vim.api.nvim_create_autocmd({ "BufEnter", "BufWritePost", "InsertLeave" }, {
group = lint_augroup,
callback = function()
lint.try_lint()
end,
})
vim.keymap.set("n", "<leader>l", function()
lint.try_lint()
end, { desc = "Trigger linting for current file" })
end,
}
This will run lint.try_lint()
whenever we use <leader>l
in Normal mode.
Install the Linters
Now open Mason with :Mason
and press 4
on your keyboard to go the Linting section.
Now you can hover the linter you need with the cursor and press i
to install it.
If you need to uninstall one, hover over it and use X
In my case I need:
- eslint_d
- pylint
Now you’re read to open a new project where linting is properly configured and see any linting errors you might have through nvim-lint
Automatically Installing Linters Through Mason
I used to use mason-null-ls to auto install linters and formatters when starting up Neovim, but you need to use the archived null-ls plugin for this to work properly.
So to replace this plugin you can use mason-tools-installer
To add this to the config open up your mason.lua
file
~/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua/
└── [name]/
├── core/
├── lazy.lua
└── plugins/
├── lsp/
│ ├── lspconfig.lua
│ └── mason.lua
├── alpha-nvim.lua
├── auto-session.lua
├── bufferline.lua
├── colorizer.lua
├── formatting.lua
├── linting.lua
├── ....
And now add the following:
return {
"williamboman/mason.nvim",
dependencies = {
"WhoIsSethDaniel/mason-tool-installer.nvim",
},
config = function()
local mason = require("mason")
local mason_tool_installer = require("mason-tool-installer")
-- enable mason and configure icons
mason.setup({
ui = {
icons = {
package_installed = "✓",
package_pending = "➜",
package_uninstalled = "✗",
},
},
})
mason_tool_installer.setup({
ensure_installed = {
"prettier", -- prettier formatter
"stylua", -- lua formatter
"isort", -- python formatter
"black", -- python formatter
"pylint", -- python linter
"eslint_d", -- js linter
},
})
end,
}
This adds mason-tool-installer as a dependency and uses a ensure_installed
list
so that Neovim automatically installs the listed linters and formatters whenever
it starts up.