From 997bc49f8bb6205ce8f8884366c03eaa4bf7bf99 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 13:53:58 -0500 Subject: [PATCH 1/4] feat(highlight): add character-level intra-line diff highlighting Line-level backgrounds (DiffsAdd/DiffsDelete) now get a second tier: changed characters within modified lines receive an intense background overlay (DiffsAddText/DiffsDeleteText at 70% alpha vs 40% for lines). Treesitter foreground colors show through since the extmarks only set bg. diff.lua extracts contiguous -/+ change groups from hunk lines and diffs each group byte-by-byte using vim.diff(). An optional libvscodediff FFI backend (lib.lua) auto-downloads the .so from codediff.nvim releases and falls back to native if unavailable. New config: highlights.intra.{enabled, algorithm, max_lines}. Gated by max_lines (default 200) to avoid stalling on huge hunks. Priority 201 sits above treesitter (200) so the character bg always wins. Closes #60 --- doc/diffs.nvim.txt | 44 ++++++ lua/diffs/diff.lua | 338 ++++++++++++++++++++++++++++++++++++++++ lua/diffs/health.lua | 7 + lua/diffs/highlight.lua | 37 +++++ lua/diffs/init.lua | 39 +++++ lua/diffs/lib.lua | 214 +++++++++++++++++++++++++ spec/diff_spec.lua | 163 +++++++++++++++++++ 7 files changed, 842 insertions(+) create mode 100644 lua/diffs/diff.lua create mode 100644 lua/diffs/lib.lua create mode 100644 spec/diff_spec.lua diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index b3240f4..76e5ea3 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -64,6 +64,11 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: enabled = false, max_lines = 200, }, + intra = { + enabled = true, + algorithm = 'auto', + max_lines = 200, + }, }, fugitive = { horizontal = 'du', @@ -116,6 +121,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Vim syntax highlighting options (experimental). See |diffs.VimConfig| for fields. + {intra} (table, default: see below) + Character-level (intra-line) diff highlighting. + See |diffs.IntraConfig| for fields. + *diffs.TreesitterConfig* Treesitter config fields: ~ {enabled} (boolean, default: true) @@ -140,6 +149,26 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: this many lines. Lower than the treesitter default due to the per-character cost of |synID()|. + *diffs.IntraConfig* + Intra config fields: ~ + {enabled} (boolean, default: true) + Enable character-level diff highlighting within + changed lines. When a line changes from `local x = 1` + to `local x = 2`, only the `1`/`2` characters get + an intense background overlay while the rest of the + line keeps the softer line-level background. + + {algorithm} (string, default: 'auto') + Diff algorithm for character-level analysis. + `'auto'`: use libvscodediff if available, else + native `vim.diff()`. `'native'`: always use + `vim.diff()`. `'vscode'`: require libvscodediff + (falls back to native if not available). + + {max_lines} (integer, default: 200) + Skip character-level highlighting for hunks larger + than this many lines. + Note: Header context (e.g., `@@ -10,3 +10,4 @@ func()`) is always highlighted with treesitter when a parser is available. @@ -259,6 +288,8 @@ Summary / commit detail views: ~ - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 198 - `Normal` extmarks at priority 199 clear underlying diff foreground - Syntax highlights are applied as extmarks at priority 200 + - Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at + priority 201 overlay changed characters with an intense background - Conceal extmarks hide diff prefixes when `hide_prefix` is enabled 4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events @@ -349,6 +380,18 @@ Fugitive unified diff highlights: ~ DiffsDeleteNr Line number for `-` lines. Foreground from `diffRemoved`, background from `DiffsDelete`. + *DiffsAddText* + DiffsAddText Character-level background for changed characters + within `+` lines. Derived by blending `DiffAdd` + background with `Normal` at 70% alpha (brighter + than line-level `DiffsAdd`). Only sets `bg`, so + treesitter foreground colors show through. + + *DiffsDeleteText* + DiffsDeleteText Character-level background for changed characters + within `-` lines. Derived by blending `DiffDelete` + background with `Normal` at 70% alpha. + Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. @@ -382,6 +425,7 @@ Run |:checkhealth| diffs to verify your setup. Checks performed: - Neovim version >= 0.9.0 - vim-fugitive is installed (optional) +- libvscode_diff shared library is available (optional) ============================================================================== ACKNOWLEDGEMENTS *diffs-acknowledgements* diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua new file mode 100644 index 0000000..8dc836b --- /dev/null +++ b/lua/diffs/diff.lua @@ -0,0 +1,338 @@ +---@class diffs.CharSpan +---@field line integer +---@field col_start integer +---@field col_end integer + +---@class diffs.IntraChanges +---@field add_spans diffs.CharSpan[] +---@field del_spans diffs.CharSpan[] + +---@class diffs.ChangeGroup +---@field del_lines {idx: integer, text: string}[] +---@field add_lines {idx: integer, text: string}[] + +local M = {} + +local dbg = require('diffs.log').dbg + +---@param hunk_lines string[] +---@return diffs.ChangeGroup[] +function M.extract_change_groups(hunk_lines) + ---@type diffs.ChangeGroup[] + local groups = {} + ---@type {idx: integer, text: string}[] + local del_buf = {} + ---@type {idx: integer, text: string}[] + local add_buf = {} + + ---@type boolean + local in_del = false + + for i, line in ipairs(hunk_lines) do + local prefix = line:sub(1, 1) + if prefix == '-' then + if not in_del and #add_buf > 0 then + if #del_buf > 0 then + table.insert(groups, { del_lines = del_buf, add_lines = add_buf }) + end + del_buf = {} + add_buf = {} + end + in_del = true + table.insert(del_buf, { idx = i, text = line:sub(2) }) + elseif prefix == '+' then + in_del = false + table.insert(add_buf, { idx = i, text = line:sub(2) }) + else + if #del_buf > 0 and #add_buf > 0 then + table.insert(groups, { del_lines = del_buf, add_lines = add_buf }) + end + del_buf = {} + add_buf = {} + in_del = false + end + end + + if #del_buf > 0 and #add_buf > 0 then + table.insert(groups, { del_lines = del_buf, add_lines = add_buf }) + end + + return groups +end + +---@param old_text string +---@param new_text string +---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[] +local function byte_diff(old_text, new_text) + local ok, result = pcall(vim.diff, old_text, new_text, { result_type = 'indices' }) + if not ok or not result then + return {} + end + ---@type {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[] + local hunks = {} + for _, h in ipairs(result) do + table.insert(hunks, { + old_start = h[1], + old_count = h[2], + new_start = h[3], + new_count = h[4], + }) + end + return hunks +end + +---@param s string +---@return string[] +local function split_bytes(s) + local bytes = {} + for i = 1, #s do + table.insert(bytes, s:sub(i, i)) + end + return bytes +end + +---@param old_line string +---@param new_line string +---@param del_idx integer +---@param add_idx integer +---@return diffs.CharSpan[], diffs.CharSpan[] +local function char_diff_pair(old_line, new_line, del_idx, add_idx) + ---@type diffs.CharSpan[] + local del_spans = {} + ---@type diffs.CharSpan[] + local add_spans = {} + + local old_bytes = split_bytes(old_line) + local new_bytes = split_bytes(new_line) + + local old_text = table.concat(old_bytes, '\n') .. '\n' + local new_text = table.concat(new_bytes, '\n') .. '\n' + + local char_hunks = byte_diff(old_text, new_text) + + for _, ch in ipairs(char_hunks) do + if ch.old_count > 0 then + table.insert(del_spans, { + line = del_idx, + col_start = ch.old_start, + col_end = ch.old_start + ch.old_count, + }) + end + + if ch.new_count > 0 then + table.insert(add_spans, { + line = add_idx, + col_start = ch.new_start, + col_end = ch.new_start + ch.new_count, + }) + end + end + + return del_spans, add_spans +end + +---@param group diffs.ChangeGroup +---@return diffs.CharSpan[], diffs.CharSpan[] +local function diff_group_native(group) + ---@type diffs.CharSpan[] + local all_del = {} + ---@type diffs.CharSpan[] + local all_add = {} + + local del_count = #group.del_lines + local add_count = #group.add_lines + + if del_count == 1 and add_count == 1 then + local ds, as = char_diff_pair( + group.del_lines[1].text, + group.add_lines[1].text, + group.del_lines[1].idx, + group.add_lines[1].idx + ) + vim.list_extend(all_del, ds) + vim.list_extend(all_add, as) + return all_del, all_add + end + + local old_texts = {} + for _, l in ipairs(group.del_lines) do + table.insert(old_texts, l.text) + end + local new_texts = {} + for _, l in ipairs(group.add_lines) do + table.insert(new_texts, l.text) + end + + local old_block = table.concat(old_texts, '\n') .. '\n' + local new_block = table.concat(new_texts, '\n') .. '\n' + + local line_hunks = byte_diff(old_block, new_block) + + ---@type table + local old_to_new = {} + for _, lh in ipairs(line_hunks) do + if lh.old_count == lh.new_count then + for k = 0, lh.old_count - 1 do + old_to_new[lh.old_start + k] = lh.new_start + k + end + end + end + + for old_i, new_i in pairs(old_to_new) do + if group.del_lines[old_i] and group.add_lines[new_i] then + local ds, as = char_diff_pair( + group.del_lines[old_i].text, + group.add_lines[new_i].text, + group.del_lines[old_i].idx, + group.add_lines[new_i].idx + ) + vim.list_extend(all_del, ds) + vim.list_extend(all_add, as) + end + end + + for _, lh in ipairs(line_hunks) do + if lh.old_count ~= lh.new_count then + local pairs_count = math.min(lh.old_count, lh.new_count) + for k = 0, pairs_count - 1 do + local oi = lh.old_start + k + local ni = lh.new_start + k + if group.del_lines[oi] and group.add_lines[ni] then + local ds, as = char_diff_pair( + group.del_lines[oi].text, + group.add_lines[ni].text, + group.del_lines[oi].idx, + group.add_lines[ni].idx + ) + vim.list_extend(all_del, ds) + vim.list_extend(all_add, as) + end + end + end + end + + return all_del, all_add +end + +---@param group diffs.ChangeGroup +---@param handle table +---@return diffs.CharSpan[], diffs.CharSpan[] +local function diff_group_vscode(group, handle) + ---@type diffs.CharSpan[] + local all_del = {} + ---@type diffs.CharSpan[] + local all_add = {} + + local ffi = require('ffi') + + local old_texts = {} + for _, l in ipairs(group.del_lines) do + table.insert(old_texts, l.text) + end + local new_texts = {} + for _, l in ipairs(group.add_lines) do + table.insert(new_texts, l.text) + end + + local orig_arr = ffi.new('const char*[?]', #old_texts) + for i, t in ipairs(old_texts) do + orig_arr[i - 1] = t + end + + local mod_arr = ffi.new('const char*[?]', #new_texts) + for i, t in ipairs(new_texts) do + mod_arr[i - 1] = t + end + + local opts = ffi.new('DiffsDiffOptions', { + ignore_trim_whitespace = false, + max_computation_time_ms = 1000, + compute_moves = false, + extend_to_subwords = false, + }) + + local result = handle.compute_diff(orig_arr, #old_texts, mod_arr, #new_texts, opts) + if result == nil then + return all_del, all_add + end + + for ci = 0, result.changes.count - 1 do + local mapping = result.changes.mappings[ci] + for ii = 0, mapping.inner_change_count - 1 do + local inner = mapping.inner_changes[ii] + + local orig_line = inner.original.start_line + if group.del_lines[orig_line] then + table.insert(all_del, { + line = group.del_lines[orig_line].idx, + col_start = inner.original.start_col, + col_end = inner.original.end_col, + }) + end + + local mod_line = inner.modified.start_line + if group.add_lines[mod_line] then + table.insert(all_add, { + line = group.add_lines[mod_line].idx, + col_start = inner.modified.start_col, + col_end = inner.modified.end_col, + }) + end + end + end + + handle.free_lines_diff(result) + + return all_del, all_add +end + +---@param hunk_lines string[] +---@param algorithm? string +---@return diffs.IntraChanges? +function M.compute_intra_hunks(hunk_lines, algorithm) + local groups = M.extract_change_groups(hunk_lines) + if #groups == 0 then + return nil + end + + algorithm = algorithm or 'auto' + + local lib = require('diffs.lib') + local vscode_handle = nil + if algorithm ~= 'native' then + vscode_handle = lib.load() + end + + if algorithm == 'vscode' and not vscode_handle then + dbg('vscode algorithm requested but library not available, falling back to native') + end + + ---@type diffs.CharSpan[] + local all_add = {} + ---@type diffs.CharSpan[] + local all_del = {} + + for _, group in ipairs(groups) do + local ds, as + if vscode_handle then + ds, as = diff_group_vscode(group, vscode_handle) + else + ds, as = diff_group_native(group) + end + vim.list_extend(all_del, ds) + vim.list_extend(all_add, as) + end + + if #all_add == 0 and #all_del == 0 then + return nil + end + + return { add_spans = all_add, del_spans = all_del } +end + +---@return boolean +function M.has_vscode() + return require('diffs.lib').has_lib() +end + +return M diff --git a/lua/diffs/health.lua b/lua/diffs/health.lua index c4777ca..54a3189 100644 --- a/lua/diffs/health.lua +++ b/lua/diffs/health.lua @@ -15,6 +15,13 @@ function M.check() else vim.health.warn('vim-fugitive not detected (required for unified diff highlighting)') end + + local lib = require('diffs.lib') + if lib.has_lib() then + vim.health.ok('libvscode_diff found at ' .. lib.lib_path()) + else + vim.health.info('libvscode_diff not found (optional, using native vim.diff fallback)') + end end return M diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 9f4d882..1eb16d7 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -1,6 +1,7 @@ local M = {} local dbg = require('diffs.log').dbg +local diff = require('diffs.diff') ---@param bufnr integer ---@param ns integer @@ -282,6 +283,30 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) local syntax_applied = extmark_count > 0 + ---@type diffs.IntraChanges? + local intra = nil + local intra_cfg = opts.highlights.intra + if intra_cfg and intra_cfg.enabled and #hunk.lines <= intra_cfg.max_lines then + intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm) + end + + ---@type table + local char_spans_by_line = {} + if intra then + for _, span in ipairs(intra.add_spans) do + if not char_spans_by_line[span.line] then + char_spans_by_line[span.line] = {} + end + table.insert(char_spans_by_line[span.line], span) + end + for _, span in ipairs(intra.del_spans) do + if not char_spans_by_line[span.line] then + char_spans_by_line[span.line] = {} + end + table.insert(char_spans_by_line[span.line], span) + end + end + for i, line in ipairs(hunk.lines) do local buf_line = hunk.start_line + i - 1 local line_len = #line @@ -317,6 +342,18 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) priority = 199, }) end + + if char_spans_by_line[i] then + local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText' + for _, span in ipairs(char_spans_by_line[i]) do + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { + end_col = span.col_end, + hl_group = char_hl, + priority = 201, + }) + extmark_count = extmark_count + 1 + end + end end dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index b9944a4..ea33a32 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -6,11 +6,17 @@ ---@field enabled boolean ---@field max_lines integer +---@class diffs.IntraConfig +---@field enabled boolean +---@field algorithm string +---@field max_lines integer + ---@class diffs.Highlights ---@field background boolean ---@field gutter boolean ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig +---@field intra diffs.IntraConfig ---@class diffs.FugitiveConfig ---@field horizontal string|false @@ -82,6 +88,11 @@ local default_config = { enabled = false, max_lines = 200, }, + intra = { + enabled = true, + algorithm = 'auto', + max_lines = 200, + }, }, fugitive = { horizontal = 'du', @@ -172,10 +183,15 @@ local function compute_highlight_groups() local blended_add = blend_color(add_bg, bg, 0.4) local blended_del = blend_color(del_bg, bg, 0.4) + local blended_add_text = blend_color(add_bg, bg, 0.7) + local blended_del_text = blend_color(del_bg, bg, 0.7) + vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del }) vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = add_fg, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { default = true, fg = del_fg, bg = blended_del }) + vim.api.nvim_set_hl(0, 'DiffsAddText', { default = true, bg = blended_add_text }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { default = true, bg = blended_del_text }) local diff_change = resolve_hl('DiffChange') local diff_text = resolve_hl('DiffText') @@ -207,6 +223,7 @@ local function init() ['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true }, ['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true }, ['highlights.vim'] = { opts.highlights.vim, 'table', true }, + ['highlights.intra'] = { opts.highlights.intra, 'table', true }, }) if opts.highlights.treesitter then @@ -226,6 +243,20 @@ local function init() ['highlights.vim.max_lines'] = { opts.highlights.vim.max_lines, 'number', true }, }) end + + if opts.highlights.intra then + vim.validate({ + ['highlights.intra.enabled'] = { opts.highlights.intra.enabled, 'boolean', true }, + ['highlights.intra.algorithm'] = { + opts.highlights.intra.algorithm, + function(v) + return v == nil or v == 'auto' or v == 'native' or v == 'vscode' + end, + "'auto', 'native', or 'vscode'", + }, + ['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true }, + }) + end end if opts.fugitive then @@ -266,6 +297,14 @@ local function init() then error('diffs: highlights.vim.max_lines must be >= 1') end + if + opts.highlights + and opts.highlights.intra + and opts.highlights.intra.max_lines + and opts.highlights.intra.max_lines < 1 + then + error('diffs: highlights.intra.max_lines must be >= 1') + end config = vim.tbl_deep_extend('force', default_config, opts) log.set_enabled(config.debug) diff --git a/lua/diffs/lib.lua b/lua/diffs/lib.lua new file mode 100644 index 0000000..5b3254b --- /dev/null +++ b/lua/diffs/lib.lua @@ -0,0 +1,214 @@ +local M = {} + +local dbg = require('diffs.log').dbg + +---@type table? +local cached_handle = nil + +---@type boolean +local download_in_progress = false + +---@return string +local function get_os() + local os_name = jit.os:lower() + if os_name == 'osx' then + return 'macos' + end + return os_name +end + +---@return string +local function get_arch() + return jit.arch:lower() +end + +---@return string +local function get_ext() + local os_name = jit.os:lower() + if os_name == 'windows' then + return 'dll' + elseif os_name == 'osx' then + return 'dylib' + end + return 'so' +end + +---@return string +local function lib_dir() + return vim.fn.stdpath('data') .. '/diffs/lib' +end + +---@return string +local function lib_path() + return lib_dir() .. '/libvscode_diff.' .. get_ext() +end + +---@return string +local function version_path() + return lib_dir() .. '/version' +end + +local EXPECTED_VERSION = '2.18.0' + +---@return boolean +function M.has_lib() + if cached_handle then + return true + end + return vim.fn.filereadable(lib_path()) == 1 +end + +---@return string +function M.lib_path() + return lib_path() +end + +---@return table? +function M.load() + if cached_handle then + return cached_handle + end + + local path = lib_path() + if vim.fn.filereadable(path) ~= 1 then + return nil + end + + local ffi = require('ffi') + + ffi.cdef([[ + typedef struct { + int start_line; + int end_line; + } DiffsLineRange; + + typedef struct { + int start_line; + int start_col; + int end_line; + int end_col; + } DiffsCharRange; + + typedef struct { + DiffsCharRange original; + DiffsCharRange modified; + } DiffsRangeMapping; + + typedef struct { + DiffsLineRange original; + DiffsLineRange modified; + DiffsRangeMapping* inner_changes; + int inner_change_count; + } DiffsDetailedMapping; + + typedef struct { + DiffsDetailedMapping* mappings; + int count; + int capacity; + } DiffsDetailedMappingArray; + + typedef struct { + DiffsLineRange original; + DiffsLineRange modified; + } DiffsMovedText; + + typedef struct { + DiffsMovedText* moves; + int count; + int capacity; + } DiffsMovedTextArray; + + typedef struct { + DiffsDetailedMappingArray changes; + DiffsMovedTextArray moves; + bool hit_timeout; + } DiffsLinesDiff; + + typedef struct { + bool ignore_trim_whitespace; + int max_computation_time_ms; + bool compute_moves; + bool extend_to_subwords; + } DiffsDiffOptions; + + DiffsLinesDiff* compute_diff( + const char** original_lines, + int original_count, + const char** modified_lines, + int modified_count, + const DiffsDiffOptions* options + ); + + void free_lines_diff(DiffsLinesDiff* diff); + ]]) + + local ok, handle = pcall(ffi.load, path) + if not ok then + dbg('failed to load libvscode_diff: %s', handle) + return nil + end + + cached_handle = handle + return handle +end + +---@param callback fun(handle: table?) +function M.ensure(callback) + if cached_handle then + callback(cached_handle) + return + end + + if M.has_lib() then + callback(M.load()) + return + end + + if download_in_progress then + dbg('download already in progress') + callback(nil) + return + end + + download_in_progress = true + + local dir = lib_dir() + vim.fn.mkdir(dir, 'p') + + local os_name = get_os() + local arch = get_arch() + local ext = get_ext() + local filename = ('libvscode_diff_%s_%s_%s.%s'):format(os_name, arch, EXPECTED_VERSION, ext) + local url = ('https://github.com/esmuellert/vscode-diff.nvim/releases/download/v%s/%s'):format( + EXPECTED_VERSION, + filename + ) + + local dest = lib_path() + vim.notify('[diffs] downloading libvscode_diff...', vim.log.levels.INFO) + + local cmd = { 'curl', '-fSL', '-o', dest, url } + + vim.system(cmd, {}, function(result) + download_in_progress = false + vim.schedule(function() + if result.code ~= 0 then + vim.notify('[diffs] failed to download libvscode_diff', vim.log.levels.WARN) + dbg('curl failed: %s', result.stderr or '') + callback(nil) + return + end + + local f = io.open(version_path(), 'w') + if f then + f:write(EXPECTED_VERSION) + f:close() + end + + vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) + callback(M.load()) + end) + end) +end + +return M diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua new file mode 100644 index 0000000..80ad8a8 --- /dev/null +++ b/spec/diff_spec.lua @@ -0,0 +1,163 @@ +require('spec.helpers') +local diff = require('diffs.diff') + +describe('diff', function() + describe('extract_change_groups', function() + it('returns empty for all context lines', function() + local groups = diff.extract_change_groups({ ' line1', ' line2', ' line3' }) + assert.are.equal(0, #groups) + end) + + it('returns empty for pure additions', function() + local groups = diff.extract_change_groups({ '+line1', '+line2' }) + assert.are.equal(0, #groups) + end) + + it('returns empty for pure deletions', function() + local groups = diff.extract_change_groups({ '-line1', '-line2' }) + assert.are.equal(0, #groups) + end) + + it('extracts single change group', function() + local groups = diff.extract_change_groups({ + ' context', + '-old line', + '+new line', + ' context', + }) + assert.are.equal(1, #groups) + assert.are.equal(1, #groups[1].del_lines) + assert.are.equal(1, #groups[1].add_lines) + assert.are.equal('old line', groups[1].del_lines[1].text) + assert.are.equal('new line', groups[1].add_lines[1].text) + end) + + it('extracts multiple change groups separated by context', function() + local groups = diff.extract_change_groups({ + '-old1', + '+new1', + ' context', + '-old2', + '+new2', + }) + assert.are.equal(2, #groups) + assert.are.equal('old1', groups[1].del_lines[1].text) + assert.are.equal('new1', groups[1].add_lines[1].text) + assert.are.equal('old2', groups[2].del_lines[1].text) + assert.are.equal('new2', groups[2].add_lines[1].text) + end) + + it('tracks correct line indices', function() + local groups = diff.extract_change_groups({ + ' context', + '-deleted', + '+added', + }) + assert.are.equal(2, groups[1].del_lines[1].idx) + assert.are.equal(3, groups[1].add_lines[1].idx) + end) + + it('handles multiple del lines followed by multiple add lines', function() + local groups = diff.extract_change_groups({ + '-del1', + '-del2', + '+add1', + '+add2', + '+add3', + }) + assert.are.equal(1, #groups) + assert.are.equal(2, #groups[1].del_lines) + assert.are.equal(3, #groups[1].add_lines) + end) + end) + + describe('compute_intra_hunks', function() + it('returns nil for all-addition hunks', function() + local result = diff.compute_intra_hunks({ '+line1', '+line2' }, 'native') + assert.is_nil(result) + end) + + it('returns nil for all-deletion hunks', function() + local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'native') + assert.is_nil(result) + end) + + it('returns nil for context-only hunks', function() + local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'native') + assert.is_nil(result) + end) + + it('returns spans for single word change', function() + local result = diff.compute_intra_hunks({ + '-local x = 1', + '+local x = 2', + }, 'native') + assert.is_not_nil(result) + assert.is_true(#result.del_spans > 0) + assert.is_true(#result.add_spans > 0) + end) + + it('identifies correct byte offsets for word change', function() + local result = diff.compute_intra_hunks({ + '-local x = 1', + '+local x = 2', + }, 'native') + assert.is_not_nil(result) + + assert.are.equal(1, #result.del_spans) + assert.are.equal(1, #result.add_spans) + local del_span = result.del_spans[1] + local add_span = result.add_spans[1] + local del_text = ('local x = 1'):sub(del_span.col_start, del_span.col_end - 1) + local add_text = ('local x = 2'):sub(add_span.col_start, add_span.col_end - 1) + assert.are.equal('1', del_text) + assert.are.equal('2', add_text) + end) + + it('handles multiple change groups separated by context', function() + local result = diff.compute_intra_hunks({ + '-local a = 1', + '+local a = 2', + ' local b = 3', + '-local c = 4', + '+local c = 5', + }, 'native') + assert.is_not_nil(result) + assert.is_true(#result.del_spans >= 2) + assert.is_true(#result.add_spans >= 2) + end) + + it('handles uneven line counts (2 old, 1 new)', function() + local result = diff.compute_intra_hunks({ + '-line one', + '-line two', + '+line combined', + }, 'native') + assert.is_not_nil(result) + end) + + it('handles multi-byte UTF-8 content', function() + local result = diff.compute_intra_hunks({ + '-local x = "héllo"', + '+local x = "wörld"', + }, 'native') + assert.is_not_nil(result) + assert.is_true(#result.del_spans > 0) + assert.is_true(#result.add_spans > 0) + end) + + it('returns nil when del and add are identical', function() + local result = diff.compute_intra_hunks({ + '-local x = 1', + '+local x = 1', + }, 'native') + assert.is_nil(result) + end) + end) + + describe('has_vscode', function() + it('returns false in test environment', function() + assert.is_false(diff.has_vscode()) + end) + end) +end) From 63b6e7d4c63f71f0f9cecc43362f213cef412c0d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 13:58:30 -0500 Subject: [PATCH 2/4] fix(ci): add jit to luarc globals for lua-language-server jit is a standard LuaJIT global (like vim), needed by lib.lua for platform detection via jit.os and jit.arch. --- .luarc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.luarc.json b/.luarc.json index 3ccfeda..b438cce 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,7 +1,7 @@ { "runtime.version": "Lua 5.1", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], - "diagnostics.globals": ["vim"], + "diagnostics.globals": ["vim", "jit"], "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], "workspace.checkThirdParty": false, "completion.callSnippet": "Replace" From f1c13966ba4a517f72b9b03761696e1dca3d7529 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 14:43:23 -0500 Subject: [PATCH 3/4] fix(highlight): use diffAdded/diffRemoved fg for char-level backgrounds The previous 70% alpha blend of DiffAdd bg was nearly identical to the 40% line-level blend, making char-level highlights invisible. Now blends the bright diffAdded/diffRemoved foreground color (same base as line number fg) into the char-level bg, matching GitHub/VSCode intensity. Also bumps intra.max_lines default from 200 to 500. --- doc/diffs.nvim.txt | 19 ++++++++++--------- lua/diffs/init.lua | 6 +++--- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 76e5ea3..dacbfa6 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -62,12 +62,12 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: }, vim = { enabled = false, - max_lines = 200, + max_lines = 500, }, intra = { enabled = true, algorithm = 'auto', - max_lines = 200, + max_lines = 500, }, }, fugitive = { @@ -165,7 +165,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: `vim.diff()`. `'vscode'`: require libvscodediff (falls back to native if not available). - {max_lines} (integer, default: 200) + {max_lines} (integer, default: 500) Skip character-level highlighting for hunks larger than this many lines. @@ -382,15 +382,16 @@ Fugitive unified diff highlights: ~ *DiffsAddText* DiffsAddText Character-level background for changed characters - within `+` lines. Derived by blending `DiffAdd` - background with `Normal` at 70% alpha (brighter - than line-level `DiffsAdd`). Only sets `bg`, so - treesitter foreground colors show through. + within `+` lines. Derived by blending `diffAdded` + foreground with `Normal` background at 40% alpha. + Uses the same base color as `DiffsAddNr` foreground, + making changed characters clearly visible. Only sets + `bg`, so treesitter foreground colors show through. *DiffsDeleteText* DiffsDeleteText Character-level background for changed characters - within `-` lines. Derived by blending `DiffDelete` - background with `Normal` at 70% alpha. + within `-` lines. Derived by blending `diffRemoved` + foreground with `Normal` background at 40% alpha. Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index ea33a32..d2097b1 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -91,7 +91,7 @@ local default_config = { intra = { enabled = true, algorithm = 'auto', - max_lines = 200, + max_lines = 500, }, }, fugitive = { @@ -183,8 +183,8 @@ local function compute_highlight_groups() local blended_add = blend_color(add_bg, bg, 0.4) local blended_del = blend_color(del_bg, bg, 0.4) - local blended_add_text = blend_color(add_bg, bg, 0.7) - local blended_del_text = blend_color(del_bg, bg, 0.7) + local blended_add_text = blend_color(add_fg, bg, 0.4) + local blended_del_text = blend_color(del_fg, bg, 0.4) vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del }) From cc947167c36e01c53cd74cd9b5bc9b5cb603ba45 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 18:28:22 -0500 Subject: [PATCH 4/4] fix(highlight): use hl_group instead of line_hl_group for diff backgrounds line_hl_group bg occupies a separate rendering channel from hl_group in Neovim's extmark system, causing character-level bg-only highlights to be invisible regardless of priority. Switching to hl_group + hl_eol ensures all backgrounds compete in the same channel. Also reorders priorities (Normal 198 < line bg 199 < syntax 200 < char bg 201), bumps char-level blend alpha from 0.4 to 0.7 for visibility, and adds debug logging throughout the intra pipeline. --- lua/diffs/debug.lua | 68 +++++++++ lua/diffs/diff.lua | 17 ++- lua/diffs/highlight.lua | 45 ++++-- lua/diffs/init.lua | 13 +- spec/highlight_spec.lua | 304 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 427 insertions(+), 20 deletions(-) create mode 100644 lua/diffs/debug.lua diff --git a/lua/diffs/debug.lua b/lua/diffs/debug.lua new file mode 100644 index 0000000..5be95bc --- /dev/null +++ b/lua/diffs/debug.lua @@ -0,0 +1,68 @@ +local M = {} + +local ns = vim.api.nvim_create_namespace('diffs') + +function M.dump() + local bufnr = vim.api.nvim_get_current_buf() + local marks = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + local by_line = {} + for _, mark in ipairs(marks) do + local id, row, col, details = mark[1], mark[2], mark[3], mark[4] + local entry = { + id = id, + row = row, + col = col, + end_row = details.end_row, + end_col = details.end_col, + hl_group = details.hl_group, + priority = details.priority, + line_hl_group = details.line_hl_group, + number_hl_group = details.number_hl_group, + virt_text = details.virt_text, + } + if not by_line[row] then + by_line[row] = { text = lines[row + 1] or '', marks = {} } + end + table.insert(by_line[row].marks, entry) + end + + local all_ns_marks = vim.api.nvim_buf_get_extmarks(bufnr, -1, 0, -1, { details = true }) + local non_diffs = {} + for _, mark in ipairs(all_ns_marks) do + local details = mark[4] + if details.ns_id ~= ns then + table.insert(non_diffs, { + ns_id = details.ns_id, + row = mark[2], + col = mark[3], + end_row = details.end_row, + end_col = details.end_col, + hl_group = details.hl_group, + priority = details.priority, + }) + end + end + + local result = { + bufnr = bufnr, + buf_name = vim.api.nvim_buf_get_name(bufnr), + ns_id = ns, + total_diffs_marks = #marks, + total_all_marks = #all_ns_marks, + non_diffs_marks = non_diffs, + lines = by_line, + } + + local state_dir = vim.fn.stdpath('state') + local path = state_dir .. '/diffs_debug.json' + local f = io.open(path, 'w') + if f then + f:write(vim.json.encode(result)) + f:close() + vim.notify('[diffs.nvim] debug dump: ' .. path, vim.log.levels.INFO) + end +end + +return M diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua index 8dc836b..65d3ac8 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -312,13 +312,28 @@ function M.compute_intra_hunks(hunk_lines, algorithm) ---@type diffs.CharSpan[] local all_del = {} - for _, group in ipairs(groups) do + dbg( + 'intra: %d change groups, algorithm=%s, vscode=%s', + #groups, + algorithm, + vscode_handle and 'yes' or 'no' + ) + + for gi, group in ipairs(groups) do + dbg('group %d: %d del lines, %d add lines', gi, #group.del_lines, #group.add_lines) local ds, as if vscode_handle then ds, as = diff_group_vscode(group, vscode_handle) else ds, as = diff_group_native(group) end + dbg('group %d result: %d del spans, %d add spans', gi, #ds, #as) + for _, s in ipairs(ds) do + dbg(' del span: line=%d col=%d..%d', s.line, s.col_start, s.col_end) + end + for _, s in ipairs(as) do + dbg(' add span: line=%d col=%d..%d', s.line, s.col_start, s.col_end) + end vim.list_extend(all_del, ds) vim.list_extend(all_add, as) end diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 1eb16d7..311a6f3 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -287,7 +287,17 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) local intra = nil local intra_cfg = opts.highlights.intra if intra_cfg and intra_cfg.enabled and #hunk.lines <= intra_cfg.max_lines then + dbg('computing intra for hunk %s:%d (%d lines)', hunk.filename, hunk.start_line, #hunk.lines) intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm) + if intra then + dbg('intra result: %d add spans, %d del spans', #intra.add_spans, #intra.del_spans) + else + dbg('intra result: nil (no change groups)') + end + elseif intra_cfg and not intra_cfg.enabled then + dbg('intra disabled by config') + elseif intra_cfg and #hunk.lines > intra_cfg.max_lines then + dbg('intra skipped: %d lines > %d max', #hunk.lines, intra_cfg.max_lines) end ---@type table @@ -324,21 +334,20 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) }) end - if opts.highlights.background and is_diff_line then - local extmark_opts = { - line_hl_group = line_hl, - priority = 198, - } - if opts.highlights.gutter then - extmark_opts.number_hl_group = number_hl - end - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, extmark_opts) - end - if line_len > 1 and syntax_applied then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { end_col = line_len, hl_group = 'Normal', + priority = 198, + }) + end + + if opts.highlights.background and is_diff_line then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_col = line_len, + hl_group = line_hl, + hl_eol = true, + number_hl_group = opts.highlights.gutter and number_hl or nil, priority = 199, }) end @@ -346,11 +355,23 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) if char_spans_by_line[i] then local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText' for _, span in ipairs(char_spans_by_line[i]) do - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { + dbg( + 'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"', + i, + buf_line, + span.col_start, + span.col_end, + 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, hl_group = char_hl, priority = 201, }) + if not ok then + dbg('char extmark FAILED: %s', err) + end extmark_count = extmark_count + 1 end end diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index d2097b1..d80cd2e 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -183,8 +183,8 @@ local function compute_highlight_groups() local blended_add = blend_color(add_bg, bg, 0.4) local blended_del = blend_color(del_bg, bg, 0.4) - local blended_add_text = blend_color(add_fg, bg, 0.4) - local blended_del_text = blend_color(del_fg, bg, 0.4) + local blended_add_text = blend_color(add_fg, bg, 0.7) + local blended_del_text = blend_color(del_fg, bg, 0.7) vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del }) @@ -193,6 +193,15 @@ local function compute_highlight_groups() vim.api.nvim_set_hl(0, 'DiffsAddText', { default = true, bg = blended_add_text }) vim.api.nvim_set_hl(0, 'DiffsDeleteText', { default = true, bg = blended_del_text }) + dbg('highlight groups: Normal.bg=#%06x DiffAdd.bg=#%06x diffAdded.fg=#%06x', bg, add_bg, add_fg) + dbg( + 'DiffsAdd.bg=#%06x DiffsAddText.bg=#%06x DiffsAddNr.fg=#%06x', + blended_add, + blended_add_text, + add_fg + ) + dbg('DiffsDelete.bg=#%06x DiffsDeleteText.bg=#%06x', blended_del, blended_del_text) + local diff_change = resolve_hl('DiffChange') local diff_text = resolve_hl('DiffText') diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index e870177..9b7e685 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -43,6 +43,11 @@ describe('highlight', function() enabled = false, max_lines = 200, }, + intra = { + enabled = false, + algorithm = 'native', + max_lines = 500, + }, }, } if overrides then @@ -322,7 +327,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -355,7 +360,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_delete = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then + if mark[4] and mark[4].hl_group == 'DiffsDelete' then has_diff_delete = true break end @@ -388,7 +393,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_line_hl = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group then + if mark[4] and (mark[4].hl_group == 'DiffsAdd' or mark[4].hl_group == 'DiffsDelete') then has_line_hl = true break end @@ -520,7 +525,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -668,7 +673,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -727,6 +732,295 @@ describe('highlight', function() assert.is_true(has_normal) delete_buffer(bufnr) end) + + it('uses hl_group not line_hl_group for line backgrounds', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,1 @@', + '-local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then + assert.is_true(d.hl_eol == true) + assert.is_nil(d.line_hl_group) + end + end + delete_buffer(bufnr) + end) + + it('line bg priority > Normal priority', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,1 @@', + '-local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + local normal_priority = nil + local line_bg_priority = nil + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'Normal' then + normal_priority = d.priority + end + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then + line_bg_priority = d.priority + end + end + assert.is_not_nil(normal_priority) + assert.is_not_nil(line_bg_priority) + assert.is_true(line_bg_priority > normal_priority) + delete_buffer(bufnr) + end) + + it('char-level extmarks have higher priority than line bg', function() + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) + + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { + background = true, + intra = { enabled = true, algorithm = 'native', max_lines = 500 }, + }, + }) + ) + + local extmarks = get_extmarks(bufnr) + local line_bg_priority = nil + local char_bg_priority = nil + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then + line_bg_priority = d.priority + end + if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then + char_bg_priority = d.priority + end + end + assert.is_not_nil(line_bg_priority) + assert.is_not_nil(char_bg_priority) + assert.is_true(char_bg_priority > line_bg_priority) + delete_buffer(bufnr) + end) + + it('creates char-level extmarks for changed characters', function() + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) + + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { intra = { enabled = true, algorithm = 'native', max_lines = 500 } }, + }) + ) + + local extmarks = get_extmarks(bufnr) + local add_text_marks = {} + local del_text_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsAddText' then + table.insert(add_text_marks, mark) + end + if d and d.hl_group == 'DiffsDeleteText' then + table.insert(del_text_marks, mark) + end + end + assert.is_true(#add_text_marks > 0) + assert.is_true(#del_text_marks > 0) + delete_buffer(bufnr) + end) + + it('does not create char-level extmarks when intra disabled', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { intra = { enabled = false, algorithm = 'native', max_lines = 500 } }, + }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local d = mark[4] + assert.is_not_equal('DiffsAddText', d and d.hl_group) + assert.is_not_equal('DiffsDeleteText', d and d.hl_group) + end + delete_buffer(bufnr) + end) + + it('does not create char-level extmarks for pure additions', function() + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + + local bufnr = create_buffer({ + '@@ -1,0 +1,2 @@', + '+local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '+local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { intra = { enabled = true, algorithm = 'native', max_lines = 500 } }, + }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local d = mark[4] + assert.is_not_equal('DiffsAddText', d and d.hl_group) + assert.is_not_equal('DiffsDeleteText', d and d.hl_group) + end + delete_buffer(bufnr) + end) + + it('enforces priority order: Normal < line bg < syntax < char bg', function() + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) + + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { + background = true, + intra = { enabled = true, algorithm = 'native', max_lines = 500 }, + }, + }) + ) + + local extmarks = get_extmarks(bufnr) + local priorities = { normal = {}, line_bg = {}, syntax = {}, char_bg = {} } + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d then + if d.hl_group == 'Normal' then + table.insert(priorities.normal, d.priority) + elseif d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete' then + table.insert(priorities.line_bg, d.priority) + elseif d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then + table.insert(priorities.char_bg, d.priority) + elseif d.hl_group and d.hl_group:match('^@.*%.lua$') then + table.insert(priorities.syntax, d.priority) + end + end + end + + assert.is_true(#priorities.normal > 0) + assert.is_true(#priorities.line_bg > 0) + assert.is_true(#priorities.syntax > 0) + assert.is_true(#priorities.char_bg > 0) + + local max_normal = math.max(unpack(priorities.normal)) + local min_line_bg = math.min(unpack(priorities.line_bg)) + local min_syntax = math.min(unpack(priorities.syntax)) + local min_char_bg = math.min(unpack(priorities.char_bg)) + + assert.is_true(max_normal < min_line_bg) + assert.is_true(min_line_bg < min_syntax) + assert.is_true(min_syntax < min_char_bg) + delete_buffer(bufnr) + end) end) describe('diff header highlighting', function()