Compare commits

...
Sign in to create a new pull request.

56 commits

Author SHA1 Message Date
f3a72926d2 doc: add plug mappings for merge conflict resolution 2026-02-08 16:28:18 -05:00
Barrett Ruth
669cca53ae
Merge pull request #96 from barrettruth/feat/conflict
feat(conflict): detect and resolve inline merge conflict markers
2026-02-08 15:23:30 -05:00
a192830d8c fix(conflict): clear stale diagnostics before re-enabling
Problem: after resolving all conflicts, vim.diagnostic.enable(true)
restored diagnostics that were cached while markers were present,
showing errors like "unexpected token end" on clean code.

Solution: call vim.diagnostic.reset() before re-enabling to flush
stale results and let the LSP re-analyze the resolved buffer.
2026-02-07 19:47:45 -05:00
35cb13419c fix(conflict): keep TextChanged autocmd alive after resolution
Problem: resolving the last conflict called M.detach(), which cleared
attached_buffers[bufnr]. The TextChanged callback then returned true,
permanently deleting the autocmd. Undo restored conflict markers but
nothing re-highlighted or re-suppressed diagnostics.

Solution: inline the cleanup in refresh() instead of calling detach().
Keep attached_buffers set so the autocmd survives. Re-suppress
diagnostics when conflicts reappear after undo.
2026-02-07 19:42:29 -05:00
bae86c5fd9 feat(conflict): show branch names in virtual text labels
Problem: virtual text showed generic "current"/"incoming" labels with
no indication of which branch each side came from.

Solution: extract the branch name from the marker line itself
(e.g. <<<<<<< HEAD, >>>>>>> feature) and display as
"HEAD (current)" / "feature (incoming)".
2026-02-07 17:58:51 -05:00
1108c33526 refactor(conflict): drop unnecessary @as cast in parser 2026-02-07 17:52:35 -05:00
98a1a4028b fix(conflict): resolve LuaLS missing-fields diagnostics
Problem: LuaLS reports missing-fields errors because the parser builds
ConflictRegion tables incrementally, but the variable is typed as
diffs.ConflictRegion? which expects all required fields at construction.

Solution: type the work-in-progress variable as table? and cast to
diffs.ConflictRegion on insertion into the results array.
2026-02-07 17:51:47 -05:00
7ae867c413 fix(conflict): resolve LuaLS duplicate-doc-field and inject-field errors
Problem: lua-language-server reports duplicate @class definitions for
ConflictKeymaps and ConflictConfig (defined in both init.lua and
conflict.lua), and inject-field errors for the untyped parser table.

Solution: remove duplicate @class annotations from conflict.lua
(init.lua is the canonical source), and annotate the parser's current
variable as diffs.ConflictRegion? so LuaLS knows its shape.
2026-02-07 17:45:23 -05:00
74c2dd4c7a docs: document conflict resolution config and highlight groups 2026-02-07 17:39:35 -05:00
731222d027 feat(conflict): detect and resolve inline merge conflict markers
Problem: when git hits a merge conflict, users stare at raw <<<<<<<
markers with broken treesitter and noisy LSP diagnostics. Existing
solutions (git-conflict.nvim) use their own highlighting rather than
integrating with diffs.nvim's color blending pipeline.

Solution: add conflict.lua module that detects <<<<<<</=======/>>>>>>>
markers (with diff3 ||||||| support), highlights ours/theirs/base
regions with blended DiffsConflict* highlight groups, provides
resolution keymaps (doo/dot/dob/don) and navigation (]x/[x),
suppresses diagnostics while markers are present, and auto-detaches
when all conflicts are resolved. Fires DiffsConflictResolved user
event on last resolution.
2026-02-07 17:38:34 -05:00
Barrett Ruth
e06d22936c
Merge pull request #95 from barrettruth/fix/ux-tweaks
fix(commands): add diff buffer UX improvements
2026-02-07 16:59:48 -05:00
6b4953bf41 docs: document q keymap and window reuse behavior 2026-02-07 16:54:54 -05:00
c72efec77d fix(commands): add diff buffer UX improvements
Problem: diffs:// buffers could trigger spurious LSP diagnostics,
opening multiple diffs from fugitive created redundant splits, and
there was no quick way to close diff windows.

Solution: disable diagnostics on diff buffers, reuse existing
diffs:// windows in the tabpage instead of creating new splits,
and add a buffer-local q keymap to close diff windows.
2026-02-07 16:48:31 -05:00
Barrett Ruth
52013d007d
Merge pull request #94 from barrettruth/feat/highlight-config-overrides
Highlight config overrides, default flag, blend alpha
2026-02-07 15:54:05 -05:00
ae65c50f92 docs(readme): mention blend alpha and highlight overrides
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
2026-02-07 15:53:08 -05:00
a0870a7892 feat(highlight): add highlights.overrides config table
Problem: users had no config-level way to override computed highlight
groups and had to call nvim_set_hl externally.

Solution: add highlights.overrides table that maps group names to
highlight definitions. Overrides are applied after all computed groups
without default = true, so they always win over both computed defaults
and colorscheme definitions.
2026-02-07 15:49:56 -05:00
b7477e3af2 feat(highlight): add configurable blend alpha
Problem: the character-level blend intensity was hardcoded to 0.6,
giving users no way to tune how strongly changed characters stand out
from the line-level background.

Solution: add highlights.blend_alpha config option (number, 0-1,
default 0.6) with type validation and range check.
2026-02-07 15:46:47 -05:00
8e0c41bf6b fix(highlight): add default flag to DiffsDiff* groups
Problem: DiffsDiff* highlight groups lacked default = true, making them
impossible for colorschemes to override, inconsistent with the fugitive
unified diff groups which already had it.

Solution: add default = true to all four DiffsDiffAdd, DiffsDiffDelete,
DiffsDiffChange, and DiffsDiffText nvim_set_hl calls.
2026-02-07 15:45:34 -05:00
bcc70280fb docs: fix vim.max_lines default in config example
Problem: the vimdoc config example showed max_lines = 500 under vim,
but the actual default in init.lua is 200.

Solution: change the example to match the real default.
2026-02-07 15:44:56 -05:00
Barrett Ruth
7049255931
Merge pull request #93 from barrettruth/fix/word-level-blend-alpha
fix(highlight): reduce word-level blend alpha and match line number bg
2026-02-07 15:30:15 -05:00
5cfa91039b fix(highlight): use correct line number gutter colors
Problem: DiffsAddNr/DiffsDeleteNr used raw diffAdded/diffRemoved
foreground and the word-level blended background, instead of matching
the line-level and character-level highlight groups.

Solution: set gutter bg to the line-level blend (DiffsAdd/DiffsDelete)
and fg to the character-level blend (DiffsAddText/DiffsDeleteText).
2026-02-07 15:29:19 -05:00
d10eaed6ac fix(highlight): reduce word-level blend alpha and match line number bg
Problem: word-level diff highlights were too intense at 70% alpha, and
line number backgrounds used the line-level blend instead of matching
the word-level highlights.

Solution: reduce DiffsAddText/DiffsDeleteText blend alpha from 0.7 to
0.6 and use the same blended background for DiffsAddNr/DiffsDeleteNr.
2026-02-07 15:23:14 -05:00
Barrett Ruth
d353a6a314
Merge pull request #92 from barrettruth/fix/header-context-highlight
fix(highlight): use hunk body as context for header treesitter parsing
2026-02-07 15:14:25 -05:00
825012daeb fix(ci): remove unused variable 2026-02-07 15:12:44 -05:00
38220ab368 fix(highlight): use hunk body as context for header treesitter parsing
Problem: the header context string (e.g. "function M.setup()") was
parsed in isolation by treesitter, which couldn't recognize "function"
as @keyword.function because the snippet is an incomplete definition
with no body or "end".

Solution: append the already-built new_code lines as trailing context
when parsing the header string, giving treesitter a complete function
definition. Filter captures to row 0 only so body-line captures don't
produce extmarks on the header line.
2026-02-07 15:10:07 -05:00
Barrett Ruth
3b2e0de2a7
Merge pull request #91 from barrettruth/fix/hl-eol
fix(highlight): make hl_eol work on background extmarks
2026-02-07 14:42:41 -05:00
011db2f8b3 fix(highlight): make hl_eol work on background extmarks
Problem: hl_eol requires a multiline extmark to extend the background
past end-of-line. The extmark used end_col = line_len on the same
row, so it was single-line and hl_eol was effectively a no-op.

Solution: replace end_col with end_row targeting the next line so
the extmark is multiline and hl_eol takes effect. Split
number_hl_group into a separate extmark to prevent it bleeding to
adjacent lines.
2026-02-07 14:40:55 -05:00
Barrett Ruth
af37d25f25
Merge pull request #88 from barrettruth/fix/treesitter-injections
fix(highlight): include treesitter injections
2026-02-07 14:33:12 -05:00
f6c0738384 docs: credit phanen for treesitter injection support 2026-02-07 14:32:04 -05:00
0cefa00d27 fix(highlight): include treesitter injections
Problem: highlight_treesitter only iterated captures from the base
language tree (trees[1]:root()). When code contained injected
languages (e.g. VimL inside vim.cmd()), those captures were never
read, so injected code got no syntax highlighting in diffs.

Solution: pass true to parse() to trigger injection discovery, then
use parser_obj:for_each_tree() to iterate all trees including
injected ones. Each tree gets its own highlights query looked up by
ltree:lang(), and @spell/@nospell captures are filtered out.
2026-02-07 14:26:11 -05:00
Barrett Ruth
2b1b1c3be2
Merge pull request #86 from barrettruth/feat/plug-mappings
feat: add <Plug> mappings
2026-02-07 14:15:26 -05:00
97a6fb2bd7 feat: add <Plug> mappings
Problem: users who want keybindings must wrap commands in closures.
There is no stable public API for key binding.

Solution: define <Plug> mappings in the plugin file and document them
in a new MAPPINGS section in the vimdoc.
2026-02-07 14:12:49 -05:00
Barrett Ruth
4dc650957b
Merge pull request #85 from barrettruth/feat/context-padding
feat(highlight): add treesitter context padding from disk
2026-02-07 13:18:15 -05:00
9e32384f18 refactor: change highlights.context config to table structure
Problem: highlights.context was a plain integer, inconsistent with the
table structure used by treesitter, vim, and intra sub-configs.

Solution: change to { enabled = true, lines = 25 } with full
vim.validate() coverage matching the existing pattern.
2026-02-07 13:16:34 -05:00
2e1ebdee03 feat(highlight): add treesitter context padding from disk
Problem: treesitter parses each diff hunk in isolation, so incomplete
syntax constructs at hunk boundaries (e.g., a function definition with
no body) produce ERROR nodes and drop captures.

Solution: read N lines from the on-disk file before/after each hunk and
prepend/append them as unmapped padding lines. The line_map guard in
highlight_treesitter skips extmarks for unmapped lines, so padding
provides syntax context without visual output. Controlled by
highlights.context (default 25, 0 to disable). Also applies to the vim
syntax fallback path via a leading_offset filter.
2026-02-07 13:05:53 -05:00
Barrett Ruth
ba1f830629
Merge pull request #79 from barrettruth/docs/acknowledgements
docs: acknowledge difftastic and conflicting plugins
2026-02-07 01:02:33 -05:00
a046f38796 docs: acknowledge difftastic and conflicting plugins 2026-02-07 00:55:37 -05:00
Barrett Ruth
12eaac4727
Merge pull request #78 from barrettruth/fix/treesitter-split-parsing
fix(highlight): split old/new treesitter parsing
2026-02-07 00:54:48 -05:00
bbb87b660e fix(highlight): split old/new treesitter parsing
Problem: highlight_treesitter concatenated all hunk lines (context, -,
+) into a single string. Mixed old/new code produced invalid syntax
(e.g. two return statements), causing treesitter error recovery to drop
captures on lines after the syntax error.

Solution: split hunk lines into two versions — new (context + added)
and old (context + deleted) — each parsed independently. Use a line_map
to resolve treesitter row indices to buffer lines, with the old version
only mapping deleted lines to avoid duplicate extmarks on context.

Also fixes three related issues exposed by the improved TS coverage:

- Replace Normal extmark with DiffsClear (explicit fg from Normal.fg).
  Normal in extmarks doesn't reliably override vim :syntax foreground.

- Reorder priority stack to DiffsClear(198) < syntax(199) < line
  bg(200) < char bg(201). TS capture groups can carry colorscheme
  backgrounds that would override diff line backgrounds at higher
  priority.

- Gate DiffsClear on per-line coverage tracking. Only clear fugitive
  syntax fg on lines where TS/vim actually produced captures, preventing
  force-clearing on lines where error recovery drops captures.
2026-02-07 00:50:21 -05:00
93f6627dd2 fix(diff): strip linematch from char-level diff
Problem: parse_diffopt() passes linematch from diffopt to byte-level
vim.diff() in char_diff_pair, where each "line" is a single byte.
linematch is meaningless at this granularity.

Solution: copy diff_opts without linematch before passing to byte_diff
in char_diff_pair. linematch remains in effect for the line-level diff
in diff_group_native where it belongs.
2026-02-07 00:50:08 -05:00
2d72963f8f fix(debug): resolve sparse array crash in json dump
Problem: vim.json.encode fails with "excessively sparse array" when
extmark row numbers are used as integer table keys, since they create
gaps in the array.

Solution: use tostring(row) as keys instead. Also add hl_eol to dumped
extmark fields for debugging line background extmarks.
2026-02-07 00:50:02 -05:00
Barrett Ruth
8d4602dbcb
Merge pull request #76 from barrettruth/fix/bufread
fix(commands): handle :e on diffs:// buffers via BufReadCmd
2026-02-06 23:20:18 -05:00
f948982848 fix(commands): handle :e on diffs:// buffers via BufReadCmd
Problem: running :e on a :Gdiff buffer cleared all content because
diffs:// buffers had no BufReadCmd handler. Neovim tried to read the
buffer name as a file path, found nothing on disk, and emptied the
buffer. This affected all three buffer creation paths (gdiff,
gdiff_file, gdiff_section).

Solution: register a BufReadCmd autocmd for diffs://* that parses the
URL and regenerates diff content from git. Change buffer options from
nofile/wipe to nowrite/delete (matching fugitive's approach) so
buffer-local autocmds and variables survive across unload/reload
cycles. Store old filepath as buffer variable for rename support.
2026-02-06 22:21:33 -05:00
Barrett Ruth
b6f1c5b749
Merge pull request #74 from barrettruth/feat/vscode-diff
feat(config): replace algorithm 'auto'/'native' with 'default'/'vscode'
2026-02-06 21:35:23 -05:00
b79adba5f2 fix(ci): typing 2026-02-06 21:33:55 -05:00
5722ccdbb2 fix(ci): typing 2026-02-06 21:30:06 -05:00
Barrett Ruth
a20623aa74
Merge branch 'main' into feat/vscode-diff 2026-02-06 21:26:25 -05:00
Barrett Ruth
ac2eb657de
Merge pull request #72 from barrettruth/feat/vscode-diff
feat(highlight): character-level intra-line diff highlighting
2026-02-06 21:25:42 -05:00
10af59a70d feat(config): replace algorithm 'auto'/'native' with 'default'/'vscode'
'default' inherits algorithm and linematch from diffopt, 'vscode' uses
the FFI library. Removes the need for diffs.nvim to duplicate settings
that users already control globally.
2026-02-06 21:23:40 -05:00
cc947167c3 fix(highlight): use hl_group instead of line_hl_group for diff backgrounds
line_hl_group bg occupies a separate rendering channel from hl_group in
Neovim's extmark system, causing character-level bg-only highlights to be
invisible regardless of priority. Switching to hl_group + hl_eol ensures
all backgrounds compete in the same channel.

Also reorders priorities (Normal 198 < line bg 199 < syntax 200 < char
bg 201), bumps char-level blend alpha from 0.4 to 0.7 for visibility,
and adds debug logging throughout the intra pipeline.
2026-02-06 18:31:10 -05:00
f1c13966ba fix(highlight): use diffAdded/diffRemoved fg for char-level backgrounds
The previous 70% alpha blend of DiffAdd bg was nearly identical to the
40% line-level blend, making char-level highlights invisible. Now blends
the bright diffAdded/diffRemoved foreground color (same base as line
number fg) into the char-level bg, matching GitHub/VSCode intensity.

Also bumps intra.max_lines default from 200 to 500.
2026-02-06 14:43:23 -05:00
63b6e7d4c6 fix(ci): add jit to luarc globals for lua-language-server
jit is a standard LuaJIT global (like vim), needed by lib.lua for
platform detection via jit.os and jit.arch.
2026-02-06 13:58:30 -05:00
997bc49f8b 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
2026-02-06 13:53:58 -05:00
Barrett Ruth
294cbad749
Merge pull request #71 from barrettruth/fix/filetype-from-file-content
fix(parser): detect filetype from file content (shebang/modeline)
2026-02-05 01:13:40 -05:00
33c58c7498 fix(parser): detect filetype from file content (shebang/modeline)
When a file has no extension but contains a shebang (e.g., `#!/bin/bash`),
filetype detection now reads the first 10 lines from disk and uses
`vim.filetype.match({ filename, contents })` for content-based detection.

This enables syntax highlighting for files like `build` scripts that rely
on shebang detection, even when the file isn't open in a buffer.

Detection order:
1. Existing buffer's filetype (already implemented in #69)
2. File content (shebang/modeline) - NEW
3. Filename extension only

Also adds `filetype on` to test helpers to ensure `vim.g.ft_ignore_pat`
is set, which is required for shell detection.
2026-02-05 01:08:43 -05:00
Barrett Ruth
0e6871b167
Merge pull request #70 from barrettruth/feat/hunk-line-position
feat(fugitive): line position tracking for keymaps
2026-02-05 00:28:33 -05:00
20 changed files with 4379 additions and 173 deletions

View file

@ -1,7 +1,7 @@
{
"runtime.version": "Lua 5.1",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim"],
"diagnostics.globals": ["vim", "jit"],
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
"workspace.checkThirdParty": false,
"completion.callSnippet": "Replace"

View file

@ -17,7 +17,10 @@ syntax highlighting.
- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`)
- Vim syntax fallback for languages without a treesitter parser
- Hunk header context highlighting (`@@ ... @@ function foo()`)
- Configurable debouncing, max lines, and diff prefix concealment
- Character-level (intra-line) diff highlighting for changed characters
- Inline merge conflict detection, highlighting, and resolution keymaps
- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and
highlight overrides
## Requirements
@ -40,12 +43,11 @@ luarocks install diffs.nvim
## Known Limitations
- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation
without surrounding code context. When a hunk shows lines added to an existing
block (e.g., adding a plugin inside `return { ... }`), the parser doesn't see
the `return` statement and may produce incorrect highlighting. This is
inherent to parsing code fragments—no diff tooling solves this without
significant complexity.
- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation.
To improve accuracy, `diffs.nvim` reads lines from disk before and after each
hunk for parsing context (`highlights.context`, enabled by default with 25
lines). This resolves most boundary issues. Set
`highlights.context.enabled = false` to disable.
- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event
triggered by `vim-fugitive`, at which point the buffer is preliminarily
@ -62,12 +64,17 @@ luarocks install diffs.nvim
compatible, but both plugins modifying line highlights may produce
unexpected results
- [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) -
conflict marker highlighting may overlap with `diffs.nvim`
`diffs.nvim` now includes built-in conflict resolution; disable one or the
other to avoid overlap
# Acknowledgements
- [`vim-fugitive`](https://github.com/tpope/vim-fugitive)
- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim)
- [`diffview.nvim`](https://github.com/sindrets/diffview.nvim)
- [`difftastic`](https://github.com/Wilfred/difftastic)
- [`mini.diff`](https://github.com/echasnovski/mini.diff)
- [`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
filetype fix, shebang/modeline detection, treesitter injection support

View file

@ -20,6 +20,7 @@ Features: ~
- Blended diff background colors that preserve syntax visibility
- Optional diff prefix (`+`/`-`/` `) concealment
- Gutter (line number) highlighting
- Inline merge conflict marker detection, highlighting, and resolution
==============================================================================
REQUIREMENTS *diffs-requirements*
@ -56,6 +57,11 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
highlights = {
background = true,
gutter = true,
blend_alpha = 0.6,
context = {
enabled = true,
lines = 25,
},
treesitter = {
enabled = true,
max_lines = 500,
@ -64,11 +70,30 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
enabled = false,
max_lines = 200,
},
intra = {
enabled = true,
algorithm = 'default',
max_lines = 500,
},
overrides = {},
},
fugitive = {
horizontal = 'du',
vertical = 'dU',
},
conflict = {
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
keymaps = {
ours = 'doo',
theirs = 'dot',
both = 'dob',
none = 'don',
next = ']x',
prev = '[x',
},
},
}
<
*diffs.Config*
@ -97,6 +122,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
Fugitive status buffer keymap options.
See |diffs.FugitiveConfig| for fields.
{conflict} (table, default: see below)
Inline merge conflict resolution options.
See |diffs.ConflictConfig| for fields.
*diffs.Highlights*
Highlights table fields: ~
{background} (boolean, default: true)
@ -108,6 +137,17 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
Highlight line numbers with matching colors.
Only visible if line numbers are enabled.
{blend_alpha} (number, default: 0.6)
Alpha value for character-level blend intensity.
Controls how strongly changed characters stand
out from the line-level background. Must be
between 0 and 1 (inclusive). Higher values
produce more vivid character-level highlights.
{context} (table, default: see below)
Syntax parsing context options.
See |diffs.ContextConfig| for fields.
{treesitter} (table, default: see below)
Treesitter highlighting options.
See |diffs.TreesitterConfig| for fields.
@ -116,6 +156,31 @@ 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.
{overrides} (table, default: {})
Map of highlight group names to highlight
definitions (see |nvim_set_hl()|). Applied
after all computed groups without `default`,
so overrides always win over both computed
defaults and colorscheme definitions.
*diffs.ContextConfig*
Context config fields: ~
{enabled} (boolean, default: true)
Read lines from disk before and after each hunk
to provide surrounding syntax context. Improves
accuracy at hunk boundaries where incomplete
constructs (e.g., a function definition with no
body) would otherwise confuse the parser.
{lines} (integer, default: 25)
Number of context lines to read in each
direction. Lines are read with early exit —
cost scales with this value, not file size.
*diffs.TreesitterConfig*
Treesitter config fields: ~
{enabled} (boolean, default: true)
@ -140,6 +205,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: 'default')
Diff algorithm for character-level analysis.
`'default'`: use |vim.diff()| with settings
inherited from |'diffopt'| (`algorithm` and
`linematch`). `'vscode'`: use libvscodediff FFI
(falls back to default if not available).
{max_lines} (integer, default: 500)
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.
@ -159,6 +244,9 @@ COMMANDS *diffs-commands*
code language, plus diff header highlighting for `diff --git`, `---`,
`+++`, and `@@` lines.
If a `diffs://` window already exists in the current tabpage, the new
diff replaces its buffer instead of creating another split.
Parameters: ~
{revision} (string, optional) Git revision to diff against.
Defaults to HEAD.
@ -176,6 +264,63 @@ COMMANDS *diffs-commands*
:Ghdiff [revision] *:Ghdiff*
Like |:Gdiff| but explicitly opens in a horizontal split.
==============================================================================
MAPPINGS *diffs-mappings*
*<Plug>(diffs-gdiff)*
<Plug>(diffs-gdiff) Show unified diff against HEAD in a horizontal
split. Equivalent to |:Gdiff| with no arguments.
*<Plug>(diffs-gvdiff)*
<Plug>(diffs-gvdiff) Show unified diff against HEAD in a vertical
split. Equivalent to |:Gvdiff| with no arguments.
Example configuration: >lua
vim.keymap.set('n', '<leader>gd', '<Plug>(diffs-gdiff)')
vim.keymap.set('n', '<leader>gD', '<Plug>(diffs-gvdiff)')
<
*<Plug>(diffs-conflict-ours)*
<Plug>(diffs-conflict-ours)
Accept current (ours) change. Replaces the
conflict block with ours content.
*<Plug>(diffs-conflict-theirs)*
<Plug>(diffs-conflict-theirs)
Accept incoming (theirs) change. Replaces the
conflict block with theirs content.
*<Plug>(diffs-conflict-both)*
<Plug>(diffs-conflict-both)
Accept both changes (ours then theirs).
*<Plug>(diffs-conflict-none)*
<Plug>(diffs-conflict-none)
Reject both changes (delete entire block).
*<Plug>(diffs-conflict-next)*
<Plug>(diffs-conflict-next)
Jump to next conflict marker. Wraps around.
*<Plug>(diffs-conflict-prev)*
<Plug>(diffs-conflict-prev)
Jump to previous conflict marker. Wraps around.
Example configuration: >lua
vim.keymap.set('n', 'co', '<Plug>(diffs-conflict-ours)')
vim.keymap.set('n', 'ct', '<Plug>(diffs-conflict-theirs)')
vim.keymap.set('n', 'cb', '<Plug>(diffs-conflict-both)')
vim.keymap.set('n', 'cn', '<Plug>(diffs-conflict-none)')
vim.keymap.set('n', ']x', '<Plug>(diffs-conflict-next)')
vim.keymap.set('n', '[x', '<Plug>(diffs-conflict-prev)')
<
Diff buffer mappings: ~
*diffs-q*
q Close the diff window. Available in all `diffs://`
buffers created by |:Gdiff|, |:Gvdiff|, |:Ghdiff|,
or the fugitive status keymaps.
==============================================================================
FUGITIVE STATUS KEYMAPS *diffs-fugitive*
@ -225,6 +370,94 @@ Configuration: ~
Keymap for unified diff in vertical split.
Set to `false` to disable.
==============================================================================
CONFLICT RESOLUTION *diffs-conflict*
diffs.nvim detects inline merge conflict markers (`<<<<<<<`/`=======`/
`>>>>>>>`) in working files and provides highlighting and resolution keymaps.
Both standard and diff3 (`|||||||`) formats are supported.
Conflict regions are detected automatically on `BufReadPost` and re-scanned
on `TextChanged`. When all conflicts in a buffer are resolved, highlighting
is removed and diagnostics are re-enabled.
Configuration: ~
*diffs.ConflictConfig*
>lua
vim.g.diffs = {
conflict = {
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
keymaps = {
ours = 'doo',
theirs = 'dot',
both = 'dob',
none = 'don',
next = ']x',
prev = '[x',
},
},
}
<
Fields: ~
{enabled} (boolean, default: true)
Enable conflict marker detection and
resolution. Set to `false` to disable
entirely.
{disable_diagnostics} (boolean, default: true)
Suppress LSP diagnostics on buffers with
conflict markers. Markers produce syntax
errors that clutter the diagnostic list.
Diagnostics are re-enabled when all conflicts
are resolved. Set `false` to leave
diagnostics alone.
{show_virtual_text} (boolean, default: true)
Show virtual text labels (" current" and
" incoming") at the end of `<<<<<<<` and
`>>>>>>>` marker lines.
{keymaps} (table, default: see above)
Buffer-local keymaps for conflict resolution
and navigation. Each value accepts a string
(custom key) or `false` (disabled).
*diffs.ConflictKeymaps*
Keymap fields: ~
{ours} (string|false, default: 'doo')
Accept current (ours) change.
{theirs} (string|false, default: 'dot')
Accept incoming (theirs) change.
{both} (string|false, default: 'dob')
Accept both changes (ours then theirs).
{none} (string|false, default: 'don')
Reject both changes (delete entire block).
{next} (string|false, default: ']x')
Jump to next conflict marker. Wraps around.
{prev} (string|false, default: '[x')
Jump to previous conflict marker. Wraps
around.
User events: ~
*DiffsConflictResolved*
DiffsConflictResolved Fired when the last conflict in a buffer is
resolved. Useful for triggering custom actions
(e.g., auto-staging the file). >lua
vim.api.nvim_create_autocmd('User', {
pattern = 'DiffsConflictResolved',
callback = function()
print('all conflicts resolved!')
end,
})
<
==============================================================================
API *diffs-api*
@ -256,9 +489,11 @@ Summary / commit detail views: ~
- Code is parsed with |vim.treesitter.get_string_parser()|
- If no treesitter parser and `vim.enabled`: vim syntax fallback via
scratch buffer and |synID()|
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 198
- `Normal` extmarks at priority 199 clear underlying diff foreground
- `Normal` extmarks at priority 198 clear underlying diff foreground
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199
- 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
@ -274,14 +509,15 @@ KNOWN LIMITATIONS *diffs-limitations*
Incomplete Syntax Context ~
*diffs-syntax-context*
Treesitter parses each diff hunk in isolation without surrounding code
context. When a hunk shows lines added to an existing block (e.g., adding a
plugin inside `return { ... }`), the parser doesn't see the `return`
statement and may produce incorrect or unusual highlighting.
Treesitter parses each diff hunk in isolation. To provide surrounding code
context, diffs.nvim reads lines from disk before and after each hunk
(see |diffs.ContextConfig|, enabled by default). This resolves most boundary
issues where incomplete constructs (e.g., a function definition at the edge
of a hunk with no body) would confuse the parser.
This is inherent to parsing code fragments. No diff tooling solves this
problem without significant complexity—the parser simply doesn't have enough
information to understand the full syntactic structure.
Set `highlights.context.enabled = false` to disable context padding. In rare
cases, context padding may not help if the relevant surrounding code is very
far from the hunk boundaries.
Syntax Highlighting Flash ~
*diffs-flash*
@ -319,8 +555,9 @@ conflict:
modifying line highlights may produce unexpected results.
- git-conflict.nvim (akinsho/git-conflict.nvim)
Provides conflict marker highlighting that may overlap with
diffs.nvim's highlighting in conflict scenarios.
Provides conflict marker highlighting and resolution keymaps.
diffs.nvim now has built-in conflict resolution (see
|diffs-conflict|). Disable one or the other to avoid overlap.
If you experience visual conflicts, try disabling the conflicting plugin's
diff-related features.
@ -328,9 +565,15 @@ diff-related features.
==============================================================================
HIGHLIGHT GROUPS *diffs-highlights*
diffs.nvim defines custom highlight groups. Fugitive unified diff groups use
`default = true`, so colorschemes can override them. Diff mode groups are
always derived from the corresponding `Diff*` groups.
diffs.nvim defines custom highlight groups. All groups use `default = true`,
so colorschemes can override them by defining the group before the plugin
loads.
All derived groups are computed by alpha-blending a source color into the
`Normal` background. Line-level groups blend at 40% alpha for a subtle tint;
character-level groups blend at 60% for more contrast. Line-number groups
combine both: background from the line-level blend, foreground from the
character-level blend.
Fugitive unified diff highlights: ~
*DiffsAdd*
@ -343,11 +586,54 @@ Fugitive unified diff highlights: ~
*DiffsAddNr*
DiffsAddNr Line number for `+` lines. Foreground from
`diffAdded`, background from `DiffsAdd`.
`DiffsAddText`, background from `DiffsAdd`.
*DiffsDeleteNr*
DiffsDeleteNr Line number for `-` lines. Foreground from
`diffRemoved`, background from `DiffsDelete`.
`DiffsDeleteText`, background from `DiffsDelete`.
*DiffsAddText*
DiffsAddText Character-level background for changed characters
within `+` lines. Derived by blending `diffAdded`
foreground with `Normal` background at 60% alpha.
Only sets `bg`, so treesitter foreground colors show
through.
*DiffsDeleteText*
DiffsDeleteText Character-level background for changed characters
within `-` lines. Derived by blending `diffRemoved`
foreground with `Normal` background at 60% alpha.
Conflict highlights: ~
*DiffsConflictOurs*
DiffsConflictOurs Background for "ours" (current) content lines.
Derived by blending `DiffAdd` background with
`Normal` at 40% alpha (green tint).
*DiffsConflictTheirs*
DiffsConflictTheirs Background for "theirs" (incoming) content lines.
Derived by blending `DiffChange` background with
`Normal` at 40% alpha.
*DiffsConflictBase*
DiffsConflictBase Background for base (ancestor) content lines in
diff3 conflicts. Derived by blending `DiffText`
background with `Normal` at 30% alpha (muted).
*DiffsConflictMarker*
DiffsConflictMarker Dimmed foreground with bold for `<<<<<<<`,
`=======`, `>>>>>>>`, and `|||||||` marker lines.
*DiffsConflictOursNr*
DiffsConflictOursNr Line number for "ours" content lines. Foreground
from higher-alpha blend, background from line-level
blend.
*DiffsConflictTheirsNr*
DiffsConflictTheirsNr Line number for "theirs" content lines.
*DiffsConflictBaseNr*
DiffsConflictBaseNr Line number for base content lines (diff3).
Diff mode window highlights: ~
These are used for |winhighlight| remapping in `&diff` windows.
@ -373,6 +659,16 @@ To customize these in your colorscheme: >lua
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = '#2e4a3a' })
vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { link = 'DiffDelete' })
<
Or via `highlights.overrides` in config: >lua
vim.g.diffs = {
highlights = {
overrides = {
DiffsAdd = { bg = '#2e4a3a' },
DiffsDiffDelete = { link = 'DiffDelete' },
},
},
}
<
==============================================================================
HEALTH CHECK *diffs-health*
@ -382,6 +678,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*
@ -389,7 +686,8 @@ ACKNOWLEDGEMENTS *diffs-acknowledgements*
- vim-fugitive (https://github.com/tpope/vim-fugitive)
- codediff.nvim (https://github.com/esmuellert/codediff.nvim)
- diffview.nvim (https://github.com/sindrets/diffview.nvim)
- @phanen (https://github.com/phanen) - diff header highlighting
- @phanen (https://github.com/phanen) - diff header highlighting,
treesitter injection support
==============================================================================
vim:tw=78:ts=8:ft=help:norl:

View file

@ -3,6 +3,27 @@ local M = {}
local git = require('diffs.git')
local dbg = require('diffs.log').dbg
---@return integer?
function M.find_diffs_window()
local tabpage = vim.api.nvim_get_current_tabpage()
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do
if vim.api.nvim_win_is_valid(win) then
local buf = vim.api.nvim_win_get_buf(win)
local name = vim.api.nvim_buf_get_name(buf)
if name:match('^diffs://') then
return win
end
end
end
return nil
end
---@param bufnr integer
function M.setup_diff_buf(bufnr)
vim.diagnostic.enable(false, { bufnr = bufnr })
vim.keymap.set('n', 'q', '<cmd>close<CR>', { buffer = bufnr })
end
---@param diff_lines string[]
---@param hunk_position { hunk_header: string, offset: integer }
---@return integer?
@ -86,8 +107,9 @@ function M.gdiff(revision, vertical)
local diff_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines)
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = diff_buf })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf })
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf })
vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf })
vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. revision .. ':' .. rel_path)
@ -95,9 +117,16 @@ function M.gdiff(revision, vertical)
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
end
vim.cmd(vertical and 'vsplit' or 'split')
vim.api.nvim_win_set_buf(0, diff_buf)
local existing_win = M.find_diffs_window()
if existing_win then
vim.api.nvim_set_current_win(existing_win)
vim.api.nvim_win_set_buf(existing_win, diff_buf)
else
vim.cmd(vertical and 'vsplit' or 'split')
vim.api.nvim_win_set_buf(0, diff_buf)
end
M.setup_diff_buf(diff_buf)
dbg('opened diff buffer %d for %s against %s', diff_buf, rel_path, revision)
vim.schedule(function()
@ -176,17 +205,27 @@ function M.gdiff_file(filepath, opts)
local diff_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines)
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = diff_buf })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf })
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf })
vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf })
vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':' .. rel_path)
if repo_root then
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
end
if old_rel_path ~= rel_path then
vim.api.nvim_buf_set_var(diff_buf, 'diffs_old_filepath', old_rel_path)
end
vim.cmd(opts.vertical and 'vsplit' or 'split')
vim.api.nvim_win_set_buf(0, diff_buf)
local existing_win = M.find_diffs_window()
if existing_win then
vim.api.nvim_set_current_win(existing_win)
vim.api.nvim_win_set_buf(existing_win, diff_buf)
else
vim.cmd(opts.vertical and 'vsplit' or 'split')
vim.api.nvim_win_set_buf(0, diff_buf)
end
if opts.hunk_position then
local target_line = M.find_hunk_line(diff_lines, opts.hunk_position)
@ -196,6 +235,7 @@ function M.gdiff_file(filepath, opts)
end
end
M.setup_diff_buf(diff_buf)
dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label)
vim.schedule(function()
@ -231,16 +271,24 @@ function M.gdiff_section(repo_root, opts)
local diff_label = opts.staged and 'staged' or 'unstaged'
local diff_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, result)
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = diff_buf })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf })
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf })
vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf })
vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':all')
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
vim.cmd(opts.vertical and 'vsplit' or 'split')
vim.api.nvim_win_set_buf(0, diff_buf)
local existing_win = M.find_diffs_window()
if existing_win then
vim.api.nvim_set_current_win(existing_win)
vim.api.nvim_win_set_buf(existing_win, diff_buf)
else
vim.cmd(opts.vertical and 'vsplit' or 'split')
vim.api.nvim_win_set_buf(0, diff_buf)
end
M.setup_diff_buf(diff_buf)
dbg('opened section diff buffer %d (%s)', diff_buf, diff_label)
vim.schedule(function()
@ -248,6 +296,77 @@ function M.gdiff_section(repo_root, opts)
end)
end
---@param bufnr integer
function M.read_buffer(bufnr)
local name = vim.api.nvim_buf_get_name(bufnr)
local url_body = name:match('^diffs://(.+)$')
if not url_body then
return
end
local label, path = url_body:match('^([^:]+):(.+)$')
if not label or not path then
return
end
local ok, repo_root = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_repo_root')
if not ok or not repo_root then
return
end
local diff_lines
if path == 'all' then
local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' }
if label == 'staged' then
table.insert(cmd, '--cached')
end
diff_lines = vim.fn.systemlist(cmd)
if vim.v.shell_error ~= 0 then
diff_lines = {}
end
else
local abs_path = repo_root .. '/' .. path
local old_ok, old_rel_path = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_old_filepath')
local old_abs_path = old_ok and old_rel_path and (repo_root .. '/' .. old_rel_path) or abs_path
local old_name = old_ok and old_rel_path or path
local old_lines, new_lines
if label == 'untracked' then
old_lines = {}
new_lines = git.get_working_content(abs_path) or {}
elseif label == 'staged' then
old_lines = git.get_file_content('HEAD', old_abs_path) or {}
new_lines = git.get_index_content(abs_path) or {}
elseif label == 'unstaged' then
old_lines = git.get_index_content(old_abs_path)
if not old_lines then
old_lines = git.get_file_content('HEAD', old_abs_path) or {}
end
new_lines = git.get_working_content(abs_path) or {}
else
old_lines = git.get_file_content(label, abs_path) or {}
new_lines = git.get_working_content(abs_path) or {}
end
diff_lines = generate_unified_diff(old_lines, new_lines, old_name, path)
end
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, diff_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr })
vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = bufnr })
vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr })
vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr })
dbg('reloaded diff buffer %d (%s:%s)', bufnr, label, path)
require('diffs').attach(bufnr)
end
function M.setup()
vim.api.nvim_create_user_command('Gdiff', function(opts)
M.gdiff(opts.args ~= '' and opts.args or nil, false)

438
lua/diffs/conflict.lua Normal file
View file

@ -0,0 +1,438 @@
---@class diffs.ConflictRegion
---@field marker_ours integer
---@field ours_start integer
---@field ours_end integer
---@field marker_base integer?
---@field base_start integer?
---@field base_end integer?
---@field marker_sep integer
---@field theirs_start integer
---@field theirs_end integer
---@field marker_theirs integer
local M = {}
local ns = vim.api.nvim_create_namespace('diffs-conflict')
---@type table<integer, true>
local attached_buffers = {}
---@type table<integer, boolean>
local diagnostics_suppressed = {}
local PRIORITY_LINE_BG = 200
---@param lines string[]
---@return diffs.ConflictRegion[]
function M.parse(lines)
local regions = {}
local state = 'idle'
---@type table?
local current = nil
for i, line in ipairs(lines) do
local idx = i - 1
if state == 'idle' then
if line:match('^<<<<<<<') then
current = { marker_ours = idx, ours_start = idx + 1 }
state = 'in_ours'
end
elseif state == 'in_ours' then
if line:match('^|||||||') then
current.ours_end = idx
current.marker_base = idx
current.base_start = idx + 1
state = 'in_base'
elseif line:match('^=======') then
current.ours_end = idx
current.marker_sep = idx
current.theirs_start = idx + 1
state = 'in_theirs'
elseif line:match('^<<<<<<<') then
current = { marker_ours = idx, ours_start = idx + 1 }
elseif line:match('^>>>>>>>') then
current = nil
state = 'idle'
end
elseif state == 'in_base' then
if line:match('^=======') then
current.base_end = idx
current.marker_sep = idx
current.theirs_start = idx + 1
state = 'in_theirs'
elseif line:match('^<<<<<<<') then
current = { marker_ours = idx, ours_start = idx + 1 }
state = 'in_ours'
elseif line:match('^>>>>>>>') then
current = nil
state = 'idle'
end
elseif state == 'in_theirs' then
if line:match('^>>>>>>>') then
current.theirs_end = idx
current.marker_theirs = idx
table.insert(regions, current)
current = nil
state = 'idle'
elseif line:match('^<<<<<<<') then
current = { marker_ours = idx, ours_start = idx + 1 }
state = 'in_ours'
end
end
end
return regions
end
---@param bufnr integer
---@return diffs.ConflictRegion[]
local function parse_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
return M.parse(lines)
end
---@param bufnr integer
---@param regions diffs.ConflictRegion[]
---@param config diffs.ConflictConfig
local function apply_highlights(bufnr, regions, config)
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
for _, region in ipairs(regions) do
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
end_row = region.marker_ours + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
priority = PRIORITY_LINE_BG,
})
if config.show_virtual_text then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
virt_text = { { ' (current)', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
end
for line = region.ours_start, region.ours_end - 1 do
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
end_row = line + 1,
hl_group = 'DiffsConflictOurs',
hl_eol = true,
priority = PRIORITY_LINE_BG,
})
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictOursNr',
priority = PRIORITY_LINE_BG,
})
end
if region.marker_base then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_base, 0, {
end_row = region.marker_base + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
priority = PRIORITY_LINE_BG,
})
for line = region.base_start, region.base_end - 1 do
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
end_row = line + 1,
hl_group = 'DiffsConflictBase',
hl_eol = true,
priority = PRIORITY_LINE_BG,
})
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictBaseNr',
priority = PRIORITY_LINE_BG,
})
end
end
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_sep, 0, {
end_row = region.marker_sep + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
priority = PRIORITY_LINE_BG,
})
for line = region.theirs_start, region.theirs_end - 1 do
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
end_row = line + 1,
hl_group = 'DiffsConflictTheirs',
hl_eol = true,
priority = PRIORITY_LINE_BG,
})
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictTheirsNr',
priority = PRIORITY_LINE_BG,
})
end
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, {
end_row = region.marker_theirs + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
priority = PRIORITY_LINE_BG,
})
if config.show_virtual_text then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, {
virt_text = { { ' (incoming)', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
end
end
end
---@param cursor_line integer
---@param regions diffs.ConflictRegion[]
---@return diffs.ConflictRegion?
local function find_conflict_at_cursor(cursor_line, regions)
for _, region in ipairs(regions) do
if cursor_line >= region.marker_ours and cursor_line <= region.marker_theirs then
return region
end
end
return nil
end
---@param bufnr integer
---@param region diffs.ConflictRegion
---@param replacement string[]
local function replace_region(bufnr, region, replacement)
vim.api.nvim_buf_set_lines(
bufnr,
region.marker_ours,
region.marker_theirs + 1,
false,
replacement
)
end
---@param bufnr integer
---@param config diffs.ConflictConfig
local function refresh(bufnr, config)
local regions = parse_buffer(bufnr)
if #regions == 0 then
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
if diagnostics_suppressed[bufnr] then
pcall(vim.diagnostic.reset, nil, bufnr)
pcall(vim.diagnostic.enable, true, { bufnr = bufnr })
diagnostics_suppressed[bufnr] = nil
end
vim.api.nvim_exec_autocmds('User', { pattern = 'DiffsConflictResolved' })
return
end
apply_highlights(bufnr, regions, config)
if config.disable_diagnostics and not diagnostics_suppressed[bufnr] then
pcall(vim.diagnostic.enable, false, { bufnr = bufnr })
diagnostics_suppressed[bufnr] = true
end
end
---@param bufnr integer
---@param config diffs.ConflictConfig
function M.resolve_ours(bufnr, config)
if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then
vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN)
return
end
local regions = parse_buffer(bufnr)
local cursor = vim.api.nvim_win_get_cursor(0)
local region = find_conflict_at_cursor(cursor[1] - 1, regions)
if not region then
return
end
local lines = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false)
replace_region(bufnr, region, lines)
refresh(bufnr, config)
end
---@param bufnr integer
---@param config diffs.ConflictConfig
function M.resolve_theirs(bufnr, config)
if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then
vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN)
return
end
local regions = parse_buffer(bufnr)
local cursor = vim.api.nvim_win_get_cursor(0)
local region = find_conflict_at_cursor(cursor[1] - 1, regions)
if not region then
return
end
local lines = vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false)
replace_region(bufnr, region, lines)
refresh(bufnr, config)
end
---@param bufnr integer
---@param config diffs.ConflictConfig
function M.resolve_both(bufnr, config)
if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then
vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN)
return
end
local regions = parse_buffer(bufnr)
local cursor = vim.api.nvim_win_get_cursor(0)
local region = find_conflict_at_cursor(cursor[1] - 1, regions)
if not region then
return
end
local ours = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false)
local theirs = vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false)
local combined = {}
for _, l in ipairs(ours) do
table.insert(combined, l)
end
for _, l in ipairs(theirs) do
table.insert(combined, l)
end
replace_region(bufnr, region, combined)
refresh(bufnr, config)
end
---@param bufnr integer
---@param config diffs.ConflictConfig
function M.resolve_none(bufnr, config)
if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then
vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN)
return
end
local regions = parse_buffer(bufnr)
local cursor = vim.api.nvim_win_get_cursor(0)
local region = find_conflict_at_cursor(cursor[1] - 1, regions)
if not region then
return
end
replace_region(bufnr, region, {})
refresh(bufnr, config)
end
---@param bufnr integer
function M.goto_next(bufnr)
local regions = parse_buffer(bufnr)
if #regions == 0 then
return
end
local cursor = vim.api.nvim_win_get_cursor(0)
local cursor_line = cursor[1] - 1
for _, region in ipairs(regions) do
if region.marker_ours > cursor_line then
vim.api.nvim_win_set_cursor(0, { region.marker_ours + 1, 0 })
return
end
end
vim.api.nvim_win_set_cursor(0, { regions[1].marker_ours + 1, 0 })
end
---@param bufnr integer
function M.goto_prev(bufnr)
local regions = parse_buffer(bufnr)
if #regions == 0 then
return
end
local cursor = vim.api.nvim_win_get_cursor(0)
local cursor_line = cursor[1] - 1
for i = #regions, 1, -1 do
if regions[i].marker_ours < cursor_line then
vim.api.nvim_win_set_cursor(0, { regions[i].marker_ours + 1, 0 })
return
end
end
vim.api.nvim_win_set_cursor(0, { regions[#regions].marker_ours + 1, 0 })
end
---@param bufnr integer
---@param config diffs.ConflictConfig
local function setup_keymaps(bufnr, config)
local km = config.keymaps
local maps = {
{ km.ours, '<Plug>(diffs-conflict-ours)' },
{ km.theirs, '<Plug>(diffs-conflict-theirs)' },
{ km.both, '<Plug>(diffs-conflict-both)' },
{ km.none, '<Plug>(diffs-conflict-none)' },
{ km.next, '<Plug>(diffs-conflict-next)' },
{ km.prev, '<Plug>(diffs-conflict-prev)' },
}
for _, map in ipairs(maps) do
if map[1] then
vim.keymap.set('n', map[1], map[2], { buffer = bufnr })
end
end
end
---@param bufnr integer
function M.detach(bufnr)
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
attached_buffers[bufnr] = nil
if diagnostics_suppressed[bufnr] then
pcall(vim.diagnostic.reset, nil, bufnr)
pcall(vim.diagnostic.enable, true, { bufnr = bufnr })
diagnostics_suppressed[bufnr] = nil
end
end
---@param bufnr integer
---@param config diffs.ConflictConfig
function M.attach(bufnr, config)
if attached_buffers[bufnr] then
return
end
local buftype = vim.api.nvim_get_option_value('buftype', { buf = bufnr })
if buftype ~= '' then
return
end
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local has_marker = false
for _, line in ipairs(lines) do
if line:match('^<<<<<<<') then
has_marker = true
break
end
end
if not has_marker then
return
end
attached_buffers[bufnr] = true
local regions = M.parse(lines)
apply_highlights(bufnr, regions, config)
setup_keymaps(bufnr, config)
if config.disable_diagnostics then
pcall(vim.diagnostic.enable, false, { bufnr = bufnr })
diagnostics_suppressed[bufnr] = true
end
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
buffer = bufnr,
callback = function()
if not attached_buffers[bufnr] then
return true
end
refresh(bufnr, config)
end,
})
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
callback = function()
attached_buffers[bufnr] = nil
diagnostics_suppressed[bufnr] = nil
end,
})
end
---@return integer
function M.get_namespace()
return ns
end
return M

