diff options
Diffstat (limited to 'lua/quicker/display.lua')
| -rw-r--r-- | lua/quicker/display.lua | 601 |
1 files changed, 601 insertions, 0 deletions
diff --git a/lua/quicker/display.lua b/lua/quicker/display.lua new file mode 100644 index 0000000..5c551d3 --- /dev/null +++ b/lua/quicker/display.lua @@ -0,0 +1,601 @@ +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<integer, string> +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 |
