diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 836a9f4..0c226ae 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -245,7 +245,7 @@ local function highlight_vim_syntax( pcall(vim.api.nvim_buf_delete, scratch, { force = true }) local hunk_line_count = #hunk.lines - local col_off = (hunk.prefix_width or 1) - 1 + local col_off = (hunk.prefix_width or 1) + (hunk.quote_width or 0) - 1 local extmark_count = 0 for _, span in ipairs(spans) do local adj = span.line - leading_offset @@ -273,6 +273,7 @@ end function M.highlight_hunk(bufnr, ns, hunk, opts) local p = opts.highlights.priorities local pw = hunk.prefix_width or 1 + local qw = hunk.quote_width or 0 local use_ts = hunk.lang and opts.highlights.treesitter.enabled local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled @@ -329,9 +330,9 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end extmark_count = - highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, pw, covered_lines, p) + highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, pw + qw, covered_lines, p) extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, pw, covered_lines, p) + + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, pw + qw, covered_lines, p) if hunk.header_context and hunk.header_context_col then local header_extmarks = highlight_text( @@ -370,11 +371,11 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) header_map[i] = hunk.header_start_line - 1 + i end extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0, nil, p, pw > 1) + + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, qw, nil, p, qw > 0 or pw > 1) end local at_raw_line - if pw > 1 and opts.highlights.treesitter.enabled then + if (qw > 0 or pw > 1) and opts.highlights.treesitter.enabled then local at_buf_line = hunk.start_line - 1 at_raw_line = vim.api.nvim_buf_get_lines(bufnr, at_buf_line, at_buf_line + 1, false)[1] end @@ -420,7 +421,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end if - pw > 1 + (qw > 0 or pw > 1) and hunk.header_start_line and hunk.header_lines and #hunk.header_lines > 0 @@ -429,35 +430,37 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) for i = 0, #hunk.header_lines - 1 do local buf_line = hunk.header_start_line - 1 + i pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = #hunk.header_lines[i + 1], + end_col = #hunk.header_lines[i + 1] + qw, hl_group = 'DiffsClear', priority = p.clear, }) - local hline = hunk.header_lines[i + 1] - if hline:match('^index ') then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = 5, - hl_group = '@keyword.diff', - priority = p.syntax, - }) - local dot_pos = hline:find('%.%.', 1, false) - if dot_pos then - local rest = hline:sub(dot_pos + 2) - local hash = rest:match('^(%x+)') - if hash then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, dot_pos + 1, { - end_col = dot_pos + 1 + #hash, - hl_group = '@constant.diff', - priority = p.syntax, - }) + if pw > 1 then + local hline = hunk.header_lines[i + 1] + if hline:match('^index ') then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, qw, { + end_col = 5 + qw, + hl_group = '@keyword.diff', + priority = p.syntax, + }) + local dot_pos = hline:find('%.%.', 1, false) + if dot_pos then + local rest = hline:sub(dot_pos + 2) + local hash = rest:match('^(%x+)') + if hash then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, dot_pos + 1 + qw, { + end_col = dot_pos + 1 + #hash + qw, + hl_group = '@constant.diff', + priority = p.syntax, + }) + end end end end end end - if pw > 1 and at_raw_line then + if (qw > 0 or pw > 1) and at_raw_line then local at_buf_line = hunk.start_line - 1 pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, { end_col = #at_raw_line, @@ -465,7 +468,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) priority = p.clear, }) if opts.highlights.treesitter.enabled then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, { + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, qw, { end_col = #at_raw_line, hl_group = '@attribute.diff', priority = p.syntax, @@ -482,9 +485,16 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) }) end + local raw_body_lines + if qw > 0 then + raw_body_lines = + vim.api.nvim_buf_get_lines(bufnr, hunk.start_line, hunk.start_line + #hunk.lines, false) + end + for i, line in ipairs(hunk.lines) do local buf_line = hunk.start_line + i - 1 local line_len = #line + local raw_len = raw_body_lines and #raw_body_lines[i] or nil local prefix = line:sub(1, pw) local has_add = prefix:find('+', 1, true) ~= nil local has_del = prefix:find('-', 1, true) ~= nil @@ -505,22 +515,30 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) if opts.hide_prefix then local virt_hl = (opts.highlights.background and line_hl) or nil pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - virt_text = { { string.rep(' ', pw), virt_hl } }, + virt_text = { { string.rep(' ', pw + qw), virt_hl } }, virt_text_pos = 'overlay', }) end - if pw > 1 then + if qw > 0 or pw > 1 then + local prefix_end = pw + qw + if raw_len and prefix_end > raw_len then + prefix_end = raw_len + end pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = pw, + end_col = prefix_end, hl_group = 'DiffsClear', priority = p.clear, }) for ci = 0, pw - 1 do local ch = line:sub(ci + 1, ci + 1) if ch == '+' or ch == '-' then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, ci, { - end_col = ci + 1, + local char_col = ci + qw + if raw_len and char_col >= raw_len then + break + end + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, char_col, { + end_col = char_col + 1, hl_group = ch == '+' and '@diff.plus' or '@diff.minus', priority = p.syntax, }) @@ -537,8 +555,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end if is_marker and line_len > pw then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, { - end_col = line_len, + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, { + end_col = line_len + qw, hl_group = 'DiffsConflictMarker', priority = p.char_bg, }) @@ -556,8 +574,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) char_hl, line:sub(span.col_start + 1, span.col_end) ) - local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { - end_col = span.col_end, + local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + qw, { + end_col = span.col_end + qw, hl_group = char_hl, priority = p.char_bg, }) @@ -570,8 +588,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end if line_len > pw and covered_lines[buf_line] then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, { - end_col = line_len, + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, { + end_col = line_len + qw, hl_group = 'DiffsClear', priority = p.clear, }) diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index a32e563..c512ac5 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -13,6 +13,7 @@ ---@field file_new_start integer? ---@field file_new_count integer? ---@field prefix_width integer +---@field quote_width integer ---@field repo_root string? local M = {} @@ -135,6 +136,17 @@ function M.parse_buffer(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local repo_root = get_repo_root(bufnr) + local quote_prefix = nil + local quote_width = 0 + for _, l in ipairs(lines) do + local qp = l:match('^(>+ )diff %-%-') or l:match('^(>+ )@@ %-') + if qp then + quote_prefix = qp + quote_width = #qp + break + end + end + ---@type diffs.Hunk[] local hunks = {} @@ -172,6 +184,7 @@ function M.parse_buffer(bufnr) local old_remaining = nil ---@type integer? local new_remaining = nil + local current_quote_width = 0 local function flush_hunk() if hunk_start and #hunk_lines > 0 then @@ -184,6 +197,7 @@ function M.parse_buffer(bufnr) header_context_col = hunk_header_context_col, lines = hunk_lines, prefix_width = hunk_prefix_width, + quote_width = current_quote_width, file_old_start = file_old_start, file_old_count = file_old_count, file_new_start = file_new_start, @@ -209,19 +223,29 @@ function M.parse_buffer(bufnr) end for i, line in ipairs(lines) do - local diff_git_file = line:match('^diff %-%-git a/.+ b/(.+)$') - or line:match('^diff %-%-combined (.+)$') - or line:match('^diff %-%-cc (.+)$') - local neogit_file = line:match('^modified%s+(.+)$') - or (not line:match('^new file mode') and line:match('^new file%s+(.+)$')) - or (not line:match('^deleted file mode') and line:match('^deleted%s+(.+)$')) - or line:match('^renamed%s+(.+)$') - or line:match('^copied%s+(.+)$') - local bare_file = not hunk_start and line:match('^([^%s]+%.[^%s]+)$') - local filename = line:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file or neogit_file or bare_file + local logical = line + if quote_prefix then + if line:sub(1, quote_width) == quote_prefix then + logical = line:sub(quote_width + 1) + elseif line:match('^>+$') then + logical = '' + end + end + + local diff_git_file = logical:match('^diff %-%-git a/.+ b/(.+)$') + or logical:match('^diff %-%-combined (.+)$') + or logical:match('^diff %-%-cc (.+)$') + local neogit_file = logical:match('^modified%s+(.+)$') + or (not logical:match('^new file mode') and logical:match('^new file%s+(.+)$')) + or (not logical:match('^deleted file mode') and logical:match('^deleted%s+(.+)$')) + or logical:match('^renamed%s+(.+)$') + or logical:match('^copied%s+(.+)$') + local bare_file = not hunk_start and logical:match('^([^%s]+%.[^%s]+)$') + local filename = logical:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file or neogit_file or bare_file if filename then flush_hunk() current_filename = filename + current_quote_width = (logical ~= line) and quote_width or 0 local cache_key = (repo_root or '') .. '\0' .. filename local cached = ft_lang_cache[cache_key] if cached then @@ -243,13 +267,13 @@ function M.parse_buffer(bufnr) hunk_prefix_width = 1 header_start = i header_lines = {} - elseif line:match('^@@+') then + elseif logical:match('^@@+') then flush_hunk() hunk_start = i - local at_prefix = line:match('^(@@+)') + local at_prefix = logical:match('^(@@+)') hunk_prefix_width = #at_prefix - 1 if #at_prefix == 2 then - local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') + local hs, hc, hs2, hc2 = logical:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') if hs then file_old_start = tonumber(hs) file_old_count = tonumber(hc) or 1 @@ -259,31 +283,31 @@ function M.parse_buffer(bufnr) new_remaining = file_new_count end else - local hs, hc = line:match('%-(%d+),?(%d*)') + local hs, hc = logical:match('%-(%d+),?(%d*)') if hs then file_old_start = tonumber(hs) file_old_count = tonumber(hc) or 1 old_remaining = file_old_count end - local hs2, hc2 = line:match('%+(%d+),?(%d*) @@') + local hs2, hc2 = logical:match('%+(%d+),?(%d*) @@') if hs2 then file_new_start = tonumber(hs2) file_new_count = tonumber(hc2) or 1 new_remaining = file_new_count end end - local at_end, context = line:match('^(@@+.-@@+%s*)(.*)') + local at_end, context = logical:match('^(@@+.-@@+%s*)(.*)') if context and context ~= '' then hunk_header_context = context - hunk_header_context_col = #at_end + hunk_header_context_col = #at_end + current_quote_width end if hunk_count then hunk_count = hunk_count + 1 end elseif hunk_start then - local prefix = line:sub(1, 1) + local prefix = logical:sub(1, 1) if prefix == ' ' or prefix == '+' or prefix == '-' then - table.insert(hunk_lines, line) + table.insert(hunk_lines, logical) if old_remaining and (prefix == ' ' or prefix == '-') then old_remaining = old_remaining - 1 end @@ -291,7 +315,7 @@ function M.parse_buffer(bufnr) new_remaining = new_remaining - 1 end elseif - line == '' + logical == '' and old_remaining and old_remaining > 0 and new_remaining @@ -301,11 +325,11 @@ function M.parse_buffer(bufnr) old_remaining = old_remaining - 1 new_remaining = new_remaining - 1 elseif - line == '' - or line:match('^[MADRC%?!]%s+') - or line:match('^diff ') - or line:match('^index ') - or line:match('^Binary ') + logical == '' + or logical:match('^[MADRC%?!]%s+') + or logical:match('^diff ') + or logical:match('^index ') + or logical:match('^Binary ') then flush_hunk() current_filename = nil @@ -315,7 +339,7 @@ function M.parse_buffer(bufnr) end end if header_start and not hunk_start then - table.insert(header_lines, line) + table.insert(header_lines, logical) end end diff --git a/spec/email_quote_spec.lua b/spec/email_quote_spec.lua new file mode 100644 index 0000000..afd0206 --- /dev/null +++ b/spec/email_quote_spec.lua @@ -0,0 +1,472 @@ +require('spec.helpers') +local parser = require('diffs.parser') +local highlight = require('diffs.highlight') + +describe('email-quoted diffs', function() + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + describe('parser', function() + it('parses a fully email-quoted unified diff', function() + local bufnr = create_buffer({ + '> diff --git a/foo.py b/foo.py', + '> index abc1234..def5678 100644', + '> --- a/foo.py', + '> +++ b/foo.py', + '> @@ -0,0 +1,3 @@', + '> +from typing import Annotated, final', + '> +', + '> +class Foo:', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('foo.py', hunks[1].filename) + assert.are.equal(3, #hunks[1].lines) + assert.are.equal('+from typing import Annotated, final', hunks[1].lines[1]) + assert.are.equal(2, hunks[1].quote_width) + delete_buffer(bufnr) + end) + + it('parses a quoted diff embedded in an email reply', function() + local bufnr = create_buffer({ + 'Looks good, one nit:', + '', + '> diff --git a/foo.py b/foo.py', + '> @@ -0,0 +1,3 @@', + '> +from typing import Annotated, final', + '> +', + '> +class Foo:', + '', + 'Maybe rename Foo to Bar?', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('foo.py', hunks[1].filename) + assert.are.equal(3, #hunks[1].lines) + assert.are.equal(2, hunks[1].quote_width) + delete_buffer(bufnr) + end) + + it('sets quote_width = 0 on normal (unquoted) diffs', function() + local bufnr = create_buffer({ + 'diff --git a/bar.lua b/bar.lua', + '@@ -1,2 +1,2 @@', + '-old_line', + '+new_line', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(0, hunks[1].quote_width) + delete_buffer(bufnr) + end) + + it('treats bare > lines as empty quoted lines', function() + local bufnr = create_buffer({ + '> diff --git a/foo.py b/foo.py', + '> @@ -1,3 +1,3 @@', + '> -old', + '>', + '> +new', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(3, #hunks[1].lines) + assert.are.equal('-old', hunks[1].lines[1]) + assert.are.equal(' ', hunks[1].lines[2]) + assert.are.equal('+new', hunks[1].lines[3]) + delete_buffer(bufnr) + end) + + it('handles deeply nested quotes', function() + local bufnr = create_buffer({ + '>> diff --git a/foo.py b/foo.py', + '>> @@ -0,0 +1,2 @@', + '>> +line1', + '>> +line2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(3, hunks[1].quote_width) + assert.are.equal('+line1', hunks[1].lines[1]) + delete_buffer(bufnr) + end) + + it('adjusts header_context_col for quote width', function() + local bufnr = create_buffer({ + '> diff --git a/foo.py b/foo.py', + '> @@ -1,2 +1,2 @@ def hello():', + '> -old', + '> +new', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('def hello():', hunks[1].header_context) + assert.are.equal(#'@@ -1,2 +1,2 @@ ' + 2, hunks[1].header_context_col) + delete_buffer(bufnr) + end) + + it('does not false-positive on prose containing > diff', function() + local bufnr = create_buffer({ + '> diff between approaches is small', + '> I think we should go with option A', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(0, #hunks) + delete_buffer(bufnr) + end) + + it('stores header lines stripped of quote prefix', function() + local bufnr = create_buffer({ + '> diff --git a/foo.lua b/foo.lua', + '> index abc1234..def5678 100644', + '> --- a/foo.lua', + '> +++ b/foo.lua', + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.is_not_nil(hunks[1].header_lines) + for _, hline in ipairs(hunks[1].header_lines) do + assert.is_nil(hline:match('^> ')) + end + delete_buffer(bufnr) + end) + end) + + describe('highlight', function() + local ns + + before_each(function() + ns = vim.api.nvim_create_namespace('diffs_email_test') + vim.api.nvim_set_hl(0, 'DiffsClear', { fg = 0xc0c0c0, bg = 0x1e1e1e }) + vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = 0x1a3a1a }) + vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = 0x3a1a1a }) + vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { fg = 0x808080, bold = true }) + end) + + local function get_extmarks(bufnr) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + end + + local function default_opts(overrides) + local opts = { + hide_prefix = false, + highlights = { + background = true, + gutter = false, + context = { enabled = false, lines = 0 }, + treesitter = { + enabled = true, + max_lines = 500, + }, + vim = { + enabled = false, + max_lines = 200, + }, + intra = { + enabled = false, + algorithm = 'default', + max_lines = 500, + }, + priorities = { + clear = 198, + syntax = 199, + line_bg = 200, + char_bg = 201, + }, + }, + } + if overrides then + if overrides.highlights then + opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights) + end + for k, v in pairs(overrides) do + if k ~= 'highlights' then + opts[k] = v + end + end + end + return opts + end + + it('applies DiffsClear on email-quoted header lines covering full buffer width', function() + local buf_lines = { + '> diff --git a/foo.lua b/foo.lua', + '> index abc1234..def5678 100644', + '> --- a/foo.lua', + '> +++ b/foo.lua', + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + } + local bufnr = create_buffer(buf_lines) + + local hunk = { + filename = 'foo.lua', + lang = 'lua', + ft = 'lua', + start_line = 5, + lines = { '-old', '+new' }, + prefix_width = 1, + quote_width = 2, + header_start_line = 1, + header_lines = { + 'diff --git a/foo.lua b/foo.lua', + 'index abc1234..def5678 100644', + '--- a/foo.lua', + '+++ b/foo.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local header_clears = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and mark[2] < 4 then + table.insert(header_clears, { row = mark[2], col = mark[3], end_col = d.end_col }) + end + end + assert.is_true(#header_clears > 0) + for _, c in ipairs(header_clears) do + assert.are.equal(0, c.col) + local buf_line_len = #buf_lines[c.row + 1] + assert.are.equal(buf_line_len, c.end_col) + end + + delete_buffer(bufnr) + end) + + it('applies body prefix DiffsClear covering [0, pw+qw)', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + }) + + local hunk = { + filename = 'foo.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { '-old', '+new' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local prefix_clears = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and d.end_col == 3 and mark[3] == 0 then + table.insert(prefix_clears, { row = mark[2] }) + end + end + assert.are.equal(2, #prefix_clears) + + delete_buffer(bufnr) + end) + + it('clamps body prefix DiffsClear on bare > lines (1-byte buffer line)', function() + local bufnr = create_buffer({ + '> @@ -1,3 +1,3 @@', + '> -old', + '>', + '> +new', + }) + + local hunk = { + filename = 'foo.lua', + ft = 'lua', + lang = 'lua', + start_line = 1, + lines = { '-old', ' ', '+new' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local bare_line_row = 2 + local bare_clears = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and mark[2] == bare_line_row and mark[3] == 0 then + table.insert(bare_clears, { end_col = d.end_col }) + end + end + assert.are.equal(1, #bare_clears) + assert.are.equal(1, bare_clears[1].end_col) + + delete_buffer(bufnr) + end) + + it('applies per-char @diff.plus/@diff.minus at ci + qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + }) + + local hunk = { + filename = 'foo.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { '-old', '+new' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local diff_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == '@diff.plus' or d.hl_group == '@diff.minus') then + table.insert(diff_marks, { row = mark[2], col = mark[3], end_col = d.end_col, hl = d.hl_group }) + end + end + assert.is_true(#diff_marks >= 2) + for _, dm in ipairs(diff_marks) do + assert.are.equal(2, dm.col) + assert.are.equal(3, dm.end_col) + end + + delete_buffer(bufnr) + end) + + it('offsets treesitter extmarks by pw + qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,2 @@', + '> local x = 1', + '> +local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local ts_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group and d.hl_group:match('^@.*%.lua$') then + table.insert(ts_marks, { row = mark[2], col = mark[3] }) + end + end + assert.is_true(#ts_marks > 0) + for _, tm in ipairs(ts_marks) do + assert.is_true(tm.col >= 3) + end + + delete_buffer(bufnr) + end) + + it('offsets intra-line char span extmarks by qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,1 @@', + '> -hello world', + '> +hello earth', + }) + + local hunk = { + filename = 'test.txt', + ft = nil, + lang = nil, + start_line = 1, + lines = { '-hello world', '+hello earth' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } } }) + ) + + local extmarks = get_extmarks(bufnr) + local char_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then + table.insert(char_marks, { row = mark[2], col = mark[3], end_col = d.end_col }) + end + end + if #char_marks > 0 then + for _, cm in ipairs(char_marks) do + assert.is_true(cm.col >= 2) + end + end + + delete_buffer(bufnr) + end) + + it('does not produce duplicate extmarks with syntax_only + qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,2 @@', + '> local x = 1', + '> +local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ syntax_only = true })) + + local extmarks = get_extmarks(bufnr) + local line_bg_count = 0 + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.line_hl_group == 'DiffsAdd' then + line_bg_count = line_bg_count + 1 + end + end + assert.are.equal(1, line_bg_count) + + delete_buffer(bufnr) + end) + end) +end)