local config = require("quicker.config") local fs = require("quicker.fs") local highlight = require("quicker.highlight") local util = require("quicker.util") local M = {} local EM_QUAD = " " local EM_QUAD_LEN = EM_QUAD:len() M.EM_QUAD = EM_QUAD M.EM_QUAD_LEN = EM_QUAD_LEN ---@class (exact) QuickFixUserData ---@field header? "hard"|"soft" When present, this line is a header ---@field lnum? integer Encode the lnum separately for valid=0 items ---@field error_text? string Error text to be added as virtual text on the line ---@class (exact) QuickFixItem ---@field text string ---@field type string ---@field lnum integer line number in the buffer (first line is 1) ---@field end_lnum integer end of line number if the item is multiline ---@field col integer column number (first column is 1) ---@field end_col integer end of column number if the item has range ---@field vcol 0|1 if true "col" is visual column. If false "col" is byte index ---@field nr integer error number ---@field pattern string search pattern used to locate the error ---@field bufnr integer number of buffer that has the file name ---@field module string ---@field valid 0|1 ---@field user_data? any ---@param type string ---@return string local function get_icon(type) return config.type_icons[type:upper()] or "U" end local sign_highlight_map = { E = "DiagnosticSignError", W = "DiagnosticSignWarn", I = "DiagnosticSignInfo", H = "DiagnosticSignHint", N = "DiagnosticSignHint", } local virt_text_highlight_map = { E = "DiagnosticVirtualTextError", W = "DiagnosticVirtualTextWarn", I = "DiagnosticVirtualTextInfo", H = "DiagnosticVirtualTextHint", N = "DiagnosticVirtualTextHint", } ---@param item QuickFixItem M.get_filename_from_item = function(item) if item.module and item.module ~= "" then return item.module elseif item.bufnr > 0 then local bufname = vim.api.nvim_buf_get_name(item.bufnr) local path = fs.shorten_path(bufname) local max_len = config.max_filename_width() if max_len == 0 then return "" elseif path:len() > max_len then path = "…" .. path:sub(path:len() - max_len - 1) end return path else return "" end end local _col_width_cache = {} ---@param id integer ---@param items QuickFixItem[] ---@return integer local function get_cached_qf_col_width(id, items) local cached = _col_width_cache[id] if not cached or cached[2] ~= #items then local max_len = 0 for _, item in ipairs(items) do max_len = math.max(max_len, vim.api.nvim_strwidth(M.get_filename_from_item(item))) end cached = { max_len, #items } _col_width_cache[id] = cached end return cached[1] end ---@param items QuickFixItem[] ---@return table local function calc_whitespace_prefix(items) local prefixes = {} if config.trim_leading_whitespace ~= "common" then return prefixes end for _, item in ipairs(items) do if item.bufnr ~= 0 and not item.text:match("^%s*$") then local prefix = prefixes[item.bufnr] if not prefix or not vim.startswith(item.text, prefix) then local new_prefix = item.text:match("^%s*") -- The new line should have strictly less whitespace as the previous line. If not, then -- there is some whitespace disagreement (e.g. tabs vs spaces) and we should not try to trim -- anything. if prefix and not vim.startswith(prefix, new_prefix) then new_prefix = "" end prefixes[item.bufnr] = new_prefix if new_prefix == "" then break end end end end return prefixes end -- Highlighting can be slow because it requires loading buffers and parsing them with treesitter, so -- we pipeline it and break it up with defers to keep the editor responsive. local add_qf_highlights -- We have two queues, one to apply "fast" highlights, and one that will load the buffer (slow) -- and then apply more correct highlights. The second queue is always processed after the first. local _pending_fast_highlights = {} local _pending_bufload_highlights = {} local _running = false local function do_next_highlight() if _running then return end _running = true local next_info = table.remove(_pending_fast_highlights, 1) if not next_info then next_info = table.remove(_pending_bufload_highlights, 1) end if next_info then local ok, err = xpcall(add_qf_highlights, debug.traceback, next_info) if not ok then vim.api.nvim_err_writeln(err) end else _running = false return end vim.defer_fn(function() _running = false do_next_highlight() end, 20) end ---@param queue QuickFixTextFuncInfo[] ---@param info QuickFixTextFuncInfo local function add_info_to_queue(queue, info) for _, i in ipairs(queue) do -- If we're already processing a highlight for this quickfix, just expand the range if i.id == info.id and i.winid == info.winid and i.quickfix == info.quickfix then i.start_idx = math.min(i.start_idx, info.start_idx) i.end_idx = math.max(i.end_idx, info.end_idx) return end end table.insert(queue, info) end ---@param info QuickFixTextFuncInfo local function schedule_highlights(info) -- If this info already has force_bufload, then we don't want to add it to the first queue. if not info.force_bufload then add_info_to_queue(_pending_fast_highlights, info) end if config.highlight.load_buffers then local info2 = vim.deepcopy(info) info2.force_bufload = true add_info_to_queue(_pending_bufload_highlights, info2) end vim.schedule(do_next_highlight) end ---@param qfbufnr integer ---@param item QuickFixItem ---@param line string ---@param lnum integer local function add_item_highlights_from_buf(qfbufnr, item, line, lnum) local prefixes = vim.b[qfbufnr].qf_prefixes or {} local ns = vim.api.nvim_create_namespace("quicker_highlights") -- TODO re-apply highlights when a buffer is loaded or a LSP receives semantic tokens local src_line = vim.api.nvim_buf_get_lines(item.bufnr, item.lnum - 1, item.lnum, false)[1] if not src_line then return end -- If the lines differ only in leading whitespace, we should add highlights anyway and adjust -- the offset. local item_space = item.text:match("^%s*"):len() local src_space = src_line:match("^%s*"):len() -- Only add highlights if the text in the quickfix matches the source line if item.text:sub(item_space + 1) == src_line:sub(src_space + 1) then local offset = line:find(EM_QUAD, 1, true) + EM_QUAD_LEN - 1 local prefix = prefixes[item.bufnr] if type(prefix) == "string" then -- Since prefixes get deserialized from vim.b, if there are holes in the map they get -- filled with `vim.NIL`, so we have to check that the retrieved value is a string. offset = offset - prefix:len() end offset = offset - src_space + item_space if config.trim_leading_whitespace == "all" then offset = offset - item_space end -- Add treesitter highlights if config.highlight.treesitter then for _, hl in ipairs(highlight.buf_get_ts_highlights(item.bufnr, item.lnum)) do local start_col, end_col, hl_group = hl[1], hl[2], hl[3] if end_col == -1 then end_col = src_line:len() end -- If the highlight starts at the beginning of the source line, then it might be off the -- buffer in the quickfix because we've removed leading whitespace. If so, clamp the value -- to 0. Except, for some reason 0 gives incorrect results, but -1 works properly even -- though -1 should indicate the *end* of the line. Not sure why this work, but it does. local hl_start = math.max(-1, start_col + offset) vim.api.nvim_buf_set_extmark(qfbufnr, ns, lnum - 1, hl_start, { hl_group = hl_group, end_col = end_col + offset, priority = 100, strict = false, }) end end -- Add LSP semantic token highlights if config.highlight.lsp then for _, hl in ipairs(highlight.buf_get_lsp_highlights(item.bufnr, item.lnum)) do local start_col, end_col, hl_group, priority = hl[1], hl[2], hl[3], hl[4] vim.api.nvim_buf_set_extmark(qfbufnr, ns, lnum - 1, start_col + offset, { hl_group = hl_group, end_col = end_col + offset, priority = vim.highlight.priorities.semantic_tokens + priority, strict = false, }) end end end end ---@param qfbufnr integer ---@param info QuickFixTextFuncInfo local function highlight_buffer_when_entered(qfbufnr, info) if vim.b[qfbufnr].pending_highlight then return end vim.api.nvim_create_autocmd("BufEnter", { desc = "Highlight quickfix buffer when entered", buffer = qfbufnr, nested = true, once = true, callback = function() vim.b[qfbufnr].pending_highlight = nil info.start_idx = 1 info.end_idx = vim.api.nvim_buf_line_count(qfbufnr) schedule_highlights(info) end, }) vim.b[qfbufnr].pending_highlight = true end ---@param info QuickFixTextFuncInfo ---@return {qfbufnr: integer, id: integer, context?: any} ---@overload fun(info: QuickFixTextFuncInfo, all: true): {qfbufnr: integer, id: integer, items: QuickFixItem[], context?: any} local function load_qf(info, all) local query if all then query = { all = 0 } else query = { id = info.id, items = 0, qfbufnr = 0, context = 0 } end if info.quickfix == 1 then return vim.fn.getqflist(query) else return vim.fn.getloclist(info.winid, query) end end ---@param info QuickFixTextFuncInfo add_qf_highlights = function(info) local qf_list = load_qf(info, true) local qfbufnr = qf_list.qfbufnr if not qfbufnr or qfbufnr == 0 then return elseif info.end_idx < info.start_idx then return end local lines = vim.api.nvim_buf_get_lines(qfbufnr, 0, -1, false) if #lines == 1 and lines[1] == "" then -- If the quickfix buffer is not visible, it is possible that quickfixtextfunc has run but the -- buffer has not been populated yet. If that is the case, we should exit early and ensure that -- the highlighting task runs again when the buffer is opened in a window. -- see https://github.com/stevearc/quicker.nvim/pull/8 highlight_buffer_when_entered(qfbufnr, info) return end local ns = vim.api.nvim_create_namespace("quicker_highlights") -- Only clear the error namespace during the first pass of "fast" highlighting if not info.force_bufload then local err_ns = vim.api.nvim_create_namespace("quicker_err") vim.api.nvim_buf_clear_namespace(qfbufnr, err_ns, 0, -1) end local start = vim.uv.hrtime() / 1e6 for i = info.start_idx, info.end_idx do vim.api.nvim_buf_clear_namespace(qfbufnr, ns, i - 1, i) ---@type nil|QuickFixItem local item = qf_list.items[i] -- If the quickfix list has changed length since the async highlight job has started, -- we should abort and let the next async highlight task pick it up. if not item then return end local line = lines[i] if not line then break end if item.bufnr ~= 0 then local loaded = vim.api.nvim_buf_is_loaded(item.bufnr) if not loaded and info.force_bufload then vim.fn.bufload(item.bufnr) loaded = true end if loaded then add_item_highlights_from_buf(qfbufnr, item, line, i) elseif config.highlight.treesitter then local filename = vim.split(line, EM_QUAD, { plain = true })[1] local offset = filename:len() + EM_QUAD_LEN local text = line:sub(offset + 1) for _, hl in ipairs(highlight.get_heuristic_ts_highlights(item, text)) do local start_col, end_col, hl_group = hl[1], hl[2], hl[3] start_col = start_col + offset end_col = end_col + offset vim.api.nvim_buf_set_extmark(qfbufnr, ns, i - 1, start_col, { hl_group = hl_group, end_col = end_col, priority = 100, strict = false, }) end end end local user_data = util.get_user_data(item) -- Set sign if item has a type if item.type and item.type ~= "" then local mark = { sign_text = get_icon(item.type), sign_hl_group = sign_highlight_map[item.type:upper()], invalidate = true, } if user_data.error_text then mark.virt_text = { { user_data.error_text, virt_text_highlight_map[item.type:upper()] or "Normal" }, } end vim.api.nvim_buf_set_extmark(qfbufnr, ns, i - 1, 0, mark) end -- If we've been processing for too long, defer to preserve editor responsiveness local delta = vim.uv.hrtime() / 1e6 - start if delta > 50 then info.start_idx = i + 1 schedule_highlights(info) return end end vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, ns, info.end_idx, -1) end ---@param str string ---@param len integer ---@return string local function rpad(str, len) return str .. string.rep(" ", len - vim.api.nvim_strwidth(str)) end ---@param items QuickFixItem[] ---@return integer local function get_lnum_width(items) local max_len = 2 local max = 99 for _, item in ipairs(items) do if item.lnum > max then max_len = tostring(item.lnum):len() max = item.lnum end end return max_len end ---@param text string ---@param prefix? string local function remove_prefix(text, prefix) local ret if prefix and prefix ~= "" then ret = text:sub(prefix:len() + 1) else ret = text end return ret end ---@class QuickFixTextFuncInfo ---@field id integer ---@field start_idx integer ---@field end_idx integer ---@field winid integer ---@field quickfix 1|0 ---@field force_bufload? boolean field injected by us to control if we're forcing a bufload for the syntax highlighting -- TODO when appending to a qflist, the alignment can be thrown off -- TODO when appending to a qflist, the prefix could mismatch earlier lines ---@param info QuickFixTextFuncInfo ---@return string[] function M.quickfixtextfunc(info) local b = config.borders local qf_list = load_qf(info, true) local locations = {} local invalid_filenames = {} local headers = {} local ret = {} local items = qf_list.items local lnum_width = get_lnum_width(items) local col_width = get_cached_qf_col_width(info.id, items) local lnum_fmt = string.format("%%%ds", lnum_width) local prefixes = calc_whitespace_prefix(items) local no_filenames = col_width == 0 local function get_virt_text(lnum) -- If none of the quickfix items have filenames, we don't need the lnum column and we only need -- to show a single delimiter. Technically we don't need any delimiter, but this maintains some -- of the original qf behavior while being a bit more visually appealing. if no_filenames then return { { b.vert, "Delimiter" } } else return { { b.vert, "Delimiter" }, { lnum_fmt:format(lnum), "QuickFixLineNr" }, { b.vert, "Delimiter" }, } end end for i = info.start_idx, info.end_idx do local item = items[i] local user_data = util.get_user_data(item) -- First check if there's a header that we need to save to render as virtual text later if user_data.header == "hard" then -- Header when expanded QF list local pieces = { string.rep(b.strong_header, col_width + 1), b.strong_cross, string.rep(b.strong_header, lnum_width), } local header_len = config.header_length("hard", col_width + lnum_width + 2) if header_len > 0 then table.insert(pieces, b.strong_cross) table.insert(pieces, string.rep(b.strong_header, header_len)) else table.insert(pieces, b.strong_end) end table.insert(headers, { i, { { table.concat(pieces, ""), "QuickFixHeaderHard" } } }) elseif user_data.header == "soft" then -- Soft header when expanded QF list local pieces = { string.rep(b.soft_header, col_width + 1), b.soft_cross, string.rep(b.soft_header, lnum_width), } local header_len = config.header_length("soft", col_width + lnum_width + 2) if header_len > 0 then table.insert(pieces, b.soft_cross) table.insert(pieces, string.rep(b.soft_header, header_len)) else table.insert(pieces, b.soft_end) end table.insert(headers, { i, { { table.concat(pieces, ""), "QuickFixHeaderSoft" } } }) end -- Construct the lines and save the filename + lnum to render as virtual text later local trimmed_text if config.trim_leading_whitespace == "all" then trimmed_text = item.text:gsub("^%s*", "") elseif config.trim_leading_whitespace == "common" then trimmed_text = remove_prefix(item.text, prefixes[item.bufnr]) else trimmed_text = item.text end if item.valid == 1 then -- Matching line local lnum = item.lnum == 0 and " " or item.lnum local filename = rpad(M.get_filename_from_item(item), col_width) table.insert(locations, get_virt_text(lnum)) table.insert(ret, filename .. EM_QUAD .. trimmed_text) elseif user_data.lnum then -- Non-matching line from quicker.nvim context lines local filename = string.rep(" ", col_width) table.insert(locations, get_virt_text(user_data.lnum)) table.insert(ret, filename .. EM_QUAD .. trimmed_text) else -- Other non-matching line local lnum = item.lnum == 0 and " " or item.lnum local filename = rpad(M.get_filename_from_item(item), col_width) table.insert(locations, get_virt_text(lnum)) invalid_filenames[#locations] = true table.insert(ret, filename .. EM_QUAD .. trimmed_text) end end -- Render the filename+lnum and the headers as virtual text local start_idx = info.start_idx local set_virt_text set_virt_text = function() qf_list = load_qf(info) if qf_list.qfbufnr > 0 then -- Sometimes the buffer is not fully populated yet. If so, we should try again later. local num_lines = vim.api.nvim_buf_line_count(qf_list.qfbufnr) if num_lines < info.end_idx then vim.schedule(set_virt_text) return end local ns = vim.api.nvim_create_namespace("quicker_locations") vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, ns, start_idx - 1, -1) local header_ns = vim.api.nvim_create_namespace("quicker_headers") vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, header_ns, start_idx - 1, -1) local filename_ns = vim.api.nvim_create_namespace("quicker_filenames") vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, filename_ns, start_idx - 1, -1) local idmap = {} local lines = vim.api.nvim_buf_get_lines(qf_list.qfbufnr, start_idx - 1, -1, false) for i, loc in ipairs(locations) do local end_col = lines[i]:find(EM_QUAD, 1, true) or col_width local lnum = start_idx + i - 1 local id = vim.api.nvim_buf_set_extmark(qf_list.qfbufnr, ns, lnum - 1, end_col + EM_QUAD_LEN - 1, { right_gravity = false, virt_text = loc, virt_text_pos = "inline", invalidate = true, }) idmap[id] = lnum -- Highlight the filename vim.api.nvim_buf_set_extmark(qf_list.qfbufnr, filename_ns, lnum - 1, 0, { hl_group = invalid_filenames[i] and "QuickFixFilenameInvalid" or "QuickFixFilename", right_gravity = false, end_col = end_col, priority = 100, invalidate = true, }) end vim.b[qf_list.qfbufnr].qf_ext_id_to_item_idx = idmap for _, pair in ipairs(headers) do local i, header = pair[1], pair[2] local lnum = start_idx + i - 1 vim.api.nvim_buf_set_extmark(qf_list.qfbufnr, header_ns, lnum - 1, 0, { virt_lines = { header }, virt_lines_above = true, }) end end end vim.schedule(set_virt_text) -- If we just rendered the last item, add highlights if info.end_idx == #items then schedule_highlights(info) if qf_list.qfbufnr > 0 then vim.b[qf_list.qfbufnr].qf_prefixes = prefixes end end return ret end return M