From 9a0b812f69a6aca115441c2de15fdb75f83b51f3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:59:13 -0500 Subject: [PATCH] performance improvements (#116) closes #111 --- .gitignore | 5 + README.md | 27 ++-- doc/diffs.nvim.txt | 18 +-- flake.lock | 43 ++++++ flake.nix | 35 +++++ lua/diffs/highlight.lua | 41 ++++++ lua/diffs/init.lua | 302 ++++++++++++++++++++++++++++++---------- lua/diffs/log.lua | 22 ++- lua/diffs/parser.lua | 21 ++- spec/init_spec.lua | 180 +++++++++++++++++++++++- 10 files changed, 590 insertions(+), 104 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.gitignore b/.gitignore index d14787c..13d3d92 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,9 @@ doc/tags CLAUDE.md .claude/ +bench/ node_modules/ + +result +result-* +.direnv/ diff --git a/README.md b/README.md index 3aa4f53..bf64d35 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ syntax highlighting. - `:Gdiff` unified diff against any revision - Background-only diff colors for `&diff` buffers - Inline merge conflict detection, highlighting, and resolution -- Vim syntax fallback, configurable blend/debounce/priorities +- Vim syntax fallback, configurable blend/priorities ## Requirements @@ -40,14 +40,24 @@ luarocks install diffs.nvim ## Known Limitations - **Incomplete syntax context**: Treesitter parses each diff hunk in isolation. - Context lines within the hunk (` ` prefix) provide syntactic context for the - parser. In rare cases, hunks that start or end mid-expression may produce - imperfect highlights due to treesitter error recovery. + Context lines within the hunk provide syntactic context for the parser. In + rare cases, hunks that start or end mid-expression may produce imperfect + highlights due to treesitter error recovery. -- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event +- **Syntax "flashing"**: `diffs.nvim` hooks into the `FileType fugitive` event triggered by `vim-fugitive`, at which point the buffer is preliminarily - painted. The buffer is then re-painted after `debounce_ms` milliseconds, - causing an unavoidable visual "flash" even when `debounce_ms = 0`. + painted. The decoration provider applies highlights on the next redraw cycle, + causing a brief visual "flash". + +- **Cold Start**: Treesitter grammar loading (~10ms) and query compilation + (~4ms) are one-time costs per language per Neovim session. Each language pays + this cost on first encounter, which may cause a brief stutter when a diff + containing a new language first enters the viewport. + +- **Vim syntax fallback is deferred**: The vim syntax fallback (for languages + without a treesitter parser) cannot run inside the decoration provider's + redraw cycle due to Neovim's restriction on buffer mutations. Vim syntax + highlights for these hunks appear slightly delayed. - **Conflicting diff plugins**: `diffs.nvim` may not interact well with other plugins that modify diff highlighting. Known plugins that may conflict: @@ -74,4 +84,5 @@ luarocks install diffs.nvim - [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim) - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - [@phanen](https://github.com/phanen) - diff header highlighting, unknown - filetype fix, shebang/modeline detection, treesitter injection support + filetype fix, shebang/modeline detection, treesitter injection support, + decoration provider highlighting architecture diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index d1df476..5bf677e 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -52,7 +52,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: >lua vim.g.diffs = { debug = false, - debounce_ms = 0, hide_prefix = false, highlights = { background = true, @@ -109,11 +108,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Enable debug logging to |:messages| with `[diffs]` prefix. - {debounce_ms} (integer, default: 0) - Debounce delay in milliseconds for re-highlighting - after buffer changes. Lower values feel snappier - but use more CPU. - {hide_prefix} (boolean, default: false) Hide diff prefixes (`+`/`-`/` `) using virtual text overlay. Makes code appear without the @@ -611,7 +605,7 @@ Summary / commit detail views: ~ priority 201 overlay changed characters with an intense background - Conceal extmarks hide diff prefixes when `hide_prefix` is enabled - All priorities are configurable via |diffs.PrioritiesConfig| -4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events +4. A decoration provider re-highlights visible hunks on each redraw Diff mode views: ~ 1. `OptionSet diff` detects when any window enters diff mode @@ -637,14 +631,8 @@ the buffer briefly shows fugitive's default diff highlighting before diffs.nvim applies treesitter highlights. This occurs because diffs.nvim hooks into the `FileType fugitive` event, -which fires after vim-fugitive has already painted the buffer. Even with -`debounce_ms = 0`, the re-painting goes through Neovim's event loop. - -To minimize the flash, use a low debounce value: >lua - vim.g.diffs = { - debounce_ms = 0, - } -< +which fires after vim-fugitive has already painted the buffer. The +decoration provider applies highlights on the next redraw cycle. Conflicting Diff Plugins ~ *diffs-plugin-conflicts* diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0c2cb09 --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1770812194, + "narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..db4c54c --- /dev/null +++ b/flake.nix @@ -0,0 +1,35 @@ +{ + description = "diffs.nvim — syntax highlighting for diffs in Neovim"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + systems.url = "github:nix-systems/default"; + }; + + outputs = + { + nixpkgs, + systems, + ... + }: + let + forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + in + { + devShells = forEachSystem (pkgs: { + default = pkgs.mkShell { + packages = [ + (pkgs.luajit.withPackages ( + ps: with ps; [ + busted + nlua + ] + )) + pkgs.prettier + pkgs.stylua + pkgs.selene + ]; + }; + }); + }; +} diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 8e80620..3b50055 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -64,6 +64,7 @@ end ---@class diffs.HunkOpts ---@field hide_prefix boolean ---@field highlights diffs.Highlights +---@field defer_vim_syntax? boolean ---@param bufnr integer ---@param ns integer @@ -283,6 +284,10 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) use_vim = false end + if use_vim and opts.defer_vim_syntax then + use_vim = false + end + ---@type table local covered_lines = {} @@ -488,4 +493,40 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) end +---@param bufnr integer +---@param ns integer +---@param hunk diffs.Hunk +---@param opts diffs.HunkOpts +function M.highlight_hunk_vim_syntax(bufnr, ns, hunk, opts) + local p = opts.highlights.priorities + local pw = hunk.prefix_width or 1 + + if not hunk.ft or #hunk.lines == 0 then + return + end + + if #hunk.lines > opts.highlights.vim.max_lines then + return + end + + local code_lines = {} + for _, line in ipairs(hunk.lines) do + table.insert(code_lines, line:sub(pw + 1)) + end + + local covered_lines = {} + highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p) + + for buf_line in pairs(covered_lines) do + local line = hunk.lines[buf_line - hunk.start_line + 1] + if line and #line > pw then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, { + end_col = #line, + hl_group = 'DiffsClear', + priority = p.clear, + }) + end + end +end + return M diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 00dddca..08e035f 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -54,8 +54,7 @@ ---@field keymaps diffs.ConflictKeymaps ---@class diffs.Config ----@field debug boolean ----@field debounce_ms integer +---@field debug boolean|string ---@field hide_prefix boolean ---@field highlights diffs.Highlights ---@field fugitive diffs.FugitiveConfig @@ -107,7 +106,6 @@ end ---@type diffs.Config local default_config = { debug = false, - debounce_ms = 0, hide_prefix = false, highlights = { background = true, @@ -162,12 +160,26 @@ local config = vim.deepcopy(default_config) local initialized = false +---@diagnostic disable-next-line: missing-fields +local fast_hl_opts = {} ---@type diffs.HunkOpts + ---@type table local attached_buffers = {} ---@type table local diff_windows = {} +---@class diffs.HunkCacheEntry +---@field hunks diffs.Hunk[] +---@field tick integer +---@field highlighted table +---@field pending_clear boolean +---@field line_count integer +---@field byte_count integer + +---@type table +local hunk_cache = {} + ---@param bufnr integer ---@return boolean function M.is_fugitive_buffer(bufnr) @@ -177,53 +189,108 @@ end local dbg = log.dbg ---@param bufnr integer -local function highlight_buffer(bufnr) - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - - local hunks = parser.parse_buffer(bufnr) - dbg('found %d hunks in buffer %d', #hunks, bufnr) - for _, hunk in ipairs(hunks) do - highlight.highlight_hunk(bufnr, ns, hunk, { - hide_prefix = config.hide_prefix, - highlights = config.highlights, - }) +local function invalidate_cache(bufnr) + local entry = hunk_cache[bufnr] + if entry then + entry.tick = -1 + entry.pending_clear = true end end ---@param bufnr integer ----@return fun() -local function create_debounced_highlight(bufnr) - local timer = nil ---@type table? - return function() - if timer then - timer:stop() ---@diagnostic disable-line: undefined-field - timer:close() ---@diagnostic disable-line: undefined-field - timer = nil - end - local t = vim.uv.new_timer() - if not t then - highlight_buffer(bufnr) +local function ensure_cache(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + local tick = vim.api.nvim_buf_get_changedtick(bufnr) + local entry = hunk_cache[bufnr] + if entry and entry.tick == tick then + return + end + if entry and not entry.pending_clear then + local lc = vim.api.nvim_buf_line_count(bufnr) + local bc = vim.api.nvim_buf_get_offset(bufnr, lc) + if lc == entry.line_count and bc == entry.byte_count then + entry.tick = tick + entry.pending_clear = true + dbg('content unchanged in buffer %d (tick %d), skipping reparse', bufnr, tick) return end - timer = t - t:start( - config.debounce_ms, - 0, - vim.schedule_wrap(function() - if timer == t then - timer = nil - t:close() - end - if vim.api.nvim_buf_is_valid(bufnr) then - highlight_buffer(bufnr) - end - end) - ) end + local hunks = parser.parse_buffer(bufnr) + local lc = vim.api.nvim_buf_line_count(bufnr) + local bc = vim.api.nvim_buf_get_offset(bufnr, lc) + dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick) + hunk_cache[bufnr] = { + hunks = hunks, + tick = tick, + highlighted = {}, + pending_clear = true, + line_count = lc, + byte_count = bc, + } + + local has_nil_ft = false + for _, hunk in ipairs(hunks) do + if not has_nil_ft and not hunk.ft and hunk.filename then + has_nil_ft = true + end + end + if has_nil_ft and vim.fn.did_filetype() ~= 0 then + vim.schedule(function() + if vim.api.nvim_buf_is_valid(bufnr) and hunk_cache[bufnr] then + dbg('retrying filetype detection for buffer %d (was blocked by did_filetype)', bufnr) + invalidate_cache(bufnr) + end + end) + end +end + +---@param hunks diffs.Hunk[] +---@param toprow integer +---@param botrow integer +---@return integer first +---@return integer last +local function find_visible_hunks(hunks, toprow, botrow) + local n = #hunks + if n == 0 then + return 0, 0 + end + + local lo, hi = 1, n + 1 + while lo < hi do + local mid = math.floor((lo + hi) / 2) + local h = hunks[mid] + local bottom = h.start_line - 1 + #h.lines - 1 + if bottom < toprow then + lo = mid + 1 + else + hi = mid + end + end + + if lo > n then + return 0, 0 + end + + local first = lo + local h = hunks[first] + local top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1) + if top >= botrow then + return 0, 0 + end + + local last = first + for i = first + 1, n do + h = hunks[i] + top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1) + if top >= botrow then + break + end + last = i + end + + return first, last end local function compute_highlight_groups() @@ -327,8 +394,13 @@ local function init() local opts = vim.g.diffs or {} vim.validate({ - debug = { opts.debug, 'boolean', true }, - debounce_ms = { opts.debounce_ms, 'number', true }, + debug = { + opts.debug, + function(v) + return v == nil or type(v) == 'boolean' or type(v) == 'string' + end, + 'boolean or string (file path)', + }, hide_prefix = { opts.hide_prefix, 'boolean', true }, highlights = { opts.highlights, 'table', true }, }) @@ -441,9 +513,6 @@ local function init() end end - if opts.debounce_ms and opts.debounce_ms < 0 then - error('diffs: debounce_ms must be >= 0') - end if opts.highlights and opts.highlights.context @@ -498,13 +567,113 @@ local function init() config = vim.tbl_deep_extend('force', default_config, opts) log.set_enabled(config.debug) + fast_hl_opts = { + hide_prefix = config.hide_prefix, + highlights = vim.tbl_deep_extend('force', config.highlights, { + treesitter = { enabled = false }, + }), + defer_vim_syntax = true, + } + compute_highlight_groups() vim.api.nvim_create_autocmd('ColorScheme', { callback = function() compute_highlight_groups() for bufnr, _ in pairs(attached_buffers) do - highlight_buffer(bufnr) + invalidate_cache(bufnr) + end + end, + }) + + vim.api.nvim_set_decoration_provider(ns, { + on_buf = function(_, bufnr) + if not attached_buffers[bufnr] then + return false + end + local t0 = config.debug and vim.uv.hrtime() or nil + ensure_cache(bufnr) + local entry = hunk_cache[bufnr] + if entry and entry.pending_clear then + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + entry.highlighted = {} + entry.pending_clear = false + end + if t0 then + dbg('on_buf %d: %.2fms', bufnr, (vim.uv.hrtime() - t0) / 1e6) + end + end, + on_win = function(_, _, bufnr, toprow, botrow) + if not attached_buffers[bufnr] then + return false + end + local entry = hunk_cache[bufnr] + if not entry then + return + end + local first, last = find_visible_hunks(entry.hunks, toprow, botrow) + if first == 0 then + return + end + local t0 = config.debug and vim.uv.hrtime() or nil + local deferred_syntax = {} + local count = 0 + for i = first, last do + if not entry.highlighted[i] then + local hunk = entry.hunks[i] + highlight.highlight_hunk(bufnr, ns, hunk, fast_hl_opts) + entry.highlighted[i] = true + count = count + 1 + local has_syntax = hunk.lang and config.highlights.treesitter.enabled + local needs_vim = not hunk.lang and hunk.ft and config.highlights.vim.enabled + if has_syntax or needs_vim then + table.insert(deferred_syntax, hunk) + end + end + end + if #deferred_syntax > 0 then + local tick = entry.tick + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + local cur = hunk_cache[bufnr] + if not cur or cur.tick ~= tick then + return + end + local t1 = config.debug and vim.uv.hrtime() or nil + local full_opts = { + hide_prefix = config.hide_prefix, + highlights = config.highlights, + } + for _, hunk in ipairs(deferred_syntax) do + local start_row = hunk.start_line - 1 + local end_row = start_row + #hunk.lines + if hunk.header_start_line then + start_row = hunk.header_start_line - 1 + end + vim.api.nvim_buf_clear_namespace(bufnr, ns, start_row, end_row) + highlight.highlight_hunk(bufnr, ns, hunk, full_opts) + if not hunk.lang and hunk.ft then + highlight.highlight_hunk_vim_syntax(bufnr, ns, hunk, full_opts) + end + end + if t1 then + dbg('deferred pass: %d hunks in %.2fms', #deferred_syntax, (vim.uv.hrtime() - t1) / 1e6) + end + end) + end + if t0 and count > 0 then + dbg( + 'on_win %d: %d hunks [%d..%d] in %.2fms (viewport %d-%d)', + bufnr, + count, + first, + last, + (vim.uv.hrtime() - t0) / 1e6, + toprow, + botrow + ) end end, }) @@ -531,35 +700,13 @@ function M.attach(bufnr) dbg('attaching to buffer %d', bufnr) - local debounced = create_debounced_highlight(bufnr) - - highlight_buffer(bufnr) - - vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { - buffer = bufnr, - callback = debounced, - }) - - vim.api.nvim_create_autocmd('Syntax', { - buffer = bufnr, - callback = function() - dbg('syntax event, re-highlighting buffer %d', bufnr) - highlight_buffer(bufnr) - end, - }) - - vim.api.nvim_create_autocmd('BufReadPost', { - buffer = bufnr, - callback = function() - dbg('BufReadPost event, re-highlighting buffer %d', bufnr) - highlight_buffer(bufnr) - end, - }) + ensure_cache(bufnr) vim.api.nvim_create_autocmd('BufWipeout', { buffer = bufnr, callback = function() attached_buffers[bufnr] = nil + hunk_cache[bufnr] = nil end, }) end @@ -567,7 +714,7 @@ end ---@param bufnr? integer function M.refresh(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() - highlight_buffer(bufnr) + invalidate_cache(bufnr) end local DIFF_WINHIGHLIGHT = table.concat({ @@ -622,4 +769,11 @@ function M.get_conflict_config() return config.conflict end +M._test = { + find_visible_hunks = find_visible_hunks, + hunk_cache = hunk_cache, + ensure_cache = ensure_cache, + invalidate_cache = invalidate_cache, +} + return M diff --git a/lua/diffs/log.lua b/lua/diffs/log.lua index 08abcc6..525f578 100644 --- a/lua/diffs/log.lua +++ b/lua/diffs/log.lua @@ -1,10 +1,17 @@ local M = {} local enabled = false +local log_file = nil ----@param val boolean +---@param val boolean|string function M.set_enabled(val) - enabled = val + if type(val) == 'string' then + enabled = true + log_file = val + else + enabled = val + log_file = nil + end end ---@param msg string @@ -13,7 +20,16 @@ function M.dbg(msg, ...) if not enabled then return end - vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG) + local formatted = '[diffs.nvim]: ' .. string.format(msg, ...) + if log_file then + local f = io.open(log_file, 'a') + if f then + f:write(string.format('%.6fs', vim.uv.hrtime() / 1e9) .. ' ' .. formatted .. '\n') + f:close() + end + else + vim.notify(formatted, vim.log.levels.DEBUG) + end end return M diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 6a04d38..c3b9aa8 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -19,6 +19,9 @@ local M = {} local dbg = require('diffs.log').dbg +---@type table +local ft_lang_cache = {} + ---@param filepath string ---@param n integer ---@return string[]? @@ -187,8 +190,18 @@ function M.parse_buffer(bufnr) if filename then flush_hunk() current_filename = filename - current_ft = get_ft_from_filename(filename, repo_root) - current_lang = current_ft and get_lang_from_ft(current_ft) or nil + local cache_key = (repo_root or '') .. '\0' .. filename + local cached = ft_lang_cache[cache_key] + if cached then + current_ft = cached.ft + current_lang = cached.lang + else + current_ft = get_ft_from_filename(filename, repo_root) + current_lang = current_ft and get_lang_from_ft(current_ft) or nil + if current_ft or vim.fn.did_filetype() == 0 then + ft_lang_cache[cache_key] = { ft = current_ft, lang = current_lang } + end + end if current_lang then dbg('file: %s -> lang: %s', filename, current_lang) elseif current_ft then @@ -254,4 +267,8 @@ function M.parse_buffer(bufnr) return hunks end +M._test = { + ft_lang_cache = ft_lang_cache, +} + return M diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 1bf71d1..139fb0d 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -24,7 +24,6 @@ describe('diffs', function() it('accepts full config', function() vim.g.diffs = { debug = true, - debounce_ms = 100, hide_prefix = false, highlights = { background = true, @@ -46,7 +45,7 @@ describe('diffs', function() it('accepts partial config', function() vim.g.diffs = { - debounce_ms = 25, + hide_prefix = true, } assert.has_no.errors(function() diffs.attach() @@ -152,6 +151,183 @@ describe('diffs', function() end) end) + describe('find_visible_hunks', function() + local find_visible_hunks = diffs._test.find_visible_hunks + + local function make_hunk(start_row, end_row, opts) + local lines = {} + for i = 1, end_row - start_row + 1 do + lines[i] = 'line' .. i + end + local h = { start_line = start_row + 1, lines = lines } + if opts and opts.header_start_line then + h.header_start_line = opts.header_start_line + end + return h + end + + it('returns (0, 0) for empty hunk list', function() + local first, last = find_visible_hunks({}, 0, 50) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('finds single hunk fully inside viewport', function() + local h = make_hunk(5, 10) + local first, last = find_visible_hunks({ h }, 0, 50) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('returns (0, 0) for single hunk fully above viewport', function() + local h = make_hunk(5, 10) + local first, last = find_visible_hunks({ h }, 20, 50) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('returns (0, 0) for single hunk fully below viewport', function() + local h = make_hunk(50, 60) + local first, last = find_visible_hunks({ h }, 0, 20) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('finds single hunk partially visible at top edge', function() + local h = make_hunk(5, 15) + local first, last = find_visible_hunks({ h }, 10, 30) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('finds single hunk partially visible at bottom edge', function() + local h = make_hunk(25, 35) + local first, last = find_visible_hunks({ h }, 10, 30) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('finds subset of visible hunks', function() + local h1 = make_hunk(5, 10) + local h2 = make_hunk(25, 30) + local h3 = make_hunk(55, 60) + local first, last = find_visible_hunks({ h1, h2, h3 }, 20, 40) + assert.are.equal(2, first) + assert.are.equal(2, last) + end) + + it('finds all hunks when all are visible', function() + local h1 = make_hunk(5, 10) + local h2 = make_hunk(15, 20) + local h3 = make_hunk(25, 30) + local first, last = find_visible_hunks({ h1, h2, h3 }, 0, 50) + assert.are.equal(1, first) + assert.are.equal(3, last) + end) + + it('returns (0, 0) when no hunks are visible', function() + local h1 = make_hunk(5, 10) + local h2 = make_hunk(15, 20) + local first, last = find_visible_hunks({ h1, h2 }, 30, 50) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('uses header_start_line for top boundary', function() + local h = make_hunk(5, 10, { header_start_line = 4 }) + local first, last = find_visible_hunks({ h }, 0, 50) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('finds both adjacent hunks at viewport edge', function() + local h1 = make_hunk(10, 20) + local h2 = make_hunk(20, 30) + local first, last = find_visible_hunks({ h1, h2 }, 15, 25) + assert.are.equal(1, first) + assert.are.equal(2, last) + end) + end) + + describe('hunk_cache', function() + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + it('creates entry on attach', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_table(entry.hunks) + assert.is_number(entry.tick) + assert.is_true(entry.tick >= 0) + delete_buffer(bufnr) + end) + + it('is idempotent on repeated attach', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry1 = diffs._test.hunk_cache[bufnr] + local tick1 = entry1.tick + local hunks1 = entry1.hunks + diffs._test.ensure_cache(bufnr) + local entry2 = diffs._test.hunk_cache[bufnr] + assert.are.equal(tick1, entry2.tick) + assert.are.equal(hunks1, entry2.hunks) + delete_buffer(bufnr) + end) + + it('marks stale on invalidate', function() + local bufnr = create_buffer({}) + diffs.attach(bufnr) + diffs._test.invalidate_cache(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(-1, entry.tick) + assert.is_true(entry.pending_clear) + delete_buffer(bufnr) + end) + + it('evicts on buffer wipeout', function() + local bufnr = create_buffer({}) + diffs.attach(bufnr) + assert.is_not_nil(diffs._test.hunk_cache[bufnr]) + vim.api.nvim_buf_delete(bufnr, { force = true }) + assert.is_nil(diffs._test.hunk_cache[bufnr]) + end) + + it('detects content change via tick', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local tick_before = diffs._test.hunk_cache[bufnr].tick + vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' }) + diffs._test.ensure_cache(bufnr) + local tick_after = diffs._test.hunk_cache[bufnr].tick + assert.is_true(tick_after > tick_before) + delete_buffer(bufnr) + end) + end) + describe('diff mode', function() local function create_diff_window() vim.cmd('new')