70
lua/diffs/debug.lua Normal file
View file

@ -0,0 +1,70 @@
local M = {}
local ns = vim.api.nvim_create_namespace('diffs')
function M.dump()
local bufnr = vim.api.nvim_get_current_buf()
local marks = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local by_line = {}
for _, mark in ipairs(marks) do
local id, row, col, details = mark[1], mark[2], mark[3], mark[4]
local entry = {
id = id,
row = row,
col = col,
end_row = details.end_row,
end_col = details.end_col,
hl_group = details.hl_group,
priority = details.priority,
hl_eol = details.hl_eol,
line_hl_group = details.line_hl_group,
number_hl_group = details.number_hl_group,
virt_text = details.virt_text,
}
local key = tostring(row)
if not by_line[key] then
by_line[key] = { text = lines[row + 1] or '', marks = {} }
end
table.insert(by_line[key].marks, entry)
end
local all_ns_marks = vim.api.nvim_buf_get_extmarks(bufnr, -1, 0, -1, { details = true })
local non_diffs = {}
for _, mark in ipairs(all_ns_marks) do
local details = mark[4]
if details.ns_id ~= ns then
table.insert(non_diffs, {
ns_id = details.ns_id,
row = mark[2],
col = mark[3],
end_row = details.end_row,
end_col = details.end_col,
hl_group = details.hl_group,
priority = details.priority,
})
end
end
local result = {
bufnr = bufnr,
buf_name = vim.api.nvim_buf_get_name(bufnr),
ns_id = ns,
total_diffs_marks = #marks,
total_all_marks = #all_ns_marks,
non_diffs_marks = non_diffs,
lines = by_line,
}
local state_dir = vim.fn.stdpath('state')
local path = state_dir .. '/diffs_debug.json'
local f = io.open(path, 'w')
if f then
f:write(vim.json.encode(result))
f:close()
vim.notify('[diffs.nvim] debug dump: ' .. path, vim.log.levels.INFO)
end
end
return M

