From b40464892849e8b9d6749b7736a57db74fc20a39 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Wed, 12 Jun 2024 09:05:53 +0200 Subject: [PATCH] feat: add linter config and clean up codebase --- lua/lsp.lua | 17 ++-- lua/lsp/keymap.lua | 34 +++---- lua/lsp/linter.lua | 163 +++++++++++++++++++++++++++++ lua/lsp/package.lua | 243 +++++++++++++++++++++----------------------- lua/lsp/server.lua | 240 ++++++++++++++++++++++++++----------------- lua/utils.lua | 186 ++++++++++++++++++++------------- stylua.toml | 10 ++ 7 files changed, 580 insertions(+), 313 deletions(-) create mode 100644 lua/lsp/linter.lua create mode 100644 stylua.toml diff --git a/lua/lsp.lua b/lua/lsp.lua index 0f77b7d..dfcc95d 100644 --- a/lua/lsp.lua +++ b/lua/lsp.lua @@ -26,11 +26,11 @@ end local reload_server_config = utils.debounce_with_id(function(name, events) utils.info(("Reloading server with new config"):format(name), name) - ---@type Server? + ---@type Server|nil local server = servers[name] if server and server.config.enable then - server:unload() + server:deinit() servers[name] = nil end @@ -54,11 +54,11 @@ local reload_server_config = utils.debounce_with_id(function(name, events) if #server:get_ft_buffers() ~= 0 then server:setup() else - server:register() + server:init() end servers[name] = server -end, 100) +end, 1000) local function process_change(error, filename, events) if error then @@ -103,12 +103,7 @@ local function load_configs() ::continue:: end - vim.uv.fs_event_start( - vim.uv.new_fs_event(), - CONFIG_DIR, - {}, - vim.schedule_wrap(process_change) - ) + vim.uv.fs_event_start(vim.uv.new_fs_event(), CONFIG_DIR, {}, vim.schedule_wrap(process_change)) end --- Setup diagnostics UI @@ -148,7 +143,7 @@ function M.setup() for _, server in pairs(servers) do if server.config.enable then - server:register() + server:init() end end end diff --git a/lua/lsp/keymap.lua b/lua/lsp/keymap.lua index ff32d75..752e01e 100644 --- a/lua/lsp/keymap.lua +++ b/lua/lsp/keymap.lua @@ -21,22 +21,22 @@ M.new = {} --- Load LSP keybinds ---@param server Server -function M:load(server, bufnr) +function M:init(server, bufnr) self.old[bufnr] = {} for _, mode in ipairs(MODE_TYPES) do vim.tbl_extend("error", self.old[bufnr], vim.api.nvim_buf_get_keymap(bufnr, mode)) end self.new[bufnr] = { - { mode = { "n" }, lhs = "df", rhs = vim.diagnostic.open_float }, - { mode = { "n" }, lhs = "[d", rhs = vim.diagnostic.goto_prev }, - { mode = { "n" }, lhs = "]d", rhs = vim.diagnostic.goto_next }, - { mode = { "n" }, lhs = "gD", rhs = vim.lsp.buf.declaration }, - { mode = { "n", "i" }, lhs = "", rhs = vim.lsp.buf.hover }, - { mode = { "n", "i" }, lhs = "", rhs = vim.lsp.buf.signature_help }, - { mode = { "n", "i" }, lhs = "", rhs = vim.lsp.buf.document_highlight }, - { mode = { "n" }, lhs = "lr", rhs = server.ca_rename }, - { mode = { "n" }, lhs = "la", rhs = vim.lsp.buf.code_action }, + { mode = { "n" }, lhs = "df", rhs = vim.diagnostic.open_float }, + { mode = { "n" }, lhs = "[d", rhs = vim.diagnostic.goto_prev }, + { mode = { "n" }, lhs = "]d", rhs = vim.diagnostic.goto_next }, + { mode = { "n" }, lhs = "gD", rhs = vim.lsp.buf.declaration }, + { mode = { "n", "i" }, lhs = "", rhs = vim.lsp.buf.hover }, + { mode = { "n", "i" }, lhs = "", rhs = vim.lsp.buf.signature_help }, + { mode = { "n", "i" }, lhs = "", rhs = vim.lsp.buf.document_highlight }, + { mode = { "n" }, lhs = "lr", rhs = server.ca_rename }, + { mode = { "n" }, lhs = "la", rhs = vim.lsp.buf.code_action }, { mode = { "n", "x" }, lhs = "lf", rhs = vim.lsp.buf.format }, { mode = { "n", "i" }, @@ -57,17 +57,17 @@ function M:load(server, bufnr) vim.list_extend(self.new[bufnr], { { mode = "n", lhs = "dl", rhs = telescope.diagnostics }, { mode = "n", lhs = "lD", rhs = telescope.lsp_type_definitions }, - { mode = "n", lhs = "gd", rhs = telescope.lsp_definitions }, - { mode = "n", lhs = "gi", rhs = telescope.lsp_implementations }, - { mode = "n", lhs = "gr", rhs = telescope.lsp_references }, + { mode = "n", lhs = "gd", rhs = telescope.lsp_definitions }, + { mode = "n", lhs = "gi", rhs = telescope.lsp_implementations }, + { mode = "n", lhs = "gr", rhs = telescope.lsp_references }, }) else vim.list_extend(self.new[bufnr], { { mode = "n", lhs = "dl", rhs = vim.diagnostic.setloclist }, { mode = "n", lhs = "ld", rhs = vim.lsp.buf.type_definition }, - { mode = "n", lhs = "gd", rhs = vim.lsp.buf.definition }, - { mode = "n", lhs = "gi", rhs = vim.lsp.buf.implementation }, - { mode = "n", lhs = "gr", rhs = vim.lsp.buf.references }, + { mode = "n", lhs = "gd", rhs = vim.lsp.buf.definition }, + { mode = "n", lhs = "gi", rhs = vim.lsp.buf.implementation }, + { mode = "n", lhs = "gr", rhs = vim.lsp.buf.references }, }) end @@ -81,7 +81,7 @@ function M:load(server, bufnr) end end -function M:unload(bufnr) +function M:deinit(bufnr) if self.new[bufnr] then for _, keymap in ipairs(self.new[bufnr]) do -- pcall to avoid error if keymap was already removed, diff --git a/lua/lsp/linter.lua b/lua/lsp/linter.lua new file mode 100644 index 0000000..d034bbc --- /dev/null +++ b/lua/lsp/linter.lua @@ -0,0 +1,163 @@ +local utils = require("utils") + +---@class Linter +---@field name string +---@field namespace number +---@field augroup number +---@field buffers number[] +---@field config LinterConfig +M = {} +M.__index = M + +---@alias Group +---| "lnum" +---| "col" +---| "severity" +---| "message" + +---@class LinterConfig +---@field cmd string[] +---@field stdin? boolean +---@field stdout? boolean +---@field stderr? boolean +---@field pattern string +---@field groups Group[] +---@field severity_map table +---@field source? string +---@field debounce? number +M.config = {} + +function M.validate(name, config) + local ok, resp = pcall(vim.validate, { + name = { name, "string" }, + config = { config, "table" }, + }) + + if ok then + ok, resp = pcall(vim.validate, { + cmd = { + config.cmd, + function(t) + return utils.is_list(t, "string") + end, + "list of strings", + }, + stdin = { config.stdin, "boolean", true }, + stdout = { config.stdout, "boolean", true }, + stderr = { config.stderr, "boolean", true }, + pattern = { config.pattern, "string" }, + groups = { + config.groups, + function(t) + return utils.is_list(t, "string") + end, + "list of strings", + }, + severity_map = { + config.severity_map, + function(t) + return utils.is_map(t, "string", "number") + end, + "map of string and number", + }, + debounce = { config.debounce, "number", true }, + source = { config.source, "string", true }, + }) + end + + if not ok then + utils.err(("Invalid config for linter:\n%s"):format(resp)) + return false + end + + return true +end + +function M:run(bufnr) + local input + -- TODO: add placeholder variables for when not using stdin + if self.config.stdin then + input = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + end + + vim.system( + self.config.cmd, + { stdin = input }, + vim.schedule_wrap(function(out) + local output + if self.config.stdout then + output = out.stdout or "" + end + if self.config.stderr then + output = out.stderr or "" + end + + local output_lines = vim.fn.split(output, "\n", false) + local diagnostics = {} + for _, line in ipairs(output_lines) do + local ok, resp = pcall( + vim.diagnostic.match, + line, + self.config.pattern, + self.config.groups, + self.config.severity_map + ) + if not ok then + utils.err(tostring(resp)) + return + elseif not resp then + utils.err(("Failed to parse linter output:\n%s"):format(line)) + end + + resp.source = self.config.source + table.insert(diagnostics, resp) + end + vim.diagnostic.set(self.namespace, bufnr, diagnostics) + end) + ) +end + +function M:init(bufnr) + table.insert(self.buffers, bufnr) + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + buffer = bufnr, + callback = utils.debounce(function() + self:run(bufnr) + end, self.config.debounce), + group = self.augroup, + }) + self:run(bufnr) +end + +function M:deinit() + for _, bufnr in ipairs(self.buffers) do + vim.api.nvim_buf_clear_namespace(bufnr, self.namespace, 0, -1) + end + self.buffers = {} + + vim.api.nvim_clear_autocmds({ group = self.augroup }) +end + +--- Create a new instance +---@param name string +---@param config LinterConfig +---@return Linter|nil +function M.new(name, config) + if not M.validate(name, config) then + return + end + + config.debounce = config.debounce or 100 + + local linter = { + name = name, + namespace = vim.api.nvim_create_namespace(name), + augroup = vim.api.nvim_create_augroup(name, {}), + buffers = {}, + config = config, + } + + return setmetatable(linter, M) +end + +return M diff --git a/lua/lsp/package.lua b/lua/lsp/package.lua index b721d0a..e3c8719 100644 --- a/lua/lsp/package.lua +++ b/lua/lsp/package.lua @@ -1,100 +1,96 @@ local utils = require("utils") ---@class PostInstallStep ----@field command string ----@field args string[] +---@field cmd string[] ---@class MasonPackage ----@field dependencies MasonPackage[]? +---@field dependencies? MasonPackage[] ---@field config MasonPackageConfig local M = {} M.__index = M ---@class MasonPackageConfig ----@field name string? ----@field version string? ----@field dependencies MasonPackageConfig[]? ----@field post_install PostInstallStep[]? +---@field [1]? string +---@field name? string +---@field version? string +---@field dependencies? string[]|MasonPackageConfig[] +---@field post_install? PostInstallStep[] M.config = {} --- Validate MasonPackageConfig ----@param config MasonPackageConfig +---@param config string|MasonPackageConfig ---@return boolean function M.validate(config) local ok, resp = pcall(vim.validate, { config = { config, { "table" } } }) - if not ok then - goto check_resp - end - - ok, resp = pcall( - vim.validate, { - name = { config.name, { "string" }, true }, + if ok then + ok, resp = pcall(vim.validate, { + name = { config.name or config[1], { "string" }, true }, version = { config.version, { "string" }, true }, dependencies = { - config.dependencies, function(f) - if not f then - return true - end + config.dependencies, + function(f) + if not f then + return true + end - if not utils.is_list(f) then - return false - end - - for _, dep in ipairs(f) do - if not M.validate(dep) then + if utils.is_list(f, "string") then + return true + elseif not utils.is_list(f) then return false end - end - return true - end, + for _, dep in ipairs(f) do + if not M.validate(dep) then + return false + end + end + + return true + end, + "list of dependencies", }, post_install = { - config.post_install, function(field) - if not field then - return true - end - - if not utils.is_list(field) then - return false - end - - for _, step in ipairs(field) do - local o, r = pcall(vim.validate, { step = { step, { "table" } } }) - if not ok then - goto check_r + config.post_install, + function(field) + if not field then + return true end - o, r = pcall( - vim.validate, { - command = { step.command, { "string" } }, - args = { - step.args, function(f) - return utils.is_list_or_nil(f, "string") - end, "list of strings or nil", - }, - } - ) - - ::check_r:: - - if not o then - utils.err(("Invalid config for post_install step: %s"):format(r)) + if not utils.is_list(field) then return false end + for _, step in ipairs(field) do + local o, r = pcall(vim.validate, { step = { step, { "table" } } }) + if o then + o, r = pcall(vim.validate, { + cmd = { + step.cmd, + function(f) + return utils.is_list(f, "string") + end, + "list of strings or nil", + }, + }) + end + + if not o then + utils.err(("Invalid config for post_install step: %s"):format(r)) + return false + end + + return true + end + return true - end - - return true - end, + end, + "list of steps", }, - } - ) + }) + end - ::check_resp:: if not ok then - utils.err(("Invalid config for %s:\n%s"):format(config.name, resp)) + utils.err(("Invalid config for %s:\n%s"):format(config.name or config[1] or config, resp)) return false end @@ -107,52 +103,47 @@ function M:run_post_install(pkg) ---@param step PostInstallStep ---@param msg string local function log_err(step, msg) - local cmd = step.command - - if step.args then - cmd = cmd .. " " .. table.concat(step.args, " ") - end + local cmd = table.concat(step.cmd, " ") utils.err( ("Post installation step for %s:\n`%s`\nfailed with:\n%s"):format( - self.config.name, cmd, msg - ), "lsp.package:run_post_install" + self.config.name, + cmd, + msg + ), + "lsp.package:run_post_install" ) end if self.config.post_install then - utils.info("running post install") for _, step in ipairs(self.config.post_install) do + utils.info("running post install step") local cwd = pkg:get_install_path() - local command = step.command + local args = step.cmd + local prog = table.remove(args, 1) - if command:find("[/\\]") then - command = vim.fn.resolve(("%s/%s"):format(cwd, command)) + if prog:find("[/\\]") then + prog = vim.fn.resolve(("%s/%s"):format(cwd, prog)) end - if not utils.is_executable(command) then + if not utils.is_executable(prog) then log_err(step, "command not executable") return end - 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 = command - if step.args then - cmd = cmd .. " " .. table.concat(step.args, " ") - end - - log_err(step, table.concat(job:stderr_result(), "\n")) - end - end, - } - ) + local job = require("plenary.job"):new({ + command = prog, + args = args, + cwd = pkg:get_install_path(), + enabled_recording = true, + on_exit = vim.schedule_wrap(function(job, code, signal) + if code ~= 0 or signal ~= 0 then + log_err(step, table.concat(job:stderr_result(), "\n")) + return + end + utils.info("post install step done") + end), + }) job:start() end end @@ -186,42 +177,40 @@ function M:mason_install(on_done) local err handle:on( - "stderr", vim.schedule_wrap( - function(msg) - err = (err or "") .. msg - end - ) + "stderr", + vim.schedule_wrap(function(msg) + err = (err or "") .. msg + end) ) handle:once( - "closed", vim.schedule_wrap( - function() - local is_installed = pkg:is_installed() + "closed", + vim.schedule_wrap(function() + local is_installed = pkg:is_installed() - if is_installed then - utils.info( - ("Successfully installed %s"):format(self.config.name), - "lsp.package:mason_install" - ) - self:run_post_install(pkg) + if is_installed then + utils.info( + ("Successfully installed %s"):format(self.config.name), + "lsp.package:mason_install" + ) + self:run_post_install(pkg) + else + if err then + err = ":\n" .. err else - if err then - err = ":\n" .. err - else - err = "" - end - - utils.err( - ("Failed to install %s%s"):format(self.config.name, err), - "lsp.package:mason_install" - ) + err = "" end - if on_done then - on_done(is_installed) - end + utils.err( + ("Failed to install %s%s"):format(self.config.name, err), + "lsp.package:mason_install" + ) end - ) + + if on_done then + on_done(is_installed) + end + end) ) end @@ -276,15 +265,19 @@ function M:install(on_done) end --- Create a new instance ----@param config MasonPackageConfig? ----@return MasonPackage? +---@param config MasonPackageConfig|string +---@return MasonPackage|nil function M.new(config) - config = config or {} + if type(config) == "string" then + config = { config } + end if not M.validate(config) then return end + config.name = config.name or config[1] + local pkg = { config = config } if pkg.config.dependencies then diff --git a/lua/lsp/server.lua b/lua/lsp/server.lua index ee945b8..13d4e61 100644 --- a/lua/lsp/server.lua +++ b/lua/lsp/server.lua @@ -1,33 +1,44 @@ -local utils = require("utils") local keymap = require("lsp.keymap") +local utils = require("utils") + +---@class Linter +local Linter = require("lsp.linter") + ---@class MasonPackage local MasonPackage = require("lsp.package") + -- override type, seems to be incorrect in either lspconfig or vim.lsp ---@class lspconfig.Config ---@field root_dir function ---@class Server ----@field name string? ----@field mason MasonPackage? ----@field client vim.lsp.Client? ----@field attached_buffers number[]? +---@field name? string +---@field mason? MasonPackage +---@field client? vim.lsp.Client +---@field attached_buffers? number[] ---@field manager lspconfig.Manager +---@field linters? Linter[] ---@field config ServerConfig local M = {} + M.__index = M + ---@class ServerConfig ----@field enable boolean? ----@field dependencies string[]? ----@field mason MasonPackageConfig? ----@field root_patterns string[]? ----@field keymaps Keymap[]? ----@field lspconfig lspconfig.Config? +---@field enable? boolean +---@field dependencies? string[] +---@field mason? string|MasonPackageConfig +---@field root_patterns? string[] +---@field keymaps? Keymap[] +---@field linters? LinterConfig[] +---@field lspconfig? lspconfig.Config M.config = {} + --- Validate ServerConfig ---@param config ServerConfig ---@return boolean function M.validate(name, config) local ok, resp = pcall(vim.validate, { config = { config, { "table" } } }) + if ok then ok, resp = pcall(vim.validate, { enable = { config.enable, { "boolean" }, true }, @@ -35,57 +46,69 @@ function M.validate(name, config) config.dependencies, function(f) return utils.is_list_or_nil(f, "string") - end, "list of strings or nil", - }, - mason = { - config.mason, function(f) - if f == nil then return true end - return MasonPackage.validate(f) - end, + end, + "list of strings or nil", }, + mason = { config.mason, { "string", "table" }, true }, root_patterns = { config.root_patterns, function(f) return utils.is_list_or_nil(f, "string") - end, "list of strings or nil", + end, + "list of strings or nil", }, keymaps = { - config.keymaps, function(f) - if not f then return true end - if not utils.is_list(f, "table") then - return false - end - for _, key in ipairs(f) do - local o, r = pcall(vim.validate, { - mode = { key.mode, { "s", "t" } }, - lhs = { key.lhs, "s" }, - rhs = { key.rhs, { "s", "f" } }, - opts = { key.opts, "t", true }, - }) - if not o then - utils.err(("Invalid keymap:\n%s"):format(r)) + config.keymaps, + function(f) + if not f then + return true + end + + if not utils.is_list(f, "table") then return false end - end - return true - end, "list of keymaps", + + for _, key in ipairs(f) do + local o, r = pcall(vim.validate, { + mode = { key.mode, { "s", "t" } }, + lhs = { key.lhs, "s" }, + rhs = { key.rhs, { "s", "f" } }, + opts = { key.opts, "t", true }, + }) + + if not o then + utils.err(("Invalid keymap:\n%s"):format(r)) + return false + end + end + + return true + end, + "list of keymaps", }, lspconfig = { config.lspconfig, { "table" }, true }, }) end + if not ok then utils.err(("Invalid config for %s:\n%s"):format(name, resp)) return false end + return true end --- Rename Code Action function M.ca_rename() local ts_utils = utils.try_require("nvim-treesitter.ts_utils") - if not ts_utils then return end + if not ts_utils then + return + end local identifier_types = { - "IDENTIFIER", "identifier", "variable_name", "word", + "IDENTIFIER", + "identifier", + "variable_name", + "word", } local node = ts_utils.get_node_at_cursor() if not node or not vim.list_contains(identifier_types, node:type()) then @@ -98,8 +121,7 @@ function M.ca_rename() 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 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", @@ -116,22 +138,20 @@ function M.ca_rename() }) 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 + 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 + end, + }) vim.keymap.set({ "n", "i", "x" }, "", function() local content = vim.api.nvim_buf_get_lines(buf, 0, -1, false) @@ -146,15 +166,14 @@ function M.ca_rename() 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.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) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("^v$", true, false, true), "n", true) end --- Called when language server attaches @@ -168,7 +187,12 @@ function M:on_attach(client, bufnr) self.attached_buffers = self.attached_buffers or {} table.insert(self.attached_buffers, bufnr) - keymap:load(self, bufnr) + keymap:init(self, bufnr) + if self.linters then + for _, linter in ipairs(self.linters) do + linter:init(bufnr) + end + end -- For document highlight vim.cmd.highlight({ "link LspReferenceRead Visual", bang = true }) @@ -178,9 +202,8 @@ function M:on_attach(client, bufnr) 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/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" }) @@ -205,8 +228,8 @@ function M:configure_client() local cmp_nvim_lsp = utils.try_require("cmp_nvim_lsp") if cmp_nvim_lsp then - capabilities = vim.tbl_deep_extend("force", capabilities, - cmp_nvim_lsp.default_capabilities()) + capabilities = + vim.tbl_deep_extend("force", capabilities, cmp_nvim_lsp.default_capabilities()) end -- local epo = utils.try_require("epo") @@ -230,19 +253,31 @@ function M:configure_client() if not ok then utils.err( ("Failed to load on_attach for %s:\n%s"):format(self.name, ret), - "lsp.server:configure_client") + "lsp.server:configure_client" + ) end end + local ok, ret = pcall(lspconfig[self.name].setup, self.config.lspconfig) if not ok then - utils.err(("Failed to setup LSP server %s with lspconfig: %s"):format( - self.name, ret)) + utils.err(("Failed to setup LSP server %s with lspconfig: %s"):format(self.name, ret)) return end + self.manager = lspconfig[self.name].manager for _, bufnr in ipairs(self:get_ft_buffers()) do self.manager:try_add_wrapper(bufnr) end + + if self.config.linters then + self.linters = {} + for i, config in ipairs(self.config.linters) do + local linter = Linter.new(("%s_linter%d"):format(self.name, i), config) + if linter then + table.insert(self.linters, linter) + end + end + end end function M:get_ft_buffers() @@ -278,8 +313,12 @@ function M:install(on_done) --- Handle install result ---@param success boolean local function handle_result(success) - if not success then self.config.enable = false end - if on_done then on_done(success) end + if not success then + self.config.enable = false + end + if on_done then + on_done(success) + end end self.mason:install(handle_result) @@ -291,13 +330,18 @@ function M:setup() 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, ", "))) + self.name, + table.concat(missing_deps, ", ") + ) + ) self.config.enable = false return end if self.mason then self:install(function(success) - if success then self:configure_client() end + if success then + self:configure_client() + end end) elseif vim.fn.executable(self.config.lspconfig.cmd[1]) == 1 then self:configure_client() @@ -307,51 +351,65 @@ function M:setup() end end ---- Register autocmd for setting up LSP server upon entering a buffer of related filetype -function M:register() +--- Load autocmd for setting up LSP server upon entering a buffer of related filetype +function M:init() local group = vim.api.nvim_create_augroup("lsp_bootstrap_" .. self.name, {}) vim.api.nvim_create_autocmd("FileType", { once = true, pattern = self.config.lspconfig.filetypes or {}, - callback = function() self:setup() end, + callback = function() + self:setup() + end, group = group, }) end -function M:unload() +function M:deinit() if self.attached_buffers then for _, bufnr in ipairs(self.attached_buffers) do - keymap:unload(bufnr) + keymap:deinit(bufnr) end end + if self.client then self.client.stop() self.client = nil end + vim.api.nvim_clear_autocmds({ group = "lsp_bootstrap_" .. self.name }) + if self.linters then + for _, linter in ipairs(self.linters) do + linter:deinit() + end + + self.linters = nil + end + require("lspconfig")[self.name] = nil end --- Create a new instance ---@param name string ----@param config ServerConfig? ----@return Server? +---@param config? ServerConfig +---@return Server|nil function M.new(name, config) config = config or {} - if not M.validate(name, config) then return end - local ok, resp = pcall(require, "lspconfig.server_configurations." .. name) - if not ok then - utils.err(("Server with name %s does not exist in lspconfig"):format( - name)) + if not M.validate(name, config) then return end - config.lspconfig = vim.tbl_deep_extend("keep", config.lspconfig or {}, - resp.default_config) + local ok, resp = pcall(require, "lspconfig.server_configurations." .. name) + if not ok then + utils.err(("Server with name %s does not exist in lspconfig"):format(name)) + return + end + config.lspconfig = vim.tbl_deep_extend("keep", config.lspconfig or {}, resp.default_config) local server = { name = name, config = config } if server.config.mason then local pkg = MasonPackage.new(server.config.mason) - if pkg then server.mason = pkg end + if pkg then + server.mason = pkg + end end return setmetatable(server, M) end diff --git a/lua/utils.lua b/lua/utils.lua index d8f638b..1bada3e 100644 --- a/lua/utils.lua +++ b/lua/utils.lua @@ -4,7 +4,7 @@ M.os_name = vim.uv.os_uname().sysname --- Get the module path of a file ---@param file string ----@return string? +---@return string|nil local function get_module_path(file) for _, rtp in ipairs(vim.api.nvim_list_runtime_paths()) do if file:sub(1, #rtp) == rtp then @@ -21,13 +21,13 @@ end --- Send a notification ---@param msg string Message to send ----@param title string? Title of notification +---@param title? string Title of notification ---@param level integer Log level local function notify(msg, title, level) if not title then local info = debug.getinfo(3) local file = info.source - and (info.source:sub(1, 1) == "@" and info.source:sub(2) or info.source) + and (info.source:sub(1, 1) == "@" and info.source:sub(2) or info.source) or nil local module = file and (get_module_path(file) or file) or nil title = module and module .. (info.name and info.name ~= "" and ":" .. info.name or "") @@ -97,28 +97,28 @@ end --- Send a debug notification ---@param msg string Message to send ----@param title string? Title of notification +---@param title? string Title of notification function M.debug(msg, title) notify(msg, title, vim.log.levels.DEBUG) end --- Send an info notification ---@param msg string Message to send ----@param title string? Title of notification +---@param title? string Title of notification function M.info(msg, title) notify(msg, title, vim.log.levels.INFO) end --- Send a warning notification ---@param msg string Message to send ----@param title string? Title of notification +---@param title? string Title of notification function M.warn(msg, title) notify(msg, title, vim.log.levels.WARN) end --- Send an error notification ---@param msg string Message to send ----@param title string? Title of notification +---@param title? string Title of notification function M.err(msg, title) notify(msg, title, vim.log.levels.ERROR) end @@ -136,21 +136,29 @@ function M.try_require(module) M.err(("Failed to load module %s:\n%s"):format(module, resp)) end +--- Checks if it is possible to require a module +---@param module string +---@return boolean +function M.has_module(module) + local has_module, _ = pcall(require, module) + return has_module +end + ---@class FormatOptions ---@field cmd string[] Command to run. The following keywords get replaces by the specified values: --- * %file% - path to the current file +--- * %filename% - name of the current file --- * %row_start% - first row of selection --- * %row_end% - last row of selection --- * %col_start% - first column position of selection --- * %col_end% - last column position of selection --- * %byte_start% - byte count of first cell in selection --- * %byte_end% - byte count of last cell in selection ----@field stdin boolean? Pass text to stdin. False by default. ----@field stdout boolean? Use stdout as the result. False by default. ----@field stderr boolean? Use stderr as the result. False by default. ----@field in_place boolean? The file is formatted in-place by `cmd`. False by default. ----@field auto_indent boolean? Perform auto indent on formatted range. False by default. ----@field selection boolean? Only format the currently selected lines. False by default. +---@field stdin? boolean Pass text to stdin. Assumes in-place formatting on False. False by default. +---@field stdout? boolean Use stdout as the result. False by default. +---@field stderr? boolean Use stderr as the result. False by default. +---@field auto_indent? boolean Perform auto indent on formatted range. False by default. +---@field only_selection? boolean Only send the selected lines to `stdin`. False by default. --- Format buffer ---@param opts FormatOptions @@ -160,23 +168,20 @@ function M.format(opts) stdin = opts.stdin or false, stdout = opts.stdout or false, stderr = opts.stderr or false, - in_place = opts.in_place or false, auto_indent = opts.auto_indent or false, - selection = opts.selection or false, + only_selection = opts.only_selection or false, } - if not opts.in_place and not opts.stdout and not opts.stderr then - M.err("One of `in_place`, `stdout` or `stderr` must be true.") + if opts.stdin and not (opts.stdout or opts.stderr) then + M.err("`stdin` requires that one of `stdout` or `stderr` is set") return - elseif opts.in_place and (opts.selection or opts.stdin or opts.stdout or opts.stderr) then - M.err( - "`in_place` is not valid together with any of " - .. "`selection`, `stdin`, `stdout` or `stderr`" - ) + elseif (opts.only_selection or opts.stdout or opts.stderr) and not opts.stdin then + M.err("`stdout`, `stderr` and `only_selection` requires `stdin` to be set") return end local file = vim.fn.expand("%") + local filename = vim.fn.expand("%:t") local mode = vim.fn.mode() local is_visual = mode == "v" or mode == "V" or mode == "" @@ -199,18 +204,19 @@ function M.format(opts) end local input - if opts.selection then + if opts.only_selection then input = vim.api.nvim_buf_get_lines(0, row_start - 1, row_end, false) - else + elseif opts.stdin then input = vim.api.nvim_buf_get_lines(0, 0, -1, false) end - if opts.in_place then + if not opts.stdin then vim.api.nvim_buf_call(0, vim.cmd.write) end for i, arg in ipairs(opts.cmd) do arg = arg:gsub("%%file%%", file) + arg = arg:gsub("%%filename%%", filename) if is_visual then arg = arg:gsub("%%row_start%%", row_start) arg = arg:gsub("%%row_end%%", row_end) @@ -222,58 +228,100 @@ function M.format(opts) opts.cmd[i] = arg end - vim.system( - opts.cmd, - { - stdin = opts.stdin and input or nil, - }, - vim.schedule_wrap(function(out) - if out.code ~= 0 or out.signal ~= 0 then - local err = out.stderr or "" - M.err(("Failed to format:\n%s"):format(err)) - return + local stdout, stderr, err + local resp = vim.system(opts.cmd, { + stdin = opts.stdin and input or nil, + stdout = opts.stdout and function(e, data) + if data then + stdout = stdout and stdout .. data or data end - if opts.in_place then - vim.api.nvim_buf_call(0, vim.cmd.edit) + if e then + err = err and err .. e or e + end + end, + stderr = opts.stderr and function(e, data) + if data then + stderr = stderr and stderr .. data or data + end + + if e then + err = err and err .. e or e + end + end, + }):wait() + + if err then + M.err("Error during formatting:\n%s" .. err) + return + end + + if resp.code ~= 0 or resp.signal ~= 0 then + M.err(("Failed to format:\n%s"):format(stderr or "")) + return + end + + if not opts.stdin then + vim.api.nvim_buf_call(0, vim.cmd.edit) + else + local output + if opts.stdout then + output = stdout or "" + end + if opts.stderr then + output = stderr or "" + end + + output = output:gsub("\n$", "") + local output_lines = vim.fn.split(output, "\n", true) + + if opts.only_selection then + vim.api.nvim_buf_set_lines(0, row_start - 1, row_end, false, output_lines) + else + vim.api.nvim_buf_set_lines(0, 0, -1, false, output_lines) + end + + if opts.auto_indent then + if is_visual then + vim.api.nvim_command( + ("%d,%dnormal! =="):format(row_start, row_start + #output_lines) + ) else - local output - if opts.stdout then - output = out.stdout or "" - end - if opts.stderr then - output = out.stderr or "" - end - - output = output:gsub("\n$", "") - local output_lines = vim.fn.split(output, "\n", true) - - if opts.selection then - vim.api.nvim_buf_set_lines(0, row_start - 1, row_end, false, output_lines) - else - vim.api.nvim_buf_set_lines(0, 0, -1, false, output_lines) - end - - if opts.auto_indent then - if is_visual then - vim.api.nvim_command( - ("%d,%dnormal! =="):format(row_start, row_start + #output_lines) - ) - else - vim.api.nvim_command("normal! gg=G") - end - end + vim.api.nvim_command("normal! gg=G") end - end) - ) + end + end end --- Check if `val` is a list of type `t` (if given) ---@param val any ----@param t type? +---@param kt type +---@param vt type +---@return boolean +function M.is_map(val, kt, vt) + if type(val) ~= "table" then + return false + end + + for k, v in pairs(val) do + if type(k) ~= kt then + return false + end + + if type(v) ~= vt then + return false + end + end + + return true +end + +--- Check if `val` is a list of type `t` (if given) +---@param val any +---@param t? type ---@return boolean function M.is_list(val, t) - if type(val) ~= "table" then + if not vim.tbl_islist(val) then return false end @@ -291,8 +339,8 @@ function M.is_list(val, t) end --- Check if `val` is a list of type `t` (if given), or nil ----@param val any? ----@param t type? +---@param val? any +---@param t? type ---@return boolean function M.is_list_or_nil(val, t) if val == nil then diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..4329fd2 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,10 @@ +column_width = 100 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 4 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" +collapse_simple_statement = "Never" + +[sort_requires] +enabled = true