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:
Barrett Ruth 2026-02-06 13:53:58 -05:00
parent 294cbad749
commit 997bc49f8b
7 changed files with 842 additions and 0 deletions

View file

@ -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
View 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

View file

@ -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

View file

@ -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)

View file

@ -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
View 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
View 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)