www-gem words

Neovim LSP, linting, and formatting: the clean way

#(Neo)vim

I’ve recently completely revisited my Neovim config and took the extra mile to also streamline how it manages LSP, linting, and formatters. I had enough to remind myself to maintain and sync three different files every time I want to add a new tool.
Here is how I’ve done it.

The files structure

My Neovim setup relies on:

  • mason-tool-installer.nvim to easily install and manage LSP servers, DAP servers, linters, and formatters. Note that i requires mason.nvim . Make sure you’re calling them like that in your plugin manager (I’m using Lazy.nvim):
		"mason-org/mason.nvim",
		lazy = false,
		config = true,
	},

	{
		"WhoIsSethDaniel/mason-tool-installer.nvim",
		dependencies = {
			"mason-org/mason.nvim",
		},

		event = "VeryLazy",

		config = function()
			require("config.mason-tools")

			vim.schedule(function()
				vim.cmd("MasonToolsInstall")
			end)
		end,
	},
  • nvim-lspconfig.lua nvim-lspconfig is taking care of LSP server default configurations for me
  • conform.lua is my formatter plugin

All these plugins are configured with a file placed in ~/.config/nvim/lua/config/. The thing is that they all called their own tool, but some are redundant across files which makes it annoying to maintain.
To simplify that, I’ve created a tools.lua file in the same folder.

tools.lua: the core of my new organization

This file (see code below) lists all the LSPs, linters, and formatters that the aforementioned plugins require. This is a template, feel free to add any more tools to the lsit.

The remaining task was to rewrite each plugin’s configuration so they can fetch what they need. I’ve copied each code below as well. You should just have to copy/paste them.

Just note that:

  • I use blink.cmp in nvim-lspconfig.lua. Adjust that if you use something else like nvim-cmp.
  • The nvim-lspconfig.lua is used to collect any specific configuration you want to overwrite. If you’re satisfied with the default, you don’t have to list any tool here.
local M = {}

M.languages = {
	lua = {
		filetypes = { "lua" },

		lsp = {
			server = "lua_ls",
			mason = "lua-language-server",
		},

		formatters = {
			{ name = "stylua", mason = "stylua" },
		},

		linters = {
			{ name = "luacheck", mason = "luacheck" },
		},
	},

	python = {
		filetypes = { "python" },

		lsp = {
			server = "basedpyright",
			mason = "basedpyright",
		},

		formatters = {
			{ name = "ruff_format", mason = "ruff" },
		},
}

M.global = {
	{ mason = "eslint_d" },
}

return M
local tools = require("config.tools").languages

local ensure = {}
local seen = {}

local function add(pkg)
	if pkg and not seen[pkg] then
		seen[pkg] = true
		table.insert(ensure, pkg)
	end
end

for _, lang in pairs(tools) do
	if lang.lsp then
		add(lang.lsp.mason)
	end

	for _, formatter in ipairs(lang.formatters or {}) do
		add(formatter.mason)
	end

	for _, linter in ipairs(lang.linters or {}) do
		add(linter.mason)
	end
end

for _, tool in ipairs(tools.global or {}) do
	add(tool.mason)
end

require("mason-tool-installer").setup({
	ensure_installed = ensure,
	-- run_on_start = true,
	-- auto_update = false,
	-- start_delay = 3000,
})
local capabilities = require("blink.cmp").get_lsp_capabilities()
local base = {
	capabilities = capabilities,
}
local tools = require("config.tools").languages

vim.api.nvim_create_autocmd("LspAttach", {
	callback = function(args)
		local client = vim.lsp.get_client_by_id(args.data.client_id)

		if client:supports_method() and client:supports_method("textDocument/codeLens") then
			vim.lsp.codelens.enable(true)
		end
	end,
})

local lsps = {
	lua_ls = {

		settings = {
			Lua = {
				filetypes = { "lua" },
				diagnostics = {
					globals = { "vim" },
				},

				workspace = {
					checkThirdParty = false,
				},
			},

			telemetry = {
				enable = false,
			},
		},
	},
}

local enabled = {}
for _, lang in pairs(tools) do
	if lang.lsp then
		enabled[lang.lsp.server] = true
	end
end

for server in pairs(enabled) do
	local config = lsps[server] or {}
	vim.lsp.config(server, vim.tbl_deep_extend("force", base, lsps[server] or {}))
	vim.lsp.enable(server)
end
local tools = require("config.tools").languages

local formatters_by_ft = {}

for _, lang in pairs(tools) do
	for _, ft in ipairs(lang.filetypes or {}) do
		formatters_by_ft[ft] = {}

		for _, formatter in ipairs(lang.formatters or {}) do
			table.insert(formatters_by_ft[ft], formatter.name)
		end
	end
end

require("conform").setup({
	formatters_by_ft = formatters_by_ft,

	format_on_save = {
		timeout_ms = 500,
		lsp_format = "fallback",
	},
})

Installing a new tool

The question you may have is: once I’ve listed the tools, how do I install them? And the answer is: you don’t :)
Any new tool will automatically be installed on Neovim start thanks to the execution of MasonToolsInstall (see the plugins configuration file above). Of course, if you need to activate a new tool without restarting Neovim, you can simply call :MasonToolsInstall yourself.

Is it perfect?

I’m not a dev and don’t have any formal IT training, but this is what works for me. It streamlines my setup and makes it way easier to maintain and update. If you have a better approach or any tips for improvement, definitely let me know, I’m all ears.



Share your feedback via email, using Mastodon or

Comment on wwwgem's post

Copy and paste this URL into the search field of your favourite Fediverse app or the web interface of your Mastodon server.