401
lua/diffs/diff.lua Normal file
View file

@ -0,0 +1,401 @@
---@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}[]
---@class diffs.DiffOpts
---@field algorithm? string
---@field linematch? integer
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
---@return diffs.DiffOpts
local function parse_diffopt()
local opts = {}
for _, item in ipairs(vim.split(vim.o.diffopt, ',')) do
local key, val = item:match('^(%w+):(.+)$')
if key == 'algorithm' then
opts.algorithm = val
elseif key == 'linematch' then
opts.linematch = tonumber(val)
end
end
return opts
end
---@param old_text string
---@param new_text string
---@param diff_opts? diffs.DiffOpts
---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[]
local function byte_diff(old_text, new_text, diff_opts)
local vim_opts = { result_type = 'indices' }
if diff_opts then
if diff_opts.algorithm then
vim_opts.algorithm = diff_opts.algorithm
end
if diff_opts.linematch then
vim_opts.linematch = diff_opts.linematch
end
end
local ok, result = pcall(vim.diff, old_text, new_text, vim_opts)
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
---@param diff_opts? diffs.DiffOpts
---@return diffs.CharSpan[], diffs.CharSpan[]
local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts)
---@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_opts = diff_opts
if diff_opts and diff_opts.linematch then
char_opts = { algorithm = diff_opts.algorithm }
end
local char_hunks = byte_diff(old_text, new_text, char_opts)
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
---@param diff_opts? diffs.DiffOpts
---@return diffs.CharSpan[], diffs.CharSpan[]
local function diff_group_native(group, diff_opts)
---@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,
diff_opts
)
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, diff_opts)
---@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,
diff_opts
)
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,
diff_opts
)
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 'default'
local vscode_handle = nil
if algorithm == 'vscode' then
vscode_handle = require('diffs.lib').load()
if not vscode_handle then
dbg('vscode algorithm requested but library not available, falling back to default')
end
end
---@type diffs.DiffOpts?
local diff_opts = nil
if not vscode_handle then
diff_opts = parse_diffopt()
if diff_opts.algorithm then
dbg('diffopt algorithm: %s', diff_opts.algorithm)
end
if diff_opts.linematch then
dbg('diffopt linematch: %d', diff_opts.linematch)
end
end
---@type diffs.CharSpan[]
local all_add = {}
---@type diffs.CharSpan[]
local all_del = {}
dbg(
'intra: %d change groups, algorithm=%s, vscode=%s',
#groups,
algorithm,
vscode_handle and 'yes' or 'no'
)
for gi, group in ipairs(groups) do
dbg('group %d: %d del lines, %d add lines', gi, #group.del_lines, #group.add_lines)
local ds, as
if vscode_handle then
ds, as = diff_group_vscode(group, vscode_handle)
else
ds, as = diff_group_native(group, diff_opts)
end
dbg('group %d result: %d del spans, %d add spans', gi, #ds, #as)
for _, s in ipairs(ds) do
dbg(' del span: line=%d col=%d..%d', s.line, s.col_start, s.col_end)
end
for _, s in ipairs(as) do
dbg(' add span: line=%d col=%d..%d', s.line, s.col_start, s.col_end)
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,39 @@
local M = {}
local dbg = require('diffs.log').dbg
local diff = require('diffs.diff')
---@param filepath string
---@param from_line integer
---@param count integer
---@return string[]
local function read_line_range(filepath, from_line, count)
if count <= 0 then
return {}
end
local f = io.open(filepath, 'r')
if not f then
return {}
end
local result = {}
local line_num = 0
for line in f:lines() do
line_num = line_num + 1
if line_num >= from_line then
table.insert(result, line)
if #result >= count then
break
end
end
end
f:close()
return result
end
local PRIORITY_CLEAR = 198
local PRIORITY_SYNTAX = 199
local PRIORITY_LINE_BG = 200
local PRIORITY_CHAR_BG = 201
---@param bufnr integer
---@param ns integer
@ -8,9 +41,15 @@ local dbg = require('diffs.log').dbg
---@param col_offset integer
---@param text string
---@param lang string
---@param context_lines? string[]
---@return integer
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang)
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, text, lang)
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines)
local parse_text = text
if context_lines and #context_lines > 0 then
parse_text = text .. '\n' .. table.concat(context_lines, '\n')
end
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, parse_text, lang)
if not ok or not parser_obj then
return 0
end
@ -28,24 +67,26 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang)
local extmark_count = 0
local header_line = hunk.start_line - 1
for id, node, metadata in query:iter_captures(trees[1]:root(), text) do
local capture_name = '@' .. query.captures[id] .. '.' .. lang
local sr, sc, er, ec = node:range()
for id, node, metadata in query:iter_captures(trees[1]:root(), parse_text) do
local sr, sc, _, ec = node:range()
if sr == 0 then
local capture_name = '@' .. query.captures[id] .. '.' .. lang
local buf_sr = header_line + sr
local buf_er = header_line + er
local buf_sc = col_offset + sc
local buf_ec = col_offset + ec
local buf_sr = header_line
local buf_er = header_line
local buf_sc = col_offset + sc
local buf_ec = col_offset + ec
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
end_col = buf_ec,
hl_group = capture_name,
priority = priority,
})
extmark_count = extmark_count + 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
end_col = buf_ec,
hl_group = capture_name,
priority = priority,
})
extmark_count = extmark_count + 1
end
end
return extmark_count
@ -57,16 +98,21 @@ end
---@param bufnr integer
---@param ns integer
---@param hunk diffs.Hunk
---@param code_lines string[]
---@param col_offset integer?
---@param lang string
---@param line_map table<integer, integer>
---@param col_offset integer
---@param covered_lines? table<integer, true>
---@return integer
local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset)
local lang = hunk.lang
if not lang then
return 0
end
local function highlight_treesitter(
bufnr,
ns,
code_lines,
lang,
line_map,
col_offset,
covered_lines
)
local code = table.concat(code_lines, '\n')
if code == '' then
return 0
@ -78,54 +124,50 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset)
return 0
end
local trees = parser_obj:parse()
local trees = parser_obj:parse(true)
if not trees or #trees == 0 then
dbg('parse returned no trees for lang: %s', lang)
return 0
end
local query = vim.treesitter.query.get(lang, 'highlights')
if not query then
dbg('no highlights query for lang: %s', lang)
return 0
end
if hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
end_col = hunk.header_context_col + #hunk.header_context,
hl_group = 'Normal',
priority = 199,
})
local header_extmarks =
highlight_text(bufnr, ns, hunk, hunk.header_context_col, hunk.header_context, lang)
if header_extmarks > 0 then
dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks)
end
end
col_offset = col_offset or 1
local extmark_count = 0
for id, node, metadata in query:iter_captures(trees[1]:root(), code) do
local capture_name = '@' .. query.captures[id] .. '.' .. lang
local sr, sc, er, ec = node:range()
parser_obj:for_each_tree(function(tree, ltree)
local tree_lang = ltree:lang()
local query = vim.treesitter.query.get(tree_lang, 'highlights')
if not query then
return
end
local buf_sr = hunk.start_line + sr
local buf_er = hunk.start_line + er
local buf_sc = sc + col_offset
local buf_ec = ec + col_offset
for id, node, metadata in query:iter_captures(tree:root(), code) do
local capture = query.captures[id]
if capture ~= 'spell' and capture ~= 'nospell' then
local capture_name = '@' .. capture .. '.' .. tree_lang
local sr, sc, er, ec = node:range()
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200
local buf_sr = line_map[sr]
if buf_sr then
local buf_er = line_map[er] or buf_sr
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
end_col = buf_ec,
hl_group = capture_name,
priority = priority,
})
extmark_count = extmark_count + 1
end
local buf_sc = sc + col_offset
local buf_ec = ec + col_offset
local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100)
or PRIORITY_SYNTAX
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
end_col = buf_ec,
hl_group = capture_name,
priority = priority,
})
extmark_count = extmark_count + 1
if covered_lines then
covered_lines[buf_sr] = true
end
end
end
end
end)
return extmark_count
end
@ -175,8 +217,10 @@ end
---@param ns integer
---@param hunk diffs.Hunk
---@param code_lines string[]
---@param covered_lines? table<integer, true>
---@param leading_offset? integer
---@return integer
local function highlight_vim_syntax(bufnr, ns, hunk, code_lines)
local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset)
local ft = hunk.ft
if not ft then
return 0
@ -186,6 +230,8 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines)
return 0
end
leading_offset = leading_offset or 0
local scratch = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(scratch, 0, -1, false, code_lines)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scratch })
@ -212,15 +258,22 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines)
vim.api.nvim_buf_delete(scratch, { force = true })
local hunk_line_count = #hunk.lines
local extmark_count = 0
for _, span in ipairs(spans) do
local buf_line = hunk.start_line + span.line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end,
hl_group = span.hl_name,
priority = 200,
})
extmark_count = extmark_count + 1
local adj = span.line - leading_offset
if adj >= 1 and adj <= hunk_line_count then
local buf_line = hunk.start_line + adj - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end,
hl_group = span.hl_name,
priority = PRIORITY_SYNTAX,
})
extmark_count = extmark_count + 1
if covered_lines then
covered_lines[buf_line] = true
end
end
end
return extmark_count
@ -247,21 +300,101 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
use_vim = false
end
local apply_syntax = use_ts or use_vim
---@type table<integer, true>
local covered_lines = {}
---@type string[]
local code_lines = {}
if apply_syntax then
for _, line in ipairs(hunk.lines) do
table.insert(code_lines, line:sub(2))
local ctx_cfg = opts.highlights.context
local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0
local leading = {}
local trailing = {}
if (use_ts or use_vim) and context > 0 and hunk.file_new_start and hunk.repo_root then
local filepath = vim.fs.joinpath(hunk.repo_root, hunk.filename)
local lead_from = math.max(1, hunk.file_new_start - context)
local lead_count = hunk.file_new_start - lead_from
if lead_count > 0 then
leading = read_line_range(filepath, lead_from, lead_count)
end
local trail_from = hunk.file_new_start + (hunk.file_new_count or 0)
trailing = read_line_range(filepath, trail_from, context)
end
local extmark_count = 0
if use_ts then
extmark_count = highlight_treesitter(bufnr, ns, hunk, code_lines)
---@type string[]
local new_code = {}
---@type table<integer, integer>
local new_map = {}
---@type string[]
local old_code = {}
---@type table<integer, integer>
local old_map = {}
for _, pad_line in ipairs(leading) do
table.insert(new_code, pad_line)
table.insert(old_code, pad_line)
end
for i, line in ipairs(hunk.lines) do
local prefix = line:sub(1, 1)
local stripped = line:sub(2)
local buf_line = hunk.start_line + i - 1
if prefix == '+' then
new_map[#new_code] = buf_line
table.insert(new_code, stripped)
elseif prefix == '-' then
old_map[#old_code] = buf_line
table.insert(old_code, stripped)
else
new_map[#new_code] = buf_line
table.insert(new_code, stripped)
table.insert(old_code, stripped)
end
end
for _, pad_line in ipairs(trailing) do
table.insert(new_code, pad_line)
table.insert(old_code, pad_line)
end
extmark_count = highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines)
extmark_count = extmark_count
+ highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines)
if hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
end_col = hunk.header_context_col + #hunk.header_context,
hl_group = 'DiffsClear',
priority = PRIORITY_CLEAR,
})
local header_extmarks = highlight_text(
bufnr,
ns,
hunk,
hunk.header_context_col,
hunk.header_context,
hunk.lang,
new_code
)
if header_extmarks > 0 then
dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks)
end
extmark_count = extmark_count + header_extmarks
end
elseif use_vim then
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines)
---@type string[]
local code_lines = {}
for _, pad_line in ipairs(leading) do
table.insert(code_lines, pad_line)
end
for _, line in ipairs(hunk.lines) do
table.insert(code_lines, line:sub(2))
end
for _, pad_line in ipairs(trailing) do
table.insert(code_lines, pad_line)
end
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, #leading)
end
if
@ -270,17 +403,48 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
and #hunk.header_lines > 0
and opts.highlights.treesitter.enabled
then
---@type table<integer, integer>
local header_map = {}
for i = 0, #hunk.header_lines - 1 do
header_map[i] = hunk.header_start_line - 1 + i
end
extmark_count = extmark_count
+ highlight_treesitter(bufnr, ns, {
filename = hunk.filename,
start_line = hunk.header_start_line - 1,
lang = 'diff',
lines = hunk.header_lines,
header_lines = {},
}, hunk.header_lines, 0)
+ highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0)
end
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
dbg('computing intra for hunk %s:%d (%d lines)', hunk.filename, hunk.start_line, #hunk.lines)
intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm)
if intra then
dbg('intra result: %d add spans, %d del spans', #intra.add_spans, #intra.del_spans)
else
dbg('intra result: nil (no change groups)')
end
elseif intra_cfg and not intra_cfg.enabled then
dbg('intra disabled by config')
elseif intra_cfg and #hunk.lines > intra_cfg.max_lines then
dbg('intra skipped: %d lines > %d max', #hunk.lines, intra_cfg.max_lines)
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
@ -299,24 +463,52 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
})
end
if opts.highlights.background and is_diff_line then
local extmark_opts = {
line_hl_group = line_hl,
priority = 198,
}
if opts.highlights.gutter then
extmark_opts.number_hl_group = number_hl
end
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, extmark_opts)
end
if line_len > 1 and syntax_applied then
if line_len > 1 and covered_lines[buf_line] then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
end_col = line_len,
hl_group = 'Normal',
priority = 199,
hl_group = 'DiffsClear',
priority = PRIORITY_CLEAR,
})
end
if opts.highlights.background and is_diff_line then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
end_row = buf_line + 1,
hl_group = line_hl,
hl_eol = true,
priority = PRIORITY_LINE_BG,
})
if opts.highlights.gutter then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
number_hl_group = number_hl,
priority = PRIORITY_LINE_BG,
})
end
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
dbg(
'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"',
i,
buf_line,
span.col_start,
span.col_end,
char_hl,
line:sub(span.col_start + 1, span.col_end)
)
local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end,
hl_group = char_hl,
priority = PRIORITY_CHAR_BG,
})
if not ok then
dbg('char extmark FAILED: %s', err)
end
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,22 +6,50 @@
---@field enabled boolean
---@field max_lines integer
---@class diffs.IntraConfig
---@field enabled boolean
---@field algorithm string
---@field max_lines integer
---@class diffs.ContextConfig
---@field enabled boolean
---@field lines integer
---@class diffs.Highlights
---@field background boolean
---@field gutter boolean
---@field blend_alpha? number
---@field overrides? table<string, table>
---@field context diffs.ContextConfig
---@field treesitter diffs.TreesitterConfig
---@field vim diffs.VimConfig
---@field intra diffs.IntraConfig
---@class diffs.FugitiveConfig
---@field horizontal string|false
---@field vertical string|false
---@class diffs.ConflictKeymaps
---@field ours string|false
---@field theirs string|false
---@field both string|false
---@field none string|false
---@field next string|false
---@field prev string|false
---@class diffs.ConflictConfig
---@field enabled boolean
---@field disable_diagnostics boolean
---@field show_virtual_text boolean
---@field keymaps diffs.ConflictKeymaps
---@class diffs.Config
---@field debug boolean
---@field debounce_ms integer
---@field hide_prefix boolean
---@field highlights diffs.Highlights
---@field fugitive diffs.FugitiveConfig
---@field conflict diffs.ConflictConfig
---@class diffs
---@field attach fun(bufnr?: integer)
@ -74,6 +102,10 @@ local default_config = {
highlights = {
background = true,
gutter = true,
context = {
enabled = true,
lines = 25,
},
treesitter = {
enabled = true,
max_lines = 500,
@ -82,11 +114,29 @@ local default_config = {
enabled = false,
max_lines = 200,
},
intra = {
enabled = true,
algorithm = 'default',
max_lines = 500,
},
},
fugitive = {
horizontal = 'du',
vertical = 'dU',
},
conflict = {
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
keymaps = {
ours = 'doo',
theirs = 'dot',
both = 'dob',
none = 'don',
next = ']x',
prev = '[x',
},
},
}
---@type diffs.Config
@ -172,18 +222,79 @@ 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 alpha = config.highlights.blend_alpha or 0.6
local blended_add_text = blend_color(add_fg, bg, alpha)
local blended_del_text = blend_color(del_fg, bg, alpha)
vim.api.nvim_set_hl(0, 'DiffsClear', { default = true, fg = normal.fg or 0xc0c0c0 })
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, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add })
vim.api.nvim_set_hl(
0,
'DiffsDeleteNr',
{ default = true, fg = blended_del_text, 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 })
dbg('highlight groups: Normal.bg=#%06x DiffAdd.bg=#%06x diffAdded.fg=#%06x', bg, add_bg, add_fg)
dbg(
'DiffsAdd.bg=#%06x DiffsAddText.bg=#%06x DiffsAddNr.fg=#%06x',
blended_add,
blended_add_text,
add_fg
)
dbg('DiffsDelete.bg=#%06x DiffsDeleteText.bg=#%06x', blended_del, blended_del_text)
local diff_change = resolve_hl('DiffChange')
local diff_text = resolve_hl('DiffText')
vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { bg = diff_add.bg })
vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { fg = diff_delete.fg, bg = diff_delete.bg })
vim.api.nvim_set_hl(0, 'DiffsDiffChange', { bg = diff_change.bg })
vim.api.nvim_set_hl(0, 'DiffsDiffText', { bg = diff_text.bg })
vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { default = true, bg = diff_add.bg })
vim.api.nvim_set_hl(
0,
'DiffsDiffDelete',
{ default = true, fg = diff_delete.fg, bg = diff_delete.bg }
)
vim.api.nvim_set_hl(0, 'DiffsDiffChange', { default = true, bg = diff_change.bg })
vim.api.nvim_set_hl(0, 'DiffsDiffText', { default = true, bg = diff_text.bg })
local change_bg = diff_change.bg or 0x3a3a4a
local text_bg = diff_text.bg or 0x4a4a5a
local change_fg = diff_change.fg or diff_text.fg or 0x80a0c0
local blended_ours = blend_color(add_bg, bg, 0.4)
local blended_theirs = blend_color(change_bg, bg, 0.4)
local blended_base = blend_color(text_bg, bg, 0.3)
local blended_ours_nr = blend_color(add_fg, bg, alpha)
local blended_theirs_nr = blend_color(change_fg, bg, alpha)
local blended_base_nr = blend_color(change_fg, bg, 0.4)
vim.api.nvim_set_hl(0, 'DiffsConflictOurs', { default = true, bg = blended_ours })
vim.api.nvim_set_hl(0, 'DiffsConflictTheirs', { default = true, bg = blended_theirs })
vim.api.nvim_set_hl(0, 'DiffsConflictBase', { default = true, bg = blended_base })
vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { default = true, fg = 0x808080, bold = true })
vim.api.nvim_set_hl(
0,
'DiffsConflictOursNr',
{ default = true, fg = blended_ours_nr, bg = blended_ours }
)
vim.api.nvim_set_hl(
0,
'DiffsConflictTheirsNr',
{ default = true, fg = blended_theirs_nr, bg = blended_theirs }
)
vim.api.nvim_set_hl(
0,
'DiffsConflictBaseNr',
{ default = true, fg = blended_base_nr, bg = blended_base }
)
if config.highlights.overrides then
for group, hl in pairs(config.highlights.overrides) do
vim.api.nvim_set_hl(0, group, hl)
end
end
end
local function init()
@ -205,10 +316,21 @@ local function init()
vim.validate({
['highlights.background'] = { opts.highlights.background, 'boolean', true },
['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true },
['highlights.blend_alpha'] = { opts.highlights.blend_alpha, 'number', true },
['highlights.overrides'] = { opts.highlights.overrides, 'table', true },
['highlights.context'] = { opts.highlights.context, 'table', 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.context then
vim.validate({
['highlights.context.enabled'] = { opts.highlights.context.enabled, 'boolean', true },
['highlights.context.lines'] = { opts.highlights.context.lines, 'number', true },
})
end
if opts.highlights.treesitter then
vim.validate({
['highlights.treesitter.enabled'] = { opts.highlights.treesitter.enabled, 'boolean', true },
@ -226,6 +348,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 == 'default' or v == 'vscode'
end,
"'default' or 'vscode'",
},
['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true },
})
end
end
if opts.fugitive then
@ -247,9 +383,41 @@ local function init()
})
end
if opts.conflict then
vim.validate({
['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true },
['conflict.disable_diagnostics'] = { opts.conflict.disable_diagnostics, 'boolean', true },
['conflict.show_virtual_text'] = { opts.conflict.show_virtual_text, 'boolean', true },
['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true },
})
if opts.conflict.keymaps then
local keymap_validator = function(v)
return v == false or type(v) == 'string'
end
for _, key in ipairs({ 'ours', 'theirs', 'both', 'none', 'next', 'prev' }) do
vim.validate({
['conflict.keymaps.' .. key] = {
opts.conflict.keymaps[key],
keymap_validator,
'string or false',
},
})
end
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
and opts.highlights.context.lines
and opts.highlights.context.lines < 0
then
error('diffs: highlights.context.lines must be >= 0')
end
if
opts.highlights
and opts.highlights.treesitter
@ -266,6 +434,21 @@ 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
if
opts.highlights
and opts.highlights.blend_alpha
and (opts.highlights.blend_alpha < 0 or opts.highlights.blend_alpha > 1)
then
error('diffs: highlights.blend_alpha must be >= 0 and <= 1')
end
config = vim.tbl_deep_extend('force', default_config, opts)
log.set_enabled(config.debug)
@ -388,4 +571,10 @@ function M.get_fugitive_config()
return config.fugitive
end
---@return diffs.ConflictConfig
function M.get_conflict_config()
init()
return config.conflict
end
return M

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

View file

@ -8,17 +8,43 @@
---@field lines string[]
---@field header_start_line integer?
---@field header_lines string[]?
---@field file_old_start integer?
---@field file_old_count integer?
---@field file_new_start integer?
---@field file_new_count integer?
---@field repo_root string?
local M = {}
local dbg = require('diffs.log').dbg
---@param filepath string
---@param n integer
---@return string[]?
local function read_first_lines(filepath, n)
local f = io.open(filepath, 'r')
if not f then
return nil
end
local lines = {}
for _ = 1, n do
local line = f:read('*l')
if not line then
break
end
table.insert(lines, line)
end
f:close()
return #lines > 0 and lines or nil
end
---@param filename string
---@param repo_root string?
---@return string?
local function get_ft_from_filename(filename, repo_root)
if repo_root then
local full_path = vim.fs.joinpath(repo_root, filename)
local buf = vim.fn.bufnr(full_path)
if buf ~= -1 then
local ft = vim.api.nvim_get_option_value('filetype', { buf = buf })
@ -30,10 +56,25 @@ local function get_ft_from_filename(filename, repo_root)
end
local ft = vim.filetype.match({ filename = filename })
if not ft then
dbg('no filetype for: %s', filename)
if ft then
dbg('filetype from filename: %s', ft)
return ft
end
return ft
if repo_root then
local full_path = vim.fs.joinpath(repo_root, filename)
local contents = read_first_lines(full_path, 10)
if contents then
ft = vim.filetype.match({ filename = filename, contents = contents })
if ft then
dbg('filetype from file content: %s', ft)
return ft
end
end
end
dbg('no filetype for: %s', filename)
return nil
end
---@param ft string
@ -96,6 +137,14 @@ function M.parse_buffer(bufnr)
local header_start = nil
---@type string[]
local header_lines = {}
---@type integer?
local file_old_start = nil
---@type integer?
local file_old_count = nil
---@type integer?
local file_new_start = nil
---@type integer?
local file_new_count = nil
local function flush_hunk()
if hunk_start and #hunk_lines > 0 then
@ -107,6 +156,11 @@ function M.parse_buffer(bufnr)
header_context = hunk_header_context,
header_context_col = hunk_header_context_col,
lines = hunk_lines,
file_old_start = file_old_start,
file_old_count = file_old_count,
file_new_start = file_new_start,
file_new_count = file_new_count,
repo_root = repo_root,
}
if hunk_count == 1 and header_start and #header_lines > 0 then
hunk.header_start_line = header_start
@ -118,6 +172,10 @@ function M.parse_buffer(bufnr)
hunk_header_context = nil
hunk_header_context_col = nil
hunk_lines = {}
file_old_start = nil
file_old_count = nil
file_new_start = nil
file_new_count = nil
end
for i, line in ipairs(lines) do
@ -138,6 +196,13 @@ function M.parse_buffer(bufnr)
elseif line:match('^@@.-@@') then
flush_hunk()
hunk_start = i
local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
if hs then
file_old_start = tonumber(hs)
file_old_count = tonumber(hc) or 1
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
end
local prefix, context = line:match('^(@@.-@@%s*)(.*)')
if context and context ~= '' then
hunk_header_context = context

View file

@ -23,6 +23,22 @@ vim.api.nvim_create_autocmd('FileType', {
end,
})
vim.api.nvim_create_autocmd('BufReadCmd', {
pattern = 'diffs://*',
callback = function(args)
require('diffs.commands').read_buffer(args.buf)
end,
})
vim.api.nvim_create_autocmd('BufReadPost', {
callback = function(args)
local conflict_config = require('diffs').get_conflict_config()
if conflict_config.enabled then
require('diffs.conflict').attach(args.buf, conflict_config)
end
end,
})
vim.api.nvim_create_autocmd('OptionSet', {
pattern = 'diff',
callback = function()
@ -33,3 +49,36 @@ vim.api.nvim_create_autocmd('OptionSet', {
end
end,
})
local cmds = require('diffs.commands')
vim.keymap.set('n', '<Plug>(diffs-gdiff)', function()
cmds.gdiff(nil, false)
end, { desc = 'Unified diff (horizontal)' })
vim.keymap.set('n', '<Plug>(diffs-gvdiff)', function()
cmds.gdiff(nil, true)
end, { desc = 'Unified diff (vertical)' })
local function conflict_action(fn)
local bufnr = vim.api.nvim_get_current_buf()
local config = require('diffs').get_conflict_config()
fn(bufnr, config)
end
vim.keymap.set('n', '<Plug>(diffs-conflict-ours)', function()
conflict_action(require('diffs.conflict').resolve_ours)
end, { desc = 'Accept current (ours) change' })
vim.keymap.set('n', '<Plug>(diffs-conflict-theirs)', function()
conflict_action(require('diffs.conflict').resolve_theirs)
end, { desc = 'Accept incoming (theirs) change' })
vim.keymap.set('n', '<Plug>(diffs-conflict-both)', function()
conflict_action(require('diffs.conflict').resolve_both)
end, { desc = 'Accept both changes' })
vim.keymap.set('n', '<Plug>(diffs-conflict-none)', function()
conflict_action(require('diffs.conflict').resolve_none)
end, { desc = 'Reject both changes' })
vim.keymap.set('n', '<Plug>(diffs-conflict-next)', function()
require('diffs.conflict').goto_next(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to next conflict' })
vim.keymap.set('n', '<Plug>(diffs-conflict-prev)', function()
require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to previous conflict' })

688
spec/conflict_spec.lua Normal file
View file

@ -0,0 +1,688 @@
local conflict = require('diffs.conflict')
local helpers = require('spec.helpers')
local function default_config(overrides)
local cfg = {
enabled = true,
disable_diagnostics = false,
show_virtual_text = true,
keymaps = {
ours = 'doo',
theirs = 'dot',
both = 'dob',
none = 'don',
next = ']x',
prev = '[x',
},
}
if overrides then
cfg = vim.tbl_deep_extend('force', cfg, overrides)
end
return cfg
end
local function create_file_buffer(lines)
local bufnr = vim.api.nvim_create_buf(false, false)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
return bufnr
end
local function get_extmarks(bufnr)
return vim.api.nvim_buf_get_extmarks(bufnr, conflict.get_namespace(), 0, -1, { details = true })
end
describe('conflict', function()
describe('parse', function()
it('parses a single conflict', function()
local lines = {
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
}
local regions = conflict.parse(lines)
assert.are.equal(1, #regions)
assert.are.equal(0, regions[1].marker_ours)
assert.are.equal(1, regions[1].ours_start)
assert.are.equal(2, regions[1].ours_end)
assert.are.equal(2, regions[1].marker_sep)
assert.are.equal(3, regions[1].theirs_start)
assert.are.equal(4, regions[1].theirs_end)
assert.are.equal(4, regions[1].marker_theirs)
end)
it('parses multiple conflicts', function()
local lines = {
'<<<<<<< HEAD',
'a',
'=======',
'b',
'>>>>>>> feat',
'normal line',
'<<<<<<< HEAD',
'c',
'=======',
'd',
'>>>>>>> feat',
}
local regions = conflict.parse(lines)
assert.are.equal(2, #regions)
assert.are.equal(0, regions[1].marker_ours)
assert.are.equal(6, regions[2].marker_ours)
end)
it('parses diff3 format', function()
local lines = {
'<<<<<<< HEAD',
'local x = 1',
'||||||| base',
'local x = 0',
'=======',
'local x = 2',
'>>>>>>> feature',
}
local regions = conflict.parse(lines)
assert.are.equal(1, #regions)
assert.are.equal(2, regions[1].marker_base)
assert.are.equal(3, regions[1].base_start)
assert.are.equal(4, regions[1].base_end)
end)
it('handles empty ours section', function()
local lines = {
'<<<<<<< HEAD',
'=======',
'local x = 2',
'>>>>>>> feature',
}
local regions = conflict.parse(lines)
assert.are.equal(1, #regions)
assert.are.equal(1, regions[1].ours_start)
assert.are.equal(1, regions[1].ours_end)
end)
it('handles empty theirs section', function()
local lines = {
'<<<<<<< HEAD',
'local x = 1',
'=======',
'>>>>>>> feature',
}
local regions = conflict.parse(lines)
assert.are.equal(1, #regions)
assert.are.equal(3, regions[1].theirs_start)
assert.are.equal(3, regions[1].theirs_end)
end)
it('returns empty for no markers', function()
local lines = { 'local x = 1', 'local y = 2' }
local regions = conflict.parse(lines)
assert.are.equal(0, #regions)
end)
it('discards malformed markers (no separator)', function()
local lines = {
'<<<<<<< HEAD',
'local x = 1',
'>>>>>>> feature',
}
local regions = conflict.parse(lines)
assert.are.equal(0, #regions)
end)
it('discards malformed markers (no end)', function()
local lines = {
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
}
local regions = conflict.parse(lines)
assert.are.equal(0, #regions)
end)
it('handles trailing text on marker lines', function()
local lines = {
'<<<<<<< HEAD (some text)',
'local x = 1',
'======= extra',
'local x = 2',
'>>>>>>> feature-branch/some-thing',
}
local regions = conflict.parse(lines)
assert.are.equal(1, #regions)
end)
it('handles empty base in diff3', function()
local lines = {
'<<<<<<< HEAD',
'local x = 1',
'||||||| base',
'=======',
'local x = 2',
'>>>>>>> feature',
}
local regions = conflict.parse(lines)
assert.are.equal(1, #regions)
assert.are.equal(3, regions[1].base_start)
assert.are.equal(3, regions[1].base_end)
end)
end)
describe('highlighting', function()
after_each(function()
conflict.detach(vim.api.nvim_get_current_buf())
end)
it('applies extmarks for conflict regions', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(bufnr, default_config())
local extmarks = get_extmarks(bufnr)
assert.is_true(#extmarks > 0)
local has_ours = false
local has_theirs = false
local has_marker = false
for _, mark in ipairs(extmarks) do
local hl = mark[4] and mark[4].hl_group
if hl == 'DiffsConflictOurs' then
has_ours = true
end
if hl == 'DiffsConflictTheirs' then
has_theirs = true
end
if hl == 'DiffsConflictMarker' then
has_marker = true
end
end
assert.is_true(has_ours)
assert.is_true(has_theirs)
assert.is_true(has_marker)
helpers.delete_buffer(bufnr)
end)
it('applies virtual text when enabled', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(bufnr, default_config({ show_virtual_text = true }))
local extmarks = get_extmarks(bufnr)
local virt_text_count = 0
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
virt_text_count = virt_text_count + 1
end
end
assert.are.equal(2, virt_text_count)
helpers.delete_buffer(bufnr)
end)
it('does not apply virtual text when disabled', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(bufnr, default_config({ show_virtual_text = false }))
local extmarks = get_extmarks(bufnr)
local virt_text_count = 0
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
virt_text_count = virt_text_count + 1
end
end
assert.are.equal(0, virt_text_count)
helpers.delete_buffer(bufnr)
end)
it('applies number_hl_group to content lines', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(bufnr, default_config())
local extmarks = get_extmarks(bufnr)
local has_ours_nr = false
local has_theirs_nr = false
for _, mark in ipairs(extmarks) do
local nr = mark[4] and mark[4].number_hl_group
if nr == 'DiffsConflictOursNr' then
has_ours_nr = true
end
if nr == 'DiffsConflictTheirsNr' then
has_theirs_nr = true
end
end
assert.is_true(has_ours_nr)
assert.is_true(has_theirs_nr)
helpers.delete_buffer(bufnr)
end)
it('highlights base region in diff3', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'||||||| base',
'local x = 0',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(bufnr, default_config())
local extmarks = get_extmarks(bufnr)
local has_base = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group == 'DiffsConflictBase' then
has_base = true
break
end
end
assert.is_true(has_base)
helpers.delete_buffer(bufnr)
end)
it('clears extmarks on detach', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(bufnr, default_config())
assert.is_true(#get_extmarks(bufnr) > 0)
conflict.detach(bufnr)
assert.are.equal(0, #get_extmarks(bufnr))
helpers.delete_buffer(bufnr)
end)
end)
describe('resolution', function()
local function make_conflict_buffer()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
vim.api.nvim_set_current_buf(bufnr)
return bufnr
end
it('resolve_ours keeps ours content', function()
local bufnr = make_conflict_buffer()
vim.api.nvim_win_set_cursor(0, { 2, 0 })
conflict.resolve_ours(bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal(1, #lines)
assert.are.equal('local x = 1', lines[1])
helpers.delete_buffer(bufnr)
end)
it('resolve_theirs keeps theirs content', function()
local bufnr = make_conflict_buffer()
vim.api.nvim_win_set_cursor(0, { 2, 0 })
conflict.resolve_theirs(bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal(1, #lines)
assert.are.equal('local x = 2', lines[1])
helpers.delete_buffer(bufnr)
end)
it('resolve_both keeps ours then theirs', function()
local bufnr = make_conflict_buffer()
vim.api.nvim_win_set_cursor(0, { 2, 0 })
conflict.resolve_both(bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal(2, #lines)
assert.are.equal('local x = 1', lines[1])
assert.are.equal('local x = 2', lines[2])
helpers.delete_buffer(bufnr)
end)
it('resolve_none removes entire block', function()
local bufnr = make_conflict_buffer()
vim.api.nvim_win_set_cursor(0, { 2, 0 })
conflict.resolve_none(bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal(1, #lines)
assert.are.equal('', lines[1])
helpers.delete_buffer(bufnr)
end)
it('does nothing when cursor is outside conflict', function()
local bufnr = create_file_buffer({
'normal line',
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
conflict.resolve_ours(bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal(6, #lines)
helpers.delete_buffer(bufnr)
end)
it('resolves one conflict among multiple', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'a',
'=======',
'b',
'>>>>>>> feat',
'middle',
'<<<<<<< HEAD',
'c',
'=======',
'd',
'>>>>>>> feat',
})
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
conflict.resolve_ours(bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal('a', lines[1])
assert.are.equal('middle', lines[2])
assert.are.equal('<<<<<<< HEAD', lines[3])
helpers.delete_buffer(bufnr)
end)
it('resolve_ours with empty ours section', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'=======',
'local x = 2',
'>>>>>>> feature',
})
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
conflict.resolve_ours(bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal(1, #lines)
assert.are.equal('', lines[1])
helpers.delete_buffer(bufnr)
end)
it('handles diff3 resolution (ignores base)', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'||||||| base',
'local x = 0',
'=======',
'local x = 2',
'>>>>>>> feature',
})
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
conflict.resolve_theirs(bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal(1, #lines)
assert.are.equal('local x = 2', lines[1])
helpers.delete_buffer(bufnr)
end)
end)
describe('navigation', function()
it('goto_next jumps to next conflict', function()
local bufnr = create_file_buffer({
'normal',
'<<<<<<< HEAD',
'a',
'=======',
'b',
'>>>>>>> feat',
'middle',
'<<<<<<< HEAD',
'c',
'=======',
'd',
'>>>>>>> feat',
})
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
conflict.goto_next(bufnr)
assert.are.equal(2, vim.api.nvim_win_get_cursor(0)[1])
conflict.goto_next(bufnr)
assert.are.equal(8, vim.api.nvim_win_get_cursor(0)[1])
helpers.delete_buffer(bufnr)
end)
it('goto_next wraps to first conflict', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'a',
'=======',
'b',
'>>>>>>> feat',
})
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 5, 0 })
conflict.goto_next(bufnr)
assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
helpers.delete_buffer(bufnr)
end)
it('goto_prev jumps to previous conflict', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'a',
'=======',
'b',
'>>>>>>> feat',
'middle',
'<<<<<<< HEAD',
'c',
'=======',
'd',
'>>>>>>> feat',
'end',
})
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 12, 0 })
conflict.goto_prev(bufnr)
assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
conflict.goto_prev(bufnr)
assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
helpers.delete_buffer(bufnr)
end)
it('goto_prev wraps to last conflict', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'a',
'=======',
'b',
'>>>>>>> feat',
})
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
conflict.goto_prev(bufnr)
assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
helpers.delete_buffer(bufnr)
end)
it('goto_next does nothing with no conflicts', function()
local bufnr = create_file_buffer({ 'normal line' })
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
conflict.goto_next(bufnr)
assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
helpers.delete_buffer(bufnr)
end)
end)
describe('lifecycle', function()
it('attach is idempotent', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'a',
'=======',
'b',
'>>>>>>> feat',
})
local cfg = default_config()
conflict.attach(bufnr, cfg)
local count1 = #get_extmarks(bufnr)
conflict.attach(bufnr, cfg)
local count2 = #get_extmarks(bufnr)
assert.are.equal(count1, count2)
conflict.detach(bufnr)
helpers.delete_buffer(bufnr)
end)
it('skips non-file buffers', function()
local bufnr = helpers.create_buffer({
'<<<<<<< HEAD',
'a',
'=======',
'b',
'>>>>>>> feat',
})
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
conflict.attach(bufnr, default_config())
assert.are.equal(0, #get_extmarks(bufnr))
helpers.delete_buffer(bufnr)
end)
it('skips buffers without conflict markers', function()
local bufnr = create_file_buffer({ 'local x = 1', 'local y = 2' })
conflict.attach(bufnr, default_config())
assert.are.equal(0, #get_extmarks(bufnr))
helpers.delete_buffer(bufnr)
end)
it('re-highlights when markers return after resolution', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
vim.api.nvim_set_current_buf(bufnr)
local cfg = default_config()
conflict.attach(bufnr, cfg)
assert.is_true(#get_extmarks(bufnr) > 0)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
conflict.resolve_ours(bufnr, cfg)
assert.are.equal(0, #get_extmarks(bufnr))
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr })
assert.is_true(#get_extmarks(bufnr) > 0)
conflict.detach(bufnr)
helpers.delete_buffer(bufnr)
end)
it('detaches after last conflict resolved', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
vim.api.nvim_set_current_buf(bufnr)
conflict.attach(bufnr, default_config())
assert.is_true(#get_extmarks(bufnr) > 0)
vim.api.nvim_win_set_cursor(0, { 2, 0 })
conflict.resolve_ours(bufnr, default_config())
assert.are.equal(0, #get_extmarks(bufnr))
helpers.delete_buffer(bufnr)
end)
end)
end)

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' }, 'default')
assert.is_nil(result)
end)
it('returns nil for all-deletion hunks', function()
local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'default')
assert.is_nil(result)
end)
it('returns nil for context-only hunks', function()
local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'default')
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',
}, 'default')
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',
}, 'default')
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',
}, 'default')
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',
}, 'default')
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"',
}, 'default')
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',
}, 'default')
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)

View file

@ -2,6 +2,8 @@ local plugin_dir = vim.fn.getcwd()
vim.opt.runtimepath:prepend(plugin_dir)
vim.opt.packpath = {}
vim.cmd('filetype on')
local function ensure_parser(lang)
local ok = pcall(vim.treesitter.language.inspect, lang)
if not ok then
@ -10,6 +12,7 @@ local function ensure_parser(lang)
end
ensure_parser('lua')
ensure_parser('vim')
local M = {}

View file

@ -7,8 +7,10 @@ describe('highlight', function()
before_each(function()
ns = vim.api.nvim_create_namespace('diffs_test')
local normal = vim.api.nvim_get_hl(0, { name = 'Normal' })
local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' })
local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' })
vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 })
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg })
vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg })
end)
@ -35,6 +37,7 @@ describe('highlight', function()
highlights = {
background = false,
gutter = false,
context = { enabled = false, lines = 0 },
treesitter = {
enabled = true,
max_lines = 500,
@ -43,6 +46,11 @@ describe('highlight', function()
enabled = false,
max_lines = 200,
},
intra = {
enabled = false,
algorithm = 'default',
max_lines = 500,
},
},
}
if overrides then
@ -77,7 +85,7 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('applies Normal extmarks to clear diff colors', function()
it('applies DiffsClear extmarks to clear diff colors', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
@ -94,14 +102,46 @@ describe('highlight', function()
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local has_normal = false
local has_clear = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group == 'Normal' then
has_normal = true
if mark[4] and mark[4].hl_group == 'DiffsClear' then
has_clear = true
break
end
end
assert.is_true(has_normal)
assert.is_true(has_clear)
delete_buffer(bufnr)
end)
it('produces treesitter captures on all lines with split parsing', function()
local bufnr = create_buffer({
'@@ -1,3 +1,3 @@',
' local x = 1',
'-local y = 2',
'+local y = 3',
' return x',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '-local y = 2', '+local y = 3', ' return x' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local lines_with_ts = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then
lines_with_ts[mark[2]] = true
end
end
assert.is_true(lines_with_ts[1] ~= nil)
assert.is_true(lines_with_ts[2] ~= nil)
assert.is_true(lines_with_ts[3] ~= nil)
assert.is_true(lines_with_ts[4] ~= nil)
delete_buffer(bufnr)
end)
@ -180,6 +220,40 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('highlights function keyword in header context', function()
local bufnr = create_buffer({
'@@ -5,3 +5,4 @@ function M.setup()',
' local x = 1',
'+local y = 2',
' return x',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
header_context = 'function M.setup()',
header_context_col = 18,
lines = { ' local x = 1', '+local y = 2', ' return x' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local has_keyword_function = false
for _, mark in ipairs(extmarks) do
if mark[2] == 0 and mark[4] and mark[4].hl_group then
local hl = mark[4].hl_group
if hl == '@keyword.function.lua' or hl == '@keyword.lua' then
has_keyword_function = true
break
end
end
end
assert.is_true(has_keyword_function)
delete_buffer(bufnr)
end)
it('does not highlight header when no header_context', function()
local bufnr = create_buffer({
'@@ -10,3 +10,4 @@',
@ -322,7 +396,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_add = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
if mark[4] and mark[4].hl_group == 'DiffsAdd' then
has_diff_add = true
break
end
@ -355,7 +429,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_delete = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then
if mark[4] and mark[4].hl_group == 'DiffsDelete' then
has_diff_delete = true
break
end
@ -388,7 +462,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_line_hl = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].line_hl_group then
if mark[4] and (mark[4].hl_group == 'DiffsAdd' or mark[4].hl_group == 'DiffsDelete') then
has_line_hl = true
break
end
@ -520,7 +594,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_add = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
if mark[4] and mark[4].hl_group == 'DiffsAdd' then
has_diff_add = true
break
end
@ -571,7 +645,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_syntax_hl = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'Normal' then
if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then
has_syntax_hl = true
break
end
@ -605,7 +679,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_syntax_hl = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'Normal' then
if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then
has_syntax_hl = true
break
end
@ -668,7 +742,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_add = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
if mark[4] and mark[4].hl_group == 'DiffsAdd' then
has_diff_add = true
break
end
@ -677,7 +751,7 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('applies Normal blanking for vim fallback hunks', function()
it('applies DiffsClear blanking for vim fallback hunks', function()
local orig_synID = vim.fn.synID
local orig_synIDtrans = vim.fn.synIDtrans
local orig_synIDattr = vim.fn.synIDattr
@ -717,14 +791,565 @@ describe('highlight', function()
vim.fn.synIDattr = orig_synIDattr
local extmarks = get_extmarks(bufnr)
local has_normal = false
local has_clear = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group == 'Normal' then
has_normal = true
if mark[4] and mark[4].hl_group == 'DiffsClear' then
has_clear = true
break
end
end
assert.is_true(has_normal)
assert.is_true(has_clear)
delete_buffer(bufnr)
end)
it('uses hl_group not line_hl_group for line backgrounds', function()
local bufnr = create_buffer({
'@@ -1,2 +1,1 @@',
'-local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { background = true } })
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
assert.is_true(d.hl_eol == true)
assert.is_nil(d.line_hl_group)
end
end
delete_buffer(bufnr)
end)
it('hl_eol background extmarks are multiline so hl_eol takes effect', function()
local bufnr = create_buffer({
'@@ -1,2 +1,1 @@',
'-local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { background = true } })
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
assert.is_true(d.end_row > mark[2])
end
end
delete_buffer(bufnr)
end)
it('number_hl_group does not bleed to adjacent lines', function()
local bufnr = create_buffer({
'@@ -1,3 +1,3 @@',
' local a = 0',
'-local x = 1',
'+local y = 2',
' local b = 3',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local a = 0', '-local x = 1', '+local y = 2', ' local b = 3' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { background = true, gutter = true } })
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.number_hl_group then
local start_row = mark[2]
local end_row = d.end_row or start_row
assert.are.equal(start_row, end_row)
end
end
delete_buffer(bufnr)
end)
it('line bg priority > DiffsClear priority', function()
local bufnr = create_buffer({
'@@ -1,2 +1,1 @@',
'-local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { background = true } })
)
local extmarks = get_extmarks(bufnr)
local clear_priority = nil
local line_bg_priority = nil
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.hl_group == 'DiffsClear' then
clear_priority = d.priority
end
if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
line_bg_priority = d.priority
end
end
assert.is_not_nil(clear_priority)
assert.is_not_nil(line_bg_priority)
assert.is_true(line_bg_priority > clear_priority)
delete_buffer(bufnr)
end)
it('char-level extmarks have higher priority than line bg', function()
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
local bufnr = create_buffer({
'@@ -1,2 +1,2 @@',
'-local x = 1',
'+local x = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local x = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({
highlights = {
background = true,
intra = { enabled = true, algorithm = 'default', max_lines = 500 },
},
})
)
local extmarks = get_extmarks(bufnr)
local line_bg_priority = nil
local char_bg_priority = nil
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
line_bg_priority = d.priority
end
if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then
char_bg_priority = d.priority
end
end
assert.is_not_nil(line_bg_priority)
assert.is_not_nil(char_bg_priority)
assert.is_true(char_bg_priority > line_bg_priority)
delete_buffer(bufnr)
end)
it('creates char-level extmarks for changed characters', function()
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
local bufnr = create_buffer({
'@@ -1,2 +1,2 @@',
'-local x = 1',
'+local x = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local x = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({
highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } },
})
)
local extmarks = get_extmarks(bufnr)
local add_text_marks = {}
local del_text_marks = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.hl_group == 'DiffsAddText' then
table.insert(add_text_marks, mark)
end
if d and d.hl_group == 'DiffsDeleteText' then
table.insert(del_text_marks, mark)
end
end
assert.is_true(#add_text_marks > 0)
assert.is_true(#del_text_marks > 0)
delete_buffer(bufnr)
end)
it('does not create char-level extmarks when intra disabled', function()
local bufnr = create_buffer({
'@@ -1,2 +1,2 @@',
'-local x = 1',
'+local x = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local x = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({
highlights = { intra = { enabled = false, algorithm = 'default', max_lines = 500 } },
})
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local d = mark[4]
assert.is_not_equal('DiffsAddText', d and d.hl_group)
assert.is_not_equal('DiffsDeleteText', d and d.hl_group)
end
delete_buffer(bufnr)
end)
it('does not create char-level extmarks for pure additions', function()
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
local bufnr = create_buffer({
'@@ -1,0 +1,2 @@',
'+local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '+local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({
highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } },
})
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local d = mark[4]
assert.is_not_equal('DiffsAddText', d and d.hl_group)
assert.is_not_equal('DiffsDeleteText', d and d.hl_group)
end
delete_buffer(bufnr)
end)
it('enforces priority order: DiffsClear < syntax < line bg < char bg', function()
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
local bufnr = create_buffer({
'@@ -1,2 +1,2 @@',
'-local x = 1',
'+local x = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local x = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({
highlights = {
background = true,
intra = { enabled = true, algorithm = 'default', max_lines = 500 },
},
})
)
local extmarks = get_extmarks(bufnr)
local priorities = { clear = {}, line_bg = {}, syntax = {}, char_bg = {} }
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d then
if d.hl_group == 'DiffsClear' then
table.insert(priorities.clear, d.priority)
elseif d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete' then
table.insert(priorities.line_bg, d.priority)
elseif d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then
table.insert(priorities.char_bg, d.priority)
elseif d.hl_group and d.hl_group:match('^@.*%.lua$') then
table.insert(priorities.syntax, d.priority)
end
end
end
assert.is_true(#priorities.clear > 0)
assert.is_true(#priorities.line_bg > 0)
assert.is_true(#priorities.syntax > 0)
assert.is_true(#priorities.char_bg > 0)
local max_clear = math.max(unpack(priorities.clear))
local min_line_bg = math.min(unpack(priorities.line_bg))
local min_syntax = math.min(unpack(priorities.syntax))
local min_char_bg = math.min(unpack(priorities.char_bg))
assert.is_true(max_clear < min_syntax)
assert.is_true(min_syntax < min_line_bg)
assert.is_true(min_line_bg < min_char_bg)
delete_buffer(bufnr)
end)
it('context padding produces no extmarks on padding lines', function()
local repo_root = '/tmp/diffs-test-context'
vim.fn.mkdir(repo_root, 'p')
local f = io.open(repo_root .. '/test.lua', 'w')
f:write('local M = {}\n')
f:write('function M.hello()\n')
f:write(' return "hi"\n')
f:write('end\n')
f:write('return M\n')
f:close()
local bufnr = create_buffer({
'@@ -3,1 +3,2 @@',
' return "hi"',
'+"bye"',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' return "hi"', '+"bye"' },
file_old_start = 3,
file_old_count = 1,
file_new_start = 3,
file_new_count = 2,
repo_root = repo_root,
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { context = { enabled = true, lines = 25 } } })
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local row = mark[2]
assert.is_true(row >= 1 and row <= 2)
end
delete_buffer(bufnr)
os.remove(repo_root .. '/test.lua')
vim.fn.delete(repo_root, 'rf')
end)
it('context disabled matches behavior without padding', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
file_new_start = 1,
file_new_count = 2,
repo_root = '/nonexistent',
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { context = { enabled = false, lines = 0 } } })
)
local extmarks = get_extmarks(bufnr)
assert.is_true(#extmarks > 0)
delete_buffer(bufnr)
end)
it('gracefully handles missing file for context padding', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
file_new_start = 1,
file_new_count = 2,
repo_root = '/nonexistent/path',
}
assert.has_no.errors(function()
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { context = { enabled = true, lines = 25 } } })
)
end)
local extmarks = get_extmarks(bufnr)
assert.is_true(#extmarks > 0)
delete_buffer(bufnr)
end)
it('highlights treesitter injections', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+vim.cmd([[ echo 1 ]])',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+vim.cmd([[ echo 1 ]])' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local has_vim_capture = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.vim$') then
has_vim_capture = true
break
end
end
assert.is_true(has_vim_capture)
delete_buffer(bufnr)
end)
it('includes captures from both base and injected languages', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+vim.cmd([[ echo 1 ]])',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+vim.cmd([[ echo 1 ]])' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local has_lua = false
local has_vim = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group then
if mark[4].hl_group:match('^@.*%.lua$') then
has_lua = true
end
if mark[4].hl_group:match('^@.*%.vim$') then
has_vim = true
end
end
end
assert.is_true(has_lua)
assert.is_true(has_vim)
delete_buffer(bufnr)
end)
it('filters @spell and @nospell captures from injections', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+vim.cmd([[ echo 1 ]])',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+vim.cmd([[ echo 1 ]])' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group then
assert.is_falsy(mark[4].hl_group:match('@spell'))
assert.is_falsy(mark[4].hl_group:match('@nospell'))
end
end
delete_buffer(bufnr)
end)
end)
@ -758,6 +1383,7 @@ describe('highlight', function()
highlights = {
background = false,
gutter = false,
context = { enabled = false, lines = 0 },
treesitter = { enabled = true, max_lines = 500 },
vim = { enabled = false, max_lines = 200 },
},
@ -914,13 +1540,14 @@ describe('highlight', function()
highlights = {
background = false,
gutter = false,
context = { enabled = false, lines = 0 },
treesitter = { enabled = true, max_lines = 500 },
vim = { enabled = false, max_lines = 200 },
},
}
end
it('uses priority 200 for code languages', function()
it('uses priority 199 for code languages', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
@ -937,16 +1564,16 @@ describe('highlight', function()
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local has_priority_200 = false
local has_priority_199 = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then
if mark[4].priority == 200 then
has_priority_200 = true
if mark[4].priority == 199 then
has_priority_199 = true
break
end
end
end
assert.is_true(has_priority_200)
assert.is_true(has_priority_199)
delete_buffer(bufnr)
end)
@ -984,7 +1611,7 @@ describe('highlight', function()
end
assert.is_true(#diff_extmark_priorities > 0)
for _, priority in ipairs(diff_extmark_priorities) do
assert.is_true(priority < 200)
assert.is_true(priority < 199)
end
delete_buffer(bufnr)
end)

View file

@ -359,5 +359,146 @@ describe('parser', function()
delete_buffer(file_buf)
delete_buffer(diff_buf)
end)
it('detects filetype from file content shebang without open buffer', function()
local repo_root = '/tmp/diffs-test-shebang'
vim.fn.mkdir(repo_root, 'p')
local file_path = repo_root .. '/build'
local f = io.open(file_path, 'w')
f:write('#!/bin/bash\n')
f:write('set -e\n')
f:write('echo "hello"\n')
f:close()
local diff_buf = create_buffer({
'M build',
'@@ -1,2 +1,3 @@',
' #!/bin/bash',
'+set -e',
' echo "hello"',
})
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
local hunks = parser.parse_buffer(diff_buf)
assert.are.equal(1, #hunks)
assert.are.equal('build', hunks[1].filename)
assert.are.equal('sh', hunks[1].ft)
delete_buffer(diff_buf)
os.remove(file_path)
vim.fn.delete(repo_root, 'rf')
end)
it('detects python from shebang without open buffer', function()
local repo_root = '/tmp/diffs-test-shebang-py'
vim.fn.mkdir(repo_root, 'p')
local file_path = repo_root .. '/deploy'
local f = io.open(file_path, 'w')
f:write('#!/usr/bin/env python3\n')
f:write('import sys\n')
f:write('print("hi")\n')
f:close()
local diff_buf = create_buffer({
'M deploy',
'@@ -1,2 +1,3 @@',
' #!/usr/bin/env python3',
'+import sys',
' print("hi")',
})
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
local hunks = parser.parse_buffer(diff_buf)
assert.are.equal(1, #hunks)
assert.are.equal('deploy', hunks[1].filename)
assert.are.equal('python', hunks[1].ft)
delete_buffer(diff_buf)
os.remove(file_path)
vim.fn.delete(repo_root, 'rf')
end)
it('extracts file line numbers from @@ header', function()
local bufnr = create_buffer({
'M lua/test.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
'+local new = true',
' return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(1, hunks[1].file_old_start)
assert.are.equal(3, hunks[1].file_old_count)
assert.are.equal(1, hunks[1].file_new_start)
assert.are.equal(4, hunks[1].file_new_count)
delete_buffer(bufnr)
end)
it('extracts large line numbers from @@ header', function()
local bufnr = create_buffer({
'M lua/test.lua',
'@@ -100,20 +200,30 @@',
' local M = {}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(100, hunks[1].file_old_start)
assert.are.equal(20, hunks[1].file_old_count)
assert.are.equal(200, hunks[1].file_new_start)
assert.are.equal(30, hunks[1].file_new_count)
delete_buffer(bufnr)
end)
it('defaults count to 1 when omitted in @@ header', function()
local bufnr = create_buffer({
'M lua/test.lua',
'@@ -1 +1 @@',
' local M = {}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(1, hunks[1].file_old_start)
assert.are.equal(1, hunks[1].file_old_count)
assert.are.equal(1, hunks[1].file_new_start)
assert.are.equal(1, hunks[1].file_new_count)
delete_buffer(bufnr)
end)
it('stores repo_root on hunk when available', function()
local bufnr = create_buffer({
'M lua/test.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
'+local new = true',
' return M',
})
vim.api.nvim_buf_set_var(bufnr, 'diffs_repo_root', '/tmp/test-repo')
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('/tmp/test-repo', hunks[1].repo_root)
delete_buffer(bufnr)
end)
it('repo_root is nil when not available', function()
local bufnr = create_buffer({
'M lua/test.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.is_nil(hunks[1].repo_root)
delete_buffer(bufnr)
end)
end)
end)

400
spec/read_buffer_spec.lua Normal file
View file

@ -0,0 +1,400 @@
require('spec.helpers')
local commands = require('diffs.commands')
local diffs = require('diffs')
local git = require('diffs.git')
local saved_git = {}
local saved_systemlist
local test_buffers = {}
local function mock_git(overrides)
overrides = overrides or {}
saved_git.get_file_content = git.get_file_content
saved_git.get_index_content = git.get_index_content
saved_git.get_working_content = git.get_working_content
git.get_file_content = overrides.get_file_content
or function()
return { 'local M = {}', 'return M' }
end
git.get_index_content = overrides.get_index_content
or function()
return { 'local M = {}', 'return M' }
end
git.get_working_content = overrides.get_working_content
or function()
return { 'local M = {}', 'local x = 1', 'return M' }
end
end
local function mock_systemlist(fn)
saved_systemlist = vim.fn.systemlist
vim.fn.systemlist = function(cmd)
local result = fn(cmd)
saved_systemlist({ 'true' })
return result
end
end
local function restore_mocks()
for k, v in pairs(saved_git) do
git[k] = v
end
saved_git = {}
if saved_systemlist then
vim.fn.systemlist = saved_systemlist
saved_systemlist = nil
end
end
---@param name string
---@param vars? table<string, any>
---@return integer
local function create_diffs_buffer(name, vars)
local existing = vim.fn.bufnr(name)
if existing ~= -1 then
vim.api.nvim_buf_delete(existing, { force = true })
end
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_name(bufnr, name)
vars = vars or {}
for k, v in pairs(vars) do
vim.api.nvim_buf_set_var(bufnr, k, v)
end
table.insert(test_buffers, bufnr)
return bufnr
end
local function cleanup_buffers()
for _, bufnr in ipairs(test_buffers) do
if vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end
test_buffers = {}
end
describe('read_buffer', function()
after_each(function()
restore_mocks()
cleanup_buffers()
end)
describe('early returns', function()
it('does nothing on non-diffs:// buffer', function()
local bufnr = vim.api.nvim_create_buf(false, true)
table.insert(test_buffers, bufnr)
assert.has_no.errors(function()
commands.read_buffer(bufnr)
end)
assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
end)
it('does nothing on malformed url without colon separator', function()
local bufnr = create_diffs_buffer('diffs://nocolonseparator')
vim.api.nvim_buf_set_var(bufnr, 'diffs_repo_root', '/tmp')
local lines_before = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.has_no.errors(function()
commands.read_buffer(bufnr)
end)
assert.are.same(lines_before, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
end)
it('does nothing when diffs_repo_root is missing', function()
local bufnr = create_diffs_buffer('diffs://staged:missing_root.lua')
assert.has_no.errors(function()
commands.read_buffer(bufnr)
end)
assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
end)
end)
describe('buffer options', function()
it('sets buftype, bufhidden, swapfile, modifiable, filetype', function()
mock_git()
local bufnr = create_diffs_buffer('diffs://staged:options_test.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.are.equal('nowrite', vim.api.nvim_get_option_value('buftype', { buf = bufnr }))
assert.are.equal('delete', vim.api.nvim_get_option_value('bufhidden', { buf = bufnr }))
assert.is_false(vim.api.nvim_get_option_value('swapfile', { buf = bufnr }))
assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = bufnr }))
assert.are.equal('diff', vim.api.nvim_get_option_value('filetype', { buf = bufnr }))
end)
end)
describe('dispatch', function()
it('calls get_file_content + get_index_content for staged label', function()
local called_get_file = false
local called_get_index = false
mock_git({
get_file_content = function()
called_get_file = true
return { 'old' }
end,
get_index_content = function()
called_get_index = true
return { 'new' }
end,
})
local bufnr = create_diffs_buffer('diffs://staged:dispatch_staged.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.is_true(called_get_file)
assert.is_true(called_get_index)
end)
it('calls get_index_content + get_working_content for unstaged label', function()
local called_get_index = false
local called_get_working = false
mock_git({
get_index_content = function()
called_get_index = true
return { 'index' }
end,
get_working_content = function()
called_get_working = true
return { 'working' }
end,
})
local bufnr = create_diffs_buffer('diffs://unstaged:dispatch_unstaged.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.is_true(called_get_index)
assert.is_true(called_get_working)
end)
it('calls only get_working_content for untracked label', function()
local called_get_file = false
local called_get_working = false
mock_git({
get_file_content = function()
called_get_file = true
return {}
end,
get_working_content = function()
called_get_working = true
return { 'new file' }
end,
})
local bufnr = create_diffs_buffer('diffs://untracked:dispatch_untracked.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.is_false(called_get_file)
assert.is_true(called_get_working)
end)
it('calls get_file_content + get_working_content for revision label', function()
local captured_rev
local called_get_working = false
mock_git({
get_file_content = function(rev)
captured_rev = rev
return { 'old' }
end,
get_working_content = function()
called_get_working = true
return { 'new' }
end,
})
local bufnr = create_diffs_buffer('diffs://HEAD~3:dispatch_rev.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.are.equal('HEAD~3', captured_rev)
assert.is_true(called_get_working)
end)
it('falls back from index to HEAD for unstaged when index returns nil', function()
local call_order = {}
mock_git({
get_index_content = function()
table.insert(call_order, 'index')
return nil
end,
get_file_content = function()
table.insert(call_order, 'head')
return { 'head content' }
end,
get_working_content = function()
return { 'working content' }
end,
})
local bufnr = create_diffs_buffer('diffs://unstaged:dispatch_fallback.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.are.same({ 'index', 'head' }, call_order)
end)
it('runs git diff for section diffs with path=all', function()
local captured_cmd
mock_systemlist(function(cmd)
captured_cmd = cmd
return {
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1 +1 @@',
'-old',
'+new',
}
end)
local bufnr = create_diffs_buffer('diffs://unstaged:all', {
diffs_repo_root = '/home/test/repo',
})
commands.read_buffer(bufnr)
assert.is_not_nil(captured_cmd)
assert.are.equal('git', captured_cmd[1])
assert.are.equal('/home/test/repo', captured_cmd[3])
assert.are.equal('diff', captured_cmd[4])
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal('diff --git a/file.lua b/file.lua', lines[1])
end)
it('passes --cached for staged section diffs', function()
local captured_cmd
mock_systemlist(function(cmd)
captured_cmd = cmd
return { 'diff --git a/f.lua b/f.lua', '@@ -1 +1 @@', '-a', '+b' }
end)
local bufnr = create_diffs_buffer('diffs://staged:all', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.is_truthy(vim.tbl_contains(captured_cmd, '--cached'))
end)
end)
describe('content', function()
it('generates valid unified diff header with correct paths', function()
mock_git({
get_file_content = function()
return { 'old' }
end,
get_working_content = function()
return { 'new' }
end,
})
local bufnr = create_diffs_buffer('diffs://HEAD:lua/diffs/init.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal('diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua', lines[1])
assert.are.equal('--- a/lua/diffs/init.lua', lines[2])
assert.are.equal('+++ b/lua/diffs/init.lua', lines[3])
end)
it('uses old_filepath for diff header in renames', function()
mock_git({
get_file_content = function(_, path)
assert.are.equal('/tmp/old_name.lua', path)
return { 'old content' }
end,
get_index_content = function()
return { 'new content' }
end,
})
local bufnr = create_diffs_buffer('diffs://staged:new_name.lua', {
diffs_repo_root = '/tmp',
diffs_old_filepath = 'old_name.lua',
})
commands.read_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal('diff --git a/old_name.lua b/new_name.lua', lines[1])
assert.are.equal('--- a/old_name.lua', lines[2])
assert.are.equal('+++ b/new_name.lua', lines[3])
end)
it('produces empty buffer when old and new are identical', function()
mock_git({
get_file_content = function()
return { 'identical' }
end,
get_working_content = function()
return { 'identical' }
end,
})
local bufnr = create_diffs_buffer('diffs://HEAD:nodiff.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.same({ '' }, lines)
end)
it('replaces existing buffer content on reload', function()
mock_git({
get_file_content = function()
return { 'old' }
end,
get_working_content = function()
return { 'new' }
end,
})
local bufnr = create_diffs_buffer('diffs://HEAD:replace_test.lua', {
diffs_repo_root = '/tmp',
})
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale', 'content', 'from', 'before' })
commands.read_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal('diff --git a/replace_test.lua b/replace_test.lua', lines[1])
for _, line in ipairs(lines) do
assert.is_not_equal('stale', line)
end
end)
end)
describe('attach integration', function()
it('calls attach on the buffer', function()
mock_git()
local attach_called_with
local original_attach = diffs.attach
diffs.attach = function(bufnr)
attach_called_with = bufnr
end
local bufnr = create_diffs_buffer('diffs://staged:attach_test.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.are.equal(bufnr, attach_called_with)
diffs.attach = original_attach
end)
end)
end)

135
spec/ux_spec.lua Normal file
View file

@ -0,0 +1,135 @@
local commands = require('diffs.commands')
local helpers = require('spec.helpers')
local counter = 0
local function create_diffs_buffer(name)
counter = counter + 1
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = bufnr })
vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr })
vim.api.nvim_buf_set_name(bufnr, name or ('diffs://unstaged:file_' .. counter .. '.lua'))
return bufnr
end
describe('ux', function()
describe('diagnostics', function()
it('disables diagnostics on diff buffers', function()
local bufnr = create_diffs_buffer()
commands.setup_diff_buf(bufnr)
assert.is_false(vim.diagnostic.is_enabled({ bufnr = bufnr }))
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('does not affect other buffers', function()
local diff_buf = create_diffs_buffer()
local normal_buf = helpers.create_buffer({ 'hello' })
commands.setup_diff_buf(diff_buf)
assert.is_true(vim.diagnostic.is_enabled({ bufnr = normal_buf }))
vim.api.nvim_buf_delete(diff_buf, { force = true })
helpers.delete_buffer(normal_buf)
end)
end)
describe('q keymap', function()
it('sets q keymap on diff buffer', function()
local bufnr = create_diffs_buffer()
commands.setup_diff_buf(bufnr)
local keymaps = vim.api.nvim_buf_get_keymap(bufnr, 'n')
local has_q = false
for _, km in ipairs(keymaps) do
if km.lhs == 'q' then
has_q = true
break
end
end
assert.is_true(has_q)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('q closes the window', function()
local bufnr = create_diffs_buffer()
commands.setup_diff_buf(bufnr)
vim.cmd('split')
local win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win, bufnr)
local win_count_before = #vim.api.nvim_tabpage_list_wins(0)
vim.api.nvim_buf_call(bufnr, function()
vim.cmd('normal q')
end)
local win_count_after = #vim.api.nvim_tabpage_list_wins(0)
assert.equals(win_count_before - 1, win_count_after)
end)
end)
describe('window reuse', function()
it('returns nil when no diffs window exists', function()
local win = commands.find_diffs_window()
assert.is_nil(win)
end)
it('finds existing diffs:// window', function()
local bufnr = create_diffs_buffer()
vim.cmd('split')
local expected_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(expected_win, bufnr)
local found = commands.find_diffs_window()
assert.equals(expected_win, found)
vim.api.nvim_win_close(expected_win, true)
end)
it('ignores non-diffs buffers', function()
local normal_buf = helpers.create_buffer({ 'hello' })
vim.cmd('split')
local win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win, normal_buf)
local found = commands.find_diffs_window()
assert.is_nil(found)
vim.api.nvim_win_close(win, true)
helpers.delete_buffer(normal_buf)
end)
it('returns first diffs window when multiple exist', function()
local buf1 = create_diffs_buffer()
local buf2 = create_diffs_buffer()
vim.cmd('split')
local win1 = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win1, buf1)
vim.cmd('split')
local win2 = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win2, buf2)
local found = commands.find_diffs_window()
assert.is_not_nil(found)
assert.is_true(found == win1 or found == win2)
vim.api.nvim_win_close(win1, true)
vim.api.nvim_win_close(win2, true)
end)
end)
end)