From e61ea7772e5eab2d0460dae858698f16b0ee8f27 Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Sun, 19 Sep 2021 16:13:23 -0600 Subject: [PATCH] feat(diagnostic): match(), tolist(), fromlist() #15704 * feat(diagnostic): add vim.diagnostic.match() Provide vim.diagnostic.match() to generate a diagnostic from a string and a Lua pattern. * feat(diagnostic): add tolist() and fromlist() --- runtime/doc/deprecated.txt | 1 + runtime/doc/diagnostic.txt | 60 ++++++++- runtime/lua/vim/diagnostic.lua | 166 +++++++++++++++++++----- test/functional/lua/diagnostic_spec.lua | 81 ++++++++++++ 4 files changed, 275 insertions(+), 33 deletions(-) diff --git a/runtime/doc/deprecated.txt b/runtime/doc/deprecated.txt index d1f26c8c81..a0c291964e 100644 --- a/runtime/doc/deprecated.txt +++ b/runtime/doc/deprecated.txt @@ -96,6 +96,7 @@ internally and are no longer exposed as part of the API. Instead, use LSP Utility Functions ~ +*vim.lsp.util.diagnostics_to_items()* Use |vim.diagnostic.toqflist()| instead. *vim.lsp.util.set_qflist()* Use |setqflist()| instead. *vim.lsp.util.set_loclist()* Use |setloclist()| instead. diff --git a/runtime/doc/diagnostic.txt b/runtime/doc/diagnostic.txt index 199c04be98..59b73771a6 100644 --- a/runtime/doc/diagnostic.txt +++ b/runtime/doc/diagnostic.txt @@ -182,8 +182,8 @@ is the first letter of the severity name (for example, "E" for ERROR). Signs can be customized using the following: > sign define DiagnosticSignError text=E texthl=DiagnosticSignError linehl= numhl= - sign define DiagnosticSignWarning text=W texthl=DiagnosticSignWarning linehl= numhl= - sign define DiagnosticSignInformation text=I texthl=DiagnosticSignInformation linehl= numhl= + sign define DiagnosticSignWarn text=W texthl=DiagnosticSignWarn linehl= numhl= + sign define DiagnosticSignInfo text=I texthl=DiagnosticSignInfo linehl= numhl= sign define DiagnosticSignHint text=H texthl=DiagnosticSignHint linehl= numhl= ============================================================================== @@ -265,6 +265,16 @@ enable({bufnr}, {namespace}) *vim.diagnostic.enable()* {namespace} number|nil Only enable diagnostics for the given namespace. +fromqflist({list}) *vim.diagnostic.fromqflist()* + Convert a list of quickfix items to a list of diagnostics. + + Parameters: ~ + {list} table A list of quickfix items from |getqflist()| + or |getloclist()|. + + Return: ~ + array of diagnostics |diagnostic-structure| + get({bufnr}, {opts}) *vim.diagnostic.get()* Get current diagnostics. @@ -384,6 +394,41 @@ hide({namespace}, {bufnr}) *vim.diagnostic.hide()* {bufnr} number|nil Buffer number. Defaults to the current buffer. + *vim.diagnostic.match()* +match({str}, {pat}, {groups}, {severity_map}, {defaults}) + Parse a diagnostic from a string. + + For example, consider a line of output from a linter: > + + WARNING filename:27:3: Variable 'foo' does not exist + + < This can be parsed into a diagnostic |diagnostic-structure| + with: > + + local s = "WARNING filename:27:3: Variable 'foo' does not exist" + local pattern = "^(%w+) %w+:(%d+):(%d+): (.+)$" + local groups = {"severity", "lnum", "col", "message"} + vim.diagnostic.match(s, pattern, groups, {WARNING = vim.diagnostic.WARN}) +< + + Parameters: ~ + {str} string String to parse diagnostics from. + {pat} string Lua pattern with capture groups. + {groups} table List of fields in a + |diagnostic-structure| to associate with + captures from {pat}. + {severity_map} table A table mapping the severity field + from {groups} with an item from + |vim.diagnostic.severity|. + {defaults} table|nil Table of default values for any + fields not listed in {groups}. When + omitted, numeric values default to 0 and + "severity" defaults to ERROR. + + Return: ~ + diagnostic |diagnostic-structure| or `nil` if {pat} fails + to match {str}. + reset({namespace}, {bufnr}) *vim.diagnostic.reset()* Remove all diagnostics from the given namespace. @@ -495,4 +540,15 @@ show_position_diagnostics({opts}, {bufnr}, {position}) Return: ~ tuple ({popup_bufnr}, {win_id}) +toqflist({diagnostics}) *vim.diagnostic.toqflist()* + Convert a list of diagnostics to a list of quickfix items that + can be passed to |setqflist()| or |setloclist()|. + + Parameters: ~ + {diagnostics} table List of diagnostics + |diagnostic-structure|. + + Return: ~ + array of quickfix list items |setqflist-what| + vim:tw=78:ts=8:ft=help:norl: diff --git a/runtime/lua/vim/diagnostic.lua b/runtime/lua/vim/diagnostic.lua index 61311f2135..c977fe0109 100644 --- a/runtime/lua/vim/diagnostic.lua +++ b/runtime/lua/vim/diagnostic.lua @@ -9,6 +9,12 @@ M.severity = { vim.tbl_add_reverse_lookup(M.severity) +-- Mappings from qflist/loclist error types to severities +M.severity.E = M.severity.ERROR +M.severity.W = M.severity.WARN +M.severity.I = M.severity.INFO +M.severity.N = M.severity.HINT + local global_diagnostic_options = { signs = true, underline = true, @@ -375,35 +381,6 @@ local function show_diagnostics(opts, diagnostics) return popup_bufnr, winnr end -local errlist_type_map = { - [M.severity.ERROR] = 'E', - [M.severity.WARN] = 'W', - [M.severity.INFO] = 'I', - [M.severity.HINT] = 'I', -} - ----@private -local function diagnostics_to_list_items(diagnostics) - local items = {} - for _, d in pairs(diagnostics) do - table.insert(items, { - bufnr = d.bufnr, - lnum = d.lnum + 1, - col = d.col + 1, - text = d.message, - type = errlist_type_map[d.severity or M.severity.ERROR] or 'E' - }) - end - table.sort(items, function(a, b) - if a.bufnr == b.bufnr then - return a.lnum < b.lnum - else - return a.bufnr < b.bufnr - end - end) - return items -end - ---@private local function set_list(loclist, opts) opts = opts or {} @@ -415,7 +392,7 @@ local function set_list(loclist, opts) bufnr = vim.api.nvim_win_get_buf(winnr) end local diagnostics = M.get(bufnr, opts) - local items = diagnostics_to_list_items(diagnostics) + local items = M.toqflist(diagnostics) if loclist then vim.fn.setloclist(winnr, {}, ' ', { title = title, items = items }) else @@ -1168,7 +1145,134 @@ function M.enable(bufnr, namespace) end end +--- Parse a diagnostic from a string. +--- +--- For example, consider a line of output from a linter: +---
+--- WARNING filename:27:3: Variable 'foo' does not exist
+--- 
+--- This can be parsed into a diagnostic |diagnostic-structure| +--- with: +---
+--- local s = "WARNING filename:27:3: Variable 'foo' does not exist"
+--- local pattern = "^(%w+) %w+:(%d+):(%d+): (.+)$"
+--- local groups = {"severity", "lnum", "col", "message"}
+--- vim.diagnostic.match(s, pattern, groups, {WARNING = vim.diagnostic.WARN})
+--- 
+--- +---@param str string String to parse diagnostics from. +---@param pat string Lua pattern with capture groups. +---@param groups table List of fields in a |diagnostic-structure| to +--- associate with captures from {pat}. +---@param severity_map table A table mapping the severity field from {groups} +--- with an item from |vim.diagnostic.severity|. +---@param defaults table|nil Table of default values for any fields not listed in {groups}. +--- When omitted, numeric values default to 0 and "severity" defaults to +--- ERROR. +---@return diagnostic |diagnostic-structure| or `nil` if {pat} fails to match {str}. +function M.match(str, pat, groups, severity_map, defaults) + vim.validate { + str = { str, 's' }, + pat = { pat, 's' }, + groups = { groups, 't' }, + severity_map = { severity_map, 't', true }, + defaults = { defaults, 't', true }, + } + + severity_map = severity_map or M.severity + + local diagnostic = {} + local matches = {string.match(str, pat)} + if vim.tbl_isempty(matches) then + return + end + + for i, match in ipairs(matches) do + local field = groups[i] + if field == "severity" then + match = severity_map[match] + elseif field == "lnum" or field == "end_lnum" or field == "col" or field == "end_col" then + match = assert(tonumber(match)) - 1 + end + diagnostic[field] = match + end + + diagnostic = vim.tbl_extend("keep", diagnostic, defaults or {}) + diagnostic.severity = diagnostic.severity or M.severity.ERROR + diagnostic.col = diagnostic.col or 0 + diagnostic.end_lnum = diagnostic.end_lnum or diagnostic.lnum + diagnostic.end_col = diagnostic.end_col or diagnostic.col + return diagnostic +end + +local errlist_type_map = { + [M.severity.ERROR] = 'E', + [M.severity.WARN] = 'W', + [M.severity.INFO] = 'I', + [M.severity.HINT] = 'N', +} + +--- Convert a list of diagnostics to a list of quickfix items that can be +--- passed to |setqflist()| or |setloclist()|. +--- +---@param diagnostics table List of diagnostics |diagnostic-structure|. +---@return array of quickfix list items |setqflist-what| +function M.toqflist(diagnostics) + vim.validate { diagnostics = {diagnostics, 't'} } + + local list = {} + for _, v in ipairs(diagnostics) do + local item = { + bufnr = v.bufnr, + lnum = v.lnum + 1, + col = v.col and (v.col + 1) or nil, + end_lnum = v.end_lnum and (v.end_lnum + 1) or nil, + end_col = v.end_col and (v.end_col + 1) or nil, + text = v.message, + type = errlist_type_map[v.severity] or 'E', + } + table.insert(list, item) + end + table.sort(list, function(a, b) + if a.bufnr == b.bufnr then + return a.lnum < b.lnum + else + return a.bufnr < b.bufnr + end + end) + return list +end + +--- Convert a list of quickfix items to a list of diagnostics. +--- +---@param list table A list of quickfix items from |getqflist()| or +--- |getloclist()|. +---@return array of diagnostics |diagnostic-structure| +function M.fromqflist(list) + vim.validate { list = {list, 't'} } + + local diagnostics = {} + for _, item in ipairs(list) do + if item.valid == 1 then + local lnum = math.max(0, item.lnum - 1) + local col = item.col > 0 and (item.col - 1) or nil + local end_lnum = item.end_lnum > 0 and (item.end_lnum - 1) or lnum + local end_col = item.end_col > 0 and (item.end_col - 1) or col + local severity = item.type ~= "" and M.severity[item.type] or M.severity.ERROR + table.insert(diagnostics, { + bufnr = item.bufnr, + lnum = lnum, + col = col, + end_lnum = end_lnum, + end_col = end_col, + severity = severity, + message = item.text, + }) + end + end + return diagnostics +end + -- }}} - return M diff --git a/test/functional/lua/diagnostic_spec.lua b/test/functional/lua/diagnostic_spec.lua index 589e0f3e6b..29dd5c60da 100644 --- a/test/functional/lua/diagnostic_spec.lua +++ b/test/functional/lua/diagnostic_spec.lua @@ -1,5 +1,6 @@ local helpers = require('test.functional.helpers')(after_each) +local NIL = helpers.NIL local command = helpers.command local clear = helpers.clear local exec_lua = helpers.exec_lua @@ -912,4 +913,84 @@ describe('vim.diagnostic', function() assert(loc_list[1].lnum < loc_list[2].lnum) end) end) + + describe('match()', function() + it('matches a string', function() + local msg = "ERROR: george.txt:19:84:Two plus two equals five" + local diagnostic = { + severity = exec_lua [[return vim.diagnostic.severity.ERROR]], + lnum = 18, + col = 83, + end_lnum = 18, + end_col = 83, + message = "Two plus two equals five", + } + eq(diagnostic, exec_lua([[ + return vim.diagnostic.match(..., "^(%w+): [^:]+:(%d+):(%d+):(.+)$", {"severity", "lnum", "col", "message"}) + ]], msg)) + end) + + it('returns nil if the pattern fails to match', function() + eq(NIL, exec_lua [[ + local msg = "The answer to life, the universe, and everything is" + return vim.diagnostic.match(msg, "This definitely will not match", {}) + ]]) + end) + + it('respects default values', function() + local msg = "anna.txt:1:Happy families are all alike" + local diagnostic = { + severity = exec_lua [[return vim.diagnostic.severity.INFO]], + lnum = 0, + col = 0, + end_lnum = 0, + end_col = 0, + message = "Happy families are all alike", + } + eq(diagnostic, exec_lua([[ + return vim.diagnostic.match(..., "^[^:]+:(%d+):(.+)$", {"lnum", "message"}, nil, {severity = vim.diagnostic.severity.INFO}) + ]], msg)) + end) + + it('accepts a severity map', function() + local msg = "46:FATAL:Et tu, Brute?" + local diagnostic = { + severity = exec_lua [[return vim.diagnostic.severity.ERROR]], + lnum = 45, + col = 0, + end_lnum = 45, + end_col = 0, + message = "Et tu, Brute?", + } + eq(diagnostic, exec_lua([[ + return vim.diagnostic.match(..., "^(%d+):(%w+):(.+)$", {"lnum", "severity", "message"}, {FATAL = vim.diagnostic.severity.ERROR}) + ]], msg)) + end) + end) + + describe('toqflist() and fromqflist()', function() + it('works', function() + local result = exec_lua [[ + vim.diagnostic.set(diagnostic_ns, diagnostic_bufnr, { + make_error('Error 1', 0, 1, 0, 1), + make_error('Error 2', 1, 1, 1, 1), + make_warning('Warning', 2, 2, 2, 2), + }) + + local diagnostics = vim.diagnostic.get(diagnostic_bufnr) + vim.fn.setqflist(vim.diagnostic.toqflist(diagnostics)) + local list = vim.fn.getqflist() + local new_diagnostics = vim.diagnostic.fromqflist(list) + + -- Remove namespace since it isn't present in the return value of + -- fromlist() + for _, v in ipairs(diagnostics) do + v.namespace = nil + end + + return {diagnostics, new_diagnostics} + ]] + eq(result[1], result[2]) + end) + end) end)