diff --git a/lua/git/hunks.lua b/lua/git/hunks.lua index bcdc19a..813bbf9 100644 --- a/lua/git/hunks.lua +++ b/lua/git/hunks.lua @@ -22,6 +22,7 @@ local NS_OVERLAY = vim.api.nvim_create_namespace("ow.git.hunks.overlay") ---@field rel string ---@field index string[]? ---@field index_sha string? +---@field index_hl { src: string[], lines: table[][]? }? ---@field hunks ow.Git.Hunks.Hunk[] ---@field overlay boolean ---@field autocmds integer[] @@ -153,20 +154,117 @@ local function render_signs(buf) end end +local SKIP_CAPTURES = { spell = true, nospell = true, conceal = true } + +---@param buf integer +---@param lines string[] +---@return table[][]? +local function highlight_index(buf, lines) + if not vim.treesitter.highlighter.active[buf] then + return nil + end + local got, parser = pcall(vim.treesitter.get_parser, buf) + if not got or not parser then + return nil + end + local lang = parser:lang() + local query = vim.treesitter.query.get(lang, "highlights") + if not query then + return nil + end + local source = table.concat(lines, "\n") + local got_root, root = pcall(function() + local trees = vim.treesitter.get_string_parser(source, lang):parse() + local tree = trees and trees[1] + return tree and tree:root() + end) + if not got_root or not root then + return nil + end + ---@type table> + local groups = {} + for id, node in query:iter_captures(root, source) do + local name = query.captures[id] + if name and name:sub(1, 1) ~= "_" and not SKIP_CAPTURES[name] then + local sr, sc, er, ec = node:range() + for row = sr, math.min(er, #lines - 1) do + local row_groups = groups[row] or {} + groups[row] = row_groups + local from = row == sr and sc or 0 + local to = row == er and ec or #(lines[row + 1] or "") + for col = from, to - 1 do + row_groups[col] = name + end + end + end + end + local out = {} + for row = 0, #lines - 1 do + local line = lines[row + 1] or "" + local row_groups = groups[row] or {} + local chunks = {} + local col = 0 + while col < #line do + local name = row_groups[col] + local stop = col + 1 + while stop < #line and row_groups[stop] == name do + stop = stop + 1 + end + local hl ---@type string|string[] + if name then + hl = { "GitHunkDeleteLine", "@" .. name } + else + hl = "GitHunkDeleteLine" + end + table.insert(chunks, { line:sub(col + 1, stop), hl }) + col = stop + end + out[row + 1] = chunks + end + return out +end + ---@param h ow.Git.Hunks.Hunk +---@param hl_lines table[][]? per-index-line syntax chunks, or nil ---@return table[] -local function delete_virt_lines(h) +local function delete_virt_lines(h, hl_lines) local width = vim.o.columns local virt = {} - for _, line in ipairs(h.old_lines) do + for i, line in ipairs(h.old_lines) do local pad = math.max(width - vim.api.nvim_strwidth(line), 0) - table.insert(virt, { - { line .. string.rep(" ", pad), "GitHunkDeleteLine" }, - }) + local cached = hl_lines and hl_lines[h.old_start + i - 1] + if cached then + local chunks = vim.list_extend({}, cached) + table.insert(chunks, { + string.rep(" ", pad), + "GitHunkDeleteLine", + }) + table.insert(virt, chunks) + else + table.insert(virt, { + { line .. string.rep(" ", pad), "GitHunkDeleteLine" }, + }) + end end return virt end +---@param state ow.Git.Hunks.BufState +---@param buf integer +---@return table[][]? +local function index_spans(state, buf) + if not state.index then + return nil + end + local cache = state.index_hl + if cache and cache.src == state.index then + return cache.lines + end + local lines = highlight_index(buf, state.index) + state.index_hl = { src = state.index, lines = lines } + return lines +end + ---@param buf integer local function render_overlay(buf) if not vim.api.nvim_buf_is_valid(buf) then @@ -178,6 +276,7 @@ local function render_overlay(buf) return end local line_count = vim.api.nvim_buf_line_count(buf) + local hl_lines = index_spans(state, buf) for _, h in ipairs(state.hunks) do if h.type ~= "delete" then for r = h.new_start, h.new_start + h.new_count - 1 do @@ -211,7 +310,7 @@ local function render_overlay(buf) row, above = math.max(h.new_start - 1, 0), true end pcall(vim.api.nvim_buf_set_extmark, buf, NS_OVERLAY, row, 0, { - virt_lines = delete_virt_lines(h), + virt_lines = delete_virt_lines(h, hl_lines), virt_lines_above = above, right_gravity = false, invalidate = true, diff --git a/test/git/hunks_test.lua b/test/git/hunks_test.lua index 3bd631a..aac40ab 100644 --- a/test/git/hunks_test.lua +++ b/test/git/hunks_test.lua @@ -82,7 +82,7 @@ t.test("pure delete (middle): hunk shape and delete sign", function() t.eq(hk.new_count, 0) t.eq(hk.old_lines, { "b" }) t.eq(sign_marks(buf), { - { row = 0, sign = "┃", hl = "GitHunkDelete" }, + { row = 0, sign = "▁", hl = "GitHunkDelete" }, }) end) @@ -94,7 +94,7 @@ t.test("top-of-file delete: sign anchors on line 1", function() t.eq(hk.new_start, 0) t.eq(hk.old_lines, { "a" }) t.eq(sign_marks(buf), { - { row = 0, sign = "┃", hl = "GitHunkDelete" }, + { row = 0, sign = "▁", hl = "GitHunkDelete" }, }) end) @@ -143,7 +143,7 @@ t.test("editing the buffer refreshes signs", function() t.eq(hk.type, "change") end) -t.test("overlay:change hunk shows deletion and addition", function() +t.test("overlay: change hunk shows deletion and addition", function() local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") hunks.toggle_overlay(buf) ---@type integer? @@ -169,7 +169,7 @@ t.test("overlay:change hunk shows deletion and addition", function() t.eq(piece[2], "GitHunkDeleteLine") end) -t.test("overlay:delete hunk shows only deletion lines", function() +t.test("overlay: delete hunk shows only deletion lines", function() local _, buf = setup("a\nb\nc\n", "a\nc\n") hunks.toggle_overlay(buf) local marks = detailed_marks(buf, "ow.git.hunks.overlay") @@ -180,7 +180,7 @@ t.test("overlay:delete hunk shows only deletion lines", function() t.eq(piece[2], "GitHunkDeleteLine") end) -t.test("overlay:add hunk highlights the added lines", function() +t.test("overlay: add hunk highlights the added lines", function() local _, buf = setup("a\nd\n", "a\nb\nc\nd\n") hunks.toggle_overlay(buf) local rows = {} @@ -194,7 +194,45 @@ t.test("overlay:add hunk highlights the added lines", function() t.eq(rows, { 1, 2 }, "both added lines highlighted") end) -t.test("overlay:toggling swaps gutter signs for the overlay", function() +t.test("overlay: deleted lines are treesitter-highlighted", function() + local _, buf = setup( + "-- a note\nlocal x = 1\nlocal y = 2\n", + "local y = 2\n", + "a.lua" + ) + t.truthy( + pcall(vim.treesitter.start, buf, "lua"), + "the lua parser should be available" + ) + hunks.toggle_overlay(buf) + ---@type table[]? + local virt + for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do + local d = assert(m[4]) + if d.virt_lines then + virt = d.virt_lines + end + end + virt = assert(virt, "a deletion virtual line should render") + ---@type table + local seen = {} + for _, line in ipairs(virt) do + for _, c in ipairs(line) do + local hl = c[2] + if + type(hl) == "table" + and hl[1] == "GitHunkDeleteLine" + and hl[2] + then + seen[hl[2]] = true + end + end + end + t.truthy(seen["@comment"], "the deleted comment keeps its @comment group") + t.truthy(seen["@keyword"], "deleted code keeps its syntax groups") +end) + +t.test("overlay: toggling swaps gutter signs for the overlay", function() local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") t.truthy( #detailed_marks(buf, "ow.git.hunks") > 0,