Files
nvim/lua/linter.lua
T

449 lines
13 KiB
Lua

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? vim.api.keyset.events[]
--- Events that clear diagnostics
---@field clear_events? vim.api.keyset.events[]
--- 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? integer
--- 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
--- Don't check exit status (default: false)
---@field ignore_exit? boolean
--- Post-process diagnostics
---@field hook? fun(self: ow.lsp.Linter, diagnostics: vim.Diagnostic[])
---@class ow.lsp.Linter
---@field namespace integer
---@field augroup integer
---@field bufnr integer
---@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 and diag.lnum > 0 then
diag.lnum = diag.lnum - 1
end
if diag.end_lnum and diag.end_lnum > 0 then
diag.end_lnum = diag.end_lnum - 1
end
end
if not self.config.zero_idx_col then
if diag.col and diag.col > 0 then
diag.col = diag.col - 1
end
if diag.end_col and diag.end_col > 0 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 function list_of(ty)
return function(t)
return util.is_list(t, ty)
end
end
local ok, err = pcall(function()
vim.validate("config", config, "table")
vim.validate("cmd", config.cmd, list_of("string"), "list of strings")
vim.validate(
"events", config.events, list_of("string"), true, "list of strings"
)
vim.validate(
"clear_events", config.clear_events, list_of("string"), true,
"list of strings"
)
vim.validate("stdin", config.stdin, "boolean", true)
vim.validate("stdout", config.stdout, "boolean", true)
vim.validate("stderr", config.stderr, "boolean", true)
vim.validate("pattern", config.pattern, "string", true)
vim.validate(
"groups", config.groups, list_of("string"), true, "list of strings"
)
vim.validate("severity_map", config.severity_map, function(t)
return util.is_map(t, "string", "number")
end, true, "map of string to number"
)
vim.validate("debounce", config.debounce, "number", true)
vim.validate("source", config.source, "string", true)
vim.validate("json", config.json, "table", true)
vim.validate("tags", config.tags, "table", true)
vim.validate("zero_idx_lnum", config.zero_idx_lnum, "boolean", true)
vim.validate("zero_idx_col", config.zero_idx_col, "boolean", true)
vim.validate("ignore_stderr", config.ignore_stderr, "boolean", true)
vim.validate("ignore_exit", config.ignore_exit, "boolean", true)
end)
if not ok then
log.error("Invalid config for linter: %s", err)
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 not output or output == "" then
return
end
local lines = vim.split(output, "\n", { trimempty = true })
if self.config.json then
local diagnostics = {}
for _, line in ipairs(lines) do
local ok, json = pcall(
vim.json.decode,
line,
{ luanil = { object = true, array = true } }
)
if not ok then
log.error("Failed to parse JSON: " .. json)
success = false
return
end
local diags = self:process_json_output(json)
if diags then
vim.list_extend(diagnostics, diags)
end
end
if #diagnostics > 0 then
if self.config.hook then
self.config.hook(self, diagnostics)
end
vim.diagnostic.set(self.namespace, self.bufnr, diagnostics)
end
return
end
local diagnostics = {}
for _, line in ipairs(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 self.config.ignore_exit and 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
local debouncer = util.debounce(function()
linter:run()
end, config.debounce)
vim.api.nvim_create_autocmd(config.events, {
buffer = linter.bufnr,
callback = function() debouncer:call() end,
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