parent
330e2bc9b8
commit
9a0b812f69
10 changed files with 590 additions and 104 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -5,4 +5,9 @@ doc/tags
|
|||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
bench/
|
||||
node_modules/
|
||||
|
||||
result
|
||||
result-*
|
||||
.direnv/
|
||||
|
|
|
|||
27
README.md
27
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
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
|
|
|
|||
43
flake.lock
generated
Normal file
43
flake.lock
generated
Normal file
|
|
@ -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
|
||||
}
|
||||
35
flake.nix
Normal file
35
flake.nix
Normal file
|
|
@ -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
|
||||
];
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -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<integer, true>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<integer, boolean>
|
||||
local attached_buffers = {}
|
||||
|
||||
---@type table<integer, boolean>
|
||||
local diff_windows = {}
|
||||
|
||||
---@class diffs.HunkCacheEntry
|
||||
---@field hunks diffs.Hunk[]
|
||||
---@field tick integer
|
||||
---@field highlighted table<integer, true>
|
||||
---@field pending_clear boolean
|
||||
---@field line_count integer
|
||||
---@field byte_count integer
|
||||
|
||||
---@type table<integer, diffs.HunkCacheEntry>
|
||||
local hunk_cache = {}
|
||||
|
||||
---@param bufnr integer
|
||||
---@return boolean
|
||||
function M.is_fugitive_buffer(bufnr)
|
||||
|
|
@ -177,55 +189,110 @@ 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
|
||||
timer = t
|
||||
t:start(
|
||||
config.debounce_ms,
|
||||
0,
|
||||
vim.schedule_wrap(function()
|
||||
if timer == t then
|
||||
timer = nil
|
||||
t:close()
|
||||
local tick = vim.api.nvim_buf_get_changedtick(bufnr)
|
||||
local entry = hunk_cache[bufnr]
|
||||
if entry and entry.tick == tick then
|
||||
return
|
||||
end
|
||||
if vim.api.nvim_buf_is_valid(bufnr) then
|
||||
highlight_buffer(bufnr)
|
||||
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
|
||||
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()
|
||||
local normal = vim.api.nvim_get_hl(0, { name = 'Normal' })
|
||||
local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' })
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ local M = {}
|
|||
|
||||
local dbg = require('diffs.log').dbg
|
||||
|
||||
---@type table<string, {ft: string?, lang: string?}>
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue