A long time ago I fully assimilated to the Vim lifestyle. Vim navigation motions are what I expect everything to work with, and working with any other text-editor or IDE is only tolerable if I can make it somewhat immitate Vim.

And a few years ago I finally made the jump from Vim (around verions 8) to Neovim, and even (after some very strong initial heistation) abandoned Vimscript for Neovim’s native Lua configuration language.

These days I use a heavily customized NVChad based setup, with Mason, and a bunch of different plugins. As part of the NVChad (and generally popular Neovim usage) I use Mason (which is integrated into NVChad) to install my LSP servers (and some other tools) and Lazy to manage and configure my Plugins.

Problem

This setup has been working really well for me, and it has grown and evolved with my needs really well. But recently I encountered some issues with certain plugins that aim to provide holistic support for a language or platform and thus seek to also configure the LSP on my behalf.

I recently encountered two cases like this when trying to add plugins for working with the Arduino and ESP-IDF toolchains. Both of these plugins try to automatically configure their respective LSPs (arduino-language-server and clangd, respectively).

But because I use Mason (plus NVChad’s integrations) to install/configure my LSPs automatically and with some degree of isolation from the system packages, when these plugins try to do this for me, I don’t get all the benefits of NVChad’s defaults and integrations if I rely on the plugin to setup the LSP. And if I don’t use the plugin’s auto-configuration I can miss-out on some helpful defaults provided by the plugin.

Let’s look at the esp32.nvim plugin. This plugin tries to help handle the clangd configuration on my behalf, and ensure that I use the version of clangd distributed with the ESP-IDF toolchain.

The plugin’s documentation states the following:

⚠️ Attention: It’s critical to ensure nvim-lspconfig is configured to a ctually use the ESP-specific clangd. This is done in the example below by setting opts for nvim-lspconfig. esp32.nvim provides a working LSP configuration via lsp_config, which can be used. If you are using a different LSP setup, make sure to adjust accordingly.

And recommends configuring the plugin for Lazy like so:

	{
		"neovim/nvim-lspconfig",
		opts = function(_, opts)
			local esp32 = require("esp32")
			opts.servers = opts.servers or {}
			opts.servers.clangd = esp32.lsp_config()
			return opts
		end,
	},

This approach however has two major downsides:

  1. It sets the global clangd configuration to the ESP32 specific configuration (which, as stated previously, tries to use ESP-IDF’s clangd) so any other usage of that LSP would be depdenent on how this plugin sets it up.
  2. NVChad’s LSP defaults are not used, so the LSP doesn’t get integrated nicely into NVChad’s ecosystem.

The Solution

The approach that works for me to first ensure maximum flexibility and customization in how LSPs are being setup for NVChad.

The NVChad Starter recommends the following approach:

require("nvchad.configs.lspconfig").defaults()

local servers = { "html", "cssls", "clangd" }
vim.lsp.enable(servers)

However this relies on some magic in NVChad for configuring each of the LSPs.

Instead, I favor an approach that allows for more fine-grained control:

local servers = {
	{
		"html",
		{},
	},
	{
		"cssls",
		{},
	},
	{
		"clangd",
		{},
	},
}

for _, lsp in pairs(servers) do
	local name, config = lsp[1], lsp[2]
	config.on_init = nvlsp.on_init
	config.on_attach = nvlsp.on_attach
	config.capabilities = nvlsp.capabilities

	vim.lsp.enable(name)
	if config then
		vim.lsp.config(name, config)
	end
end

This bypasses some of the magic done by NVChad for configuring the LSPs, but still uses NVChad’s default hooks.

However, this still doesn’t solve the problem when a plugin wants to configure the LSP on my behalf.

Reconfigure the LSP on Plugin Load

Instead of telling neovim/nvim-lspconfig to use esp32.nvim’s LSP configuration, like the plugin documentation recommends, let’s borrow from my preferred approach for LSP configuration under NVChad to reconfigure the LSP when loading the Plugin:

	{
		"Aietes/esp32.nvim",
		-- ... Logic to ensure this plugin only loads for esp32 projects.
		config = function(_, opts)
			-- ... Logic to ensure this plugin only loads for esp32 projects.
			-- Default config steps.
			local setup_return = esp32.setup(opts)

			-- Reconfigure LSP for clangd
			local nvlsp = require("nvchad.configs.lspconfig")
			local merge_table = function(dst, src)
				for k,v in pairs(src)
				do
					dst[k] = v
				end
				return dst
			end

			local name, config = "clangd", esp32.lsp_config()
			config.on_init = nvlsp.on_init
			config.on_attach = nvlsp.on_attach
			config.capabilities = merge_table((config.capabilities or {}), nvlsp.capabilities)

			vim.lsp.enable(name)
			if config then
				vim.lsp.config(name, config)
			end

			return setup_return
		end,
	}

This way I get the best of both worlds, while not changing how clangd works for other instances.

Potential Issues

The above has worked for me. But I don’t tend to keep a single Neovim instance running across multiple projects in different languages. So it’s possible the above approach would still mess up the LSP for other non-ESP32 projects. But this merge approach has worked nicely for me.