Compare commits

...

78 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
9e857d4b29 feat(fugitive): line position tracking for keymaps
When pressing `du`/`dU` from a hunk line in the fugitive status buffer
(after expanding with `=`), the unified diff now opens at the
corresponding line instead of line 1.

Implementation:
- `fugitive.get_hunk_position()` returns @@ header and offset when on a hunk line
- `commands.find_hunk_line()` finds matching @@ header in diff buffer
- `commands.gdiff_file()` accepts optional `hunk_position` and jumps after opening

Also updates @phanen's README credit for the previous two fixes.

Closes #65
2026-02-05 00:27:35 -05:00
Barrett Ruth
a6d4dcff1f
Merge pull request #69 from barrettruth/fix/filetype-from-existing-buffer
fix(parser): detect filetype from existing buffer
2026-02-05 00:16:11 -05:00
c51d625dc6 fix(parser): detect filetype from existing buffer
Files detected via shebang or modeline (e.g., `build` with `#!/bin/bash`)
weren't getting syntax highlighting because `vim.filetype.match()` only
does filename-based detection.

Now `get_ft_from_filename()` first checks if a buffer already exists for
the file and uses its detected filetype. This requires knowing the repo
root to construct the full path, so:

- `parse_buffer()` reads `b:diffs_repo_root` or `b:git_dir` (fugitive)
- `commands.lua` sets `b:diffs_repo_root` on diff buffers it creates

Co-authored-by: phanen <phanen@qq.com>
2026-02-05 00:13:32 -05:00
Barrett Ruth
b4e40e4093
Merge pull request #68 from barrettruth/fix/highlight-unknown-filetypes
fix(parser): emit hunks for files with unknown filetypes
2026-02-04 23:52:13 -05:00
980bedc8a6 fix(parser): emit hunks for files with unknown filetypes
Previously, hunks were discarded entirely if vim.filetype.match()
returned nil. This meant files with unrecognized extensions got no
highlighting at all - not even the basic green/red backgrounds for
added/deleted lines.

Remove the (current_lang or current_ft) condition from flush_hunk()
so all hunks are collected. highlight_hunk() already handles the
case where ft/lang are nil by skipping syntax highlighting but still
applying background colors.

Co-authored-by: phanen <phanen@qq.com>
2026-02-04 23:51:15 -05:00
Barrett Ruth
83f6069d49
Merge pull request #67 from barrettruth/docs/fugitive-keymaps
docs: add fugitive status buffer keymaps documentation
2026-02-04 23:39:47 -05:00
d8332895f3 docs: add fugitive status buffer keymaps documentation
Document the du/dU keymaps for unified diffs in vim-fugitive status
buffers, including behavior by file status and configuration options.
2026-02-04 23:37:39 -05:00
Barrett Ruth
b5ec99fd06
Merge pull request #64 from barrettruth/feat/fugitive-keymaps
feat(fugitive): add unified diff keymaps to status buffer
2026-02-04 23:32:36 -05:00
08e22af113 fix(tests): suppress unused variable warnings for selene 2026-02-04 23:25:39 -05:00
9ed0639005 fix(fugitive): handle renamed files correctly
Parse both old and new filenames from rename lines (R old -> new).
When diffing staged renames, use old filename as base to correctly
show content changes rather than treating the file as entirely new.

Also adds comprehensive tests for filename edge cases:
- Double extensions, hyphens, underscores, dotfiles
- Deep nested paths, complex renames
- Documents known limitation with filenames containing ' -> '
2026-02-04 23:12:30 -05:00
6072dd0156 feat(fugitive): add section header and untracked file support
Section headers (Staged/Unstaged) now show all diffs in that section,
matching fugitive's behavior. Untracked files show as all-added diffs.
Deleted files show as all-removed diffs.

Also handles edge cases:
- Empty new/old content for deleted/new files
- Section header detection returns is_header flag
2026-02-04 22:39:07 -05:00
ce8fe3b89b test(fugitive): add unit tests for line parsing
Tests for get_section_at_line() and get_file_at_line() covering:
- Section detection (staged, unstaged, untracked)
- File parsing (modified, added, deleted, renamed, untracked)
- Hunk line walk-back to parent file
- Files appearing in multiple sections
2026-02-04 22:23:25 -05:00
9289f33639 feat(fugitive): add status buffer keymaps for unified diffs
Adds du/dU keymaps to fugitive's :Git status buffer for opening unified
diffs instead of side-by-side diffs:
- du opens horizontal split (mirrors dd)
- dU opens vertical split (mirrors dv)

Parses status buffer lines to extract filename and detect section
(staged/unstaged/untracked). For staged files, diffs index vs HEAD.
For unstaged files, diffs working tree vs index.

Configurable via vim.g.diffs.fugitive.horizontal/vertical (set to
false to disable).
2026-02-04 22:23:21 -05:00
ea60ab8d01 feat(commands): add gdiff_file for diffing arbitrary paths
Adds gdiff_file() which can diff any file path (not just current buffer)
with support for staged vs unstaged changes:
- staged=true diffs index against HEAD
- staged=false diffs working tree against index
- Falls back to HEAD if file not in index (for untracked comparison)
2026-02-04 22:23:13 -05:00
85080514b6 feat(git): add index and working tree content retrieval
Adds functions for accessing git index content and working tree files:
- get_index_content() retrieves file from staging area via :0:path
- get_working_content() reads file directly from disk
- file_exists_in_index() checks if file is staged
- file_exists_at_revision() checks if file exists at given revision
2026-02-04 22:23:09 -05:00
Barrett Ruth
44995aeb19
Merge pull request #57 from barrettruth/feat/gdiff
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
feat: add :Gdiff command
2026-02-04 19:57:23 -05:00
045a9044b5 feat: add :Gdiff, :Gvdiff, :Ghdiff commands for unified diff view
Compares current buffer against any git revision (default HEAD), opens result
with full diffs.nvim syntax highlighting. Follows fugitive convention:
:Gdiff/:Gvdiff open vertical split, :Ghdiff opens horizontal split.
2026-02-04 19:52:17 -05:00
Barrett Ruth
2ce76e7683
Merge pull request #54 from barrettruth/feat/diff-header-highlight
feat: treesitter highlighting for diff headers
2026-02-04 15:41:56 -05:00
f83fb8b4a7 fix: remove useless ci script 2026-02-04 15:39:27 -05:00
a25f1e9d84 feat: treesitter highlighting for diff headers
Apply treesitter highlighting to diff metadata lines (diff --git, index,
---, +++) using the diff language parser. Header info is attached only
to the first hunk of each file to avoid duplicate highlighting.

Based on PR #52 by @phanen with fixes:
- header_lines now only contains diff metadata, not hunk content
- header info attached only to first hunk per file
- removed arbitrary hunk count restriction
2026-02-04 15:11:35 -05:00
Barrett Ruth
4008df3558
Merge pull request #53 from barrettruth/fix/known-limitation-syntax
docs: add incomplete syntax context as known limitation
2026-02-04 13:17:31 -05:00
da7b76555a docs: add incomplete syntax context as known limitation
Treesitter parses diff hunks in isolation without surrounding code
context, which can cause incorrect highlighting when hunks show partial
blocks (e.g., adding lines inside `return { ... }` without seeing the
`return`). Document this as a known limitation in README and vimdoc.
2026-02-04 13:16:22 -05:00
28 changed files with 6228 additions and 202 deletions

View file

