mason.nvim icon indicating copy to clipboard operation
mason.nvim copied to clipboard

Option to install LSPs automatically when you open a new file type

Open VoxelPrismatic opened this issue 8 months ago • 3 comments

I've searched open issues for similar requests

  • [x] Yes

Is your feature request related to a problem? Please describe.

I'm just curious about different languages, and try new ones from time to time. I don't want to search through the massive list, I just want the first one associated to this language to be installed.

Describe the solution you'd like

An option automatic_install (and an ignore and manual_link option, see my implementation)

Describe potential alternatives you've considered

I've already effectively implemented it myself. It also ignores LSPs with 8+ languages because those are usually grammar checkers or otherwise generic LSPs.

code
-- Run updates on these packages
---@type table<string, string | false>
local updateable = {
	grammarly = false, -- Ignore Grammarly
}

-- Do not bind multiple LSPs to the same filetype
---@type table<string, integer>
local autocmds = {}

-- Mason Lsp Config is not complete
---@type table<string, string>
local manual_link = {
	["c3-lsp"] = "c3_lsp",
}

local GENERIC_THRESHOLD = 8

local nvim_lsp, mason_lsp, mapping, registry

local function bind_lsp(lsp_name)
	local pkg = updateable[lsp_name]
	if pkg ~= nil then
		if type(pkg) == "string" then
			-- Auto-update
			pkg = registry.get_package(pkg)
			pkg:check_new_version(function(_ok, version)
				if _ok and version.current_version ~= version.latest_version then
					pkg:install()
				end
			end)
		end
		return
	end

	local lsp = nvim_lsp[lsp_name]
	local fts = lsp.config_def ~= nil and lsp.config_def.default_config.filetypes or {}
	-- Generic LSP like harper_ls, not specific to language
	if #fts > GENERIC_THRESHOLD or #fts == 0 then
		return
	end

	local mason_name = mapping.lspconfig_to_package[lsp_name] or lsp_name
	pkg = registry.get_package(mason_name)
	-- Generic LSP like harper_ls, not specific to language
	if #pkg.spec.languages > GENERIC_THRESHOLD then
		return
	end

	local buf_fts = {}
	for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
		table.insert(buf_fts, vim.bo[bufnr].filetype)
	end

	-- Install the first LSP for each filetype
	for _, ft in ipairs(fts) do
		if autocmds[ft] == nil then
			if vim.tbl_contains(buf_fts, ft) then
				pkg:install()
			else
				autocmds[ft] = vim.api.nvim_create_autocmd("FileType", {
					pattern = ft,
					callback = function()
						pkg:install()
					end,
					once = true,
				})
			end
		end
	end
end

return { ---@type LazyPluginSpec[]
	{
		"williamboman/mason.nvim",
		name = "mason",
		build = ":MasonUpdate",
		config = true,
		opts = { ---@type MasonSettings
			pip = {
				upgrade_pip = true,
			},
		},
	},
	{
		"williamboman/mason-lspconfig.nvim",
		name = "mason-lspconfig",
		dependencies = {
			{ "williamboman/mason.nvim", name = "mason" },
			{ "neovim/nvim-lspconfig" },
		},
		lazy = false,
		config = function(_)
			nvim_lsp = require("lspconfig")
			mason_lsp = require("mason-lspconfig")
			mapping = require("mason-lspconfig.mappings.server")
			registry = require("mason-registry")

			for mason_name, lsp_name in pairs(manual_link) do
				mapping.package_to_lspconfig[mason_name] = lsp_name
				mapping.lspconfig_to_package[lsp_name] = mason_name
			end

			mason_lsp.setup({
				automatic_installation = true,
				ensure_installed = {},
			})

			mason_lsp.setup_handlers({
				function(lsp_name)
					local lsp = nvim_lsp[lsp_name]
					updateable[lsp_name] = mapping.lspconfig_to_package[lsp_name] or lsp_name
					lsp.setup({})
					local fts = lsp.config_def ~= nil and lsp.config_def.default_config.filetypes or {}
					if #fts < GENERIC_THRESHOLD then
						for _, ft in ipairs(fts) do
							autocmds[ft] = 0
						end
					end
				end,
			})

			-- Launch LSP immediately upon installation
			registry:on(
				"package:install:success",
				vim.schedule_wrap(function(pkg)
					local lsp = nvim_lsp[mapping.package_to_lspconfig[pkg.name] or pkg.name]
					lsp.setup({})
					lsp.launch()
				end)
			)

			registry.update(vim.schedule_wrap(function(ok, msg)
				if not ok then
					error(msg)
				end
				for _, lsp_name in ipairs(mason_lsp.get_available_servers()) do
					bind_lsp(lsp_name)
				end
			end))
		end,
	},
}

https://github.com/user-attachments/assets/4c38d86a-8196-46f6-9049-c6e45a09f638

[!NOTE] This video is outdated. The example above no longer shows any warnings.

mason-lspconfig now consumes 20ms of my startup time, but this is also an eight-year-old laptop and shouldn't be significant on modern machines. (The previous variant I completed last night consumed 50ms)

Additional context

No response

VoxelPrismatic avatar Apr 06 '25 06:04 VoxelPrismatic

https://github.com/williamboman/mason-lspconfig.nvim/issues/535

Currently, the regular auto-install option is broken with the new lspconfig changes

