From 2bc21b248cad296a6f3cb071c2d70173a6ccec39 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Sun, 14 Apr 2024 15:41:39 +0200 Subject: [PATCH] feat(lsp): refactor * Move configs into config subdirectory * Move LSP logic into classes * Make it possible to define mason package in lsp config, including nested dependency resolution and post install steps * replace jedi_language_server with pylsp --- lua/lsp.lua | 387 +----------------- lua/lsp/{ => config}/bashls.lua | 5 +- lua/lsp/{ => config}/clangd.lua | 0 lua/lsp/{ => config}/cmake.lua | 0 lua/lsp/{ => config}/diagnosticls.lua | 11 +- lua/lsp/{ => config}/gopls.lua | 0 lua/lsp/{ => config}/groovyls.lua | 0 lua/lsp/config/hls.lua | 18 + lua/lsp/{ => config}/intelephense.lua | 0 lua/lsp/{ => config}/jedi_language_server.lua | 2 +- lua/lsp/{ => config}/lemminx.lua | 0 lua/lsp/{ => config}/lua_ls.lua | 1 + lua/lsp/config/pylsp.lua | 85 ++++ lua/lsp/{ => config}/rust_analyzer.lua | 0 lua/lsp/{ => config}/zls.lua | 0 lua/lsp/package.lua | 171 ++++++++ lua/lsp/server.lua | 367 +++++++++++++++++ 17 files changed, 671 insertions(+), 376 deletions(-) rename lua/lsp/{ => config}/bashls.lua (81%) rename lua/lsp/{ => config}/clangd.lua (100%) rename lua/lsp/{ => config}/cmake.lua (100%) rename lua/lsp/{ => config}/diagnosticls.lua (95%) rename lua/lsp/{ => config}/gopls.lua (100%) rename lua/lsp/{ => config}/groovyls.lua (100%) create mode 100644 lua/lsp/config/hls.lua rename lua/lsp/{ => config}/intelephense.lua (100%) rename lua/lsp/{ => config}/jedi_language_server.lua (99%) rename lua/lsp/{ => config}/lemminx.lua (100%) rename lua/lsp/{ => config}/lua_ls.lua (98%) create mode 100644 lua/lsp/config/pylsp.lua rename lua/lsp/{ => config}/rust_analyzer.lua (100%) rename lua/lsp/{ => config}/zls.lua (100%) create mode 100644 lua/lsp/package.lua create mode 100644 lua/lsp/server.lua diff --git a/lua/lsp.lua b/lua/lsp.lua index ba79637..4885e19 100644 --- a/lua/lsp.lua +++ b/lua/lsp.lua @@ -1,11 +1,13 @@ local module_name = "lsp" local utils = require("utils") +---@class ServerConfig +local Server = require("lsp.server") + local M = {} -local capabilities = {} - -local config = { +---@type table +local servers = { bashls = {}, clangd = {}, cmake = {}, @@ -13,140 +15,26 @@ local config = { gopls = {}, groovyls = {}, intelephense = {}, - jedi_language_server = {}, + pylsp = {}, lemminx = {}, lua_ls = {}, rust_analyzer = {}, zls = {}, } -for server, _ in pairs(config) do - utils.try_require("lsp." .. server, module_name, function (mod) - config[server] = mod - end) -end - -local function ca_rename_fallback() - local old = vim.fn.expand("") - vim.ui.input( - { prompt = ("Rename `%s` to: "):format(old), }, - function (input) - if input ~= "" then - vim.lsp.buf.rename(input) - end +for name, _ in pairs(servers) do + utils.try_require( + "lsp.config." .. name, + module_name, + ---@param cfg ServerConfig + function (cfg) + cfg.name = name + servers[name] = Server:new(cfg) end ) end -local function ca_rename() - local ts_utils = utils.try_require("nvim-treesitter.ts_utils", module_name) - if not ts_utils then - return ca_rename_fallback() - end - - local node = ts_utils.get_node_at_cursor() - if not node or node:type() ~= "IDENTIFIER" then - utils.info("Only identifiers may be renamed", module_name) - return - end - - vim.lsp.buf.document_highlight() - - local old = vim.fn.expand("") - local buf = vim.api.nvim_create_buf(false, true) - local min_width = 10 - local max_width = 50 - local default_width = math.min( - max_width, - math.max(min_width, vim.str_utfindex(old) + 1) - ) - local row, col, _, _ = node:range() - local win = vim.api.nvim_open_win( - buf, - true, - { - relative = "win", - anchor = "NW", - width = default_width, - height = 1, - bufpos = { row, col - 1, }, - focusable = true, - zindex = 50, - style = "minimal", - border = "rounded", - title = "Rename", - title_pos = "center", - } - ) - - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { old, }) - - vim.api.nvim_create_autocmd( - { "TextChanged", "TextChangedI", "TextChangedP", }, { - buffer = buf, - callback = function () - local win_width = vim.api.nvim_win_get_width(win) - local content = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - if #content > 0 then - local cwidth = vim.str_utfindex(content[1] or "") + 1 - local new_width = math.min( - max_width, - math.max(min_width, cwidth) - ) - if new_width ~= win_width then - vim.api.nvim_win_set_width(win, new_width) - end - end - end, - }) - - vim.keymap.set( - { "n", "i", "x", }, - "", - function () - local content = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - vim.api.nvim_win_close(win, true) - vim.cmd.stopinsert() - if #content > 0 then - local new_name = content[1] - vim.lsp.buf.rename(new_name) - end - end, - { buffer = buf, } - ) - vim.keymap.set( - { "n", "i", "x", }, - "", - function () - vim.api.nvim_win_close(win, true) - vim.cmd.stopinsert() - end, - { buffer = buf, } - ) - vim.keymap.set( - { "n", "x", }, - "", - function () - vim.api.nvim_win_close(win, true) - end, - { buffer = buf, } - ) - vim.keymap.set( - { "n", "x", }, - "q", - function () - vim.api.nvim_win_close(win, true) - end, - { buffer = buf, } - ) - - vim.api.nvim_feedkeys( - vim.api.nvim_replace_termcodes("^v$", true, false, true), - "n", - true - ) -end - +--- Setup diagnostics UI local function setup_diagnostics() -- https://github.com/neovim/nvim-lspconfig/wiki/UI-Customization#customizing-how-diagnostics-are-displayed vim.diagnostic.config({ @@ -160,7 +48,7 @@ local function setup_diagnostics() }, float = { show_header = false, - source = "always", + source = true, border = "single", focusable = false, format = function (diagnostic) @@ -177,251 +65,12 @@ local function setup_diagnostics() end end -local function on_attach(client, bufnr) - -- Mappings. - -- See `:help vim.lsp.*` for documentation on any of the below functions - local opts = { buffer = bufnr, } - vim.keymap.set("n", "df", vim.diagnostic.open_float, opts) - vim.keymap.set("n", "[d", vim.diagnostic.goto_prev, opts) - vim.keymap.set("n", "]d", vim.diagnostic.goto_next, opts) - vim.keymap.set("n", "gD", vim.lsp.buf.declaration, opts) - vim.keymap.set({ "n", "i", }, "", vim.lsp.buf.hover, opts) - vim.keymap.set({ "n", "i", }, "", vim.lsp.buf.signature_help, opts) - vim.keymap.set({ "n", "i", }, "", vim.lsp.buf.document_highlight, opts) - vim.keymap.set("n", "lr", ca_rename, opts) - vim.keymap.set("n", "la", vim.lsp.buf.code_action, opts) - vim.keymap.set( - { "n", "x", }, - "lf", - vim.lsp.buf.format, - opts - ) - - ---@module "telescope.builtin" - local telescope = utils.try_require("telescope.builtin", module_name) - if telescope then - vim.keymap.set("n", "dl", telescope.diagnostics, opts) - vim.keymap.set("n", "lD", telescope.lsp_type_definitions, opts) - vim.keymap.set("n", "gd", telescope.lsp_definitions, opts) - vim.keymap.set("n", "gi", telescope.lsp_implementations, opts) - vim.keymap.set("n", "gr", telescope.lsp_references, opts) - else - vim.keymap.set("n", "dl", vim.diagnostic.setloclist, opts) - vim.keymap.set("n", "ld", vim.lsp.buf.type_definition, opts) - vim.keymap.set("n", "gd", vim.lsp.buf.definition, opts) - vim.keymap.set("n", "gi", vim.lsp.buf.implementation, opts) - vim.keymap.set("n", "gr", vim.lsp.buf.references, opts) - end - - -- For document highlight - vim.cmd.highlight({ "link LspReferenceRead Visual", bang = true, }) - vim.cmd.highlight({ "link LspReferenceText Visual", bang = true, }) - vim.cmd.highlight({ "link LspReferenceWrite Visual", bang = true, }) - -- vim.api.nvim_create_autocmd({ "CursorHold", "CursorHoldI", }, { - -- buffer = bufnr, - -- callback = vim.lsp.buf.document_highlight, - -- }) - vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI", }, { - buffer = bufnr, - callback = vim.lsp.buf.clear_references, - }) - - -- Auto show signature on insert in function parameters - -- if client.server_capabilities.signatureHelpProvider then - -- local chars = client.server_capabilities.signatureHelpProvider - -- .triggerCharacters - -- if chars and #chars > 0 then - -- vim.api.nvim_create_autocmd("CursorHoldI", { - -- buffer = bufnr, - -- callback = vim.lsp.buf.signature_help, - -- }) - -- end - -- end - - vim.opt.updatetime = 300 - - require("lsp-inlayhints").on_attach(client, bufnr, false) - - vim.lsp.handlers["textDocument/hover"] = vim.lsp.with( - vim.lsp.handlers.hover, { - border = "single", - } - ) - vim.lsp.handlers["textDocument/signatureHelp"] = vim.lsp.with( - vim.lsp.handlers.signature_help, { - border = "single", - } - ) -end - -local function reload_server_buf(name) - local server = config[name] - local ft_map = {} - for _, ft in ipairs(server.lspconfig.filetypes) do - ft_map[ft] = true - end - for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(bufnr) then - local buf_ft = vim.api.nvim_get_option_value( - "filetype", - { buf = bufnr, } - ) - if ft_map[buf_ft] then - vim.api.nvim_buf_call( - bufnr, - vim.cmd.edit - ) - end - end - end -end - - -local function configure_server(name, server) - local ok, ret = pcall(require, "lspconfig") - if not ok then - utils.err("Missing required plugin lspconfig", module_name) - return - end - local lspconfig = ret - - if server.root_pattern then - server.lspconfig.root_dir = lspconfig.util.root_pattern( - unpack(server.root_pattern) - ) - else - server.lspconfig.root_dir = lspconfig.util.find_git_ancestor - end - server.lspconfig.capabilities = capabilities - server.lspconfig.on_attach = function (...) - ok, ret = pcall(on_attach, ...) - if not ok then - utils.err( - ("Failed to load on_attach for %s:\n%s"):format(name, ret), - module_name - ) - end - end - - ok, ret = pcall(lspconfig[name].setup, server.lspconfig) - if not ok then - utils.err( - ("Failed to setup LSP server %s with lspconfig: %s"):format( - name, - ret - ), - module_name - ) - return - end - - reload_server_buf(name) -end - -local function get_missing_deps(server) - local missing_deps = {} - - if server.dependencies ~= nil then - for _, dep in ipairs(server.dependencies) do - if not utils.is_installed(dep) then - table.insert(missing_deps, dep) - end - end - end - - if server.py_module_deps ~= nil then - for _, mod in ipairs(server.py_module_deps) do - if not utils.python3_module_is_installed(mod) then - table.insert(missing_deps, "python3-" .. mod) - end - end - end - - return missing_deps -end - -local function setup_server(name, server) - local missing_deps = get_missing_deps(server) - if #missing_deps > 0 then - utils.warn( - ("Disabling %s because the following package(s) " - .. "are not installed: %s") - :format( - name, - table.concat(missing_deps, ", ") - ), - module_name - ) - server.enable = false - return - end - - local registry = require("mason-registry") - local pkg_name - - if server.mason then - pkg_name = server.mason.name - end - - if (pkg_name and not registry.is_installed(pkg_name)) then - local pkg = registry.get_package(pkg_name) - local handle = pkg:install({ version = server.mason.version, }) - utils.info("Installing " .. pkg_name) - local err - handle:on("stderr", vim.schedule_wrap(function (msg) - err = (err or "") .. msg - end)) - handle:once("closed", vim.schedule_wrap(function () - if err then - utils.err(err, module_name) - end - - if pkg:is_installed() then - utils.info("Installation finished for " .. pkg_name) - configure_server(name, server) - else - utils.err("Installation failed for " .. pkg_name) - server.enable = false - end - end)) - else - if vim.fn.executable(server.lspconfig.cmd[1]) == 1 then - configure_server(name, server) - else - utils.warn(name .. " not installed, disabling", module_name) - server.enable = false - end - end -end - -local function register_server(name, server) - local augroup = vim.api.nvim_create_augroup("LSP-" .. name, {}) - vim.api.nvim_create_autocmd("FileType", { - once = true, - pattern = table.concat(server.lspconfig.filetypes, ","), - callback = vim.schedule_wrap(function () - setup_server(name, server) - vim.api.nvim_del_augroup_by_id(augroup) - end), - group = augroup, - }) -end - function M.setup() setup_diagnostics() - capabilities = vim.lsp.protocol.make_client_capabilities() - - utils.try_require("cmp_nvim_lsp", module_name, function (cmp_nvim_lsp) - capabilities = vim.tbl_deep_extend( - "force", capabilities, - cmp_nvim_lsp.default_capabilities() - ) - end) - - for name, server in pairs(config) do + for _, server in pairs(servers) do if server.enable then - register_server(name, server) + server:register() end end end diff --git a/lua/lsp/bashls.lua b/lua/lsp/config/bashls.lua similarity index 81% rename from lua/lsp/bashls.lua rename to lua/lsp/config/bashls.lua index 4856570..325b829 100644 --- a/lua/lsp/bashls.lua +++ b/lua/lsp/config/bashls.lua @@ -2,11 +2,12 @@ return { enable = true, dependencies = { "npm", - "shellcheck", }, mason = { name = "bash-language-server", - -- version = "", + dependencies = { + { name = "shellcheck", }, + }, }, lspconfig = { filetypes = { diff --git a/lua/lsp/clangd.lua b/lua/lsp/config/clangd.lua similarity index 100% rename from lua/lsp/clangd.lua rename to lua/lsp/config/clangd.lua diff --git a/lua/lsp/cmake.lua b/lua/lsp/config/cmake.lua similarity index 100% rename from lua/lsp/cmake.lua rename to lua/lsp/config/cmake.lua diff --git a/lua/lsp/diagnosticls.lua b/lua/lsp/config/diagnosticls.lua similarity index 95% rename from lua/lsp/diagnosticls.lua rename to lua/lsp/config/diagnosticls.lua index a0e3995..630e6d6 100644 --- a/lua/lsp/diagnosticls.lua +++ b/lua/lsp/config/diagnosticls.lua @@ -10,12 +10,14 @@ return { "npm", }, mason = { + -- TODO: figure out if possible to install required formatters/linters + -- in this language server automatically through mason name = "diagnostic-languageserver", -- version = "", }, lspconfig = { filetypes = { - "python", + -- "python", "sh", "bash", "zsh", @@ -25,7 +27,7 @@ return { single_file_support = true, init_options = { filetypes = { - python = "flake8", + -- python = "flake8", php = "phpcs", }, linters = { @@ -112,7 +114,7 @@ return { }, }, formatFiletypes = { - python = { "black", "isort", }, + -- python = { "black", "isort", }, sh = { "shfmt", }, bash = { "shfmt", }, zsh = { "shfmt", }, @@ -137,7 +139,8 @@ return { "--stdin-filename", "%filename", "--quiet", - "-", + "-code", + "%text", }, rootPatterns = { "Pipfile", ".git", "tox.ini", }, isStdout = true, diff --git a/lua/lsp/gopls.lua b/lua/lsp/config/gopls.lua similarity index 100% rename from lua/lsp/gopls.lua rename to lua/lsp/config/gopls.lua diff --git a/lua/lsp/groovyls.lua b/lua/lsp/config/groovyls.lua similarity index 100% rename from lua/lsp/groovyls.lua rename to lua/lsp/config/groovyls.lua diff --git a/lua/lsp/config/hls.lua b/lua/lsp/config/hls.lua new file mode 100644 index 0000000..d9994bc --- /dev/null +++ b/lua/lsp/config/hls.lua @@ -0,0 +1,18 @@ +return { + enable = true, + mason = { + name = "haskell-language-server", + -- version = "", + }, + lspconfig = { + filetypes = { "haskell", "lhaskell", "cabal", }, + cmd = { "haskell-language-server-wrapper", "--lsp", }, + single_file_support = true, + settings = { + haskell = { + cabalFormattingProvider = "cabalfmt", + formattingProvider = "ormolu", + }, + }, + }, +} diff --git a/lua/lsp/intelephense.lua b/lua/lsp/config/intelephense.lua similarity index 100% rename from lua/lsp/intelephense.lua rename to lua/lsp/config/intelephense.lua diff --git a/lua/lsp/jedi_language_server.lua b/lua/lsp/config/jedi_language_server.lua similarity index 99% rename from lua/lsp/jedi_language_server.lua rename to lua/lsp/config/jedi_language_server.lua index 6d455aa..256c714 100644 --- a/lua/lsp/jedi_language_server.lua +++ b/lua/lsp/config/jedi_language_server.lua @@ -1,5 +1,5 @@ return { - enable = true, + enable = false, dependencies = { "python3", }, diff --git a/lua/lsp/lemminx.lua b/lua/lsp/config/lemminx.lua similarity index 100% rename from lua/lsp/lemminx.lua rename to lua/lsp/config/lemminx.lua diff --git a/lua/lsp/lua_ls.lua b/lua/lsp/config/lua_ls.lua similarity index 98% rename from lua/lsp/lua_ls.lua rename to lua/lsp/config/lua_ls.lua index 2e48518..b9b85c6 100644 --- a/lua/lsp/lua_ls.lua +++ b/lua/lsp/config/lua_ls.lua @@ -1,5 +1,6 @@ -- spec: https://luals.github.io/wiki/settings/ +---@type ServerConfig return { enable = true, mason = { diff --git a/lua/lsp/config/pylsp.lua b/lua/lsp/config/pylsp.lua new file mode 100644 index 0000000..36b119e --- /dev/null +++ b/lua/lsp/config/pylsp.lua @@ -0,0 +1,85 @@ +--- @type ServerConfig +return { + enable = true, + dependencies = { + "python3", + }, + py_module_deps = { + "venv", + }, + mason = { + name = "python-lsp-server", + post_install = { + { + command = "./venv/bin/pip", + args = { + "install", + "python-lsp-black", + "python-lsp-isort", + }, + }, + -- { + -- command = "./venv/bin/pip", + -- args = { "alsdkfjhaklsdfjhl", }, + -- }, + }, + }, + lspconfig = { + filetypes = { + "python", + }, + cmd = { "pylsp", }, + single_file_support = true, + settings = { + pylsp = { + configurationSources = { "flake8", }, + plugins = { + autopep8 = { + enabled = false, + }, + black = { + enabled = true, + line_length = 100, + }, + flake8 = { + enabled = true, + exclude = { ".venv", "build/", }, + filename = { "*.py", }, + -- B - flake8-bugbear https://github.com/PyCQA/flake8-bugbear + -- C - only one violation, C901. mccabe https://github.com/PyCQA/mccabe + -- D - flake8-docstrings (pydocstyle) http://www.pydocstyle.org/en/stable/error_codes.html + -- E - pycodestyle https://pycodestyle.pycqa.org/en/latest/intro.html#error-codes + -- F - flake8 https://flake8.pycqa.org/en/latest/user/error-codes.html + -- W - pycodestyle https://pycodestyle.pycqa.org/en/latest/intro.html#error-codes + select = { + "B", "B902", "B903", "B904", "C", "E", "E999", "E501", "F", "W", + }, + ignore = { + "B950", "D201", "D203", "D205", "D301", "D400", "E133", "E203", "W503", + }, + max_line_length = 100, + max_doc_length = 100, + }, + isort = { + enabled = true, + }, + mccabe = { + enabled = false, + }, + pycodestyle = { + enabled = false, + }, + pydocstyle = { + enabled = false, + }, + pyflakes = { + enabled = false, + }, + yapf = { + enabled = false, + }, + }, + }, + }, + }, +} diff --git a/lua/lsp/rust_analyzer.lua b/lua/lsp/config/rust_analyzer.lua similarity index 100% rename from lua/lsp/rust_analyzer.lua rename to lua/lsp/config/rust_analyzer.lua diff --git a/lua/lsp/zls.lua b/lua/lsp/config/zls.lua similarity index 100% rename from lua/lsp/zls.lua rename to lua/lsp/config/zls.lua diff --git a/lua/lsp/package.lua b/lua/lsp/package.lua new file mode 100644 index 0000000..27fe3ed --- /dev/null +++ b/lua/lsp/package.lua @@ -0,0 +1,171 @@ +local module_name = "lsp.package" +local utils = require("utils") + +---@class PostInstallStep +---@field command string +---@field args string[] + +---@class MasonPackageConfig +---@field name string? +---@field version string? +---@field dependencies MasonPackageConfig[]? +---@field post_install PostInstallStep[]? +local M = {} +M.__index = M + +--- Run post installation steps +---@param pkg Package +function M:run_post_install(pkg) + if self.post_install then + for _, step in ipairs(self.post_install) do + local job = require("plenary.job"):new({ + command = step.command, + args = step.args, + cwd = pkg:get_install_path(), + enabled_recording = true, + on_exit = function (job, code, signal) + if code ~= 0 or signal ~= 0 then + local cmd = step.command + if step.args then + cmd = cmd .. " " .. table.concat(step.args, " ") + end + + utils.err( + ("Post installation step for %s:\n`%s`\nfailed with:\n%s"):format( + self.name, + cmd, + table.concat(job:stderr_result(), "\n") + ), + module_name + ) + end + end, + }) + job:start() + end + end +end + +--- Perform installation +---@param on_done fun(success: boolean)? +function M:mason_install(on_done) + local registry = require("mason-registry") + local ok, pkg = pcall(registry.get_package, self.name) + if not ok then + utils.err("Could not locate package " .. self.name, module_name) + + if on_done then + on_done(false) + end + + return + end + + if pkg:is_installed() then + if on_done then + on_done(true) + end + + return + end + + utils.info(("Installing %s"):format(self.name), module_name) + local handle = pkg:install({ version = self.version, }) + + local err + handle:on("stderr", vim.schedule_wrap(function (msg) + err = (err or "") .. msg + end)) + + handle:once("closed", vim.schedule_wrap(function () + local is_installed = pkg:is_installed() + + if is_installed then + self:run_post_install(pkg) + utils.info(("Successfully installed %s"):format(self.name), module_name) + else + if err then + err = ":\n" .. err + else + err = "" + end + + utils.err( + ("Failed to install %s%s"):format(self.name, err), + module_name + ) + end + + if on_done then + on_done(is_installed) + end + end)) +end + +--- Install package dependencies +---@param on_done fun(success: boolean)? +function M:install_dependencies(on_done) + if not self.dependencies or #self.dependencies == 0 then + if on_done then + on_done(true) + end + + return + end + + local total = #self.dependencies + local completed = 0 + local all_successful = true + + --- Handle install result + ---@param success boolean + local function handle_result(success) + completed = completed + 1 + + if not success then + all_successful = false + end + + if completed == total and on_done then + on_done(all_successful) + end + end + + for _, dep in ipairs(self.dependencies) do + dep:install(handle_result) + end +end + +--- Install package and any defined dependencies +---@param on_done fun(success: boolean)? +function M:install(on_done) + --- Handle install result + ---@param success boolean + local function handle_result(success) + if success then + self:mason_install(on_done) + elseif on_done then + on_done(success) + end + end + + self:install_dependencies(handle_result) +end + +--- Create a new instance +---@param config MasonPackageConfig +---@return MasonPackageConfig +function M:new(config) + config = config or {} + + if config.dependencies then + for i, dep in ipairs(config.dependencies) do + config.dependencies[i] = M:new(dep) + end + end + + setmetatable(config, self) + return config +end + +return M diff --git a/lua/lsp/server.lua b/lua/lsp/server.lua new file mode 100644 index 0000000..5cbf145 --- /dev/null +++ b/lua/lsp/server.lua @@ -0,0 +1,367 @@ +local module_name = "lsp.server" +local utils = require("utils") + +---@class MasonPackageConfig +local Package = require("lsp.package") + +-- override type, seems to be incorrect in either lspconfig or vim.lsp +---@class lspconfig.Config +---@field root_dir function + +---@class ServerConfig +---@field name string? +---@field enable boolean? +---@field dependencies string[] +---@field py_module_deps string[] +---@field mason MasonPackageConfig? +---@field root_patterns string[]? +---@field lspconfig lspconfig.Config +local M = {} +M.__index = M + +--- Reload all buffers attached by a server +function M:reload_buffers() + local ft_map = {} + for _, ft in ipairs(self.lspconfig.filetypes) do + ft_map[ft] = true + end + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(bufnr) then + local buf_ft = vim.api.nvim_get_option_value("filetype", { buf = bufnr, }) + if ft_map[buf_ft] then + vim.api.nvim_buf_call(bufnr, vim.cmd.edit) + end + end + end +end + +--- Rename Code Action +function M.ca_rename() + local ts_utils = utils.try_require("nvim-treesitter.ts_utils", module_name) + if not ts_utils then + return + end + + local identifier_types = { "IDENTIFIER", "identifier", "variable_name", "word", } + + local node = ts_utils.get_node_at_cursor() + if not node or not utils.has_value(identifier_types, node:type()) then + utils.info("Only identifiers may be renamed", module_name) + return + end + + vim.lsp.buf.document_highlight() + + local old = vim.fn.expand("") + local buf = vim.api.nvim_create_buf(false, true) + local min_width = 10 + local max_width = 50 + local default_width = math.min( + max_width, + math.max(min_width, vim.str_utfindex(old) + 1) + ) + local row, col, _, _ = node:range() + local win = vim.api.nvim_open_win( + buf, + true, + { + relative = "win", + anchor = "NW", + width = default_width, + height = 1, + bufpos = { row, col - 1, }, + focusable = true, + zindex = 50, + style = "minimal", + border = "rounded", + title = "Rename", + title_pos = "center", + } + ) + + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { old, }) + + vim.api.nvim_create_autocmd( + { "TextChanged", "TextChangedI", "TextChangedP", }, { + buffer = buf, + callback = function () + local win_width = vim.api.nvim_win_get_width(win) + local content = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + if #content > 0 then + local cwidth = vim.str_utfindex(content[1] or "") + 1 + local new_width = math.min( + max_width, + math.max(min_width, cwidth) + ) + if new_width ~= win_width then + vim.api.nvim_win_set_width(win, new_width) + end + end + end, + }) + + vim.keymap.set( + { "n", "i", "x", }, + "", + function () + local content = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + vim.api.nvim_win_close(win, true) + vim.cmd.stopinsert() + if #content > 0 then + local new_name = content[1] + vim.lsp.buf.rename(new_name) + end + end, + { buffer = buf, } + ) + vim.keymap.set( + { "n", "i", "x", }, + "", + function () + vim.api.nvim_win_close(win, true) + vim.cmd.stopinsert() + end, + { buffer = buf, } + ) + vim.keymap.set( + { "n", "x", }, + "", + function () + vim.api.nvim_win_close(win, true) + end, + { buffer = buf, } + ) + vim.keymap.set( + { "n", "x", }, + "q", + function () + vim.api.nvim_win_close(win, true) + end, + { buffer = buf, } + ) + + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("^v$", true, false, true), + "n", + true + ) +end + +--- Called when language server attaches +---@param client vim.lsp.Client +---@param bufnr integer +function M:on_attach(client, bufnr) + -- Mappings. + -- See `:help vim.lsp.*` for documentation on any of the below functions + local opts = { buffer = bufnr, } + vim.keymap.set("n", "df", vim.diagnostic.open_float, opts) + vim.keymap.set("n", "[d", vim.diagnostic.goto_prev, opts) + vim.keymap.set("n", "]d", vim.diagnostic.goto_next, opts) + vim.keymap.set("n", "gD", vim.lsp.buf.declaration, opts) + vim.keymap.set({ "n", "i", }, "", vim.lsp.buf.hover, opts) + vim.keymap.set({ "n", "i", }, "", vim.lsp.buf.signature_help, opts) + vim.keymap.set({ "n", "i", }, "", vim.lsp.buf.document_highlight, opts) + vim.keymap.set("n", "lr", self.ca_rename, opts) + vim.keymap.set("n", "la", vim.lsp.buf.code_action, opts) + vim.keymap.set({ "n", "x", }, "lf", vim.lsp.buf.format, opts) + + ---@module "telescope.builtin" + local telescope = utils.try_require("telescope.builtin", module_name) + if telescope then + vim.keymap.set("n", "dl", telescope.diagnostics, opts) + vim.keymap.set("n", "lD", telescope.lsp_type_definitions, opts) + vim.keymap.set("n", "gd", telescope.lsp_definitions, opts) + vim.keymap.set("n", "gi", telescope.lsp_implementations, opts) + vim.keymap.set("n", "gr", telescope.lsp_references, opts) + else + vim.keymap.set("n", "dl", vim.diagnostic.setloclist, opts) + vim.keymap.set("n", "ld", vim.lsp.buf.type_definition, opts) + vim.keymap.set("n", "gd", vim.lsp.buf.definition, opts) + vim.keymap.set("n", "gi", vim.lsp.buf.implementation, opts) + vim.keymap.set("n", "gr", vim.lsp.buf.references, opts) + end + + -- For document highlight + vim.cmd.highlight({ "link LspReferenceRead Visual", bang = true, }) + vim.cmd.highlight({ "link LspReferenceText Visual", bang = true, }) + vim.cmd.highlight({ "link LspReferenceWrite Visual", bang = true, }) + -- vim.api.nvim_create_autocmd({ "CursorHold", "CursorHoldI", }, { + -- buffer = bufnr, + -- callback = vim.lsp.buf.document_highlight, + -- }) + vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI", }, { + buffer = bufnr, + callback = vim.lsp.buf.clear_references, + }) + + -- Auto show signature on insert in function parameters + -- if client.server_capabilities.signatureHelpProvider then + -- local chars = client.server_capabilities.signatureHelpProvider + -- .triggerCharacters + -- if chars and #chars > 0 then + -- vim.api.nvim_create_autocmd("CursorHoldI", { + -- buffer = bufnr, + -- callback = vim.lsp.buf.signature_help, + -- }) + -- end + -- end + + vim.opt.updatetime = 300 + + require("lsp-inlayhints").on_attach(client, bufnr, false) + + vim.lsp.handlers["textDocument/hover"] = vim.lsp.with( + vim.lsp.handlers.hover, { border = "single", } + ) + vim.lsp.handlers["textDocument/signatureHelp"] = vim.lsp.with( + vim.lsp.handlers.signature_help, { border = "single", } + ) +end + +--- Configure the LSP client +function M:configure_client() + local ok, ret = pcall(require, "lspconfig") + if not ok then + utils.err("Missing required plugin lspconfig", module_name) + return + end + local lspconfig = ret + + if self.root_patterns then + self.lspconfig.root_dir = lspconfig.util.root_pattern(unpack(self.root_patterns)) + else + self.lspconfig.root_dir = lspconfig.util.find_git_ancestor + end + + local capabilities = vim.lsp.protocol.make_client_capabilities() + utils.try_require("cmp_nvim_lsp", module_name, function (cmp_nvim_lsp) + capabilities = vim.tbl_deep_extend( + "force", + capabilities, + cmp_nvim_lsp.default_capabilities() + ) + end) + self.lspconfig.capabilities = capabilities + + self.lspconfig.on_attach = function (...) + ok, ret = pcall(self.on_attach, self, ...) + if not ok then + utils.err( + ("Failed to load on_attach for %s:\n%s"):format(self.name, ret), + module_name + ) + end + end + + ok, ret = pcall(lspconfig[self.name].setup, self.lspconfig) + if not ok then + utils.err( + ("Failed to setup LSP server %s with lspconfig: %s"):format(self.name, ret), + module_name + ) + return + end + + self:reload_buffers() +end + +--- Check for and return missing dependencies +---@return table +function M:get_missing_unmanaged_deps() + local missing_deps = {} + + if self.dependencies ~= nil then + for _, dep in ipairs(self.dependencies) do + if not utils.is_installed(dep) then + table.insert(missing_deps, dep) + end + end + end + + if self.py_module_deps ~= nil then + for _, mod in ipairs(self.py_module_deps) do + if not utils.python3_module_is_installed(mod) then + table.insert(missing_deps, "python3-" .. mod) + end + end + end + + return missing_deps +end + +--- Install LSP server +---@param on_done fun(success: boolean)? +function M:install(on_done) + --- Handle install result + ---@param success boolean + local function handle_result(success) + if not success then + self.enable = false + end + + if on_done then + on_done(success) + end + end + + self.mason:install(handle_result) +end + +--- Setup LSP server +function M:setup() + local missing_deps = self:get_missing_unmanaged_deps() + if #missing_deps > 0 then + utils.warn( + ("Disabling %s because the following package(s) are not installed: %s") + :format(self.name, table.concat(missing_deps, ", ")), + module_name + ) + self.enable = false + return + end + + if self.mason then + self:install(function (success) + if success then + self:configure_client() + end + end) + else + if vim.fn.executable(self.lspconfig.cmd[1]) == 1 then + self:configure_client() + else + utils.warn(self.name .. " not installed, disabling", module_name) + self.enable = false + end + end +end + +--- Register autocmd for setting up LSP server upon entering a buffer of related filetype +function M:register() + local augroup = vim.api.nvim_create_augroup("LSP-" .. self.name, {}) + vim.api.nvim_create_autocmd("FileType", { + once = true, + pattern = table.concat(self.lspconfig.filetypes, ","), + callback = vim.schedule_wrap(function () + self:setup() + vim.api.nvim_del_augroup_by_id(augroup) + end), + group = augroup, + }) +end + +--- Create a new instance +---@param config ServerConfig +---@return ServerConfig +function M:new(config) + config = config or {} + + if config.mason then + config.mason = Package:new(config.mason) + end + + setmetatable(config, self) + return config +end + +return M