@ -20,3 +20,9 @@ jobs:
- uses: nvim-neorocks/nvim-busted-action@v1 - uses: nvim-neorocks/nvim-busted-action@v1
with: with:
nvim_version: ${{ matrix.nvim }} nvim_version: ${{ matrix.nvim }}
before: |
git clone --depth 1 https://github.com/the-mikedavis/tree-sitter-diff /tmp/ts-diff
cd /tmp/ts-diff && cc -shared -fPIC -o diff.so -I./src src/parser.c
mkdir -p ~/.local/share/nvim/site/parser ~/.local/share/nvim/site/queries/diff
cp diff.so ~/.local/share/nvim/site/parser/
cp queries/*.scm ~/.local/share/nvim/site/queries/diff/

View file

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

View file

@ -10,11 +10,17 @@ syntax highlighting.
## Features ## Features
- Treesitter syntax highlighting in `:Git` diffs and commit views - Treesitter syntax highlighting in `:Git` diffs and commit views
- Diff header highlighting (`diff --git`, `index`, `---`, `+++`)
- `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds - `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds
- `:Gdiff` unified diff against any git revision with syntax highlighting
- Fugitive status buffer keymaps (`du`/`dU`) for unified diffs
- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`) - Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`)
- Vim syntax fallback for languages without a treesitter parser - Vim syntax fallback for languages without a treesitter parser
- Hunk header context highlighting (`@@ ... @@ function foo()`) - 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 ## Requirements
@ -37,6 +43,12 @@ luarocks install diffs.nvim
## Known Limitations ## Known Limitations
- **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 - **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event
triggered by `vim-fugitive`, at which point the buffer is preliminarily triggered by `vim-fugitive`, at which point the buffer is preliminarily
painted. The buffer is then re-painted after `debounce_ms` milliseconds, painted. The buffer is then re-painted after `debounce_ms` milliseconds,
@ -52,10 +64,17 @@ luarocks install diffs.nvim
compatible, but both plugins modifying line highlights may produce compatible, but both plugins modifying line highlights may produce
unexpected results unexpected results
- [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - - [`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 # Acknowledgements
- [`vim-fugitive`](https://github.com/tpope/vim-fugitive) - [`vim-fugitive`](https://github.com/tpope/vim-fugitive)
- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim)
- [`diffview.nvim`](https://github.com/sindrets/diffview.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, treesitter injection support

View file

@ -12,12 +12,15 @@ built-in diff mode.
Features: ~ Features: ~
- Syntax highlighting in |:Git| summary diffs and commit detail views - Syntax highlighting in |:Git| summary diffs and commit detail views
- Diff header highlighting (`diff --git`, `index`, `---`, `+++`)
- Syntax highlighting in |:Gdiffsplit| / |:Gvdiffsplit| side-by-side diffs - Syntax highlighting in |:Gdiffsplit| / |:Gvdiffsplit| side-by-side diffs
- |:Gdiff| command for unified diff against any git revision
- Background-only diff colors for any `&diff` buffer (vimdiff, diffthis, etc.) - Background-only diff colors for any `&diff` buffer (vimdiff, diffthis, etc.)
- Vim syntax fallback for languages without a treesitter parser - Vim syntax fallback for languages without a treesitter parser
- Blended diff background colors that preserve syntax visibility - Blended diff background colors that preserve syntax visibility
- Optional diff prefix (`+`/`-`/` `) concealment - Optional diff prefix (`+`/`-`/` `) concealment
- Gutter (line number) highlighting - Gutter (line number) highlighting
- Inline merge conflict marker detection, highlighting, and resolution
============================================================================== ==============================================================================
REQUIREMENTS *diffs-requirements* REQUIREMENTS *diffs-requirements*
@ -54,6 +57,11 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
highlights = { highlights = {
background = true, background = true,
gutter = true, gutter = true,
blend_alpha = 0.6,
context = {
enabled = true,
lines = 25,
},
treesitter = { treesitter = {
enabled = true, enabled = true,
max_lines = 500, max_lines = 500,
@ -62,6 +70,29 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
enabled = false, enabled = false,
max_lines = 200, 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',
},
}, },
} }
< <
@ -87,6 +118,14 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
Controls which highlight features are enabled. Controls which highlight features are enabled.
See |diffs.Highlights| for fields. See |diffs.Highlights| for fields.
{fugitive} (table, default: see below)
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* *diffs.Highlights*
Highlights table fields: ~ Highlights table fields: ~
{background} (boolean, default: true) {background} (boolean, default: true)
@ -98,6 +137,17 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
Highlight line numbers with matching colors. Highlight line numbers with matching colors.
Only visible if line numbers are enabled. 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} (table, default: see below)
Treesitter highlighting options. Treesitter highlighting options.
See |diffs.TreesitterConfig| for fields. See |diffs.TreesitterConfig| for fields.
@ -106,6 +156,31 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
Vim syntax highlighting options (experimental). Vim syntax highlighting options (experimental).
See |diffs.VimConfig| for fields. 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* *diffs.TreesitterConfig*
Treesitter config fields: ~ Treesitter config fields: ~
{enabled} (boolean, default: true) {enabled} (boolean, default: true)
@ -130,6 +205,26 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
this many lines. Lower than the treesitter default this many lines. Lower than the treesitter default
due to the per-character cost of |synID()|. 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 Note: Header context (e.g., `@@ -10,3 +10,4 @@ func()`) is always
highlighted with treesitter when a parser is available. highlighted with treesitter when a parser is available.
@ -138,6 +233,231 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
or register treesitter parsers for custom filetypes, use or register treesitter parsers for custom filetypes, use
|vim.filetype.add()| and |vim.treesitter.language.register()|. |vim.filetype.add()| and |vim.treesitter.language.register()|.
==============================================================================
COMMANDS *diffs-commands*
:Gdiff [revision] *:Gdiff*
Open a unified diff of the current file against a git revision. Displays
in a horizontal split below the current window.
The diff buffer shows `+`/`-` lines with full syntax highlighting for the
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.
Examples: >vim
:Gdiff " diff against HEAD
:Gdiff main " diff against main branch
:Gdiff HEAD~3 " diff against 3 commits ago
:Gdiff abc123 " diff against specific commit
<
:Gvdiff [revision] *:Gvdiff*
Like |:Gdiff| but opens in a vertical split.
: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*
When inside a vim-fugitive |:Git| status buffer, diffs.nvim provides keymaps
to open unified diffs for files or entire sections.
Keymaps: ~
*diffs-du* *diffs-dU*
du Open unified diff in a horizontal split.
dU Open unified diff in a vertical split.
These keymaps work on:
- File lines (e.g., `M src/foo.lua`) - opens diff for that file
- Section headers (e.g., `Staged (3)`) - opens diff for all files in section
- Hunk/context lines below a file - opens diff for the parent file
Behavior by file status: ~
Status Section Base Current Result ~
M Unstaged index working tree unstaged changes
M Staged HEAD index staged changes
A Staged (empty) index file as all-added
D Staged HEAD (empty) file as all-removed
R Staged HEAD:oldname index:newname content diff
? Untracked (empty) working tree file as all-added
On section headers, the keymap runs `git diff` (or `git diff --cached` for
staged) and displays all changes in that section as a single unified diff.
Untracked section headers show a warning since there is no meaningful diff.
Configuration: ~
*diffs.FugitiveConfig*
>lua
vim.g.diffs = {
fugitive = {
horizontal = 'du', -- keymap for horizontal split, false to disable
vertical = 'dU', -- keymap for vertical split, false to disable
},
}
<
Fields: ~
{horizontal} (string|false, default: 'du')
Keymap for unified diff in horizontal split.
Set to `false` to disable.
{vertical} (string|false, default: 'dU')
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* API *diffs-api*
@ -169,9 +489,11 @@ Summary / commit detail views: ~
- Code is parsed with |vim.treesitter.get_string_parser()| - Code is parsed with |vim.treesitter.get_string_parser()|
- If no treesitter parser and `vim.enabled`: vim syntax fallback via - If no treesitter parser and `vim.enabled`: vim syntax fallback via
scratch buffer and |synID()| scratch buffer and |synID()|
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 198 - `Normal` extmarks at priority 198 clear underlying diff foreground
- `Normal` extmarks at priority 199 clear underlying diff foreground - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199
- Syntax highlights are applied as extmarks at priority 200 - 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 - Conceal extmarks hide diff prefixes when `hide_prefix` is enabled
4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events 4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events
@ -185,6 +507,18 @@ Diff mode views: ~
============================================================================== ==============================================================================
KNOWN LIMITATIONS *diffs-limitations* KNOWN LIMITATIONS *diffs-limitations*
Incomplete Syntax Context ~
*diffs-syntax-context*
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.
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 ~ Syntax Highlighting Flash ~
*diffs-flash* *diffs-flash*
When opening a fugitive buffer, there is an unavoidable visual "flash" where When opening a fugitive buffer, there is an unavoidable visual "flash" where
@ -221,8 +555,9 @@ conflict:
modifying line highlights may produce unexpected results. modifying line highlights may produce unexpected results.
- git-conflict.nvim (akinsho/git-conflict.nvim) - git-conflict.nvim (akinsho/git-conflict.nvim)
Provides conflict marker highlighting that may overlap with Provides conflict marker highlighting and resolution keymaps.
diffs.nvim's highlighting in conflict scenarios. 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 If you experience visual conflicts, try disabling the conflicting plugin's
diff-related features. diff-related features.
@ -230,9 +565,15 @@ diff-related features.
============================================================================== ==============================================================================
HIGHLIGHT GROUPS *diffs-highlights* HIGHLIGHT GROUPS *diffs-highlights*
diffs.nvim defines custom highlight groups. Fugitive unified diff groups use diffs.nvim defines custom highlight groups. All groups use `default = true`,
`default = true`, so colorschemes can override them. Diff mode groups are so colorschemes can override them by defining the group before the plugin
always derived from the corresponding `Diff*` groups. 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: ~ Fugitive unified diff highlights: ~
*DiffsAdd* *DiffsAdd*
@ -245,11 +586,54 @@ Fugitive unified diff highlights: ~
*DiffsAddNr* *DiffsAddNr*
DiffsAddNr Line number for `+` lines. Foreground from DiffsAddNr Line number for `+` lines. Foreground from
`diffAdded`, background from `DiffsAdd`. `DiffsAddText`, background from `DiffsAdd`.
*DiffsDeleteNr* *DiffsDeleteNr*
DiffsDeleteNr Line number for `-` lines. Foreground from 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: ~ Diff mode window highlights: ~
These are used for |winhighlight| remapping in `&diff` windows. These are used for |winhighlight| remapping in `&diff` windows.
@ -275,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, 'DiffsAdd', { bg = '#2e4a3a' })
vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { link = 'DiffDelete' }) 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* HEALTH CHECK *diffs-health*
@ -284,6 +678,16 @@ Run |:checkhealth| diffs to verify your setup.
Checks performed: Checks performed:
- Neovim version >= 0.9.0 - Neovim version >= 0.9.0
- vim-fugitive is installed (optional) - vim-fugitive is installed (optional)
- libvscode_diff shared library is available (optional)
==============================================================================
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,
treesitter injection support
============================================================================== ==============================================================================
vim:tw=78:ts=8:ft=help:norl: vim:tw=78:ts=8:ft=help:norl:

393
lua/diffs/commands.lua Normal file
View file

@ -0,0 +1,393 @@
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?
function M.find_hunk_line(diff_lines, hunk_position)
for i, line in ipairs(diff_lines) do
if line == hunk_position.hunk_header then
return i + hunk_position.offset
end
end
return nil
end
---@param old_lines string[]
---@param new_lines string[]
---@param old_name string
---@param new_name string
---@return string[]
local function generate_unified_diff(old_lines, new_lines, old_name, new_name)
local old_content = table.concat(old_lines, '\n')
local new_content = table.concat(new_lines, '\n')
local diff_fn = vim.text and vim.text.diff or vim.diff
local diff_output = diff_fn(old_content, new_content, {
result_type = 'unified',
ctxlen = 3,
})
if not diff_output or diff_output == '' then
return {}
end
local diff_lines = vim.split(diff_output, '\n', { plain = true })
local result = {
'diff --git a/' .. old_name .. ' b/' .. new_name,
'--- a/' .. old_name,
'+++ b/' .. new_name,
}
for _, line in ipairs(diff_lines) do
table.insert(result, line)
end
return result
end
---@param revision? string
---@param vertical? boolean
function M.gdiff(revision, vertical)
revision = revision or 'HEAD'
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.api.nvim_buf_get_name(bufnr)
if filepath == '' then
vim.notify('[diffs.nvim]: cannot diff unnamed buffer', vim.log.levels.ERROR)
return
end
local rel_path = git.get_relative_path(filepath)
if not rel_path then
vim.notify('[diffs.nvim]: not in a git repository', vim.log.levels.ERROR)
return
end
local old_lines, err = git.get_file_content(revision, filepath)
if not old_lines then
vim.notify('[diffs.nvim]: ' .. (err or 'unknown error'), vim.log.levels.ERROR)
return
end
local new_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local diff_lines = generate_unified_diff(old_lines, new_lines, rel_path, rel_path)
if #diff_lines == 0 then
vim.notify('[diffs.nvim]: no diff against ' .. revision, vim.log.levels.INFO)
return
end
local repo_root = git.get_repo_root(filepath)
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', '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)
if repo_root then
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
end
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()
require('diffs').attach(diff_buf)
end)
end
---@class diffs.GdiffFileOpts
---@field vertical? boolean
---@field staged? boolean
---@field untracked? boolean
---@field old_filepath? string
---@field hunk_position? { hunk_header: string, offset: integer }
---@param filepath string
---@param opts? diffs.GdiffFileOpts
function M.gdiff_file(filepath, opts)
opts = opts or {}
local rel_path = git.get_relative_path(filepath)
if not rel_path then
vim.notify('[diffs.nvim]: not in a git repository', vim.log.levels.ERROR)
return
end
local old_rel_path = opts.old_filepath and git.get_relative_path(opts.old_filepath) or rel_path
local old_lines, new_lines, err
local diff_label
if opts.untracked then
old_lines = {}
new_lines, err = git.get_working_content(filepath)
if not new_lines then
vim.notify('[diffs.nvim]: ' .. (err or 'cannot read file'), vim.log.levels.ERROR)
return
end
diff_label = 'untracked'
elseif opts.staged then
old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath)
if not old_lines then
old_lines = {}
end
new_lines, err = git.get_index_content(filepath)
if not new_lines then
new_lines = {}
end
diff_label = 'staged'
else
old_lines, err = git.get_index_content(opts.old_filepath or filepath)
if not old_lines then
old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath)
if not old_lines then
old_lines = {}
diff_label = 'untracked'
else
diff_label = 'unstaged'
end
else
diff_label = 'unstaged'
end
new_lines, err = git.get_working_content(filepath)
if not new_lines then
new_lines = {}
end
end
local diff_lines = generate_unified_diff(old_lines, new_lines, old_rel_path, rel_path)
if #diff_lines == 0 then
vim.notify('[diffs.nvim]: no changes', vim.log.levels.INFO)
return
end
local repo_root = git.get_repo_root(filepath)
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', '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
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)
if target_line then
vim.api.nvim_win_set_cursor(0, { target_line, 0 })
dbg('jumped to line %d for hunk', target_line)
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()
require('diffs').attach(diff_buf)
end)
end
---@class diffs.GdiffSectionOpts
---@field vertical? boolean
---@field staged? boolean
---@param repo_root string
---@param opts? diffs.GdiffSectionOpts
function M.gdiff_section(repo_root, opts)
opts = opts or {}
local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' }
if opts.staged then
table.insert(cmd, '--cached')
end
local result = vim.fn.systemlist(cmd)
if vim.v.shell_error ~= 0 then
vim.notify('[diffs.nvim]: git diff failed', vim.log.levels.ERROR)
return
end
if #result == 0 then
vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO)
return
end
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', '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)
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()
require('diffs').attach(diff_buf)
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)
end, {
nargs = '?',
desc = 'Show unified diff against git revision (default: HEAD)',
})
vim.api.nvim_create_user_command('Gvdiff', function(opts)
M.gdiff(opts.args ~= '' and opts.args or nil, true)
end, {
nargs = '?',
desc = 'Show unified diff against git revision in vertical split',
})
vim.api.nvim_create_user_command('Ghdiff', function(opts)
M.gdiff(opts.args ~= '' and opts.args or nil, false)
end, {
nargs = '?',
desc = 'Show unified diff against git revision in horizontal split',
})
end
return M

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

218
lua/diffs/fugitive.lua Normal file
View file

@ -0,0 +1,218 @@
local M = {}
local commands = require('diffs.commands')
local git = require('diffs.git')
local dbg = require('diffs.log').dbg
---@alias diffs.FugitiveSection 'staged' | 'unstaged' | 'untracked' | nil
---@param bufnr integer
---@param lnum integer
---@return diffs.FugitiveSection
function M.get_section_at_line(bufnr, lnum)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false)
for i = #lines, 1, -1 do
local line = lines[i]
if line:match('^Staged ') then
return 'staged'
elseif line:match('^Unstaged ') then
return 'unstaged'
elseif line:match('^Untracked ') then
return 'untracked'
end
end
return nil
end
---@param line string
---@return string?, string?
local function parse_file_line(line)
local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$')
if old and new then
return vim.trim(new), vim.trim(old)
end
local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$')
if filename then
return vim.trim(filename), nil
end
return nil, nil
end
---@param line string
---@return diffs.FugitiveSection?
local function parse_section_header(line)
if line:match('^Staged %(%d') then
return 'staged'
elseif line:match('^Unstaged %(%d') then
return 'unstaged'
elseif line:match('^Untracked %(%d') then
return 'untracked'
end
return nil
end
---@param bufnr integer
---@param lnum integer
---@return string?, diffs.FugitiveSection, boolean, string?
function M.get_file_at_line(bufnr, lnum)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local current_line = lines[lnum]
if not current_line then
return nil, nil, false, nil
end
local section_header = parse_section_header(current_line)
if section_header then
return nil, section_header, true, nil
end
local filename, old_filename = parse_file_line(current_line)
if filename then
local section = M.get_section_at_line(bufnr, lnum)
return filename, section, false, old_filename
end
local prefix = current_line:sub(1, 1)
if prefix == '+' or prefix == '-' or prefix == ' ' then
for i = lnum - 1, 1, -1 do
local prev_line = lines[i]
filename, old_filename = parse_file_line(prev_line)
if filename then
local section = M.get_section_at_line(bufnr, i)
return filename, section, false, old_filename
end
if prev_line:match('^%w+ %(') or prev_line == '' then
break
end
end
end
return nil, nil, false, nil
end
---@class diffs.HunkPosition
---@field hunk_header string
---@field offset integer
---@param bufnr integer
---@param lnum integer
---@return diffs.HunkPosition?
function M.get_hunk_position(bufnr, lnum)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false)
local current = lines[lnum]
if not current then
return nil
end
local prefix = current:sub(1, 1)
if prefix ~= '+' and prefix ~= '-' and prefix ~= ' ' then
return nil
end
for i = lnum - 1, 1, -1 do
local line = lines[i]
if line:match('^@@.-@@') then
return {
hunk_header = line,
offset = lnum - i,
}
end
if line:match('^[MADRCU?!]%s') or line:match('^%w+ %(') then
break
end
end
return nil
end
---@param bufnr integer
---@return string?
local function get_repo_root_from_fugitive(bufnr)
local bufname = vim.api.nvim_buf_get_name(bufnr)
local fugitive_path = bufname:match('^fugitive://(.+)///')
if fugitive_path then
return fugitive_path
end
local cwd = vim.fn.getcwd()
local root = git.get_repo_root(cwd .. '/.')
return root
end
---@param vertical boolean
function M.diff_file_under_cursor(vertical)
local bufnr = vim.api.nvim_get_current_buf()
local lnum = vim.api.nvim_win_get_cursor(0)[1]
local filename, section, is_header, old_filename = M.get_file_at_line(bufnr, lnum)
local repo_root = get_repo_root_from_fugitive(bufnr)
if not repo_root then
vim.notify('[diffs.nvim]: could not determine repository root', vim.log.levels.ERROR)
return
end
if is_header then
dbg('diff_section: %s', section or 'unknown')
if section == 'untracked' then
vim.notify('[diffs.nvim]: cannot diff untracked section', vim.log.levels.WARN)
return
end
commands.gdiff_section(repo_root, {
vertical = vertical,
staged = section == 'staged',
})
return
end
if not filename then
vim.notify('[diffs.nvim]: no file under cursor', vim.log.levels.WARN)
return
end
local filepath = repo_root .. '/' .. filename
local old_filepath = old_filename and (repo_root .. '/' .. old_filename) or nil
local hunk_position = M.get_hunk_position(bufnr, lnum)
dbg(
'diff_file_under_cursor: %s (section: %s, old: %s, hunk_offset: %s)',
filename,
section or 'unknown',
old_filename or 'none',
hunk_position and tostring(hunk_position.offset) or 'none'
)
commands.gdiff_file(filepath, {
vertical = vertical,
staged = section == 'staged',
untracked = section == 'untracked',
old_filepath = old_filepath,
hunk_position = hunk_position,
})
end
---@param bufnr integer
---@param config { horizontal: string|false, vertical: string|false }
function M.setup_keymaps(bufnr, config)
if config.horizontal and config.horizontal ~= '' then
vim.keymap.set('n', config.horizontal, function()
M.diff_file_under_cursor(false)
end, { buffer = bufnr, desc = 'Unified diff (horizontal)' })
dbg('set keymap %s for buffer %d', config.horizontal, bufnr)
end
if config.vertical and config.vertical ~= '' then
vim.keymap.set('n', config.vertical, function()
M.diff_file_under_cursor(true)
end, { buffer = bufnr, desc = 'Unified diff (vertical)' })
dbg('set keymap %s for buffer %d', config.vertical, bufnr)
end
end
return M

113
lua/diffs/git.lua Normal file
View file

@ -0,0 +1,113 @@
local M = {}
---@param filepath string
---@return string?
function M.get_repo_root(filepath)
local dir = vim.fn.fnamemodify(filepath, ':h')
local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' })
if vim.v.shell_error ~= 0 then
return nil
end
return result[1]
end
---@param revision string
---@param filepath string
---@return string[]?, string?
function M.get_file_content(revision, filepath)
local repo_root = M.get_repo_root(filepath)
if not repo_root then
return nil, 'not in a git repository'
end
local rel_path = vim.fn.fnamemodify(filepath, ':.')
if vim.startswith(filepath, repo_root) then
rel_path = filepath:sub(#repo_root + 2)
end
local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'show', revision .. ':' .. rel_path })
if vim.v.shell_error ~= 0 then
return nil, 'failed to get file at revision: ' .. revision
end
return result, nil
end
---@param filepath string
---@return string?
function M.get_relative_path(filepath)
local repo_root = M.get_repo_root(filepath)
if not repo_root then
return nil
end
if vim.startswith(filepath, repo_root) then
return filepath:sub(#repo_root + 2)
end
return vim.fn.fnamemodify(filepath, ':.')
end
---@param filepath string
---@return string[]?, string?
function M.get_index_content(filepath)
local repo_root = M.get_repo_root(filepath)
if not repo_root then
return nil, 'not in a git repository'
end
local rel_path = M.get_relative_path(filepath)
if not rel_path then
return nil, 'could not determine relative path'
end
local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'show', ':0:' .. rel_path })
if vim.v.shell_error ~= 0 then
return nil, 'file not in index'
end
return result, nil
end
---@param filepath string
---@return string[]?, string?
function M.get_working_content(filepath)
if vim.fn.filereadable(filepath) ~= 1 then
return nil, 'file not readable'
end
local lines = vim.fn.readfile(filepath)
return lines, nil
end
---@param filepath string
---@return boolean
function M.file_exists_in_index(filepath)
local repo_root = M.get_repo_root(filepath)
if not repo_root then
return false
end
local rel_path = M.get_relative_path(filepath)
if not rel_path then
return false
end
vim.fn.system({ 'git', '-C', repo_root, 'ls-files', '--stage', '--', rel_path })
return vim.v.shell_error == 0
end
---@param revision string
---@param filepath string
---@return boolean
function M.file_exists_at_revision(revision, filepath)
local repo_root = M.get_repo_root(filepath)
if not repo_root then
return false
end
local rel_path = M.get_relative_path(filepath)
if not rel_path then
return false
end
vim.fn.system({ 'git', '-C', repo_root, 'cat-file', '-e', revision .. ':' .. rel_path })
return vim.v.shell_error == 0
end
return M

View file

@ -15,6 +15,13 @@ function M.check()
else else
vim.health.warn('vim-fugitive not detected (required for unified diff highlighting)') vim.health.warn('vim-fugitive not detected (required for unified diff highlighting)')
end 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 end
return M return M

View file

@ -1,6 +1,39 @@
local M = {} local M = {}
local dbg = require('diffs.log').dbg 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 bufnr integer
---@param ns integer ---@param ns integer
@ -8,9 +41,15 @@ local dbg = require('diffs.log').dbg
---@param col_offset integer ---@param col_offset integer
---@param text string ---@param text string
---@param lang string ---@param lang string
---@param context_lines? string[]
---@return integer ---@return integer
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang) local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines)
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, text, lang) 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 if not ok or not parser_obj then
return 0 return 0
end end
@ -28,22 +67,26 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang)
local extmark_count = 0 local extmark_count = 0
local header_line = hunk.start_line - 1 local header_line = hunk.start_line - 1
for id, node, _ in query:iter_captures(trees[1]:root(), text) do for id, node, metadata in query:iter_captures(trees[1]:root(), parse_text) do
local capture_name = '@' .. query.captures[id] local sr, sc, _, ec = node:range()
local sr, sc, er, ec = node:range() if sr == 0 then
local capture_name = '@' .. query.captures[id] .. '.' .. lang
local buf_sr = header_line + sr local buf_sr = header_line
local buf_er = header_line + er local buf_er = header_line
local buf_sc = col_offset + sc local buf_sc = col_offset + sc
local buf_ec = col_offset + ec local buf_ec = col_offset + ec
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX
end_row = buf_er,
end_col = buf_ec, pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
hl_group = capture_name, end_row = buf_er,
priority = 200, end_col = buf_ec,
}) hl_group = capture_name,
extmark_count = extmark_count + 1 priority = priority,
})
extmark_count = extmark_count + 1
end
end end
return extmark_count return extmark_count
@ -55,15 +98,21 @@ end
---@param bufnr integer ---@param bufnr integer
---@param ns integer ---@param ns integer
---@param hunk diffs.Hunk
---@param code_lines string[] ---@param code_lines string[]
---@param lang string
---@param line_map table<integer, integer>
---@param col_offset integer
---@param covered_lines? table<integer, true>
---@return integer ---@return integer
local function highlight_treesitter(bufnr, ns, hunk, code_lines) local function highlight_treesitter(
local lang = hunk.lang bufnr,
if not lang then ns,
return 0 code_lines,
end lang,
line_map,
col_offset,
covered_lines
)
local code = table.concat(code_lines, '\n') local code = table.concat(code_lines, '\n')
if code == '' then if code == '' then
return 0 return 0
@ -75,50 +124,50 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines)
return 0 return 0
end end
local trees = parser_obj:parse() local trees = parser_obj:parse(true)
if not trees or #trees == 0 then if not trees or #trees == 0 then
dbg('parse returned no trees for lang: %s', lang) dbg('parse returned no trees for lang: %s', lang)
return 0 return 0
end 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
local extmark_count = 0 local extmark_count = 0
for id, node, _ in query:iter_captures(trees[1]:root(), code) do parser_obj:for_each_tree(function(tree, ltree)
local capture_name = '@' .. query.captures[id] local tree_lang = ltree:lang()
local sr, sc, er, ec = node:range() local query = vim.treesitter.query.get(tree_lang, 'highlights')
if not query then
return
end
local buf_sr = hunk.start_line + sr for id, node, metadata in query:iter_captures(tree:root(), code) do
local buf_er = hunk.start_line + er local capture = query.captures[id]
local buf_sc = sc + 1 if capture ~= 'spell' and capture ~= 'nospell' then
local buf_ec = ec + 1 local capture_name = '@' .. capture .. '.' .. tree_lang
local sr, sc, er, ec = node:range()
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { local buf_sr = line_map[sr]
end_row = buf_er, if buf_sr then
end_col = buf_ec, local buf_er = line_map[er] or buf_sr
hl_group = capture_name,
priority = 200, local buf_sc = sc + col_offset
}) local buf_ec = ec + col_offset
extmark_count = extmark_count + 1
end 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 return extmark_count
end end
@ -168,8 +217,10 @@ end
---@param ns integer ---@param ns integer
---@param hunk diffs.Hunk ---@param hunk diffs.Hunk
---@param code_lines string[] ---@param code_lines string[]
---@param covered_lines? table<integer, true>
---@param leading_offset? integer
---@return 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 local ft = hunk.ft
if not ft then if not ft then
return 0 return 0
@ -179,6 +230,8 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines)
return 0 return 0
end end
leading_offset = leading_offset or 0
local scratch = vim.api.nvim_create_buf(false, true) local scratch = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(scratch, 0, -1, false, code_lines) vim.api.nvim_buf_set_lines(scratch, 0, -1, false, code_lines)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scratch }) vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scratch })
@ -205,15 +258,22 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines)
vim.api.nvim_buf_delete(scratch, { force = true }) vim.api.nvim_buf_delete(scratch, { force = true })
local hunk_line_count = #hunk.lines
local extmark_count = 0 local extmark_count = 0
for _, span in ipairs(spans) do for _, span in ipairs(spans) do
local buf_line = hunk.start_line + span.line - 1 local adj = span.line - leading_offset
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { if adj >= 1 and adj <= hunk_line_count then
end_col = span.col_end, local buf_line = hunk.start_line + adj - 1
hl_group = span.hl_name, pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
priority = 200, end_col = span.col_end,
}) hl_group = span.hl_name,
extmark_count = extmark_count + 1 priority = PRIORITY_SYNTAX,
})
extmark_count = extmark_count + 1
if covered_lines then
covered_lines[buf_line] = true
end
end
end end
return extmark_count return extmark_count
@ -240,24 +300,151 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
use_vim = false use_vim = false
end end
local apply_syntax = use_ts or use_vim ---@type table<integer, true>
local covered_lines = {}
---@type string[] local ctx_cfg = opts.highlights.context
local code_lines = {} local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0
if apply_syntax then local leading = {}
for _, line in ipairs(hunk.lines) do local trailing = {}
table.insert(code_lines, line:sub(2)) 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 end
local trail_from = hunk.file_new_start + (hunk.file_new_count or 0)
trailing = read_line_range(filepath, trail_from, context)
end end
local extmark_count = 0 local extmark_count = 0
if use_ts then 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 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 end
local syntax_applied = extmark_count > 0 if
hunk.header_start_line
and hunk.header_lines
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, hunk.header_lines, 'diff', header_map, 0)
end
---@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 for i, line in ipairs(hunk.lines) do
local buf_line = hunk.start_line + i - 1 local buf_line = hunk.start_line + i - 1
@ -276,24 +463,52 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
}) })
end end
if opts.highlights.background and is_diff_line then if line_len > 1 and covered_lines[buf_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
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
end_col = line_len, end_col = line_len,
hl_group = 'Normal', hl_group = 'DiffsClear',
priority = 199, priority = PRIORITY_CLEAR,
}) })
end 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 end
dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count)

View file

@ -6,17 +6,50 @@
---@field enabled boolean ---@field enabled boolean
---@field max_lines integer ---@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 ---@class diffs.Highlights
---@field background boolean ---@field background boolean
---@field gutter boolean ---@field gutter boolean
---@field blend_alpha? number
---@field overrides? table<string, table>
---@field context diffs.ContextConfig
---@field treesitter diffs.TreesitterConfig ---@field treesitter diffs.TreesitterConfig
---@field vim diffs.VimConfig ---@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 ---@class diffs.Config
---@field debug boolean ---@field debug boolean
---@field debounce_ms integer ---@field debounce_ms integer
---@field hide_prefix boolean ---@field hide_prefix boolean
---@field highlights diffs.Highlights ---@field highlights diffs.Highlights
---@field fugitive diffs.FugitiveConfig
---@field conflict diffs.ConflictConfig
---@class diffs ---@class diffs
---@field attach fun(bufnr?: integer) ---@field attach fun(bufnr?: integer)
@ -69,6 +102,10 @@ local default_config = {
highlights = { highlights = {
background = true, background = true,
gutter = true, gutter = true,
context = {
enabled = true,
lines = 25,
},
treesitter = { treesitter = {
enabled = true, enabled = true,
max_lines = 500, max_lines = 500,
@ -77,6 +114,28 @@ local default_config = {
enabled = false, enabled = false,
max_lines = 200, 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',
},
}, },
} }
@ -163,18 +222,79 @@ local function compute_highlight_groups()
local blended_add = blend_color(add_bg, bg, 0.4) local blended_add = blend_color(add_bg, bg, 0.4)
local blended_del = blend_color(del_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, 'DiffsAdd', { default = true, bg = blended_add })
vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del }) 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, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add })
vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { default = true, fg = del_fg, bg = blended_del }) 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_change = resolve_hl('DiffChange')
local diff_text = resolve_hl('DiffText') local diff_text = resolve_hl('DiffText')
vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { bg = diff_add.bg }) vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { default = true, bg = diff_add.bg })
vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { fg = diff_delete.fg, bg = diff_delete.bg }) vim.api.nvim_set_hl(
vim.api.nvim_set_hl(0, 'DiffsDiffChange', { bg = diff_change.bg }) 0,
vim.api.nvim_set_hl(0, 'DiffsDiffText', { bg = diff_text.bg }) '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 end
local function init() local function init()
@ -196,10 +316,21 @@ local function init()
vim.validate({ vim.validate({
['highlights.background'] = { opts.highlights.background, 'boolean', true }, ['highlights.background'] = { opts.highlights.background, 'boolean', true },
['highlights.gutter'] = { opts.highlights.gutter, '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.treesitter'] = { opts.highlights.treesitter, 'table', true },
['highlights.vim'] = { opts.highlights.vim, '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 if opts.highlights.treesitter then
vim.validate({ vim.validate({
['highlights.treesitter.enabled'] = { opts.highlights.treesitter.enabled, 'boolean', true }, ['highlights.treesitter.enabled'] = { opts.highlights.treesitter.enabled, 'boolean', true },
@ -217,11 +348,76 @@ local function init()
['highlights.vim.max_lines'] = { opts.highlights.vim.max_lines, 'number', true }, ['highlights.vim.max_lines'] = { opts.highlights.vim.max_lines, 'number', true },
}) })
end 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
vim.validate({
['fugitive.horizontal'] = {
opts.fugitive.horizontal,
function(v)
return v == false or type(v) == 'string'
end,
'string or false',
},
['fugitive.vertical'] = {
opts.fugitive.vertical,
function(v)
return v == false or type(v) == 'string'
end,
'string or false',
},
})
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 end
if opts.debounce_ms and opts.debounce_ms < 0 then if opts.debounce_ms and opts.debounce_ms < 0 then
error('diffs: debounce_ms must be >= 0') error('diffs: debounce_ms must be >= 0')
end 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 if
opts.highlights opts.highlights
and opts.highlights.treesitter and opts.highlights.treesitter
@ -238,6 +434,21 @@ local function init()
then then
error('diffs: highlights.vim.max_lines must be >= 1') error('diffs: highlights.vim.max_lines must be >= 1')
end 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) config = vim.tbl_deep_extend('force', default_config, opts)
log.set_enabled(config.debug) log.set_enabled(config.debug)
@ -354,4 +565,16 @@ function M.detach_diff()
end end
end end
---@return diffs.FugitiveConfig
function M.get_fugitive_config()
init()
return config.fugitive
end
---@return diffs.ConflictConfig
function M.get_conflict_config()
init()
return config.conflict
end
return M 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

@ -13,7 +13,7 @@ function M.dbg(msg, ...)
if not enabled then if not enabled then
return return
end end
vim.notify('[diffs] ' .. string.format(msg, ...), vim.log.levels.DEBUG) vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG)
end end
return M return M

View file

@ -6,19 +6,75 @@
---@field header_context string? ---@field header_context string?
---@field header_context_col integer? ---@field header_context_col integer?
---@field lines string[] ---@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 M = {}
local dbg = require('diffs.log').dbg local dbg = require('diffs.log').dbg
---@param filename string ---@param filepath string
---@return string? ---@param n integer
local function get_ft_from_filename(filename) ---@return string[]?
local ft = vim.filetype.match({ filename = filename }) local function read_first_lines(filepath, n)
if not ft then local f = io.open(filepath, 'r')
dbg('no filetype for: %s', filename) if not f then
return nil
end end
return ft 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 })
if ft and ft ~= '' then
dbg('filetype from existing buffer %d: %s', buf, ft)
return ft
end
end
end
local ft = vim.filetype.match({ filename = filename })
if ft then
dbg('filetype from filename: %s', ft)
return ft
end
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 end
---@param ft string ---@param ft string
@ -37,10 +93,27 @@ local function get_lang_from_ft(ft)
return nil return nil
end end
---@param bufnr integer
---@return string?
local function get_repo_root(bufnr)
local ok, repo_root = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_repo_root')
if ok and repo_root then
return repo_root
end
local ok2, git_dir = pcall(vim.api.nvim_buf_get_var, bufnr, 'git_dir')
if ok2 and git_dir then
return vim.fn.fnamemodify(git_dir, ':h')
end
return nil
end
---@param bufnr integer ---@param bufnr integer
---@return diffs.Hunk[] ---@return diffs.Hunk[]
function M.parse_buffer(bufnr) function M.parse_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local repo_root = get_repo_root(bufnr)
---@type diffs.Hunk[] ---@type diffs.Hunk[]
local hunks = {} local hunks = {}
@ -58,10 +131,24 @@ function M.parse_buffer(bufnr)
local hunk_header_context_col = nil local hunk_header_context_col = nil
---@type string[] ---@type string[]
local hunk_lines = {} local hunk_lines = {}
---@type integer?
local hunk_count = nil
---@type integer?
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() local function flush_hunk()
if hunk_start and #hunk_lines > 0 and (current_lang or current_ft) then if hunk_start and #hunk_lines > 0 then
table.insert(hunks, { local hunk = {
filename = current_filename, filename = current_filename,
ft = current_ft, ft = current_ft,
lang = current_lang, lang = current_lang,
@ -69,12 +156,26 @@ function M.parse_buffer(bufnr)
header_context = hunk_header_context, header_context = hunk_header_context,
header_context_col = hunk_header_context_col, header_context_col = hunk_header_context_col,
lines = hunk_lines, 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
hunk.header_lines = header_lines
end
table.insert(hunks, hunk)
end end
hunk_start = nil hunk_start = nil
hunk_header_context = nil hunk_header_context = nil
hunk_header_context_col = nil hunk_header_context_col = nil
hunk_lines = {} hunk_lines = {}
file_old_start = nil
file_old_count = nil
file_new_start = nil
file_new_count = nil
end end
for i, line in ipairs(lines) do for i, line in ipairs(lines) do
@ -82,21 +183,34 @@ function M.parse_buffer(bufnr)
if filename then if filename then
flush_hunk() flush_hunk()
current_filename = filename current_filename = filename
current_ft = get_ft_from_filename(filename) current_ft = get_ft_from_filename(filename, repo_root)
current_lang = current_ft and get_lang_from_ft(current_ft) or nil current_lang = current_ft and get_lang_from_ft(current_ft) or nil
if current_lang then if current_lang then
dbg('file: %s -> lang: %s', filename, current_lang) dbg('file: %s -> lang: %s', filename, current_lang)
elseif current_ft then elseif current_ft then
dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft) dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft)
end end
hunk_count = 0
header_start = i
header_lines = {}
elseif line:match('^@@.-@@') then elseif line:match('^@@.-@@') then
flush_hunk() flush_hunk()
hunk_start = i 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*)(.*)') local prefix, context = line:match('^(@@.-@@%s*)(.*)')
if context and context ~= '' then if context and context ~= '' then
hunk_header_context = context hunk_header_context = context
hunk_header_context_col = #prefix hunk_header_context_col = #prefix
end end
if hunk_count then
hunk_count = hunk_count + 1
end
elseif hunk_start then elseif hunk_start then
local prefix = line:sub(1, 1) local prefix = line:sub(1, 1)
if prefix == ' ' or prefix == '+' or prefix == '-' then if prefix == ' ' or prefix == '+' or prefix == '-' then
@ -112,8 +226,12 @@ function M.parse_buffer(bufnr)
current_filename = nil current_filename = nil
current_ft = nil current_ft = nil
current_lang = nil current_lang = nil
header_start = nil
end end
end end
if header_start and not hunk_start then
table.insert(header_lines, line)
end
end end
flush_hunk() flush_hunk()

View file

@ -3,6 +3,8 @@ if vim.g.loaded_diffs then
end end
vim.g.loaded_diffs = 1 vim.g.loaded_diffs = 1
require('diffs.commands').setup()
vim.api.nvim_create_autocmd('FileType', { vim.api.nvim_create_autocmd('FileType', {
pattern = { 'fugitive', 'git' }, pattern = { 'fugitive', 'git' },
callback = function(args) callback = function(args)
@ -11,6 +13,29 @@ vim.api.nvim_create_autocmd('FileType', {
return return
end end
diffs.attach(args.buf) diffs.attach(args.buf)
if args.match == 'fugitive' then
local fugitive_config = diffs.get_fugitive_config()
if fugitive_config.horizontal or fugitive_config.vertical then
require('diffs.fugitive').setup_keymaps(args.buf, fugitive_config)
end
end
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, end,
}) })
@ -24,3 +49,36 @@ vim.api.nvim_create_autocmd('OptionSet', {
end end
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' })

View file

@ -1,65 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BOLD='\033[1m'
RESET='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT_DIR"
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
run_job() {
local name=$1
shift
local log="$tmpdir/$name.log"
if "$@" >"$log" 2>&1; then
echo -e "${GREEN}${RESET} $name"
return 0
else
echo -e "${RED}${RESET} $name"
cat "$log"
return 1
fi
}
echo -e "${BOLD}Running CI jobs in parallel...${RESET}"
echo
pids=()
jobs_names=()
run_job "stylua" stylua --check . &
pids+=($!); jobs_names+=("stylua")
run_job "selene" selene --display-style quiet . &
pids+=($!); jobs_names+=("selene")
run_job "prettier" prettier --check . &
pids+=($!); jobs_names+=("prettier")
run_job "busted" env \
LUA_PATH="/usr/share/lua/5.1/?.lua;/usr/share/lua/5.1/?/init.lua;/usr/lib/lua/5.1/?.lua;/usr/lib/lua/5.1/?/init.lua;;" \
LUA_CPATH="/usr/lib/lua/5.1/?.so;;" \
nvim -l /usr/lib/luarocks/rocks-5.1/busted/2.3.0-1/bin/busted --verbose spec/ &
pids+=($!); jobs_names+=("busted")
failed=0
for i in "${!pids[@]}"; do
if ! wait "${pids[$i]}"; then
failed=1
fi
done
echo
if [ "$failed" -eq 0 ]; then
echo -e "${GREEN}${BOLD}All jobs passed.${RESET}"
else
echo -e "${RED}${BOLD}Some jobs failed.${RESET}"
exit 1
fi

98
spec/commands_spec.lua Normal file
View file

@ -0,0 +1,98 @@
require('spec.helpers')
local commands = require('diffs.commands')
describe('commands', function()
describe('setup', function()
it('registers Gdiff, Gvdiff, and Ghdiff commands', function()
commands.setup()
local cmds = vim.api.nvim_get_commands({})
assert.is_not_nil(cmds.Gdiff)
assert.is_not_nil(cmds.Gvdiff)
assert.is_not_nil(cmds.Ghdiff)
end)
end)
describe('unified diff generation', function()
local old_lines = { 'local M = {}', 'return M' }
local new_lines = { 'local M = {}', 'local x = 1', 'return M' }
local diff_fn = vim.text and vim.text.diff or vim.diff
it('generates valid unified diff', function()
local old_content = table.concat(old_lines, '\n')
local new_content = table.concat(new_lines, '\n')
local diff_output = diff_fn(old_content, new_content, {
result_type = 'unified',
ctxlen = 3,
})
assert.is_not_nil(diff_output)
assert.is_true(diff_output:find('@@ ') ~= nil)
assert.is_true(diff_output:find('+local x = 1') ~= nil)
end)
it('returns empty for identical content', function()
local content = table.concat(old_lines, '\n')
local diff_output = diff_fn(content, content, {
result_type = 'unified',
ctxlen = 3,
})
assert.are.equal('', diff_output)
end)
end)
describe('find_hunk_line', function()
it('finds matching @@ header and returns target line', function()
local diff_lines = {
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
'+local new = true',
' return M',
}
local hunk_position = {
hunk_header = '@@ -1,3 +1,4 @@',
offset = 2,
}
local target_line = commands.find_hunk_line(diff_lines, hunk_position)
assert.equals(6, target_line)
end)
it('returns nil when hunk header not found', function()
local diff_lines = {
'diff --git a/file.lua b/file.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
}
local hunk_position = {
hunk_header = '@@ -99,3 +99,4 @@',
offset = 1,
}
local target_line = commands.find_hunk_line(diff_lines, hunk_position)
assert.is_nil(target_line)
end)
it('handles multiple hunks and finds correct one', function()
local diff_lines = {
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
'+local x = 1',
' ',
'@@ -10,3 +11,4 @@',
' function M.foo()',
'+ print("hello")',
' end',
}
local hunk_position = {
hunk_header = '@@ -10,3 +11,4 @@',
offset = 2,
}
local target_line = commands.find_hunk_line(diff_lines, hunk_position)
assert.equals(10, target_line)
end)
end)
end)

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)

480
spec/fugitive_spec.lua Normal file
View file

@ -0,0 +1,480 @@
require('spec.helpers')
local fugitive = require('diffs.fugitive')
describe('fugitive', function()
describe('get_section_at_line', function()
local function create_status_buffer(lines)
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
return buf
end
it('returns staged for lines in Staged section', function()
local buf = create_status_buffer({
'Head: main',
'',
'Staged (2)',
'M file1.lua',
'A file2.lua',
'',
'Unstaged (1)',
'M file3.lua',
})
assert.equals('staged', fugitive.get_section_at_line(buf, 4))
assert.equals('staged', fugitive.get_section_at_line(buf, 5))
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns unstaged for lines in Unstaged section', function()
local buf = create_status_buffer({
'Head: main',
'',
'Staged (1)',
'M file1.lua',
'',
'Unstaged (2)',
'M file2.lua',
'M file3.lua',
})
assert.equals('unstaged', fugitive.get_section_at_line(buf, 7))
assert.equals('unstaged', fugitive.get_section_at_line(buf, 8))
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns untracked for lines in Untracked section', function()
local buf = create_status_buffer({
'Head: main',
'',
'Untracked (2)',
'? newfile.lua',
'? another.lua',
})
assert.equals('untracked', fugitive.get_section_at_line(buf, 4))
assert.equals('untracked', fugitive.get_section_at_line(buf, 5))
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns nil for lines before any section', function()
local buf = create_status_buffer({
'Head: main',
'Push: origin/main',
'',
'Staged (1)',
'M file1.lua',
})
assert.is_nil(fugitive.get_section_at_line(buf, 1))
assert.is_nil(fugitive.get_section_at_line(buf, 2))
vim.api.nvim_buf_delete(buf, { force = true })
end)
end)
describe('get_file_at_line', function()
local function create_status_buffer(lines)
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
return buf
end
it('parses simple modified file', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M src/foo.lua',
})
local filename, section = fugitive.get_file_at_line(buf, 2)
assert.equals('src/foo.lua', filename)
assert.equals('unstaged', section)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('parses added file', function()
local buf = create_status_buffer({
'Staged (1)',
'A newfile.lua',
})
local filename, section = fugitive.get_file_at_line(buf, 2)
assert.equals('newfile.lua', filename)
assert.equals('staged', section)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('parses deleted file', function()
local buf = create_status_buffer({
'Staged (1)',
'D oldfile.lua',
})
local filename, section = fugitive.get_file_at_line(buf, 2)
assert.equals('oldfile.lua', filename)
assert.equals('staged', section)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('parses renamed file and returns both names', function()
local buf = create_status_buffer({
'Staged (1)',
'R oldname.lua -> newname.lua',
})
local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('newname.lua', filename)
assert.equals('staged', section)
assert.is_false(is_header)
assert.equals('oldname.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('parses renamed file with similarity index', function()
local buf = create_status_buffer({
'Staged (1)',
'R100 old.lua -> new.lua',
})
local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('new.lua', filename)
assert.equals('staged', section)
assert.equals('old.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns nil old_filename for non-renames', function()
local buf = create_status_buffer({
'Staged (1)',
'M modified.lua',
})
local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('modified.lua', filename)
assert.equals('staged', section)
assert.is_nil(old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles renamed file with spaces in name', function()
local buf = create_status_buffer({
'Staged (1)',
'R old file.lua -> new file.lua',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('new file.lua', filename)
assert.equals('old file.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles renamed file in subdirectory', function()
local buf = create_status_buffer({
'Staged (1)',
'R src/old.lua -> src/new.lua',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('src/new.lua', filename)
assert.equals('src/old.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles renamed file moved to different directory', function()
local buf = create_status_buffer({
'Staged (1)',
'R old/file.lua -> new/file.lua',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('new/file.lua', filename)
assert.equals('old/file.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('KNOWN LIMITATION: filename containing arrow parsed incorrectly', function()
local buf = create_status_buffer({
'Staged (1)',
'R a -> b.lua -> c.lua',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('b.lua -> c.lua', filename)
assert.equals('a', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles double extensions', function()
local buf = create_status_buffer({
'Staged (1)',
'M test.spec.lua',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('test.spec.lua', filename)
assert.is_nil(old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles hyphenated filenames', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M my-component-test.lua',
})
local filename, section = fugitive.get_file_at_line(buf, 2)
assert.equals('my-component-test.lua', filename)
assert.equals('unstaged', section)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles underscores and numbers', function()
local buf = create_status_buffer({
'Staged (1)',
'A test_file_123.lua',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('test_file_123.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles dotfiles', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M .gitignore',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('.gitignore', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles renamed with complex names', function()
local buf = create_status_buffer({
'Staged (1)',
'R src/old-file.spec.lua -> src/new-file.spec.lua',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('src/new-file.spec.lua', filename)
assert.equals('src/old-file.spec.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles deeply nested paths', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M lua/diffs/ui/components/diff-view.lua',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('lua/diffs/ui/components/diff-view.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('parses untracked file', function()
local buf = create_status_buffer({
'Untracked (1)',
'? untracked.lua',
})
local filename, section = fugitive.get_file_at_line(buf, 2)
assert.equals('untracked.lua', filename)
assert.equals('untracked', section)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns nil for section header', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M file.lua',
})
local filename = fugitive.get_file_at_line(buf, 1)
assert.is_nil(filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('walks back from hunk line to find file', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M file.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
'+local new = true',
' return M',
})
local filename, section = fugitive.get_file_at_line(buf, 5)
assert.equals('file.lua', filename)
assert.equals('unstaged', section)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles file with both staged and unstaged indicator', function()
local buf = create_status_buffer({
'Staged (1)',
'M both.lua',
'',
'Unstaged (1)',
'M both.lua',
})
local filename1, section1 = fugitive.get_file_at_line(buf, 2)
assert.equals('both.lua', filename1)
assert.equals('staged', section1)
local filename2, section2 = fugitive.get_file_at_line(buf, 5)
assert.equals('both.lua', filename2)
assert.equals('unstaged', section2)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('detects section header for Staged', function()
local buf = create_status_buffer({
'Head: main',
'',
'Staged (2)',
'M file1.lua',
})
local filename, section, is_header = fugitive.get_file_at_line(buf, 3)
assert.is_nil(filename)
assert.equals('staged', section)
assert.is_true(is_header)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('detects section header for Unstaged', function()
local buf = create_status_buffer({
'Unstaged (3)',
'M file1.lua',
})
local filename, section, is_header = fugitive.get_file_at_line(buf, 1)
assert.is_nil(filename)
assert.equals('unstaged', section)
assert.is_true(is_header)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('detects section header for Untracked', function()
local buf = create_status_buffer({
'Untracked (1)',
'? newfile.lua',
})
local filename, section, is_header = fugitive.get_file_at_line(buf, 1)
assert.is_nil(filename)
assert.equals('untracked', section)
assert.is_true(is_header)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns is_header=false for file lines', function()
local buf = create_status_buffer({
'Staged (1)',
'M file.lua',
})
local filename, section, is_header = fugitive.get_file_at_line(buf, 2)
assert.equals('file.lua', filename)
assert.equals('staged', section)
assert.is_false(is_header)
vim.api.nvim_buf_delete(buf, { force = true })
end)
end)
describe('get_hunk_position', function()
local function create_status_buffer(lines)
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
return buf
end
it('returns nil when on file header line', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M file.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
'+local new = true',
})
local pos = fugitive.get_hunk_position(buf, 2)
assert.is_nil(pos)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns nil when on @@ header line', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M file.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
})
local pos = fugitive.get_hunk_position(buf, 3)
assert.is_nil(pos)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns hunk header and offset for + line', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M file.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
'+local new = true',
' return M',
})
local pos = fugitive.get_hunk_position(buf, 5)
assert.is_not_nil(pos)
assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header)
assert.equals(2, pos.offset)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns hunk header and offset for - line', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M file.lua',
'@@ -1,3 +1,3 @@',
' local M = {}',
'-local old = false',
' return M',
})
local pos = fugitive.get_hunk_position(buf, 5)
assert.is_not_nil(pos)
assert.equals('@@ -1,3 +1,3 @@', pos.hunk_header)
assert.equals(2, pos.offset)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns hunk header and offset for context line', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M file.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
'+local new = true',
' return M',
})
local pos = fugitive.get_hunk_position(buf, 6)
assert.is_not_nil(pos)
assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header)
assert.equals(3, pos.offset)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns correct offset for first line after @@', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M file.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
})
local pos = fugitive.get_hunk_position(buf, 4)
assert.is_not_nil(pos)
assert.equals(1, pos.offset)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles @@ header with context text', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M file.lua',
'@@ -10,3 +10,4 @@ function M.hello()',
' print("hi")',
'+ print("world")',
})
local pos = fugitive.get_hunk_position(buf, 5)
assert.is_not_nil(pos)
assert.equals('@@ -10,3 +10,4 @@ function M.hello()', pos.hunk_header)
assert.equals(2, pos.offset)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns nil when section header interrupts search', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M file.lua',
' some orphan line',
})
local pos = fugitive.get_hunk_position(buf, 3)
assert.is_nil(pos)
vim.api.nvim_buf_delete(buf, { force = true })
end)
end)
end)

54
spec/git_spec.lua Normal file
View file

@ -0,0 +1,54 @@
require('spec.helpers')
local git = require('diffs.git')
describe('git', function()
describe('get_repo_root', function()
it('returns repo root for current repo', function()
local cwd = vim.fn.getcwd()
local root = git.get_repo_root(cwd .. '/lua/diffs/init.lua')
assert.is_not_nil(root)
assert.are.equal(cwd, root)
end)
it('returns nil for non-git directory', function()
local root = git.get_repo_root('/tmp')
assert.is_nil(root)
end)
end)
describe('get_file_content', function()
it('returns file content at HEAD', function()
local cwd = vim.fn.getcwd()
local content, err = git.get_file_content('HEAD', cwd .. '/lua/diffs/init.lua')
assert.is_nil(err)
assert.is_not_nil(content)
assert.is_true(#content > 0)
end)
it('returns error for non-existent file', function()
local cwd = vim.fn.getcwd()
local content, err = git.get_file_content('HEAD', cwd .. '/does_not_exist.lua')
assert.is_nil(content)
assert.is_not_nil(err)
end)
it('returns error for non-git directory', function()
local content, err = git.get_file_content('HEAD', '/tmp/some_file.txt')
assert.is_nil(content)
assert.is_not_nil(err)
end)
end)
describe('get_relative_path', function()
it('returns relative path within repo', function()
local cwd = vim.fn.getcwd()
local rel = git.get_relative_path(cwd .. '/lua/diffs/init.lua')
assert.are.equal('lua/diffs/init.lua', rel)
end)
it('returns nil for non-git directory', function()
local rel = git.get_relative_path('/tmp/some_file.txt')
assert.is_nil(rel)
end)
end)
end)

View file

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

File diff suppressed because it is too large Load diff

View file

@ -215,5 +215,290 @@ describe('parser', function()
assert.are.equal(1, #hunks[2].lines) assert.are.equal(1, #hunks[2].lines)
delete_buffer(bufnr) delete_buffer(bufnr)
end) end)
it('attaches header_lines to first hunk only', function()
local bufnr = create_buffer({
'diff --git a/parser.lua b/parser.lua',
'index 3e8afa0..018159c 100644',
'--- a/parser.lua',
'+++ b/parser.lua',
'@@ -1,2 +1,3 @@',
' local M = {}',
'+local x = 1',
'@@ -10,2 +11,3 @@',
' function M.foo()',
'+ return true',
' end',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(2, #hunks)
assert.is_not_nil(hunks[1].header_start_line)
assert.is_not_nil(hunks[1].header_lines)
assert.are.equal(1, hunks[1].header_start_line)
assert.is_nil(hunks[2].header_start_line)
assert.is_nil(hunks[2].header_lines)
delete_buffer(bufnr)
end)
it('header_lines contains only diff metadata, not hunk content', function()
local bufnr = create_buffer({
'diff --git a/parser.lua b/parser.lua',
'index 3e8afa0..018159c 100644',
'--- a/parser.lua',
'+++ b/parser.lua',
'@@ -1,2 +1,3 @@',
' local M = {}',
'+local x = 1',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(4, #hunks[1].header_lines)
assert.are.equal('diff --git a/parser.lua b/parser.lua', hunks[1].header_lines[1])
assert.are.equal('index 3e8afa0..018159c 100644', hunks[1].header_lines[2])
assert.are.equal('--- a/parser.lua', hunks[1].header_lines[3])
assert.are.equal('+++ b/parser.lua', hunks[1].header_lines[4])
delete_buffer(bufnr)
end)
it('handles fugitive status format with diff headers', function()
local bufnr = create_buffer({
'Head: main',
'Push: origin/main',
'',
'Unstaged (1)',
'M parser.lua',
'diff --git a/parser.lua b/parser.lua',
'index 3e8afa0..018159c 100644',
'--- a/parser.lua',
'+++ b/parser.lua',
'@@ -1,2 +1,3 @@',
' local M = {}',
'+local x = 1',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(6, hunks[1].header_start_line)
assert.are.equal(4, #hunks[1].header_lines)
assert.are.equal('diff --git a/parser.lua b/parser.lua', hunks[1].header_lines[1])
delete_buffer(bufnr)
end)
it('emits hunk for files with unknown filetype', function()
local bufnr = create_buffer({
'M config.obscuretype',
'@@ -1,2 +1,3 @@',
' setting1 = value1',
'-setting2 = value2',
'+setting2 = MODIFIED',
'+setting4 = newvalue',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('config.obscuretype', hunks[1].filename)
assert.is_nil(hunks[1].ft)
assert.is_nil(hunks[1].lang)
assert.are.equal(4, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('uses filetype from existing buffer when available', function()
local repo_root = '/tmp/test-repo'
local file_path = repo_root .. '/build'
local file_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_name(file_buf, file_path)
vim.api.nvim_set_option_value('filetype', 'bash', { buf = file_buf })
local diff_buf = create_buffer({
'M build',
'@@ -1,2 +1,3 @@',
' echo "hello"',
'+set -e',
' echo "done"',
})
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('bash', hunks[1].ft)
delete_buffer(file_buf)
delete_buffer(diff_buf)
end)
it('uses filetype from existing buffer via git_dir', function()
local git_dir = '/tmp/test-repo/.git'
local repo_root = '/tmp/test-repo'
local file_path = repo_root .. '/script'
local file_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_name(file_buf, file_path)
vim.api.nvim_set_option_value('filetype', 'python', { buf = file_buf })
local diff_buf = create_buffer({
'M script',
'@@ -1,2 +1,3 @@',
' def main():',
'+ print("hi")',
' pass',
})
vim.api.nvim_buf_set_var(diff_buf, 'git_dir', git_dir)
local hunks = parser.parse_buffer(diff_buf)
assert.are.equal(1, #hunks)
assert.are.equal('script', hunks[1].filename)
assert.are.equal('python', hunks[1].ft)
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)
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)