From 10af59a70d09cb5769cf859d70cedef933d1c6eb Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 21:23:40 -0500 Subject: [PATCH 1/3] feat(config): replace algorithm 'auto'/'native' with 'default'/'vscode' 'default' inherits algorithm and linematch from diffopt, 'vscode' uses the FFI library. Removes the need for diffs.nvim to duplicate settings that users already control globally. --- README.md | 1 + doc/diffs.nvim.txt | 25 +++++++-------- lua/diffs/diff.lua | 70 +++++++++++++++++++++++++++++++---------- lua/diffs/init.lua | 6 ++-- spec/diff_spec.lua | 18 +++++------ spec/highlight_spec.lua | 12 +++---- 6 files changed, 85 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 583799e..89254ac 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ syntax highlighting. - Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`) - Vim syntax fallback for languages without a treesitter parser - Hunk header context highlighting (`@@ ... @@ function foo()`) +- Character-level (intra-line) diff highlighting for changed characters - Configurable debouncing, max lines, and diff prefix concealment ## Requirements diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index dacbfa6..9554059 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -66,7 +66,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: }, intra = { enabled = true, - algorithm = 'auto', + algorithm = 'default', max_lines = 500, }, }, @@ -158,12 +158,12 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: an intense background overlay while the rest of the line keeps the softer line-level background. - {algorithm} (string, default: 'auto') + {algorithm} (string, default: 'default') 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). + `'default'`: use |vim.diff()| with settings + inherited from |'diffopt'| (`algorithm` and + `linematch`). `'vscode'`: use libvscodediff FFI + (falls back to default if not available). {max_lines} (integer, default: 500) Skip character-level highlighting for hunks larger @@ -285,8 +285,8 @@ Summary / commit detail views: ~ - Code is parsed with |vim.treesitter.get_string_parser()| - If no treesitter parser and `vim.enabled`: vim syntax fallback via scratch buffer and |synID()| - - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 198 - - `Normal` extmarks at priority 199 clear underlying diff foreground + - `Normal` extmarks at priority 198 clear underlying diff foreground + - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199 - 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 @@ -383,15 +383,14 @@ Fugitive unified diff highlights: ~ *DiffsAddText* DiffsAddText Character-level background for changed characters 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. + foreground with `Normal` background at 70% alpha. + Only sets `bg`, so treesitter foreground colors show + through. *DiffsDeleteText* DiffsDeleteText Character-level background for changed characters within `-` lines. Derived by blending `diffRemoved` - foreground with `Normal` background at 40% alpha. + foreground with `Normal` background at 70% alpha. Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua index 65d3ac8..618677a 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -60,11 +60,35 @@ function M.extract_change_groups(hunk_lines) return groups end +---@return {algorithm?: string, linematch?: integer} +local function parse_diffopt() + local opts = {} + for _, item in ipairs(vim.split(vim.o.diffopt, ',')) do + local key, val = item:match('^(%w+):(.+)$') + if key == 'algorithm' then + opts.algorithm = val + elseif key == 'linematch' then + opts.linematch = tonumber(val) + end + end + return opts +end + ---@param old_text string ---@param new_text string +---@param diff_opts? {algorithm?: string, linematch?: integer} ---@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' }) +local function byte_diff(old_text, new_text, diff_opts) + local vim_opts = { result_type = 'indices' } + if diff_opts then + if diff_opts.algorithm then + vim_opts.algorithm = diff_opts.algorithm + end + if diff_opts.linematch then + vim_opts.linematch = diff_opts.linematch + end + end + local ok, result = pcall(vim.diff, old_text, new_text, vim_opts) if not ok or not result then return {} end @@ -95,8 +119,9 @@ end ---@param new_line string ---@param del_idx integer ---@param add_idx integer +---@param diff_opts? {algorithm?: string, linematch?: integer} ---@return diffs.CharSpan[], diffs.CharSpan[] -local function char_diff_pair(old_line, new_line, del_idx, add_idx) +local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts) ---@type diffs.CharSpan[] local del_spans = {} ---@type diffs.CharSpan[] @@ -108,7 +133,7 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx) 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) + local char_hunks = byte_diff(old_text, new_text, diff_opts) for _, ch in ipairs(char_hunks) do if ch.old_count > 0 then @@ -132,8 +157,9 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx) end ---@param group diffs.ChangeGroup +---@param diff_opts? {algorithm?: string, linematch?: integer} ---@return diffs.CharSpan[], diffs.CharSpan[] -local function diff_group_native(group) +local function diff_group_native(group, diff_opts) ---@type diffs.CharSpan[] local all_del = {} ---@type diffs.CharSpan[] @@ -147,7 +173,8 @@ local function diff_group_native(group) group.del_lines[1].text, group.add_lines[1].text, group.del_lines[1].idx, - group.add_lines[1].idx + group.add_lines[1].idx, + diff_opts ) vim.list_extend(all_del, ds) vim.list_extend(all_add, as) @@ -166,7 +193,7 @@ local function diff_group_native(group) 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) + local line_hunks = byte_diff(old_block, new_block, diff_opts) ---@type table local old_to_new = {} @@ -184,7 +211,8 @@ local function diff_group_native(group) group.del_lines[old_i].text, group.add_lines[new_i].text, group.del_lines[old_i].idx, - group.add_lines[new_i].idx + group.add_lines[new_i].idx, + diff_opts ) vim.list_extend(all_del, ds) vim.list_extend(all_add, as) @@ -202,7 +230,8 @@ local function diff_group_native(group) group.del_lines[oi].text, group.add_lines[ni].text, group.del_lines[oi].idx, - group.add_lines[ni].idx + group.add_lines[ni].idx, + diff_opts ) vim.list_extend(all_del, ds) vim.list_extend(all_add, as) @@ -295,16 +324,25 @@ function M.compute_intra_hunks(hunk_lines, algorithm) return nil end - algorithm = algorithm or 'auto' + algorithm = algorithm or 'default' - local lib = require('diffs.lib') local vscode_handle = nil - if algorithm ~= 'native' then - vscode_handle = lib.load() + if algorithm == 'vscode' then + vscode_handle = require('diffs.lib').load() + if not vscode_handle then + dbg('vscode algorithm requested but library not available, falling back to default') + end end - if algorithm == 'vscode' and not vscode_handle then - dbg('vscode algorithm requested but library not available, falling back to native') + local diff_opts = nil + if not vscode_handle then + diff_opts = parse_diffopt() + if diff_opts.algorithm then + dbg('diffopt algorithm: %s', diff_opts.algorithm) + end + if diff_opts.linematch then + dbg('diffopt linematch: %d', diff_opts.linematch) + end end ---@type diffs.CharSpan[] @@ -325,7 +363,7 @@ function M.compute_intra_hunks(hunk_lines, algorithm) if vscode_handle then ds, as = diff_group_vscode(group, vscode_handle) else - ds, as = diff_group_native(group) + ds, as = diff_group_native(group, diff_opts) end dbg('group %d result: %d del spans, %d add spans', gi, #ds, #as) for _, s in ipairs(ds) do diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index d80cd2e..ca8aa1a 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -90,7 +90,7 @@ local default_config = { }, intra = { enabled = true, - algorithm = 'auto', + algorithm = 'default', max_lines = 500, }, }, @@ -259,9 +259,9 @@ local function init() ['highlights.intra.algorithm'] = { opts.highlights.intra.algorithm, function(v) - return v == nil or v == 'auto' or v == 'native' or v == 'vscode' + return v == nil or v == 'default' or v == 'vscode' end, - "'auto', 'native', or 'vscode'", + "'default' or 'vscode'", }, ['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true }, }) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 80ad8a8..2cc22ac 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -73,17 +73,17 @@ describe('diff', function() describe('compute_intra_hunks', function() it('returns nil for all-addition hunks', function() - local result = diff.compute_intra_hunks({ '+line1', '+line2' }, 'native') + local result = diff.compute_intra_hunks({ '+line1', '+line2' }, 'default') assert.is_nil(result) end) it('returns nil for all-deletion hunks', function() - local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'native') + local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'default') assert.is_nil(result) end) it('returns nil for context-only hunks', function() - local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'native') + local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'default') assert.is_nil(result) end) @@ -91,7 +91,7 @@ describe('diff', function() local result = diff.compute_intra_hunks({ '-local x = 1', '+local x = 2', - }, 'native') + }, 'default') assert.is_not_nil(result) assert.is_true(#result.del_spans > 0) assert.is_true(#result.add_spans > 0) @@ -101,7 +101,7 @@ describe('diff', function() local result = diff.compute_intra_hunks({ '-local x = 1', '+local x = 2', - }, 'native') + }, 'default') assert.is_not_nil(result) assert.are.equal(1, #result.del_spans) @@ -121,7 +121,7 @@ describe('diff', function() ' local b = 3', '-local c = 4', '+local c = 5', - }, 'native') + }, 'default') assert.is_not_nil(result) assert.is_true(#result.del_spans >= 2) assert.is_true(#result.add_spans >= 2) @@ -132,7 +132,7 @@ describe('diff', function() '-line one', '-line two', '+line combined', - }, 'native') + }, 'default') assert.is_not_nil(result) end) @@ -140,7 +140,7 @@ describe('diff', function() local result = diff.compute_intra_hunks({ '-local x = "héllo"', '+local x = "wörld"', - }, 'native') + }, 'default') assert.is_not_nil(result) assert.is_true(#result.del_spans > 0) assert.is_true(#result.add_spans > 0) @@ -150,7 +150,7 @@ describe('diff', function() local result = diff.compute_intra_hunks({ '-local x = 1', '+local x = 1', - }, 'native') + }, 'default') assert.is_nil(result) end) end) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 9b7e685..bb125fd 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -45,7 +45,7 @@ describe('highlight', function() }, intra = { enabled = false, - algorithm = 'native', + algorithm = 'default', max_lines = 500, }, }, @@ -828,7 +828,7 @@ describe('highlight', function() default_opts({ highlights = { background = true, - intra = { enabled = true, algorithm = 'native', max_lines = 500 }, + intra = { enabled = true, algorithm = 'default', max_lines = 500 }, }, }) ) @@ -873,7 +873,7 @@ describe('highlight', function() ns, hunk, default_opts({ - highlights = { intra = { enabled = true, algorithm = 'native', max_lines = 500 } }, + highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } }, }) ) @@ -913,7 +913,7 @@ describe('highlight', function() ns, hunk, default_opts({ - highlights = { intra = { enabled = false, algorithm = 'native', max_lines = 500 } }, + highlights = { intra = { enabled = false, algorithm = 'default', max_lines = 500 } }, }) ) @@ -947,7 +947,7 @@ describe('highlight', function() ns, hunk, default_opts({ - highlights = { intra = { enabled = true, algorithm = 'native', max_lines = 500 } }, + highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } }, }) ) @@ -984,7 +984,7 @@ describe('highlight', function() default_opts({ highlights = { background = true, - intra = { enabled = true, algorithm = 'native', max_lines = 500 }, + intra = { enabled = true, algorithm = 'default', max_lines = 500 }, }, }) ) From 5722ccdbb2fbcbb21147a841f4aed6de842e342c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 21:29:53 -0500 Subject: [PATCH 2/3] fix(ci): typing --- lua/diffs/diff.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua index 618677a..0617151 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -334,6 +334,7 @@ function M.compute_intra_hunks(hunk_lines, algorithm) end end + ---@type {algorithm?: string, linematch?: integer}? local diff_opts = nil if not vscode_handle then diff_opts = parse_diffopt() From b79adba5f2b11f5d9f21df90b23a8f55334369fa Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 21:33:55 -0500 Subject: [PATCH 3/3] fix(ci): typing --- lua/diffs/diff.lua | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua index 0617151..5f10f9a 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -11,6 +11,10 @@ ---@field del_lines {idx: integer, text: string}[] ---@field add_lines {idx: integer, text: string}[] +---@class diffs.DiffOpts +---@field algorithm? string +---@field linematch? integer + local M = {} local dbg = require('diffs.log').dbg @@ -60,7 +64,7 @@ function M.extract_change_groups(hunk_lines) return groups end ----@return {algorithm?: string, linematch?: integer} +---@return diffs.DiffOpts local function parse_diffopt() local opts = {} for _, item in ipairs(vim.split(vim.o.diffopt, ',')) do @@ -76,7 +80,7 @@ end ---@param old_text string ---@param new_text string ----@param diff_opts? {algorithm?: string, linematch?: integer} +---@param diff_opts? diffs.DiffOpts ---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[] local function byte_diff(old_text, new_text, diff_opts) local vim_opts = { result_type = 'indices' } @@ -119,7 +123,7 @@ end ---@param new_line string ---@param del_idx integer ---@param add_idx integer ----@param diff_opts? {algorithm?: string, linematch?: integer} +---@param diff_opts? diffs.DiffOpts ---@return diffs.CharSpan[], diffs.CharSpan[] local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts) ---@type diffs.CharSpan[] @@ -157,7 +161,7 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts) end ---@param group diffs.ChangeGroup ----@param diff_opts? {algorithm?: string, linematch?: integer} +---@param diff_opts? diffs.DiffOpts ---@return diffs.CharSpan[], diffs.CharSpan[] local function diff_group_native(group, diff_opts) ---@type diffs.CharSpan[] @@ -334,7 +338,7 @@ function M.compute_intra_hunks(hunk_lines, algorithm) end end - ---@type {algorithm?: string, linematch?: integer}? + ---@type diffs.DiffOpts? local diff_opts = nil if not vscode_handle then diff_opts = parse_diffopt()