feat: refactor lsp configs and drop nvim-cmp
This commit is contained in:
@@ -1,540 +0,0 @@
|
||||
---@type fun(name: string, cfg: vim.lsp.Config)
|
||||
vim.lsp.config = vim.lsp.config
|
||||
|
||||
local Keymap = require("lsp.keymap")
|
||||
local Linter = require("lsp.linter")
|
||||
local log = require("log")
|
||||
local util = require("util")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.diagnostic_signs = {
|
||||
text = {
|
||||
[vim.diagnostic.severity.ERROR] = "E",
|
||||
[vim.diagnostic.severity.WARN] = "W",
|
||||
[vim.diagnostic.severity.INFO] = "I",
|
||||
[vim.diagnostic.severity.HINT] = "H",
|
||||
},
|
||||
}
|
||||
|
||||
---@param server string
|
||||
---@param fn? fun(client: vim.lsp.Client, bufnr: integer)
|
||||
---@return fun(client: vim.lsp.Client, bufnr: integer)
|
||||
function M.with_defaults(server, fn)
|
||||
local default_cb = vim.lsp.config[server].on_attach
|
||||
return function(client, bufnr)
|
||||
if default_cb then
|
||||
default_cb(client, bufnr)
|
||||
end
|
||||
|
||||
Keymap.set_defaults(bufnr)
|
||||
|
||||
-- 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 })
|
||||
|
||||
client.settings = M.with_file(
|
||||
string.format(".%s.json", client.name),
|
||||
client.settings
|
||||
) or client.settings
|
||||
|
||||
if fn then
|
||||
fn(client, bufnr)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Load a JSON file and return a parsed table merged with settings
|
||||
---@param path string
|
||||
---@param settings? table
|
||||
---@return table?
|
||||
function M.with_file(path, settings)
|
||||
local file = io.open(path, "r")
|
||||
if not file then
|
||||
return
|
||||
end
|
||||
|
||||
local json = file:read("*all")
|
||||
file:close()
|
||||
local ok, resp = pcall(
|
||||
vim.json.decode,
|
||||
json,
|
||||
{ luanil = { object = true, array = true } }
|
||||
)
|
||||
if not ok then
|
||||
log.warning("Failed to parse json file %s: %s", path, resp)
|
||||
return
|
||||
end
|
||||
|
||||
return vim.tbl_deep_extend("force", settings or {}, resp)
|
||||
end
|
||||
|
||||
function M.setup()
|
||||
vim.diagnostic.config({
|
||||
underline = true,
|
||||
signs = M.diagnostic_signs,
|
||||
virtual_text = false,
|
||||
float = {
|
||||
show_header = false,
|
||||
source = true,
|
||||
border = "rounded",
|
||||
focusable = true,
|
||||
format = function(diagnostic)
|
||||
return string.format("%s", diagnostic.message)
|
||||
end,
|
||||
width = 80,
|
||||
},
|
||||
update_in_insert = false,
|
||||
severity_sort = true,
|
||||
jump = {
|
||||
float = true,
|
||||
wrap = false,
|
||||
},
|
||||
})
|
||||
|
||||
vim.lsp.enable({
|
||||
"bashls",
|
||||
"clangd",
|
||||
"cmake",
|
||||
"gopls",
|
||||
"hyprls",
|
||||
"intelephense",
|
||||
-- "jedi_language_server",
|
||||
"lemminx",
|
||||
"lua_ls",
|
||||
"mesonlsp",
|
||||
-- "phpactor",
|
||||
-- "pyrefly",
|
||||
"pyright",
|
||||
"ruff",
|
||||
"rust_analyzer",
|
||||
"zls",
|
||||
})
|
||||
|
||||
local capabilities = vim.lsp.protocol.make_client_capabilities()
|
||||
local cmp_nvim_lsp = util.try_require("cmp_nvim_lsp")
|
||||
if cmp_nvim_lsp then
|
||||
capabilities = vim.tbl_deep_extend(
|
||||
"force",
|
||||
capabilities,
|
||||
cmp_nvim_lsp.default_capabilities()
|
||||
)
|
||||
end
|
||||
|
||||
vim.lsp.config("*", {
|
||||
on_attach = M.with_defaults("*"),
|
||||
capabilities = capabilities,
|
||||
})
|
||||
|
||||
vim.lsp.config("bashls", {
|
||||
filetypes = {
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
},
|
||||
on_attach = M.with_defaults("bashls", function(_, bufnr)
|
||||
Keymap.set(bufnr, {
|
||||
{
|
||||
mode = "n",
|
||||
lhs = "<leader>lf",
|
||||
rhs = function()
|
||||
util.format({
|
||||
buf = bufnr,
|
||||
cmd = { "shfmt", "-s", "-i", "4", "-" },
|
||||
output = "stdout",
|
||||
})
|
||||
end,
|
||||
},
|
||||
})
|
||||
end),
|
||||
})
|
||||
|
||||
vim.lsp.config("clangd", {
|
||||
filetypes = {
|
||||
"c",
|
||||
"cpp",
|
||||
},
|
||||
cmd = {
|
||||
"clangd",
|
||||
"--clang-tidy",
|
||||
"--enable-config",
|
||||
-- Fix for errors in files outside of project
|
||||
-- https://clangd.llvm.org/faq#how-do-i-fix-errors-i-get-when-opening-headers-outside-of-my-project-directory
|
||||
"--compile-commands-dir=build",
|
||||
},
|
||||
single_file_support = true,
|
||||
on_attach = M.with_defaults("clangd", function(_, bufnr)
|
||||
Linter.add(bufnr, {
|
||||
cmd = {
|
||||
"clang-tidy",
|
||||
"-p=build",
|
||||
"--quiet",
|
||||
"--checks=-*,"
|
||||
.. "clang-analyzer-*,"
|
||||
.. "-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling,"
|
||||
.. "-clang-analyzer-security.insecureAPI.strcpy",
|
||||
"%file%",
|
||||
},
|
||||
events = { "BufWritePost" },
|
||||
clear_events = { "TextChanged", "TextChangedI" },
|
||||
stdin = false,
|
||||
stdout = true,
|
||||
pattern = "^.+:(%d+):(%d+): (%w+): (.*) %[(.*)%]$",
|
||||
groups = { "lnum", "col", "severity", "message", "code" },
|
||||
source = "clang-tidy",
|
||||
severity_map = {
|
||||
error = vim.diagnostic.severity.ERROR,
|
||||
warning = vim.diagnostic.severity.WARN,
|
||||
note = vim.diagnostic.severity.HINT,
|
||||
},
|
||||
zero_idx_col = true,
|
||||
zero_idx_lnum = true,
|
||||
ignore_stderr = true,
|
||||
})
|
||||
Keymap.set(bufnr, {
|
||||
{
|
||||
mode = "n",
|
||||
lhs = "gs",
|
||||
rhs = vim.cmd.LspClangdSwitchSourceHeader,
|
||||
},
|
||||
})
|
||||
end),
|
||||
})
|
||||
|
||||
vim.lsp.config("cmake", {
|
||||
init_options = {
|
||||
buildDirectory = "build",
|
||||
},
|
||||
})
|
||||
|
||||
vim.lsp.config("gopls", {
|
||||
settings = {
|
||||
gopls = {
|
||||
staticcheck = true,
|
||||
semanticTokens = true,
|
||||
},
|
||||
},
|
||||
on_attach = M.with_defaults("gopls", function(_, bufnr)
|
||||
Keymap.set(bufnr, {
|
||||
{
|
||||
mode = "n",
|
||||
lhs = "<leader>lf",
|
||||
rhs = function()
|
||||
util.format({
|
||||
buf = bufnr,
|
||||
cmd = {
|
||||
"golines",
|
||||
"-m",
|
||||
"80",
|
||||
"--shorten-comments",
|
||||
},
|
||||
output = "stdout",
|
||||
})
|
||||
vim.lsp.buf.format({ async = true })
|
||||
end,
|
||||
},
|
||||
})
|
||||
end),
|
||||
})
|
||||
|
||||
vim.lsp.config("intelephense", {
|
||||
settings = {
|
||||
intelephense = {
|
||||
environment = {
|
||||
phpVersion = "8.4",
|
||||
},
|
||||
format = {
|
||||
enable = true,
|
||||
braces = "psr12",
|
||||
},
|
||||
},
|
||||
},
|
||||
on_attach = M.with_defaults("intelephense", function(_, bufnr)
|
||||
Linter.add(bufnr, {
|
||||
cmd = {
|
||||
"phpcs",
|
||||
"--standard=PSR12",
|
||||
"--report=emacs",
|
||||
"-s",
|
||||
"-q",
|
||||
"-",
|
||||
},
|
||||
stdin = true,
|
||||
stdout = true,
|
||||
pattern = "^.+:(%d+):(%d+): (%w+) %- (.*) %((.*)%)$",
|
||||
groups = { "lnum", "col", "severity", "message", "code" },
|
||||
source = "phpcs",
|
||||
severity_map = {
|
||||
error = vim.diagnostic.severity.ERROR,
|
||||
warning = vim.diagnostic.severity.WARN,
|
||||
},
|
||||
zero_idx_col = true,
|
||||
zero_idx_lnum = true,
|
||||
})
|
||||
|
||||
Keymap.set(bufnr, {
|
||||
{
|
||||
mode = "n",
|
||||
lhs = "<leader>lf",
|
||||
rhs = function()
|
||||
vim.lsp.buf.format()
|
||||
util.format({
|
||||
buf = bufnr,
|
||||
cmd = {
|
||||
"php-cs-fixer",
|
||||
"fix",
|
||||
"%file%",
|
||||
"--quiet",
|
||||
},
|
||||
output = "in_place",
|
||||
ignore_stderr = true,
|
||||
env = { PHP_CS_FIXER_IGNORE_ENV = "1" },
|
||||
})
|
||||
end,
|
||||
},
|
||||
})
|
||||
end),
|
||||
})
|
||||
|
||||
vim.lsp.config("jedi_language_server", {
|
||||
init_options = {
|
||||
completion = {
|
||||
disableSnippets = true,
|
||||
},
|
||||
diagnostics = {
|
||||
enable = true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vim.lsp.config("lemminx", {
|
||||
init_options = {
|
||||
settings = {
|
||||
xml = {
|
||||
format = {
|
||||
enabled = true, -- is able to format document
|
||||
splitAttributes = true, -- each attribute is formatted onto new line
|
||||
joinCDATALines = false, -- normalize content inside CDATA
|
||||
joinCommentLines = false, -- normalize content inside comments
|
||||
formatComments = true, -- keep comment in relative position
|
||||
joinContentLines = false, -- normalize content inside elements
|
||||
spaceBeforeEmptyCloseLine = true, -- insert whitespace before self closing tag end bracket
|
||||
},
|
||||
validation = {
|
||||
noGrammar = "ignore",
|
||||
enabled = true,
|
||||
schema = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
local lua_library_paths = {
|
||||
vim.env.VIMRUNTIME,
|
||||
}
|
||||
for _, plugin in ipairs(require("lazy").plugins()) do
|
||||
table.insert(lua_library_paths, plugin.dir)
|
||||
end
|
||||
|
||||
vim.lsp.config("lua_ls", {
|
||||
settings = {
|
||||
Lua = {
|
||||
completion = { showWord = "Disable" },
|
||||
runtime = {
|
||||
version = "LuaJIT",
|
||||
path = {
|
||||
"lua/?.lua",
|
||||
"lua/?/init.lua",
|
||||
},
|
||||
pathStrict = true,
|
||||
},
|
||||
workspace = {
|
||||
library = lua_library_paths,
|
||||
checkThirdParty = false,
|
||||
},
|
||||
hint = {
|
||||
enable = false,
|
||||
arrayIndex = "Disable",
|
||||
await = true,
|
||||
paramName = "All",
|
||||
paramType = true,
|
||||
semicolon = "Disable",
|
||||
setType = true,
|
||||
},
|
||||
telemetry = { enable = false },
|
||||
},
|
||||
},
|
||||
on_attach = M.with_defaults("lua_ls", function(_, bufnr)
|
||||
Keymap.set(bufnr, {
|
||||
{
|
||||
mode = "n",
|
||||
lhs = "<leader>lf",
|
||||
rhs = function()
|
||||
util.format({
|
||||
buf = bufnr,
|
||||
cmd = {
|
||||
"stylua",
|
||||
"--stdin-filepath",
|
||||
"%file%",
|
||||
"-",
|
||||
},
|
||||
output = "stdout",
|
||||
auto_indent = true,
|
||||
})
|
||||
end,
|
||||
},
|
||||
{
|
||||
mode = "x",
|
||||
lhs = "<leader>lf",
|
||||
rhs = function()
|
||||
util.format({
|
||||
buf = bufnr,
|
||||
cmd = {
|
||||
"stylua",
|
||||
"--range-start",
|
||||
"%byte_start%",
|
||||
"--range-end",
|
||||
"%byte_end%",
|
||||
"--stdin-filepath",
|
||||
"%file%",
|
||||
"-",
|
||||
},
|
||||
output = "stdout",
|
||||
})
|
||||
end,
|
||||
},
|
||||
})
|
||||
end),
|
||||
})
|
||||
|
||||
vim.lsp.config("mesonlsp", {
|
||||
on_attach = M.with_defaults("mesonlsp"),
|
||||
settings = {
|
||||
others = {
|
||||
disableInlayHints = true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vim.lsp.config("pyright", {
|
||||
-- Handled in ruff instead
|
||||
-- on_attach = M.with_defaults("pyright"),
|
||||
settings = {
|
||||
python = {
|
||||
analysis = {
|
||||
autoSearchPaths = true,
|
||||
diagnosticMode = "openFilesOnly",
|
||||
useLibraryCodeForTypes = true,
|
||||
typeCheckingMode = "strict",
|
||||
stubPath = "stubs",
|
||||
},
|
||||
},
|
||||
pyright = {
|
||||
disableLanguageServices = false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vim.lsp.config("ruff", {
|
||||
on_attach = M.with_defaults("ruff", function(_, bufnr)
|
||||
Keymap.set(bufnr, {
|
||||
{
|
||||
mode = "n",
|
||||
lhs = "<leader>lf",
|
||||
rhs = function()
|
||||
vim.lsp.buf.format()
|
||||
util.format({
|
||||
buf = bufnr,
|
||||
cmd = {
|
||||
"ruff",
|
||||
"check",
|
||||
"--stdin-filename=%file%",
|
||||
"--select=I",
|
||||
"--fix",
|
||||
"--quiet",
|
||||
"-",
|
||||
},
|
||||
output = "stdout",
|
||||
})
|
||||
end,
|
||||
},
|
||||
})
|
||||
end),
|
||||
})
|
||||
|
||||
vim.lsp.config("rust_analyzer", {
|
||||
on_attach = M.with_defaults("rust_analyzer", function(client)
|
||||
local handler_name = "textDocument/publishDiagnostics"
|
||||
local default_handler = client.handlers[handler_name]
|
||||
or vim.lsp.handlers[handler_name]
|
||||
client.handlers[handler_name] = function(
|
||||
err,
|
||||
result,
|
||||
context,
|
||||
config
|
||||
)
|
||||
if result and result.diagnostics then
|
||||
result.diagnostics = vim.tbl_filter(function(diagnostic)
|
||||
return diagnostic.severity
|
||||
< vim.diagnostic.severity.HINT
|
||||
end, result.diagnostics)
|
||||
end
|
||||
|
||||
default_handler(err, result, context, config)
|
||||
end
|
||||
end),
|
||||
settings = {
|
||||
["rust-analyzer"] = {
|
||||
check = {
|
||||
command = "clippy",
|
||||
extraArgs = {
|
||||
"--",
|
||||
"-Wclippy::pedantic",
|
||||
},
|
||||
},
|
||||
diagnostics = {
|
||||
styleLints = {
|
||||
enable = true,
|
||||
},
|
||||
},
|
||||
imports = {
|
||||
prefix = "self",
|
||||
},
|
||||
inlayHints = {
|
||||
chainingHints = {
|
||||
enable = false,
|
||||
},
|
||||
parameterHints = {
|
||||
enable = false,
|
||||
},
|
||||
typeHints = {
|
||||
enable = false,
|
||||
},
|
||||
},
|
||||
rustfmt = {
|
||||
extraArgs = { "+nightly" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vim.lsp.config("zls", {
|
||||
on_attach = M.with_defaults("zls"),
|
||||
settings = {
|
||||
zls = {
|
||||
warn_style = true,
|
||||
highlight_global_var_declarations = true,
|
||||
inlay_hints_show_variable_type_hints = false,
|
||||
inlay_hints_show_struct_literal_field_type = false,
|
||||
inlay_hints_show_parameter_name = false,
|
||||
inlay_hints_show_builtin = false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
vim.lsp.config("pyrefly", {})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,111 +0,0 @@
|
||||
local util = require("util")
|
||||
|
||||
---@class ow.lsp.Keymap
|
||||
---@field mode string|string[]
|
||||
---@field lhs string
|
||||
---@field rhs string|function
|
||||
---@field opts? vim.keymap.set.Opts
|
||||
|
||||
local Keymap = {}
|
||||
|
||||
---@param bufnr integer
|
||||
---@param keymaps ow.lsp.Keymap[]
|
||||
function Keymap.set(bufnr, keymaps)
|
||||
for _, keymap in ipairs(keymaps) do
|
||||
keymap.opts = vim.tbl_extend(
|
||||
"force",
|
||||
keymap.opts or {},
|
||||
{ buffer = bufnr, remap = true }
|
||||
)
|
||||
vim.keymap.set(keymap.mode, keymap.lhs, keymap.rhs, keymap.opts)
|
||||
end
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
function Keymap.set_defaults(bufnr)
|
||||
---@type ow.lsp.Keymap[]
|
||||
local keymaps = {
|
||||
{ mode = { "n" }, lhs = "<leader>df", rhs = vim.diagnostic.open_float },
|
||||
{
|
||||
mode = { "n" },
|
||||
lhs = "[d",
|
||||
rhs = function()
|
||||
vim.diagnostic.jump({ count = -1, float = true })
|
||||
end,
|
||||
},
|
||||
{
|
||||
mode = { "n" },
|
||||
lhs = "]d",
|
||||
rhs = function()
|
||||
vim.diagnostic.jump({ count = 1, float = true })
|
||||
end,
|
||||
},
|
||||
{ mode = { "n" }, lhs = "gD", rhs = vim.lsp.buf.declaration },
|
||||
{
|
||||
mode = "n",
|
||||
lhs = "K",
|
||||
rhs = function()
|
||||
vim.lsp.buf.hover({ border = "rounded", max_width = 80 })
|
||||
end,
|
||||
},
|
||||
{
|
||||
mode = "i",
|
||||
lhs = "<C-s>",
|
||||
rhs = function()
|
||||
vim.lsp.buf.signature_help({
|
||||
border = "rounded",
|
||||
max_width = 80,
|
||||
})
|
||||
end,
|
||||
},
|
||||
{
|
||||
mode = { "n", "i" },
|
||||
lhs = "<C-h>",
|
||||
rhs = vim.lsp.buf.document_highlight,
|
||||
},
|
||||
{ mode = { "n", "x" }, lhs = "<leader>lf", rhs = vim.lsp.buf.format },
|
||||
{
|
||||
mode = { "n" },
|
||||
lhs = "<leader>ld",
|
||||
rhs = function()
|
||||
vim.diagnostic.enable(
|
||||
not vim.diagnostic.is_enabled({ bufnr = bufnr }),
|
||||
{ bufnr = bufnr }
|
||||
)
|
||||
end,
|
||||
},
|
||||
{
|
||||
mode = { "n", "i" },
|
||||
lhs = "<C-l>",
|
||||
rhs = function()
|
||||
vim.lsp.buf.clear_references()
|
||||
vim.cmd.nohlsearch()
|
||||
vim.schedule(vim.cmd.diffupdate)
|
||||
return "<C-l>"
|
||||
end,
|
||||
opts = { expr = true },
|
||||
},
|
||||
}
|
||||
|
||||
local telescope = util.try_require("telescope.builtin")
|
||||
|
||||
if telescope then
|
||||
vim.list_extend(keymaps, {
|
||||
{ mode = "n", lhs = "<leader>dl", rhs = telescope.diagnostics },
|
||||
{ mode = "n", lhs = "grt", rhs = telescope.lsp_type_definitions },
|
||||
{ mode = "n", lhs = "gd", rhs = telescope.lsp_definitions },
|
||||
{ mode = "n", lhs = "gri", rhs = telescope.lsp_implementations },
|
||||
{ mode = "n", lhs = "grr", rhs = telescope.lsp_references },
|
||||
})
|
||||
else
|
||||
vim.list_extend(keymaps, {
|
||||
{ mode = "n", lhs = "<leader>dl", rhs = vim.diagnostic.setloclist },
|
||||
{ mode = "n", lhs = "grt", rhs = vim.lsp.buf.type_definition },
|
||||
{ mode = "n", lhs = "gd", rhs = vim.lsp.buf.definition },
|
||||
})
|
||||
end
|
||||
|
||||
Keymap.set(bufnr, keymaps)
|
||||
end
|
||||
|
||||
return Keymap
|
||||
@@ -1,455 +0,0 @@
|
||||
local log = require("log")
|
||||
local util = require("util")
|
||||
|
||||
---@alias ow.lsp.linter.Group
|
||||
---| "lnum"
|
||||
---| "end_lnum"
|
||||
---| "col"
|
||||
---| "end_col"
|
||||
---| "severity"
|
||||
---| "message"
|
||||
---| "source"
|
||||
---| "code"
|
||||
|
||||
---@class ow.lsp.linter.JsonConfig
|
||||
---@field diagnostics_root? string
|
||||
---@field lnum? string
|
||||
---@field end_lnum? string
|
||||
---@field col? string
|
||||
---@field end_col? string
|
||||
---@field severity? string
|
||||
---@field message? string
|
||||
---@field source? string
|
||||
---@field code? string
|
||||
---@field callback? fun(diag: vim.Diagnostic)
|
||||
|
||||
---@class ow.lsp.linter.DiagnosticTagMap
|
||||
---@field unnecessary? string[]
|
||||
---@field deprecated? string[]
|
||||
|
||||
---@class ow.lsp.linter.Config
|
||||
--- Command to run. The following keywords get replaces by the specified values:
|
||||
--- * %file% - path to the current file
|
||||
--- * %filename% - name of the current file
|
||||
---@field cmd string[]
|
||||
--- Events that trigger the linter (default: TextChanged, TextChangedI)
|
||||
---@field events? string[]
|
||||
--- Events that clear diagnostics
|
||||
---@field clear_events? string[]
|
||||
--- Pass buffer content via stdin (default: false)
|
||||
---@field stdin? boolean
|
||||
--- Read diagnostics from stdout (default: false)
|
||||
---@field stdout? boolean
|
||||
--- Read diagnostics from stderr (default: false)
|
||||
---@field stderr? boolean
|
||||
--- Regex pattern to parse diagnostic lines (required if not using json)
|
||||
---@field pattern? string
|
||||
--- Named capture groups for pattern matching (required if not using json)
|
||||
---@field groups? ow.lsp.linter.Group[]
|
||||
--- Map severity strings to vim diagnostic levels
|
||||
---@field severity_map? table<string, vim.diagnostic.Severity>
|
||||
--- Source name for diagnostics (default: command name)
|
||||
---@field source? string
|
||||
--- Debounce delay in ms (default: 100)
|
||||
---@field debounce? number
|
||||
--- Configuration for JSON output parsing
|
||||
---@field json? ow.lsp.linter.JsonConfig
|
||||
--- Map diagnostic codes to tags
|
||||
---@field tags? ow.lsp.linter.DiagnosticTagMap
|
||||
--- Line numbers are 0-indexed (default: false, 1-indexed)
|
||||
---@field zero_idx_lnum? boolean
|
||||
--- Column numbers are 0-indexed (default: false, 1-indexed)
|
||||
---@field zero_idx_col? boolean
|
||||
--- Don't log stderr as errors (default: false)
|
||||
---@field ignore_stderr? boolean
|
||||
--- Post-process diagnostics
|
||||
---@field hook? fun(self: ow.lsp.Linter, diagnostics: vim.Diagnostic[])
|
||||
|
||||
---@class ow.lsp.Linter
|
||||
---@field namespace number
|
||||
---@field augroup number
|
||||
---@field bufnr number
|
||||
---@field config ow.lsp.linter.Config
|
||||
Linter = {}
|
||||
Linter.__index = Linter
|
||||
|
||||
-- Extract a value from a JSON object using a path
|
||||
---@param obj table The JSON object
|
||||
---@param path string Path to the value (dot notation string)
|
||||
---@return any The value at the specified path, or nil if not found
|
||||
function Linter.get_json_value(obj, path)
|
||||
if not obj then
|
||||
return nil
|
||||
end
|
||||
|
||||
local current = obj
|
||||
local parts = {}
|
||||
|
||||
for part in path:gmatch("[^%.]+") do
|
||||
table.insert(parts, part)
|
||||
end
|
||||
|
||||
for _, key in ipairs(parts) do
|
||||
if tonumber(key) ~= nil then
|
||||
key = tonumber(key)
|
||||
end
|
||||
|
||||
if type(current) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
current = current[key]
|
||||
if current == nil then
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
return current
|
||||
end
|
||||
|
||||
--- Clamp column to line length
|
||||
---@param diag vim.Diagnostic
|
||||
function Linter:clamp_col(diag)
|
||||
local lines =
|
||||
vim.api.nvim_buf_get_lines(self.bufnr, diag.lnum, diag.lnum + 1, false)
|
||||
if #lines == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local line_len = #lines[1] - 1
|
||||
if diag.col > line_len then
|
||||
diag.col = line_len
|
||||
end
|
||||
end
|
||||
|
||||
--- Add diagnostic tags
|
||||
---@param diag vim.Diagnostic
|
||||
function Linter:add_tags(diag)
|
||||
if not self.config.tags then
|
||||
return
|
||||
end
|
||||
|
||||
local have_unnecessary = vim.islist(self.config.tags.unnecessary)
|
||||
local have_deprecated = vim.islist(self.config.tags.deprecated)
|
||||
|
||||
if not have_unnecessary and not have_deprecated then
|
||||
return
|
||||
end
|
||||
|
||||
diag._tags = {}
|
||||
|
||||
if
|
||||
have_unnecessary
|
||||
and vim.list_contains(self.config.tags.unnecessary, diag.code)
|
||||
then
|
||||
diag._tags.unnecessary = true
|
||||
diag.severity = vim.diagnostic.severity.HINT
|
||||
end
|
||||
|
||||
if
|
||||
have_deprecated
|
||||
and vim.list_contains(self.config.tags.deprecated, diag.code)
|
||||
then
|
||||
diag._tags.deprecated = true
|
||||
diag.severity = vim.diagnostic.severity.WARN
|
||||
end
|
||||
end
|
||||
|
||||
--- Resolve 0/1-based indexing for lnum/col
|
||||
---@param diag vim.Diagnostic
|
||||
function Linter:fix_indexing(diag)
|
||||
if not self.config.zero_idx_lnum then
|
||||
if diag.lnum then
|
||||
diag.lnum = diag.lnum - 1
|
||||
end
|
||||
|
||||
if diag.end_lnum then
|
||||
diag.end_lnum = diag.end_lnum - 1
|
||||
end
|
||||
end
|
||||
|
||||
if not self.config.zero_idx_col then
|
||||
if diag.col then
|
||||
diag.col = diag.col - 1
|
||||
end
|
||||
|
||||
if diag.end_col then
|
||||
diag.end_col = diag.end_col - 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Linter:process_json_output(json)
|
||||
---@type vim.Diagnostic[]
|
||||
local diagnostics = {}
|
||||
|
||||
local items = json
|
||||
if self.config.json.diagnostics_root then
|
||||
items = Linter.get_json_value(json, self.config.json.diagnostics_root)
|
||||
end
|
||||
|
||||
if type(items) ~= "table" then
|
||||
log.error("diagnostics root is not an array or object")
|
||||
return
|
||||
end
|
||||
|
||||
if not vim.islist(items) then
|
||||
items = { items }
|
||||
end
|
||||
|
||||
for _, item in ipairs(items) do
|
||||
local diag = {}
|
||||
|
||||
for field, path in pairs(self.config.json) do
|
||||
if field ~= "diagnostics_root" and field ~= "callback" then
|
||||
diag[field] = Linter.get_json_value(item, path)
|
||||
end
|
||||
end
|
||||
|
||||
diag.source = diag.source or self.config.source
|
||||
|
||||
if
|
||||
diag.severity
|
||||
and self.config.severity_map
|
||||
and self.config.severity_map[diag.severity]
|
||||
then
|
||||
diag.severity = self.config.severity_map[diag.severity]
|
||||
end
|
||||
|
||||
self:fix_indexing(diag)
|
||||
self:clamp_col(diag)
|
||||
self:add_tags(diag)
|
||||
|
||||
if type(self.config.json.callback) == "function" then
|
||||
self.config.json.callback(diag)
|
||||
end
|
||||
|
||||
table.insert(diagnostics, diag)
|
||||
end
|
||||
|
||||
return diagnostics
|
||||
end
|
||||
|
||||
--- Validate input
|
||||
---@param config ow.lsp.linter.Config
|
||||
---@return boolean
|
||||
function Linter.validate(config)
|
||||
local ok, resp = pcall(vim.validate, {
|
||||
config = { config, "table" },
|
||||
})
|
||||
|
||||
if ok then
|
||||
ok, resp = pcall(vim.validate, {
|
||||
cmd = {
|
||||
config.cmd,
|
||||
function(t)
|
||||
return util.is_list(t, "string")
|
||||
end,
|
||||
"list of strings",
|
||||
},
|
||||
events = {
|
||||
config.events,
|
||||
function(t)
|
||||
return util.is_list(t, "string")
|
||||
end,
|
||||
true,
|
||||
"list of strings",
|
||||
},
|
||||
clear_events = {
|
||||
config.clear_events,
|
||||
function(t)
|
||||
return util.is_list(t, "string")
|
||||
end,
|
||||
true,
|
||||
"list of strings",
|
||||
},
|
||||
stdin = { config.stdin, "boolean", true },
|
||||
stdout = { config.stdout, "boolean", true },
|
||||
stderr = { config.stderr, "boolean", true },
|
||||
pattern = { config.pattern, "string", true },
|
||||
groups = {
|
||||
config.groups,
|
||||
function(t)
|
||||
return util.is_list(t, "string")
|
||||
end,
|
||||
true,
|
||||
"list of strings",
|
||||
},
|
||||
severity_map = {
|
||||
config.severity_map,
|
||||
function(t)
|
||||
return util.is_map(t, "string", "number")
|
||||
end,
|
||||
true,
|
||||
"map of string and number",
|
||||
},
|
||||
debounce = { config.debounce, "number", true },
|
||||
source = { config.source, "string", true },
|
||||
json = { config.json, "table", true },
|
||||
tags = { config.tags, "table", true },
|
||||
zero_idx_lnum = { config.zero_idx_lnum, "boolean", true },
|
||||
zero_idx_col = { config.zero_idx_col, "boolean", true },
|
||||
ignore_stderr = { config.ignore_stderr, "boolean", true },
|
||||
})
|
||||
end
|
||||
|
||||
if not ok then
|
||||
log.error("Invalid config for linter: %s", resp)
|
||||
return false
|
||||
end
|
||||
|
||||
if not config.json and (not config.pattern or not config.groups) then
|
||||
log.error("Either json or pattern and groups must be provided")
|
||||
return false
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
---@return boolean success
|
||||
function Linter:run()
|
||||
local input
|
||||
|
||||
if self.config.stdin then
|
||||
input = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false)
|
||||
end
|
||||
|
||||
local cmd = vim.fn.copy(self.config.cmd)
|
||||
local file = vim.fn.expand("%:.")
|
||||
local filename = vim.fn.expand("%:t")
|
||||
for i, arg in ipairs(cmd) do
|
||||
arg = arg:gsub("%%file%%", file)
|
||||
arg = arg:gsub("%%filename%%", filename)
|
||||
cmd[i] = arg
|
||||
end
|
||||
|
||||
local success = true
|
||||
local ok, resp = pcall(
|
||||
vim.system,
|
||||
cmd,
|
||||
{ stdin = input },
|
||||
---@param out vim.SystemCompleted
|
||||
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 ""
|
||||
elseif
|
||||
not self.config.ignore_stderr
|
||||
and out.stderr
|
||||
and out.stderr ~= ""
|
||||
then
|
||||
log.error(out.stderr)
|
||||
end
|
||||
|
||||
if self.config.json then
|
||||
local ok, json = pcall(
|
||||
vim.json.decode,
|
||||
output,
|
||||
{ luanil = { object = true, array = true } }
|
||||
)
|
||||
if not ok then
|
||||
log.error("Failed to parse JSON: " .. json)
|
||||
success = false
|
||||
return
|
||||
end
|
||||
|
||||
local diagnostics = self:process_json_output(json)
|
||||
if diagnostics then
|
||||
if self.config.hook then
|
||||
self.config.hook(self, diagnostics)
|
||||
end
|
||||
vim.diagnostic.set(self.namespace, self.bufnr, diagnostics)
|
||||
end
|
||||
|
||||
return
|
||||
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
|
||||
log.error(tostring(resp))
|
||||
success = false
|
||||
return
|
||||
elseif resp then
|
||||
resp.source = resp.source or self.config.source
|
||||
self:clamp_col(resp)
|
||||
self:add_tags(resp)
|
||||
self:fix_indexing(resp)
|
||||
table.insert(diagnostics, resp)
|
||||
end
|
||||
end
|
||||
|
||||
if self.config.hook then
|
||||
self.config.hook(self, diagnostics)
|
||||
end
|
||||
vim.diagnostic.set(self.namespace, self.bufnr, diagnostics)
|
||||
end)
|
||||
)
|
||||
|
||||
if not ok then
|
||||
log.error("Failed to run %s: %s", self.config.cmd[1], resp)
|
||||
success = false
|
||||
end
|
||||
|
||||
return success
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@param config ow.lsp.linter.Config
|
||||
function Linter.add(bufnr, config)
|
||||
if not Linter.validate(config) then
|
||||
return
|
||||
end
|
||||
|
||||
config.debounce = config.debounce or 100
|
||||
config.events = config.events or { "TextChanged", "TextChangedI" }
|
||||
|
||||
local linter = {
|
||||
namespace = vim.api.nvim_create_namespace("lsp.linter"),
|
||||
augroup = vim.api.nvim_create_augroup("lsp.linter", { clear = false }),
|
||||
bufnr = bufnr,
|
||||
config = config,
|
||||
}
|
||||
|
||||
linter = setmetatable(linter, Linter)
|
||||
|
||||
local success = linter:run()
|
||||
if not success then
|
||||
log.error("Not adding linter because of previous errors")
|
||||
return
|
||||
end
|
||||
|
||||
vim.api.nvim_create_autocmd(config.events, {
|
||||
buffer = linter.bufnr,
|
||||
callback = util.debounce(function()
|
||||
linter:run()
|
||||
end, linter.config.debounce),
|
||||
group = linter.augroup,
|
||||
})
|
||||
|
||||
if config.clear_events then
|
||||
vim.api.nvim_create_autocmd(config.clear_events, {
|
||||
buffer = linter.bufnr,
|
||||
callback = function()
|
||||
vim.diagnostic.reset(linter.namespace, linter.bufnr)
|
||||
end,
|
||||
group = linter.augroup,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return Linter
|
||||
Reference in New Issue
Block a user