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)