diff options
Diffstat (limited to 'lua/quicker')
| -rw-r--r-- | lua/quicker/config.lua | 189 | ||||
| -rw-r--r-- | lua/quicker/context.lua | 315 | ||||
| -rw-r--r-- | lua/quicker/cursor.lua | 44 | ||||
| -rw-r--r-- | lua/quicker/display.lua | 601 | ||||
| -rw-r--r-- | lua/quicker/editor.lua | 405 | ||||
| -rw-r--r-- | lua/quicker/follow.lua | 84 | ||||
| -rw-r--r-- | lua/quicker/fs.lua | 98 | ||||
| -rw-r--r-- | lua/quicker/highlight.lua | 222 | ||||
| -rw-r--r-- | lua/quicker/init.lua | 189 | ||||
| -rw-r--r-- | lua/quicker/keys.lua | 20 | ||||
| -rw-r--r-- | lua/quicker/opts.lua | 61 | ||||
| -rw-r--r-- | lua/quicker/util.lua | 95 |
12 files changed, 2323 insertions, 0 deletions
diff --git a/lua/quicker/config.lua b/lua/quicker/config.lua new file mode 100644 index 0000000..716f010 --- /dev/null +++ b/lua/quicker/config.lua @@ -0,0 +1,189 @@ +local default_config = { + -- Local options to set for quickfix + opts = { + buflisted = false, + number = false, + relativenumber = false, + signcolumn = "auto", + winfixheight = true, + wrap = false, + }, + -- Set to false to disable the default options in `opts` + use_default_opts = true, + -- Keymaps to set for the quickfix buffer + keys = { + -- { ">", "<cmd>lua require('quicker').expand()<CR>", desc = "Expand quickfix content" }, + }, + -- Callback function to run any custom logic or keymaps for the quickfix buffer + on_qf = function(bufnr) end, + edit = { + -- Enable editing the quickfix like a normal buffer + enabled = true, + -- Set to true to write buffers after applying edits. + -- Set to "unmodified" to only write unmodified buffers. + autosave = "unmodified", + }, + -- Keep the cursor to the right of the filename and lnum columns + constrain_cursor = true, + highlight = { + -- Use treesitter highlighting + treesitter = true, + -- Use LSP semantic token highlighting + lsp = true, + -- Load the referenced buffers to apply more accurate highlights (may be slow) + load_buffers = true, + }, + follow = { + -- When quickfix window is open, scroll to closest item to the cursor + enabled = false, + }, + -- Map of quickfix item type to icon + type_icons = { + E = " ", + W = " ", + I = " ", + N = " ", + H = " ", + }, + -- Border characters + borders = { + vert = "┃", + -- Strong headers separate results from different files + strong_header = "━", + strong_cross = "╋", + strong_end = "┫", + -- Soft headers separate results within the same file + soft_header = "╌", + soft_cross = "╂", + soft_end = "┨", + }, + -- How to trim the leading whitespace from results. Can be 'all', 'common', or false + trim_leading_whitespace = "common", + -- Maximum width of the filename column + max_filename_width = function() + return math.floor(math.min(95, vim.o.columns / 2)) + end, + -- How far the header should extend to the right + header_length = function(type, start_col) + return vim.o.columns - start_col + end, +} + +---@alias quicker.TrimEnum "all"|"common"|false + +---@class quicker.Config +---@field on_qf fun(bufnr: number) +---@field opts table<string, any> +---@field keys quicker.Keymap[] +---@field use_default_opts boolean +---@field constrain_cursor boolean +---@field highlight quicker.HighlightConfig +---@field follow quicker.FollowConfig +---@field edit quicker.EditConfig +---@field type_icons table<string, string> +---@field borders quicker.Borders +---@field trim_leading_whitespace quicker.TrimEnum +---@field max_filename_width fun(): integer +---@field header_length fun(type: "hard"|"soft", start_col: integer): integer +local M = {} + +---@class (exact) quicker.SetupOptions +---@field on_qf? fun(bufnr: number) Callback function to run any custom logic or keymaps for the quickfix buffer +---@field opts? table<string, any> Local options to set for quickfix +---@field keys? quicker.Keymap[] Keymaps to set for the quickfix buffer +---@field use_default_opts? boolean Set to false to disable the default options in `opts` +---@field constrain_cursor? boolean Keep the cursor to the right of the filename and lnum columns +---@field highlight? quicker.SetupHighlightConfig Configure syntax highlighting +---@field follow? quicker.SetupFollowConfig Configure cursor following +---@field edit? quicker.SetupEditConfig +---@field type_icons? table<string, string> Map of quickfix item type to icon +---@field borders? quicker.SetupBorders Characters used for drawing the borders +---@field trim_leading_whitespace? quicker.TrimEnum How to trim the leading whitespace from results +---@field max_filename_width? fun(): integer Maximum width of the filename column +---@field header_length? fun(type: "hard"|"soft", start_col: integer): integer How far the header should extend to the right + +local has_setup = false +---@param opts? quicker.SetupOptions +M.setup = function(opts) + opts = opts or {} + local new_conf = vim.tbl_deep_extend("keep", opts, default_config) + + for k, v in pairs(new_conf) do + M[k] = v + end + + -- Shim for when this was only a boolean. 'true' meant 'common' + if M.trim_leading_whitespace == true then + M.trim_leading_whitespace = "common" + end + + -- Remove the default opts values if use_default_opts is false + if not new_conf.use_default_opts then + M.opts = opts.opts or {} + end + has_setup = true +end + +---@class (exact) quicker.Keymap +---@field [1] string Key sequence +---@field [2] any Command to run +---@field desc? string +---@field mode? string +---@field expr? boolean +---@field nowait? boolean +---@field remap? boolean +---@field replace_keycodes? boolean +---@field silent? boolean + +---@class (exact) quicker.Borders +---@field vert string +---@field strong_header string +---@field strong_cross string +---@field strong_end string +---@field soft_header string +---@field soft_cross string +---@field soft_end string + +---@class (exact) quicker.SetupBorders +---@field vert? string +---@field strong_header? string Strong headers separate results from different files +---@field strong_cross? string +---@field strong_end? string +---@field soft_header? string Soft headers separate results within the same file +---@field soft_cross? string +---@field soft_end? string + +---@class (exact) quicker.HighlightConfig +---@field treesitter boolean +---@field lsp boolean +---@field load_buffers boolean + +---@class (exact) quicker.SetupHighlightConfig +---@field treesitter? boolean Enable treesitter syntax highlighting +---@field lsp? boolean Use LSP semantic token highlighting +---@field load_buffers? boolean Load the referenced buffers to apply more accurate highlights (may be slow) + +---@class (exact) quicker.FollowConfig +---@field enabled boolean + +---@class (exact) quicker.SetupFollowConfig +---@field enabled? boolean + +---@class (exact) quicker.EditConfig +---@field enabled boolean +---@field autosave boolean|"unmodified" + +---@class (exact) quicker.SetupEditConfig +---@field enabled? boolean +---@field autosave? boolean|"unmodified" + +return setmetatable(M, { + -- If the user hasn't called setup() yet, make sure we correctly set up the config object so there + -- aren't random crashes. + __index = function(self, key) + if not has_setup then + M.setup() + end + return rawget(self, key) + end, +}) diff --git a/lua/quicker/context.lua b/lua/quicker/context.lua new file mode 100644 index 0000000..f5bbb87 --- /dev/null +++ b/lua/quicker/context.lua @@ -0,0 +1,315 @@ +local util = require("quicker.util") + +local M = {} + +---@class (exact) quicker.QFContext +---@field num_before integer +---@field num_after integer + +---@class (exact) quicker.ExpandOpts +---@field before? integer Number of lines of context to show before the line (default 2) +---@field after? integer Number of lines of context to show after the line (default 2) +---@field add_to_existing? boolean +---@field loclist_win? integer + +---@param item QuickFixItem +---@param new_text string +local function update_item_text_keep_diagnostics(item, new_text) + -- If this is an "error" item, replace the text with the source line and store that text + -- in the user data so we can add it as virtual text later + if item.type ~= "" and not vim.endswith(new_text, item.text) then + local user_data = util.get_user_data(item) + if not user_data.error_text then + user_data.error_text = item.text + item.user_data = user_data + end + end + item.text = new_text +end + +---@param opts? quicker.ExpandOpts +function M.expand(opts) + opts = opts or {} + if not opts.loclist_win and util.get_win_type(0) == "l" then + opts.loclist_win = vim.api.nvim_get_current_win() + end + local qf_list + if opts.loclist_win then + qf_list = vim.fn.getloclist(opts.loclist_win, { all = 0 }) + else + qf_list = vim.fn.getqflist({ all = 0 }) + end + local winid = qf_list.winid + if not winid then + vim.notify("Cannot find quickfix window", vim.log.levels.ERROR) + return + end + local ctx = qf_list.context or {} + if type(ctx) ~= "table" then + -- If the quickfix had a non-table context, we're going to have to overwrite it + ctx = {} + end + ---@type quicker.QFContext + local quicker_ctx = ctx.quicker + if not quicker_ctx then + quicker_ctx = { num_before = 0, num_after = 0 } + ctx.quicker = quicker_ctx + end + local curpos = vim.api.nvim_win_get_cursor(winid)[1] + local cur_item = qf_list.items[curpos] + local newpos + + -- calculate the number of lines to show before and after the current line + local num_before = opts.before or 2 + if opts.add_to_existing then + num_before = num_before + quicker_ctx.num_before + end + num_before = math.max(0, num_before) + quicker_ctx.num_before = num_before + local num_after = opts.after or 2 + if opts.add_to_existing then + num_after = num_after + quicker_ctx.num_after + end + num_after = math.max(0, num_after) + quicker_ctx.num_after = num_after + + local items = {} + ---@type nil|QuickFixItem + local prev_item + ---@param i integer + ---@return nil|QuickFixItem + local function get_next_item(i) + local item = qf_list.items[i] + for j = i + 1, #qf_list.items do + local next_item = qf_list.items[j] + -- Next valid item that is on a different line (since we dedupe same-line items) + if + next_item.valid == 1 and (item.bufnr ~= next_item.bufnr or item.lnum ~= next_item.lnum) + then + return next_item + end + end + end + + for i, item in ipairs(qf_list.items) do + (function() + ---@cast item QuickFixItem + if item.valid == 0 or item.bufnr == 0 then + return + end + + if not vim.api.nvim_buf_is_loaded(item.bufnr) then + vim.fn.bufload(item.bufnr) + end + + local overlaps_previous = false + local header_type = "hard" + local low = math.max(0, item.lnum - 1 - num_before) + if prev_item then + if prev_item.bufnr == item.bufnr then + -- If this is the second match on the same line, skip this item + if prev_item.lnum == item.lnum then + return + end + header_type = "soft" + if prev_item.lnum + num_after >= low then + low = math.min(item.lnum - 1, prev_item.lnum + num_after) + overlaps_previous = true + end + end + end + + local high = item.lnum + num_after + local next_item = get_next_item(i) + if next_item then + if next_item.bufnr == item.bufnr and next_item.lnum <= high then + high = next_item.lnum - 1 + end + end + + local item_start_idx = #items + local lines = vim.api.nvim_buf_get_lines(item.bufnr, low, high, false) + for j, line in ipairs(lines) do + if j + low == item.lnum then + update_item_text_keep_diagnostics(item, line) + table.insert(items, item) + else + table.insert(items, { + bufnr = item.bufnr, + lnum = low + j, + text = line, + valid = 0, + user_data = { lnum = low + j }, + }) + end + if cur_item.bufnr == item.bufnr and cur_item.lnum == low + j then + newpos = #items + end + end + + -- Add the header to the first item in this sequence, if one is needed + if prev_item and not overlaps_previous then + local first_item = items[item_start_idx + 1] + if first_item then + first_item.user_data = first_item.user_data or {} + first_item.user_data.header = header_type + end + end + + prev_item = item + end)() + + if i == curpos and not newpos then + newpos = #items + end + end + + if opts.loclist_win then + vim.fn.setloclist( + opts.loclist_win, + {}, + "r", + { items = items, title = qf_list.title, context = ctx } + ) + else + vim.fn.setqflist({}, "r", { items = items, title = qf_list.title, context = ctx }) + end + + pcall(vim.api.nvim_win_set_cursor, qf_list.winid, { newpos, 0 }) +end + +---@class (exact) quicker.CollapseArgs +---@field loclist_win? integer +--- +function M.collapse(opts) + opts = opts or {} + if not opts.loclist_win and util.get_win_type(0) == "l" then + opts.loclist_win = vim.api.nvim_get_current_win() + end + local curpos = vim.api.nvim_win_get_cursor(0)[1] + local qf_list + if opts.loclist_win then + qf_list = vim.fn.getloclist(opts.loclist_win, { all = 0 }) + else + qf_list = vim.fn.getqflist({ all = 0 }) + end + local items = {} + local last_item + for i, item in ipairs(qf_list.items) do + if item.valid == 1 then + if item.user_data then + -- Clear the header, if present + item.user_data.header = nil + end + table.insert(items, item) + if i <= curpos then + last_item = #items + end + end + end + + vim.tbl_filter(function(item) + return item.valid == 1 + end, qf_list.items) + + local ctx = qf_list.context or {} + if type(ctx) == "table" then + local quicker_ctx = ctx.quicker + if quicker_ctx then + quicker_ctx = { num_before = 0, num_after = 0 } + ctx.quicker = quicker_ctx + end + end + + if opts.loclist_win then + vim.fn.setloclist( + opts.loclist_win, + {}, + "r", + { items = items, title = qf_list.title, context = qf_list.context } + ) + else + vim.fn.setqflist({}, "r", { items = items, title = qf_list.title, context = qf_list.context }) + end + if qf_list.winid then + if last_item then + vim.api.nvim_win_set_cursor(qf_list.winid, { last_item, 0 }) + end + end +end + +---@param opts? quicker.ExpandOpts +function M.toggle(opts) + opts = opts or {} + local ctx + if opts.loclist_win then + ctx = vim.fn.getloclist(opts.loclist_win, { context = 0 }).context + else + ctx = vim.fn.getqflist({ context = 0 }).context + end + + if + type(ctx) == "table" + and ctx.quicker + and (ctx.quicker.num_before > 0 or ctx.quicker.num_after > 0) + then + M.collapse() + else + M.expand(opts) + end +end + +---@class (exact) quicker.RefreshOpts +---@field keep_diagnostics? boolean If a line has a diagnostic type, keep the original text and display it as virtual text after refreshing from source. + +---@param loclist_win? integer +---@param opts? quicker.RefreshOpts +function M.refresh(loclist_win, opts) + opts = vim.tbl_extend("keep", opts or {}, { keep_diagnostics = true }) + if not loclist_win then + local ok, qf = pcall(vim.fn.getloclist, 0, { filewinid = 0 }) + if ok and qf.filewinid and qf.filewinid ~= 0 then + loclist_win = qf.filewinid + end + end + + local qf_list + if loclist_win then + qf_list = vim.fn.getloclist(loclist_win, { all = 0 }) + else + qf_list = vim.fn.getqflist({ all = 0 }) + end + + local items = {} + for _, item in ipairs(qf_list.items) do + if item.bufnr ~= 0 and item.lnum ~= 0 then + if not vim.api.nvim_buf_is_loaded(item.bufnr) then + vim.fn.bufload(item.bufnr) + end + local line = vim.api.nvim_buf_get_lines(item.bufnr, item.lnum - 1, item.lnum, false)[1] + if line then + if opts.keep_diagnostics then + update_item_text_keep_diagnostics(item, line) + else + item.text = line + end + table.insert(items, item) + end + else + table.insert(items, item) + end + end + + if loclist_win then + vim.fn.setloclist( + loclist_win, + {}, + "r", + { items = items, title = qf_list.title, context = qf_list.context } + ) + else + vim.fn.setqflist({}, "r", { items = items, title = qf_list.title, context = qf_list.context }) + end +end + +return M diff --git a/lua/quicker/cursor.lua b/lua/quicker/cursor.lua new file mode 100644 index 0000000..58ee587 --- /dev/null +++ b/lua/quicker/cursor.lua @@ -0,0 +1,44 @@ +local M = {} + +local function constrain_cursor() + local display = require("quicker.display") + local cur = vim.api.nvim_win_get_cursor(0) + local line = vim.api.nvim_buf_get_lines(0, cur[1] - 1, cur[1], true)[1] + local idx = line:find(display.EM_QUAD, 1, true) + if not idx then + return + end + local min_col = idx + display.EM_QUAD_LEN - 1 + if cur[2] < min_col then + vim.api.nvim_win_set_cursor(0, { cur[1], min_col }) + end +end + +---@param bufnr number +function M.constrain_cursor(bufnr) + -- HACK: we have to defer this call because sometimes the autocmds don't take effect. + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + local aug = vim.api.nvim_create_augroup("quicker", { clear = false }) + vim.api.nvim_create_autocmd("InsertEnter", { + desc = "Constrain quickfix cursor position", + group = aug, + nested = true, + buffer = bufnr, + -- For some reason the cursor bounces back to its original position, + -- so we have to defer the call + callback = vim.schedule_wrap(constrain_cursor), + }) + vim.api.nvim_create_autocmd({ "CursorMoved", "ModeChanged" }, { + desc = "Constrain quickfix cursor position", + nested = true, + group = aug, + buffer = bufnr, + callback = constrain_cursor, + }) + end) +end + +return M 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 diff --git a/lua/quicker/editor.lua b/lua/quicker/editor.lua new file mode 100644 index 0000000..3f5db65 --- /dev/null +++ b/lua/quicker/editor.lua @@ -0,0 +1,405 @@ +local config = require("quicker.config") +local display = require("quicker.display") +local util = require("quicker.util") +local M = {} + +---@class (exact) quicker.ParsedLine +---@field filename? string +---@field lnum? integer +---@field text? string + +---@param n integer +---@param base string +---@param pluralized? string +---@return string +local function plural(n, base, pluralized) + if n == 1 then + return base + elseif pluralized then + return pluralized + else + return base .. "s" + end +end + +---Replace the text in a quickfix line, preserving the lineno virt text +---@param bufnr integer +---@param lnum integer +---@param new_text string +local function replace_qf_line(bufnr, lnum, new_text) + local old_line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1] + + local old_idx = old_line:find(display.EM_QUAD, 1, true) + local new_idx = new_text:find(display.EM_QUAD, 1, true) + + -- If we're missing the em quad delimiter in either the old or new text, the best we can do is + -- replace the whole line + if not old_idx or not new_idx then + vim.api.nvim_buf_set_text(bufnr, lnum - 1, 0, lnum - 1, -1, { new_text }) + return + end + + -- Replace first the text after the em quad, then the filename before. + -- This keeps the line number virtual text in the same location. + vim.api.nvim_buf_set_text( + bufnr, + lnum - 1, + old_idx + display.EM_QUAD_LEN - 1, + lnum - 1, + -1, + { new_text:sub(new_idx + display.EM_QUAD_LEN) } + ) + vim.api.nvim_buf_set_text(bufnr, lnum - 1, 0, lnum - 1, old_idx, { new_text:sub(1, new_idx) }) +end + +---@param bufnr integer +---@param lnum integer +---@param text string +---@param text_hl? string +local function add_qf_error(bufnr, lnum, text, text_hl) + local line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1] + local col = line:find(config.borders.vert, 1, true) + if col then + col = line:find(config.borders.vert, col + config.borders.vert:len(), true) + + config.borders.vert:len() + - 1 + else + col = 0 + end + local offset = vim.api.nvim_strwidth(line:sub(1, col)) + local ns = vim.api.nvim_create_namespace("quicker_err") + vim.api.nvim_buf_set_extmark(bufnr, ns, lnum - 1, col, { + virt_text = { { config.type_icons.E, "DiagnosticSignError" } }, + virt_text_pos = "inline", + virt_lines = { + { + { string.rep(" ", offset), "Normal" }, + { "↳ ", "DiagnosticError" }, + { text, text_hl or "Normal" }, + }, + }, + }) +end + +---@param item QuickFixItem +---@param needle string +---@param src_line nil|string +---@return nil|table text_change +---@return nil|string error +local function get_text_edit(item, needle, src_line) + if not src_line then + return nil + elseif item.text == needle then + return nil + elseif src_line ~= item.text then + if item.text:gsub("^%s*", "") == src_line:gsub("^%s*", "") then + -- If they only disagree in their leading whitespace, just take the changes after the + -- whitespace and assume that the whitespace hasn't changed. + -- This can happen if the setqflist caller doesn't use the same whitespace as the source file, + -- for example overseer.nvim Grep will convert tabs to spaces because the embedded terminal + -- will convert tabs to spaces. + needle = src_line:match("^%s*") .. needle:gsub("^%s*", "") + else + return nil, "buffer text does not match source text" + end + end + + return { + newText = needle, + range = { + start = { + line = item.lnum - 1, + character = 0, + }, + ["end"] = { + line = item.lnum - 1, + character = #src_line, + }, + }, + } +end + +---Deserialize qf_prefixes from the buffer, converting vim.NIL to nil +---@param bufnr integer +---@return table<integer, string> +local function load_qf_prefixes(bufnr) + local prefixes = vim.b[bufnr].qf_prefixes or {} + for k, v in pairs(prefixes) do + if v == vim.NIL then + prefixes[k] = nil + end + end + return prefixes +end + +---@param bufnr integer +---@param loclist_win? integer +local function save_changes(bufnr, loclist_win) + if not vim.bo[bufnr].modified then + return + end + local ns = vim.api.nvim_create_namespace("quicker_err") + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + local qf_list + if loclist_win then + qf_list = vim.fn.getloclist(loclist_win, { all = 0 }) + else + qf_list = vim.fn.getqflist({ all = 0 }) + end + + local changes = {} + local function add_change(buf, text_edit) + if not changes[buf] then + changes[buf] = {} + end + local last_edit = changes[buf][#changes[buf]] + if last_edit and vim.deep_equal(last_edit.range, text_edit.range) then + if last_edit.newText == text_edit.newText then + return + else + return "conflicting changes on the same line" + end + end + table.insert(changes[buf], text_edit) + end + + -- Parse the buffer + local winid = util.buf_find_win(bufnr) + local new_items = {} + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) + local errors = {} + local exit_early = false + local prefixes = load_qf_prefixes(bufnr) + local ext_id_to_item_idx = vim.b[bufnr].qf_ext_id_to_item_idx + for i, line in ipairs(lines) do + (function() + local extmarks = util.get_lnum_extmarks(bufnr, i, line:len()) + assert(#extmarks <= 1, string.format("Found more than one extmark on line %d", i)) + local found_idx + if extmarks[1] then + found_idx = ext_id_to_item_idx[extmarks[1][1]] + end + + -- If we didn't find a match, the line was most likely added or reordered + if not found_idx then + add_qf_error( + bufnr, + i, + "quicker.nvim does not support adding or reordering quickfix items", + "DiagnosticError" + ) + if winid then + vim.api.nvim_win_set_cursor(winid, { i, 0 }) + end + exit_early = true + return + end + + -- Trim the filename off of the line + local idx = string.find(line, display.EM_QUAD, 1, true) + if not idx then + add_qf_error( + bufnr, + i, + "The delimiter between filename and text has been deleted. Undo, delete line, or :Refresh.", + "DiagnosticError" + ) + if winid then + vim.api.nvim_win_set_cursor(winid, { i, 0 }) + end + exit_early = true + return + end + local text = line:sub(idx + display.EM_QUAD_LEN) + + local item = qf_list.items[found_idx] + if item.bufnr ~= 0 and item.lnum ~= 0 then + if not vim.api.nvim_buf_is_loaded(item.bufnr) then + vim.fn.bufload(item.bufnr) + end + local src_line = vim.api.nvim_buf_get_lines(item.bufnr, item.lnum - 1, item.lnum, false)[1] + + -- add the whitespace prefix back to the parsed line text + if config.trim_leading_whitespace == "common" then + text = (prefixes[item.bufnr] or "") .. text + elseif config.trim_leading_whitespace == "all" and src_line then + text = src_line:match("^%s*") .. text + end + + if src_line and text ~= src_line then + if text:gsub("^%s*", "") == src_line:gsub("^%s*", "") then + -- If they only disagree in their leading whitespace, just take the changes after the + -- whitespace and assume that the whitespace hasn't changed + text = src_line:match("^%s*") .. text:gsub("^%s*", "") + end + end + + local text_edit, err = get_text_edit(item, text, src_line) + if text_edit then + local chng_err = add_change(item.bufnr, text_edit) + if chng_err then + add_qf_error(bufnr, i, chng_err, "DiagnosticError") + if winid then + vim.api.nvim_win_set_cursor(winid, { i, 0 }) + end + exit_early = true + return + end + elseif err then + table.insert(new_items, item) + errors[#new_items] = line + return + end + end + + -- add item to future qflist + item.text = text + table.insert(new_items, item) + end)() + if exit_early then + vim.schedule(function() + vim.bo[bufnr].modified = true + end) + return + end + end + + ---@type table<integer, boolean> + local buf_was_modified = {} + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + buf_was_modified[buf] = vim.bo[buf].modified + end + local autosave = config.edit.autosave + local num_applied = 0 + local modified_bufs = {} + for chg_buf, text_edits in pairs(changes) do + modified_bufs[chg_buf] = true + num_applied = num_applied + #text_edits + vim.lsp.util.apply_text_edits(text_edits, chg_buf, "utf-8") + local was_modified = buf_was_modified[chg_buf] + local should_save = autosave == true or (autosave == "unmodified" and not was_modified) + -- Autosave changed buffers if they were not modified before + if should_save then + vim.api.nvim_buf_call(chg_buf, function() + vim.cmd.update({ mods = { emsg_silent = true, noautocmd = true } }) + end) + end + end + if num_applied > 0 then + local num_files = vim.tbl_count(modified_bufs) + local num_errors = vim.tbl_count(errors) + if num_errors > 0 then + local total = num_errors + num_applied + vim.notify( + string.format( + "Applied %d/%d %s in %d %s", + num_applied, + total, + plural(total, "change"), + num_files, + plural(num_files, "file") + ), + vim.log.levels.WARN + ) + else + vim.notify( + string.format( + "Applied %d %s in %d %s", + num_applied, + plural(num_applied, "change"), + num_files, + plural(num_files, "file") + ), + vim.log.levels.INFO + ) + end + end + + local view + if winid then + view = vim.api.nvim_win_call(winid, function() + return vim.fn.winsaveview() + end) + end + if loclist_win then + vim.fn.setloclist( + loclist_win, + {}, + "r", + { items = new_items, title = qf_list.title, context = qf_list.context } + ) + else + vim.fn.setqflist( + {}, + "r", + { items = new_items, title = qf_list.title, context = qf_list.context } + ) + end + if winid and view then + vim.api.nvim_win_call(winid, function() + vim.fn.winrestview(view) + end) + end + + -- Schedule this so it runs after the save completes, and the buffer will be correctly marked as modified + if not vim.tbl_isempty(errors) then + vim.schedule(function() + -- Mark the lines with changes that could not be applied + for lnum, new_text in pairs(errors) do + replace_qf_line(bufnr, lnum, new_text) + local item = new_items[lnum] + local src_line = vim.api.nvim_buf_get_lines(item.bufnr, item.lnum - 1, item.lnum, false)[1] + add_qf_error(bufnr, lnum, src_line) + if winid and vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_set_cursor(winid, { lnum, 0 }) + end + end + end) + + -- Notify user that some changes could not be applied + local cnt = vim.tbl_count(errors) + local change_text = cnt == 1 and "change" or "changes" + vim.notify( + string.format( + "%d %s could not be applied due to conflicts in the source buffer. Please :Refresh and try again.", + cnt, + change_text + ), + vim.log.levels.ERROR + ) + end +end + +-- TODO add support for undo past last change + +---@param bufnr integer +function M.setup_editor(bufnr) + local aug = vim.api.nvim_create_augroup("quicker", { clear = false }) + local loclist_win + vim.api.nvim_buf_call(bufnr, function() + local ok, qf = pcall(vim.fn.getloclist, 0, { filewinid = 0 }) + if ok and qf.filewinid and qf.filewinid ~= 0 then + loclist_win = qf.filewinid + end + end) + + -- Set a name for the buffer so we can save it + local bufname = string.format("quickfix-%d", bufnr) + if vim.api.nvim_buf_get_name(bufnr) == "" then + vim.api.nvim_buf_set_name(bufnr, bufname) + end + vim.bo[bufnr].modifiable = true + + vim.api.nvim_create_autocmd("BufWriteCmd", { + desc = "quicker.nvim apply changes on write", + group = aug, + buffer = bufnr, + nested = true, + callback = function(args) + save_changes(args.buf, loclist_win) + vim.bo[args.buf].modified = false + end, + }) +end + +return M diff --git a/lua/quicker/follow.lua b/lua/quicker/follow.lua new file mode 100644 index 0000000..d5500a2 --- /dev/null +++ b/lua/quicker/follow.lua @@ -0,0 +1,84 @@ +local util = require("quicker.util") +local M = {} + +M.seek_to_position = function() + if util.is_open(0) then + local qf_list = vim.fn.getloclist(0, { winid = 0, items = 0 }) + local new_pos = M.calculate_pos(qf_list.items) + if new_pos then + M.set_pos(qf_list.winid, new_pos) + end + end + + if util.is_open() then + local qf_list = vim.fn.getqflist({ winid = 0, items = 0 }) + local new_pos = M.calculate_pos(qf_list.items) + if new_pos then + M.set_pos(qf_list.winid, new_pos) + end + end +end + +---Calculate the current buffer/cursor location in the quickfix list +---@param list QuickFixItem[] +---@return nil|integer +M.calculate_pos = function(list) + if vim.bo.buftype ~= "" then + return + end + local bufnr = vim.api.nvim_get_current_buf() + local cursor = vim.api.nvim_win_get_cursor(0) + local lnum, col = cursor[1], cursor[2] + 1 + local prev_lnum = -1 + local prev_col = -1 + local found_buf = false + local ret + for i, entry in ipairs(list) do + if entry.bufnr ~= bufnr then + if found_buf then + return ret + end + else + found_buf = true + + -- If we detect that the list isn't sorted, bail. + if + prev_lnum > -1 + and (entry.lnum < prev_lnum or (entry.lnum == prev_lnum and entry.col <= prev_col)) + then + return + end + + if prev_lnum == -1 or lnum > entry.lnum or (lnum == entry.lnum and col >= entry.col) then + ret = i + end + prev_lnum = entry.lnum + prev_col = entry.col + end + end + + return ret +end + +local timers = {} +---@param winid integer +---@param pos integer +M.set_pos = function(winid, pos) + local timer = timers[winid] + if timer then + timer:close() + end + timer = assert(vim.uv.new_timer()) + timers[winid] = timer + timer:start(10, 0, function() + timer:close() + timers[winid] = nil + vim.schedule(function() + if vim.api.nvim_win_is_valid(winid) then + pcall(vim.api.nvim_win_set_cursor, winid, { pos, 0 }) + end + end) + end) +end + +return M diff --git a/lua/quicker/fs.lua b/lua/quicker/fs.lua new file mode 100644 index 0000000..f0e94dc --- /dev/null +++ b/lua/quicker/fs.lua @@ -0,0 +1,98 @@ +local M = {} + +---@type boolean +M.is_windows = vim.uv.os_uname().version:match("Windows") + +M.is_mac = vim.uv.os_uname().sysname == "Darwin" + +M.is_linux = not M.is_windows and not M.is_mac + +---@type string +M.sep = M.is_windows and "\\" or "/" + +---@param ... string +M.join = function(...) + return table.concat({ ... }, M.sep) +end + +---Check if OS path is absolute +---@param dir string +---@return boolean +M.is_absolute = function(dir) + if M.is_windows then + return dir:match("^%a:\\") + else + return vim.startswith(dir, "/") + end +end + +M.abspath = function(path) + if not M.is_absolute(path) then + path = vim.fn.fnamemodify(path, ":p") + end + return path +end + +local home_dir = assert(vim.uv.os_homedir()) + +---@param path string +---@param relative_to? string Shorten relative to this path (default cwd) +---@return string +M.shorten_path = function(path, relative_to) + if not relative_to then + relative_to = vim.fn.getcwd() + end + local relpath + if M.is_subpath(relative_to, path) then + local idx = relative_to:len() + 1 + -- Trim the dividing slash if it's not included in relative_to + if not vim.endswith(relative_to, "/") and not vim.endswith(relative_to, "\\") then + idx = idx + 1 + end + relpath = path:sub(idx) + if relpath == "" then + relpath = "." + end + end + if M.is_subpath(home_dir, path) then + local homepath = "~" .. path:sub(home_dir:len() + 1) + if not relpath or homepath:len() < relpath:len() then + return homepath + end + end + return relpath or path +end + +--- Returns true if candidate is a subpath of root, or if they are the same path. +---@param root string +---@param candidate string +---@return boolean +M.is_subpath = function(root, candidate) + if candidate == "" then + return false + end + root = vim.fs.normalize(M.abspath(root)) + -- Trim trailing "/" from the root + if root:find("/", -1) then + root = root:sub(1, -2) + end + candidate = vim.fs.normalize(M.abspath(candidate)) + if M.is_windows then + root = root:lower() + candidate = candidate:lower() + end + if root == candidate then + return true + end + local prefix = candidate:sub(1, root:len()) + if prefix ~= root then + return false + end + + local candidate_starts_with_sep = candidate:find("/", root:len() + 1, true) == root:len() + 1 + local root_ends_with_sep = root:find("/", root:len(), true) == root:len() + + return candidate_starts_with_sep or root_ends_with_sep +end + +return M diff --git a/lua/quicker/highlight.lua b/lua/quicker/highlight.lua new file mode 100644 index 0000000..a7323aa --- /dev/null +++ b/lua/quicker/highlight.lua @@ -0,0 +1,222 @@ +local M = {} + +---@class quicker.TSHighlight +---@field [1] integer start_col +---@field [2] integer end_col +---@field [3] string highlight group + +local _cached_queries = {} +---@param lang string +---@return vim.treesitter.Query? +local function get_highlight_query(lang) + local query = _cached_queries[lang] + if query == nil then + query = vim.treesitter.query.get(lang, "highlights") or false + _cached_queries[lang] = query + end + if query then + return query + end +end + +---@param bufnr integer +---@param lnum integer +---@return quicker.TSHighlight[] +function M.buf_get_ts_highlights(bufnr, lnum) + local filetype = vim.bo[bufnr].filetype + if not filetype or filetype == "" then + filetype = vim.filetype.match({ buf = bufnr }) or "" + end + local lang = vim.treesitter.language.get_lang(filetype) or filetype + if lang == "" then + return {} + end + local ok, parser = pcall(vim.treesitter.get_parser, bufnr, lang) + if not ok or not parser then + return {} + end + + local row = lnum - 1 + if not parser:is_valid() then + parser:parse(true) + end + + local highlights = {} + parser:for_each_tree(function(tstree, tree) + if not tstree then + return + end + + local root_node = tstree:root() + local root_start_row, _, root_end_row, _ = root_node:range() + + -- Only worry about trees within the line range + if root_start_row > row or root_end_row < row then + return + end + + local query = get_highlight_query(tree:lang()) + + -- Some injected languages may not have highlight queries. + if not query then + return + end + + for capture, node, metadata in query:iter_captures(root_node, bufnr, row, root_end_row + 1) do + if capture == nil then + break + end + + local range = vim.treesitter.get_range(node, bufnr, metadata[capture]) + local start_row, start_col, _, end_row, end_col, _ = unpack(range) + if start_row > row then + break + end + local capture_name = query.captures[capture] + local hl = string.format("@%s.%s", capture_name, tree:lang()) + if end_row > start_row then + end_col = -1 + end + table.insert(highlights, { start_col, end_col, hl }) + end + end) + + return highlights +end + +---@class quicker.LSPHighlight +---@field [1] integer start_col +---@field [2] integer end_col +---@field [3] string highlight group +---@field [4] integer priority modifier + +-- We're accessing private APIs here. This could break in the future. +local STHighlighter = vim.lsp.semantic_tokens.__STHighlighter + +--- Copied from Neovim semantic_tokens.lua +--- Do a binary search of the tokens in the half-open range [lo, hi). +--- +--- Return the index i in range such that tokens[j].line < line for all j < i, and +--- tokens[j].line >= line for all j >= i, or return hi if no such index is found. +--- +---@private +local function lower_bound(tokens, line, lo, hi) + while lo < hi do + local mid = bit.rshift(lo + hi, 1) -- Equivalent to floor((lo + hi) / 2). + if tokens[mid].line < line then + lo = mid + 1 + else + hi = mid + end + end + return lo +end + +---@param bufnr integer +---@param lnum integer +---@return quicker.LSPHighlight[] +function M.buf_get_lsp_highlights(bufnr, lnum) + local highlighter = STHighlighter.active[bufnr] + if not highlighter then + return {} + end + local ft = vim.bo[bufnr].filetype + + local lsp_highlights = {} + for _, client in pairs(highlighter.client_state) do + local highlights = client.current_result.highlights + if highlights then + local idx = lower_bound(highlights, lnum - 1, 1, #highlights + 1) + for i = idx, #highlights do + local token = highlights[i] + + if token.line >= lnum then + break + end + + table.insert( + lsp_highlights, + { token.start_col, token.end_col, string.format("@lsp.type.%s.%s", token.type, ft), 0 } + ) + for modifier, _ in pairs(token.modifiers) do + table.insert( + lsp_highlights, + { token.start_col, token.end_col, string.format("@lsp.mod.%s.%s", modifier, ft), 1 } + ) + table.insert(lsp_highlights, { + token.start_col, + token.end_col, + string.format("@lsp.typemod.%s.%s.%s", token.type, modifier, ft), + 2, + }) + end + end + end + end + + return lsp_highlights +end + +---@param item QuickFixItem +---@param line string +---@return quicker.TSHighlight[] +M.get_heuristic_ts_highlights = function(item, line) + local filetype = vim.filetype.match({ buf = item.bufnr }) + if not filetype then + return {} + end + + local lang = vim.treesitter.language.get_lang(filetype) + if not lang then + return {} + end + + local has_parser, parser = pcall(vim.treesitter.get_string_parser, line, lang) + if not has_parser then + return {} + end + + local root = parser:parse(true)[1]:root() + local query = vim.treesitter.query.get(lang, "highlights") + if not query then + return {} + end + + local highlights = {} + for capture, node, metadata in query:iter_captures(root, line) do + if capture == nil then + break + end + + local range = vim.treesitter.get_range(node, line, metadata[capture]) + local start_row, start_col, _, end_row, end_col, _ = unpack(range) + local capture_name = query.captures[capture] + local hl = string.format("@%s.%s", capture_name, lang) + if end_row > start_row then + end_col = -1 + end + table.insert(highlights, { start_col, end_col, hl }) + end + + return highlights +end + +function M.set_highlight_groups() + if vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = "QuickFixHeaderHard" })) then + vim.api.nvim_set_hl(0, "QuickFixHeaderHard", { link = "Delimiter", default = true }) + end + if vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = "QuickFixHeaderSoft" })) then + vim.api.nvim_set_hl(0, "QuickFixHeaderSoft", { link = "Comment", default = true }) + end + if vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = "QuickFixFilename" })) then + vim.api.nvim_set_hl(0, "QuickFixFilename", { link = "Directory", default = true }) + end + if vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = "QuickFixFilenameInvalid" })) then + vim.api.nvim_set_hl(0, "QuickFixFilenameInvalid", { link = "Comment", default = true }) + end + if vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = "QuickFixLineNr" })) then + vim.api.nvim_set_hl(0, "QuickFixLineNr", { link = "LineNr", default = true }) + end +end + +return M diff --git a/lua/quicker/init.lua b/lua/quicker/init.lua new file mode 100644 index 0000000..42ae32b --- /dev/null +++ b/lua/quicker/init.lua @@ -0,0 +1,189 @@ +local M = {} + +---@param opts? quicker.SetupOptions +local function setup(opts) + local config = require("quicker.config") + config.setup(opts) + + local aug = vim.api.nvim_create_augroup("quicker", { clear = true }) + vim.api.nvim_create_autocmd("FileType", { + pattern = "qf", + group = aug, + desc = "quicker.nvim set up quickfix mappings", + callback = function(args) + require("quicker.highlight").set_highlight_groups() + require("quicker.opts").set_opts(args.buf) + require("quicker.keys").set_keymaps(args.buf) + vim.api.nvim_buf_create_user_command(args.buf, "Refresh", function() + require("quicker.context").refresh() + end, { + desc = "Update the quickfix list with the current buffer text for each item", + }) + + if config.constrain_cursor then + require("quicker.cursor").constrain_cursor(args.buf) + end + + config.on_qf(args.buf) + end, + }) + vim.api.nvim_create_autocmd("ColorScheme", { + pattern = "*", + group = aug, + desc = "quicker.nvim set up quickfix highlight groups", + callback = function() + require("quicker.highlight").set_highlight_groups() + end, + }) + if config.edit.enabled then + vim.api.nvim_create_autocmd("BufReadPost", { + pattern = "quickfix", + group = aug, + desc = "quicker.nvim set up quickfix editing", + callback = function(args) + require("quicker.editor").setup_editor(args.buf) + end, + }) + end + if config.follow.enabled then + vim.api.nvim_create_autocmd({ "CursorMoved", "BufEnter" }, { + desc = "quicker.nvim scroll to nearest location in quickfix", + pattern = "*", + group = aug, + callback = function() + require("quicker.follow").seek_to_position() + end, + }) + end + + vim.o.quickfixtextfunc = "v:lua.require'quicker.display'.quickfixtextfunc" + + -- If the quickfix/loclist is already open, refresh it so the quickfixtextfunc will take effect. + -- This is required for lazy-loading to work properly. + local list = vim.fn.getqflist({ all = 0 }) + if not vim.tbl_isempty(list.items) then + vim.fn.setqflist({}, "r", list) + end + for _, winid in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(winid) then + local llist = vim.fn.getloclist(winid, { all = 0 }) + if not vim.tbl_isempty(list.items) then + vim.fn.setloclist(winid, {}, "r", llist) + end + end + end +end + +M.setup = setup + +---Expand the context around the quickfix results. +---@param opts? quicker.ExpandOpts +---@note +--- If there are multiple quickfix items for the same line of a file, only the first +--- one will remain after calling expand(). +M.expand = function(opts) + return require("quicker.context").expand(opts) +end + +---Collapse the context around quickfix results, leaving only the `valid` items. +M.collapse = function() + return require("quicker.context").collapse() +end + +---Toggle the expanded context around the quickfix results. +---@param opts? quicker.ExpandOpts +M.toggle_expand = function(opts) + return require("quicker.context").toggle(opts) +end + +---Update the quickfix list with the current buffer text for each item. +---@param loclist_win? integer +---@param opts? quicker.RefreshOpts +M.refresh = function(loclist_win, opts) + return require("quicker.context").refresh(loclist_win, opts) +end + +---@param loclist_win? integer Check if loclist is open for the given window. If nil, check quickfix. +M.is_open = function(loclist_win) + return require("quicker.util").is_open(loclist_win) +end + +---@class quicker.OpenCmdMods: vim.api.keyset.parse_cmd.mods + +---@class (exact) quicker.OpenOpts +---@field loclist? boolean Toggle the loclist instead of the quickfix list +---@field focus? boolean Focus the quickfix window after toggling (default false) +---@field height? integer Height of the quickfix window when opened. Defaults to number of items in the list. +---@field min_height? integer Minimum height of the quickfix window. Default 4. +---@field max_height? integer Maximum height of the quickfix window. Default 10. +---@field open_cmd_mods? quicker.OpenCmdMods A table of modifiers for the quickfix or loclist open commands. + +---Toggle the quickfix or loclist window. +---@param opts? quicker.OpenOpts +M.toggle = function(opts) + ---@type {loclist: boolean, focus: boolean, height?: integer, min_height: integer, max_height: integer, open_cmd_mods?: quicker.OpenCmdMods} + opts = vim.tbl_deep_extend("keep", opts or {}, { + loclist = false, + focus = false, + min_height = 4, + max_height = 10, + open_cmd_mods = {}, + }) + local loclist_win = opts.loclist and 0 or nil + if M.is_open(loclist_win) then + M.close({ loclist = opts.loclist }) + else + M.open(opts) + end +end + +---Open the quickfix or loclist window. +---@param opts? quicker.OpenOpts +M.open = function(opts) + local util = require("quicker.util") + ---@type {loclist: boolean, focus: boolean, height?: integer, min_height: integer, max_height: integer, open_cmd_mods?: quicker.OpenCmdMods} + opts = vim.tbl_deep_extend("keep", opts or {}, { + loclist = false, + focus = false, + min_height = 4, + max_height = 10, + open_cmd_mods = {}, + }) + local height + if opts.loclist then + local ok, err = pcall(vim.cmd.lopen, { mods = opts.open_cmd_mods }) + if not ok then + vim.notify(err, vim.log.levels.ERROR) + return + end + height = #vim.fn.getloclist(0) + else + vim.cmd.copen({ mods = opts.open_cmd_mods }) + height = #vim.fn.getqflist() + end + + -- only set the height if the quickfix is not a full-height vsplit + if not util.is_full_height_vsplit(0) then + height = math.min(opts.max_height, math.max(opts.min_height, height)) + vim.api.nvim_win_set_height(0, height) + end + + if not opts.focus then + vim.cmd.wincmd({ args = { "p" } }) + end +end + +---@class (exact) quicker.CloseOpts +---@field loclist? boolean Close the loclist instead of the quickfix list + +---Close the quickfix or loclist window. +---@param opts? quicker.CloseOpts +M.close = function(opts) + if opts and opts.loclist then + vim.cmd.lclose() + else + vim.cmd.cclose() + end +end + +return M diff --git a/lua/quicker/keys.lua b/lua/quicker/keys.lua new file mode 100644 index 0000000..17ea331 --- /dev/null +++ b/lua/quicker/keys.lua @@ -0,0 +1,20 @@ +local config = require("quicker.config") + +local M = {} + +---@param bufnr integer +function M.set_keymaps(bufnr) + for _, defn in ipairs(config.keys) do + vim.keymap.set(defn.mode or "n", defn[1], defn[2], { + buffer = bufnr, + desc = defn.desc, + expr = defn.expr, + nowait = defn.nowait, + remap = defn.remap, + replace_keycodes = defn.replace_keycodes, + silent = defn.silent, + }) + end +end + +return M diff --git a/lua/quicker/opts.lua b/lua/quicker/opts.lua new file mode 100644 index 0000000..1cf77b6 --- /dev/null +++ b/lua/quicker/opts.lua @@ -0,0 +1,61 @@ +local config = require("quicker.config") +local util = require("quicker.util") + +local M = {} + +---@param bufnr integer +local function set_buf_opts(bufnr) + for k, v in pairs(config.opts) do + local opt_info = vim.api.nvim_get_option_info2(k, {}) + if opt_info.scope == "buf" then + local ok, err = pcall(vim.api.nvim_set_option_value, k, v, { buf = bufnr }) + if not ok then + vim.notify( + string.format("Error setting quickfix option %s = %s: %s", k, vim.inspect(v), err), + vim.log.levels.ERROR + ) + end + end + end +end + +---@param winid integer +local function set_win_opts(winid) + for k, v in pairs(config.opts) do + local opt_info = vim.api.nvim_get_option_info2(k, {}) + if opt_info.scope == "win" then + local ok, err = pcall(vim.api.nvim_set_option_value, k, v, { scope = "local", win = winid }) + if not ok then + vim.notify( + string.format("Error setting quickfix window option %s = %s: %s", k, vim.inspect(v), err), + vim.log.levels.ERROR + ) + end + end + end +end + +---@param bufnr integer +function M.set_opts(bufnr) + set_buf_opts(bufnr) + local winid = util.buf_find_win(bufnr) + if winid then + set_win_opts(winid) + else + local aug = vim.api.nvim_create_augroup("quicker", { clear = false }) + vim.api.nvim_create_autocmd("BufWinEnter", { + desc = "Set quickfix window options", + buffer = bufnr, + group = aug, + callback = function() + winid = util.buf_find_win(bufnr) + if winid then + set_win_opts(winid) + end + return winid ~= nil + end, + }) + end +end + +return M diff --git a/lua/quicker/util.lua b/lua/quicker/util.lua new file mode 100644 index 0000000..3794091 --- /dev/null +++ b/lua/quicker/util.lua @@ -0,0 +1,95 @@ +local M = {} + +---@param bufnr integer +---@return nil|integer +function M.buf_find_win(bufnr) + for _, winid in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(winid) and vim.api.nvim_win_get_buf(winid) == bufnr then + return winid + end + end +end + +---@param loclist_win? integer Check if loclist is open for the given window. If nil, check quickfix. +M.is_open = function(loclist_win) + if loclist_win then + return vim.fn.getloclist(loclist_win or 0, { winid = 0 }).winid ~= 0 + else + return vim.fn.getqflist({ winid = 0 }).winid ~= 0 + end +end + +---@param winid nil|integer +---@return nil|"c"|"l" +M.get_win_type = function(winid) + if not winid or winid == 0 then + winid = vim.api.nvim_get_current_win() + end + local info = vim.fn.getwininfo(winid)[1] + if info.quickfix == 0 then + return nil + elseif info.loclist == 0 then + return "c" + else + return "l" + end +end + +---@param item QuickFixItem +---@return QuickFixUserData +M.get_user_data = function(item) + if type(item.user_data) == "table" then + return item.user_data + else + return {} + end +end + +---Get valid location extmarks for a line in the quickfix +---@param bufnr integer +---@param lnum integer +---@param line_len? integer how long this particular line is +---@param ns? integer namespace of extmarks +---@return table[] extmarks +M.get_lnum_extmarks = function(bufnr, lnum, line_len, ns) + if not ns then + ns = vim.api.nvim_create_namespace("quicker_locations") + end + if not line_len then + local line = vim.api.nvim_buf_get_lines(0, lnum - 1, lnum, true)[1] + line_len = line:len() + end + local extmarks = vim.api.nvim_buf_get_extmarks( + bufnr, + ns, + { lnum - 1, 0 }, + { lnum - 1, line_len }, + { details = true } + ) + return vim.tbl_filter(function(mark) + return not mark[4].invalid + end, extmarks) +end + +---Return true if the window is a full-height leaf window +---@param winid? integer +---@return boolean +M.is_full_height_vsplit = function(winid) + if not winid or winid == 0 then + winid = vim.api.nvim_get_current_win() + end + local layout = vim.fn.winlayout() + -- If the top layout is not vsplit, then it's not a vertical leaf + if layout[1] ~= "row" then + return false + end + for _, v in ipairs(layout[2]) do + if v[1] == "leaf" and v[2] == winid then + return true + end + end + + return false +end + +return M |
