summaryrefslogtreecommitdiff
path: root/lua
diff options
context:
space:
mode:
Diffstat (limited to 'lua')
-rw-r--r--lua/quicker/config.lua189
-rw-r--r--lua/quicker/context.lua315
-rw-r--r--lua/quicker/cursor.lua44
-rw-r--r--lua/quicker/display.lua601
-rw-r--r--lua/quicker/editor.lua405
-rw-r--r--lua/quicker/follow.lua84
-rw-r--r--lua/quicker/fs.lua98
-rw-r--r--lua/quicker/highlight.lua222
-rw-r--r--lua/quicker/init.lua189
-rw-r--r--lua/quicker/keys.lua20
-rw-r--r--lua/quicker/opts.lua61
-rw-r--r--lua/quicker/util.lua95
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