From a6fcb0b387313c148d0c8304a743878aaf47828a Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Mon, 1 Sep 2025 18:47:43 +0200 Subject: [PATCH] feat(clangd): add clang-tidy linter for clang-analyzer checks --- lua/ow/lsp.lua | 24 ++++++++++++++ lua/ow/lsp/linter.lua | 73 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/lua/ow/lsp.lua b/lua/ow/lsp.lua index 3592f0e..1345999 100644 --- a/lua/ow/lsp.lua +++ b/lua/ow/lsp.lua @@ -164,6 +164,30 @@ function M.setup() }, single_file_support = true, on_attach = M.with_defaults("clangd", function(_, bufnr) + linter.add(bufnr, { + cmd = { + "clang-tidy", + "-p=build", + "--quiet", + "--checks=-*,clang-analyzer-*", + "%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", diff --git a/lua/ow/lsp/linter.lua b/lua/ow/lsp/linter.lua index e8d0927..59bafc8 100644 --- a/lua/ow/lsp/linter.lua +++ b/lua/ow/lsp/linter.lua @@ -1,5 +1,5 @@ -local util = require("ow.util") local log = require("ow.log") +local util = require("ow.util") ---@class Linter ---@field namespace number @@ -36,21 +36,42 @@ M.__index = M ---@field deprecated? string[] ---@class LinterConfig ----@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 +--- 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? Group[] +--- Map severity strings to vim diagnostic levels ---@field severity_map? table +--- 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? JsonConfig +--- Map diagnostic codes to tags (unnecessary/deprecated) ---@field tags? 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: Linter, diagnostics: vim.Diagnostic[]) M.config = {} -- Extract a value from a JSON object using a path @@ -227,6 +248,22 @@ function M.validate(config) 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 }, @@ -251,6 +288,9 @@ function M.validate(config) 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 @@ -299,7 +339,11 @@ function M:run() if self.config.stderr then output = out.stderr or "" - elseif out.stderr and out.stderr ~= "" then + elseif + not self.config.ignore_stderr + and out.stderr + and out.stderr ~= "" + then log.error(out.stderr) end @@ -317,6 +361,9 @@ function M:run() 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 @@ -347,6 +394,9 @@ function M:run() end end + if self.config.hook then + self.config.hook(self, diagnostics) + end vim.diagnostic.set(self.namespace, self.bufnr, diagnostics) end) ) @@ -367,6 +417,7 @@ function M.add(bufnr, config) end config.debounce = config.debounce or 100 + config.events = config.events or { "TextChanged", "TextChangedI" } local linter = { namespace = vim.api.nvim_create_namespace("ow.lsp.linter"), @@ -386,13 +437,23 @@ function M.add(bufnr, config) return end - vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + 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 M