It is slightly different, as that one only applies to ones you call setup on, but if you were unaware of said option, it is currently broken.

How does your idea determine which is "the first one associated with a filetype" for filetypes with multiple available LSPs?

BirdeeHub avatar Apr 16 '25 06:04 BirdeeHub

Which ever shows up first in the registry. If you choose to install a different LSP, no new one will be installed.

VoxelPrismatic avatar Apr 16 '25 12:04 VoxelPrismatic

New version that works with mason-lspconfig v2

-- Mason Lsp Config is not complete
---@type table<string, string>
local manual_link = {
	["c3-lsp"] = "c3_lsp",
}

local GENERIC_THRESHOLD = 8

---@type { [string]: boolean }
local handled = {
	grammarly = true, -- Disable grammarly install
}

local _bound = {}
local function launch_lsp(name, bufnr)
	local active = vim.lsp.get_clients({ name = name })
	if #active > 0 or _bound[name] == true then
		return
	end
	vim.print("Launching " .. name)
	_bound[name] = true
	local lsp = require("lspconfig")[name]
	lsp.setup({})
	vim.lsp.start(lsp, {
		bufnr = bufnr or 0,
	})
end

---@param evt vim.api.keyset.create_autocmd.callback_args
---@param ft string
local function bind_lsp(evt, ft)
	local map = require("mason-lspconfig").get_mappings()
	local mason = {
		lsp = require("mason-lspconfig"),
		dap = require("mason-nvim-dap"),
		registry = require("mason-registry"),
		main = require("mason"),
	}
	for mason_name, lsp_name in pairs(manual_link) do
		map.lspconfig_to_package[lsp_name] = mason_name
		map.package_to_lspconfig[mason_name] = lsp_name
	end

	---@type { [string]: true }
	local installed = {}
	for _, name in ipairs(mason.lsp.get_installed_servers()) do
		installed[name] = true
	end

	local ok, pkg
	for name, _ in pairs(map.lspconfig_to_package) do
		local valid = false
		for _, t in ipairs((vim.lsp.config[name] or {}).filetypes or {}) do
			if t == ft then
				valid = true
				break
			end
		end

		if not valid then
			goto continue
		end
		local tries = {
			map.package_to_lspconfig[name] or name,
			name,
		}

		local config, lsp_name
		while config == nil and #tries > 0 do
			lsp_name = table.remove(tries, 1)
			config = vim.lsp.config[lsp_name]
		end

		if config == nil then
			goto continue
		end

		local binaries = config.cmd
		if type(binaries) == "table" then
			if vim.fn.executable(binaries[1]) == 1 then
				launch_lsp(lsp_name, evt.buf)
			end
		end

		local fts = config.filetypes or {}
		if #fts >= GENERIC_THRESHOLD or map.lspconfig_to_package[name] == nil then
			-- pass
		elseif installed[name] then
			ok, pkg = pcall(mason.registry.get_package, map.lspconfig_to_package[name])
			if ok then
				break
			end
		end

		::continue::
	end

	if pkg == nil then
		return
	end

	local current = pkg:get_installed_version()
	local latest = pkg:get_latest_version()

	local name = map.package_to_lspconfig[pkg.name]

	if current ~= latest then
		pkg:install({
			force = false,
			strict = false,
			version = pkg:get_latest_version(),
		})
	else
		launch_lsp(name)
	end
end

vim.api.nvim_create_autocmd({ "FileType", "BufEnter", "BufRead" }, {
	pattern = { "*" },
	callback = function(evt)
		local ft = vim.bo[evt.buf or 0].filetype
		if ft == nil or ft == "" or handled[ft] then
			return
		end

		handled[ft] = true

		vim.defer_fn(function()
			bind_lsp(evt, ft)
		end, 50)
	end,
})

return { ---@type LazyPluginSpec[]
	{
		"williamboman/mason-lspconfig.nvim",
		name = "mason-lspconfig",
		dependencies = {
			{ "williamboman/mason.nvim", name = "mason" },
			{ "neovim/nvim-lspconfig" },
			{ "jay-babu/mason-nvim-dap.nvim" },
		},
		lazy = false,
		config = function(_)
			require("mason").setup({
				pip = {
					upgrade_pip = true,
				},
			})
			local registry = require("mason-registry")

			local mapping = require("mason-lspconfig").get_mappings()
			for mason_name, lsp_name in pairs(manual_link) do
				mapping.package_to_lspconfig[mason_name] = lsp_name
				mapping.lspconfig_to_package[lsp_name] = mason_name
			end

			-- Launch LSP immediately upon installation
			registry:on(
				"package:install:success",
				vim.schedule_wrap(function(pkg) ---@param pkg Package
					local is_lsp = false
					for _, category in ipairs(pkg.spec.categories) do
						if category == "LSP" then
							is_lsp = true
							break
						end
					end

					if is_lsp then
						launch_lsp(mapping.package_to_lspconfig[pkg.name] or pkg.name)
					end
				end)
			)

			local mason_dap = require("mason-nvim-dap")

			mason_dap.setup({
				automatic_installation = true,
				ensure_installed = {},
				handlers = {
					mason_dap.default_setup,
				},
			})
		end,
	},
}

VoxelPrismatic avatar May 13 '25 19:05 VoxelPrismatic