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
This commit is contained in:
parent
294cbad749
commit
997bc49f8b
7 changed files with 842 additions and 0 deletions
|
|
@ -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*
|
||||
|
|
|
|||
338
lua/diffs/diff.lua
Normal file
338
lua/diffs/diff.lua
Normal file
|
|
@ -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<integer, integer>
|
||||
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<integer, diffs.CharSpan[]>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
214
lua/diffs/lib.lua
Normal file
214
lua/diffs/lib.lua
Normal file
|
|
@ -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
|
||||
163
spec/diff_spec.lua
Normal file
163
spec/diff_spec.lua
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue