diff options
| author | Mike Vink <mike@pionative.com> | 2025-01-19 13:52:31 +0100 |
|---|---|---|
| committer | Mike Vink <mike@pionative.com> | 2025-01-19 13:52:31 +0100 |
| commit | c65afb488eb9eab85063d79783d40ae1d7138586 (patch) | |
| tree | 48ce8318f6fc22eb0b82df83b5c175469b853643 /lua/quicker/editor.lua | |
Squashed 'mut/neovim/pack/plugins/start/quicker.nvim/' content from commit 049def7
git-subtree-dir: mut/neovim/pack/plugins/start/quicker.nvim
git-subtree-split: 049def718213d3cdf49fdf29835aded09b3e54a3
Diffstat (limited to 'lua/quicker/editor.lua')
| -rw-r--r-- | lua/quicker/editor.lua | 405 |
1 files changed, 405 insertions, 0 deletions
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 |
