Compare commits

..

No commits in common. "doc/merge-conflicts" and "fix/known-limitation-syntax" have entirely different histories.

28 changed files with 212 additions and 6220 deletions

View file

@ -20,9 +20,3 @@ 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", "jit"], "diagnostics.globals": ["vim"],
"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,17 +10,11 @@ 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()`)
- Character-level (intra-line) diff highlighting for changed characters - Configurable debouncing, max lines, and diff prefix concealment
- Inline merge conflict detection, highlighting, and resolution keymaps
- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and
highlight overrides
## Requirements ## Requirements
@ -43,11 +37,12 @@ luarocks install diffs.nvim
## Known Limitations ## Known Limitations
- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation. - **Incomplete syntax context**: Treesitter parses each diff hunk in isolation
To improve accuracy, `diffs.nvim` reads lines from disk before and after each without surrounding code context. When a hunk shows lines added to an existing
hunk for parsing context (`highlights.context`, enabled by default with 25 block (e.g., adding a plugin inside `return { ... }`), the parser doesn't see
lines). This resolves most boundary issues. Set the `return` statement and may produce incorrect highlighting. This is
`highlights.context.enabled = false` to disable. inherent to parsing code fragments—no diff tooling solves this without
significant complexity.
- **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
@ -64,17 +59,10 @@ 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) -
`diffs.nvim` now includes built-in conflict resolution; disable one or the conflict marker highlighting may overlap with `diffs.nvim`
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,15 +12,12 @@ 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*
@ -57,11 +54,6 @@ 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,
@ -70,29 +62,6 @@ 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',
},
}, },
} }
< <
@ -118,14 +87,6 @@ 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)
@ -137,17 +98,6 @@ 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.
@ -156,31 +106,6 @@ 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)
@ -205,26 +130,6 @@ 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.
@ -233,231 +138,6 @@ 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*
@ -489,11 +169,9 @@ 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()|
- `Normal` extmarks at priority 198 clear underlying diff foreground - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 198
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199 - `Normal` extmarks at priority 199 clear underlying diff foreground
- 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
@ -509,15 +187,14 @@ KNOWN LIMITATIONS *diffs-limitations*
Incomplete Syntax Context ~ Incomplete Syntax Context ~
*diffs-syntax-context* *diffs-syntax-context*
Treesitter parses each diff hunk in isolation. To provide surrounding code Treesitter parses each diff hunk in isolation without surrounding code
context, diffs.nvim reads lines from disk before and after each hunk context. When a hunk shows lines added to an existing block (e.g., adding a
(see |diffs.ContextConfig|, enabled by default). This resolves most boundary plugin inside `return { ... }`), the parser doesn't see the `return`
issues where incomplete constructs (e.g., a function definition at the edge statement and may produce incorrect or unusual highlighting.
of a hunk with no body) would confuse the parser.
Set `highlights.context.enabled = false` to disable context padding. In rare This is inherent to parsing code fragments. No diff tooling solves this
cases, context padding may not help if the relevant surrounding code is very problem without significant complexity—the parser simply doesn't have enough
far from the hunk boundaries. information to understand the full syntactic structure.
Syntax Highlighting Flash ~ Syntax Highlighting Flash ~
*diffs-flash* *diffs-flash*
@ -555,9 +232,8 @@ 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 and resolution keymaps. Provides conflict marker highlighting that may overlap with
diffs.nvim now has built-in conflict resolution (see diffs.nvim's highlighting in conflict scenarios.
|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.
@ -565,15 +241,9 @@ diff-related features.
============================================================================== ==============================================================================
HIGHLIGHT GROUPS *diffs-highlights* HIGHLIGHT GROUPS *diffs-highlights*
diffs.nvim defines custom highlight groups. All groups use `default = true`, diffs.nvim defines custom highlight groups. Fugitive unified diff groups use
so colorschemes can override them by defining the group before the plugin `default = true`, so colorschemes can override them. Diff mode groups are
loads. always derived from the corresponding `Diff*` groups.
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*
@ -586,54 +256,11 @@ Fugitive unified diff highlights: ~
*DiffsAddNr* *DiffsAddNr*
DiffsAddNr Line number for `+` lines. Foreground from DiffsAddNr Line number for `+` lines. Foreground from
`DiffsAddText`, background from `DiffsAdd`. `diffAdded`, background from `DiffsAdd`.
*DiffsDeleteNr* *DiffsDeleteNr*
DiffsDeleteNr Line number for `-` lines. Foreground from DiffsDeleteNr Line number for `-` lines. Foreground from
`DiffsDeleteText`, background from `DiffsDelete`. `diffRemoved`, 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.
@ -659,16 +286,6 @@ 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*
@ -678,16 +295,6 @@ 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:

View file

@ -1,393 +0,0 @@
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

View file

@ -1,438 +0,0 @@
---@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

View file

@ -1,70 +0,0 @@
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

View file

@ -1,401 +0,0 @@
---@class diffs.CharSpan
---@field line integer
---@field col_start integer
---@field col_end integer
---@class diffs.IntraChanges
---@field add_spans diffs.CharSpan[]
---@field del_spans diffs.CharSpan[]
---@class diffs.ChangeGroup
---@field del_lines {idx: integer, text: string}[]
---@field add_lines {idx: integer, text: string}[]
---@class diffs.DiffOpts
---@field algorithm? string
---@field linematch? integer
local M = {}
local dbg = require('diffs.log').dbg
---@param hunk_lines string[]
---@return diffs.ChangeGroup[]
function M.extract_change_groups(hunk_lines)
---@type diffs.ChangeGroup[]
local groups = {}
---@type {idx: integer, text: string}[]
local del_buf = {}
---@type {idx: integer, text: string}[]
local add_buf = {}
---@type boolean
local in_del = false
for i, line in ipairs(hunk_lines) do
local prefix = line:sub(1, 1)
if prefix == '-' then
if not in_del and #add_buf > 0 then
if #del_buf > 0 then
table.insert(groups, { del_lines = del_buf, add_lines = add_buf })
end
del_buf = {}
add_buf = {}
end
in_del = true
table.insert(del_buf, { idx = i, text = line:sub(2) })
elseif prefix == '+' then
in_del = false
table.insert(add_buf, { idx = i, text = line:sub(2) })
else
if #del_buf > 0 and #add_buf > 0 then
table.insert(groups, { del_lines = del_buf, add_lines = add_buf })
end
del_buf = {}
add_buf = {}
in_del = false
end
end
if #del_buf > 0 and #add_buf > 0 then
table.insert(groups, { del_lines = del_buf, add_lines = add_buf })
end
return groups
end
---@return diffs.DiffOpts
local function parse_diffopt()
local opts = {}
for _, item in ipairs(vim.split(vim.o.diffopt, ',')) do
local key, val = item:match('^(%w+):(.+)$')
if key == 'algorithm' then
opts.algorithm = val
elseif key == 'linematch' then
opts.linematch = tonumber(val)
end
end
return opts
end
---@param old_text string
---@param new_text string
---@param diff_opts? diffs.DiffOpts
---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[]
local function byte_diff(old_text, new_text, diff_opts)
local vim_opts = { result_type = 'indices' }
if diff_opts then
if diff_opts.algorithm then
vim_opts.algorithm = diff_opts.algorithm
end
if diff_opts.linematch then
vim_opts.linematch = diff_opts.linematch
end
end
local ok, result = pcall(vim.diff, old_text, new_text, vim_opts)
if not ok or not result then
return {}
end
---@type {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[]
local hunks = {}
for _, h in ipairs(result) do
table.insert(hunks, {
old_start = h[1],
old_count = h[2],
new_start = h[3],
new_count = h[4],
})
end
return hunks
end
---@param s string
---@return string[]
local function split_bytes(s)
local bytes = {}
for i = 1, #s do
table.insert(bytes, s:sub(i, i))
end
return bytes
end
---@param old_line string
---@param new_line string
---@param del_idx integer
---@param add_idx integer
---@param diff_opts? diffs.DiffOpts
---@return diffs.CharSpan[], diffs.CharSpan[]
local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts)
---@type diffs.CharSpan[]
local del_spans = {}
---@type diffs.CharSpan[]
local add_spans = {}
local old_bytes = split_bytes(old_line)
local new_bytes = split_bytes(new_line)
local old_text = table.concat(old_bytes, '\n') .. '\n'
local new_text = table.concat(new_bytes, '\n') .. '\n'
local char_opts = diff_opts
if diff_opts and diff_opts.linematch then
char_opts = { algorithm = diff_opts.algorithm }
end
local char_hunks = byte_diff(old_text, new_text, char_opts)
for _, ch in ipairs(char_hunks) do
if ch.old_count > 0 then
table.insert(del_spans, {
line = del_idx,
col_start = ch.old_start,
col_end = ch.old_start + ch.old_count,
})
end
if ch.new_count > 0 then
table.insert(add_spans, {
line = add_idx,
col_start = ch.new_start,
col_end = ch.new_start + ch.new_count,
})
end
end
return del_spans, add_spans
end
---@param group diffs.ChangeGroup
---@param diff_opts? diffs.DiffOpts
---@return diffs.CharSpan[], diffs.CharSpan[]
local function diff_group_native(group, diff_opts)
---@type diffs.CharSpan[]
local all_del = {}
---@type diffs.CharSpan[]
local all_add = {}
local del_count = #group.del_lines
local add_count = #group.add_lines
if del_count == 1 and add_count == 1 then
local ds, as = char_diff_pair(
group.del_lines[1].text,
group.add_lines[1].text,
group.del_lines[1].idx,
group.add_lines[1].idx,
diff_opts
)
vim.list_extend(all_del, ds)
vim.list_extend(all_add, as)
return all_del, all_add
end
local old_texts = {}
for _, l in ipairs(group.del_lines) do
table.insert(old_texts, l.text)
end
local new_texts = {}
for _, l in ipairs(group.add_lines) do
table.insert(new_texts, l.text)
end
local old_block = table.concat(old_texts, '\n') .. '\n'
local new_block = table.concat(new_texts, '\n') .. '\n'
local line_hunks = byte_diff(old_block, new_block, diff_opts)
---@type table<integer, integer>
local old_to_new = {}
for _, lh in ipairs(line_hunks) do
if lh.old_count == lh.new_count then
for k = 0, lh.old_count - 1 do
old_to_new[lh.old_start + k] = lh.new_start + k
end
end
end
for old_i, new_i in pairs(old_to_new) do
if group.del_lines[old_i] and group.add_lines[new_i] then
local ds, as = char_diff_pair(
group.del_lines[old_i].text,
group.add_lines[new_i].text,
group.del_lines[old_i].idx,
group.add_lines[new_i].idx,
diff_opts
)
vim.list_extend(all_del, ds)
vim.list_extend(all_add, as)
end
end
for _, lh in ipairs(line_hunks) do
if lh.old_count ~= lh.new_count then
local pairs_count = math.min(lh.old_count, lh.new_count)
for k = 0, pairs_count - 1 do
local oi = lh.old_start + k
local ni = lh.new_start + k
if group.del_lines[oi] and group.add_lines[ni] then
local ds, as = char_diff_pair(
group.del_lines[oi].text,
group.add_lines[ni].text,
group.del_lines[oi].idx,
group.add_lines[ni].idx,
diff_opts
)
vim.list_extend(all_del, ds)
vim.list_extend(all_add, as)
end
end
end
end
return all_del, all_add
end
---@param group diffs.ChangeGroup
---@param handle table
---@return diffs.CharSpan[], diffs.CharSpan[]
local function diff_group_vscode(group, handle)
---@type diffs.CharSpan[]
local all_del = {}
---@type diffs.CharSpan[]
local all_add = {}
local ffi = require('ffi')
local old_texts = {}
for _, l in ipairs(group.del_lines) do
table.insert(old_texts, l.text)
end
local new_texts = {}
for _, l in ipairs(group.add_lines) do
table.insert(new_texts, l.text)
end
local orig_arr = ffi.new('const char*[?]', #old_texts)
for i, t in ipairs(old_texts) do
orig_arr[i - 1] = t
end
local mod_arr = ffi.new('const char*[?]', #new_texts)
for i, t in ipairs(new_texts) do
mod_arr[i - 1] = t
end
local opts = ffi.new('DiffsDiffOptions', {
ignore_trim_whitespace = false,
max_computation_time_ms = 1000,
compute_moves = false,
extend_to_subwords = false,
})
local result = handle.compute_diff(orig_arr, #old_texts, mod_arr, #new_texts, opts)
if result == nil then
return all_del, all_add
end
for ci = 0, result.changes.count - 1 do
local mapping = result.changes.mappings[ci]
for ii = 0, mapping.inner_change_count - 1 do
local inner = mapping.inner_changes[ii]
local orig_line = inner.original.start_line
if group.del_lines[orig_line] then
table.insert(all_del, {
line = group.del_lines[orig_line].idx,
col_start = inner.original.start_col,
col_end = inner.original.end_col,
})
end
local mod_line = inner.modified.start_line
if group.add_lines[mod_line] then
table.insert(all_add, {
line = group.add_lines[mod_line].idx,
col_start = inner.modified.start_col,
col_end = inner.modified.end_col,
})
end
end
end
handle.free_lines_diff(result)
return all_del, all_add
end
---@param hunk_lines string[]
---@param algorithm? string
---@return diffs.IntraChanges?
function M.compute_intra_hunks(hunk_lines, algorithm)
local groups = M.extract_change_groups(hunk_lines)
if #groups == 0 then
return nil
end
algorithm = algorithm or 'default'
local vscode_handle = nil
if algorithm == 'vscode' then
vscode_handle = require('diffs.lib').load()
if not vscode_handle then
dbg('vscode algorithm requested but library not available, falling back to default')
end
end
---@type diffs.DiffOpts?
local diff_opts = nil
if not vscode_handle then
diff_opts = parse_diffopt()
if diff_opts.algorithm then
dbg('diffopt algorithm: %s', diff_opts.algorithm)
end
if diff_opts.linematch then
dbg('diffopt linematch: %d', diff_opts.linematch)
end
end
---@type diffs.CharSpan[]
local all_add = {}
---@type diffs.CharSpan[]
local all_del = {}
dbg(
'intra: %d change groups, algorithm=%s, vscode=%s',
#groups,
algorithm,
vscode_handle and 'yes' or 'no'
)
for gi, group in ipairs(groups) do
dbg('group %d: %d del lines, %d add lines', gi, #group.del_lines, #group.add_lines)
local ds, as
if vscode_handle then
ds, as = diff_group_vscode(group, vscode_handle)
else
ds, as = diff_group_native(group, diff_opts)
end
dbg('group %d result: %d del spans, %d add spans', gi, #ds, #as)
for _, s in ipairs(ds) do
dbg(' del span: line=%d col=%d..%d', s.line, s.col_start, s.col_end)
end
for _, s in ipairs(as) do
dbg(' add span: line=%d col=%d..%d', s.line, s.col_start, s.col_end)
end
vim.list_extend(all_del, ds)
vim.list_extend(all_add, as)
end
if #all_add == 0 and #all_del == 0 then
return nil
end
return { add_spans = all_add, del_spans = all_del }
end
---@return boolean
function M.has_vscode()
return require('diffs.lib').has_lib()
end
return M

View file

@ -1,218 +0,0 @@
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

View file

@ -1,113 +0,0 @@
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,13 +15,6 @@ 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,39 +1,6 @@
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
@ -41,15 +8,9 @@ local PRIORITY_CHAR_BG = 201
---@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, context_lines) local function highlight_text(bufnr, ns, hunk, col_offset, text, lang)
local parse_text = text local ok, parser_obj = pcall(vim.treesitter.get_string_parser, text, lang)
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
@ -67,26 +28,22 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_l
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, metadata in query:iter_captures(trees[1]:root(), parse_text) do for id, node, _ in query:iter_captures(trees[1]:root(), text) do
local sr, sc, _, ec = node:range() local capture_name = '@' .. query.captures[id]
if sr == 0 then local sr, sc, er, ec = node:range()
local capture_name = '@' .. query.captures[id] .. '.' .. lang
local buf_sr = header_line local buf_sr = header_line + sr
local buf_er = header_line local buf_er = header_line + er
local buf_sc = col_offset + sc local buf_sc = col_offset + sc
local buf_ec = col_offset + ec local buf_ec = col_offset + ec
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { end_col = buf_ec,
end_row = buf_er, hl_group = capture_name,
end_col = buf_ec, priority = 200,
hl_group = capture_name, })
priority = priority, extmark_count = extmark_count + 1
})
extmark_count = extmark_count + 1
end
end end
return extmark_count return extmark_count
@ -98,21 +55,15 @@ 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( local function highlight_treesitter(bufnr, ns, hunk, code_lines)
bufnr, local lang = hunk.lang
ns, if not lang then
code_lines, return 0
lang, end
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
@ -124,50 +75,50 @@ local function highlight_treesitter(
return 0 return 0
end end
local trees = parser_obj:parse(true) local trees = parser_obj:parse()
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
parser_obj:for_each_tree(function(tree, ltree) for id, node, _ in query:iter_captures(trees[1]:root(), code) do
local tree_lang = ltree:lang() local capture_name = '@' .. query.captures[id]
local query = vim.treesitter.query.get(tree_lang, 'highlights') local sr, sc, er, ec = node:range()
if not query then
return
end
for id, node, metadata in query:iter_captures(tree:root(), code) do local buf_sr = hunk.start_line + sr
local capture = query.captures[id] local buf_er = hunk.start_line + er
if capture ~= 'spell' and capture ~= 'nospell' then local buf_sc = sc + 1
local capture_name = '@' .. capture .. '.' .. tree_lang local buf_ec = ec + 1
local sr, sc, er, ec = node:range()
local buf_sr = line_map[sr] pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
if buf_sr then end_row = buf_er,
local buf_er = line_map[er] or buf_sr end_col = buf_ec,
hl_group = capture_name,
local buf_sc = sc + col_offset priority = 200,
local buf_ec = ec + col_offset })
extmark_count = extmark_count + 1
local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100) end
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
@ -217,10 +168,8 @@ 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, covered_lines, leading_offset) local function highlight_vim_syntax(bufnr, ns, hunk, code_lines)
local ft = hunk.ft local ft = hunk.ft
if not ft then if not ft then
return 0 return 0
@ -230,8 +179,6 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_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 })
@ -258,22 +205,15 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_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 adj = span.line - leading_offset local buf_line = hunk.start_line + span.line - 1
if adj >= 1 and adj <= hunk_line_count then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
local buf_line = hunk.start_line + adj - 1 end_col = span.col_end,
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { hl_group = span.hl_name,
end_col = span.col_end, priority = 200,
hl_group = span.hl_name, })
priority = PRIORITY_SYNTAX, extmark_count = extmark_count + 1
})
extmark_count = extmark_count + 1
if covered_lines then
covered_lines[buf_line] = true
end
end
end end
return extmark_count return extmark_count
@ -300,151 +240,24 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
use_vim = false use_vim = false
end end
---@type table<integer, true> local apply_syntax = use_ts or use_vim
local covered_lines = {}
local ctx_cfg = opts.highlights.context ---@type string[]
local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0 local code_lines = {}
local leading = {} if apply_syntax then
local trailing = {} for _, line in ipairs(hunk.lines) do
if (use_ts or use_vim) and context > 0 and hunk.file_new_start and hunk.repo_root then table.insert(code_lines, line:sub(2))
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
---@type string[] extmark_count = highlight_treesitter(bufnr, ns, hunk, code_lines)
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
---@type string[] extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines)
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
if local syntax_applied = extmark_count > 0
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
@ -463,52 +276,24 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
}) })
end end
if line_len > 1 and covered_lines[buf_line] then if opts.highlights.background and is_diff_line then
local extmark_opts = {
line_hl_group = line_hl,
priority = 198,
}
if opts.highlights.gutter then
extmark_opts.number_hl_group = number_hl
end
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, extmark_opts)
end
if line_len > 1 and syntax_applied then
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 = 'DiffsClear', hl_group = 'Normal',
priority = PRIORITY_CLEAR, priority = 199,
}) })
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,50 +6,17 @@
---@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)
@ -102,10 +69,6 @@ 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,
@ -114,28 +77,6 @@ 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',
},
}, },
} }
@ -222,79 +163,18 @@ 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 = blended_add_text, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = add_fg, bg = blended_add })
vim.api.nvim_set_hl( vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { default = true, fg = del_fg, bg = blended_del })
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', { default = true, bg = diff_add.bg }) vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { bg = diff_add.bg })
vim.api.nvim_set_hl( vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { fg = diff_delete.fg, bg = diff_delete.bg })
0, vim.api.nvim_set_hl(0, 'DiffsDiffChange', { bg = diff_change.bg })
'DiffsDiffDelete', vim.api.nvim_set_hl(0, 'DiffsDiffText', { bg = diff_text.bg })
{ 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()
@ -316,21 +196,10 @@ 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 },
@ -348,76 +217,11 @@ 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
@ -434,21 +238,6 @@ 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)
@ -565,16 +354,4 @@ 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

View file

@ -1,214 +0,0 @@
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.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG) vim.notify('[diffs] ' .. string.format(msg, ...), vim.log.levels.DEBUG)
end end
return M return M

View file

@ -6,75 +6,19 @@
---@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 filepath string
---@param n integer
---@return string[]?
local function read_first_lines(filepath, n)
local f = io.open(filepath, 'r')
if not f then
return nil
end
local lines = {}
for _ = 1, n do
local line = f:read('*l')
if not line then
break
end
table.insert(lines, line)
end
f:close()
return #lines > 0 and lines or nil
end
---@param filename string ---@param filename string
---@param repo_root string?
---@return string? ---@return string?
local function get_ft_from_filename(filename, repo_root) local function get_ft_from_filename(filename)
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 }) local ft = vim.filetype.match({ filename = filename })
if ft then if not ft then
dbg('filetype from filename: %s', ft) dbg('no filetype for: %s', filename)
return ft
end end
return ft
if repo_root then
local full_path = vim.fs.joinpath(repo_root, filename)
local contents = read_first_lines(full_path, 10)
if contents then
ft = vim.filetype.match({ filename = filename, contents = contents })
if ft then
dbg('filetype from file content: %s', ft)
return ft
end
end
end
dbg('no filetype for: %s', filename)
return nil
end end
---@param ft string ---@param ft string
@ -93,27 +37,10 @@ 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 = {}
@ -131,24 +58,10 @@ 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 then if hunk_start and #hunk_lines > 0 and (current_lang or current_ft) then
local hunk = { table.insert(hunks, {
filename = current_filename, filename = current_filename,
ft = current_ft, ft = current_ft,
lang = current_lang, lang = current_lang,
@ -156,26 +69,12 @@ 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
@ -183,34 +82,21 @@ 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, repo_root) current_ft = get_ft_from_filename(filename)
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
@ -226,12 +112,8 @@ 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,8 +3,6 @@ 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)
@ -13,29 +11,6 @@ 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,
}) })
@ -49,36 +24,3 @@ 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' })

65
scripts/ci.sh Executable file
View file

@ -0,0 +1,65 @@
#!/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

View file

@ -1,98 +0,0 @@
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)

View file

@ -1,688 +0,0 @@
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)

View file

@ -1,163 +0,0 @@
require('spec.helpers')
local diff = require('diffs.diff')
describe('diff', function()
describe('extract_change_groups', function()
it('returns empty for all context lines', function()
local groups = diff.extract_change_groups({ ' line1', ' line2', ' line3' })
assert.are.equal(0, #groups)
end)
it('returns empty for pure additions', function()
local groups = diff.extract_change_groups({ '+line1', '+line2' })
assert.are.equal(0, #groups)
end)
it('returns empty for pure deletions', function()
local groups = diff.extract_change_groups({ '-line1', '-line2' })
assert.are.equal(0, #groups)
end)
it('extracts single change group', function()
local groups = diff.extract_change_groups({
' context',
'-old line',
'+new line',
' context',
})
assert.are.equal(1, #groups)
assert.are.equal(1, #groups[1].del_lines)
assert.are.equal(1, #groups[1].add_lines)
assert.are.equal('old line', groups[1].del_lines[1].text)
assert.are.equal('new line', groups[1].add_lines[1].text)
end)
it('extracts multiple change groups separated by context', function()
local groups = diff.extract_change_groups({
'-old1',
'+new1',
' context',
'-old2',
'+new2',
})
assert.are.equal(2, #groups)
assert.are.equal('old1', groups[1].del_lines[1].text)
assert.are.equal('new1', groups[1].add_lines[1].text)
assert.are.equal('old2', groups[2].del_lines[1].text)
assert.are.equal('new2', groups[2].add_lines[1].text)
end)
it('tracks correct line indices', function()
local groups = diff.extract_change_groups({
' context',
'-deleted',
'+added',
})
assert.are.equal(2, groups[1].del_lines[1].idx)
assert.are.equal(3, groups[1].add_lines[1].idx)
end)
it('handles multiple del lines followed by multiple add lines', function()
local groups = diff.extract_change_groups({
'-del1',
'-del2',
'+add1',
'+add2',
'+add3',
})
assert.are.equal(1, #groups)
assert.are.equal(2, #groups[1].del_lines)
assert.are.equal(3, #groups[1].add_lines)
end)
end)
describe('compute_intra_hunks', function()
it('returns nil for all-addition hunks', function()
local result = diff.compute_intra_hunks({ '+line1', '+line2' }, 'default')
assert.is_nil(result)
end)
it('returns nil for all-deletion hunks', function()
local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'default')
assert.is_nil(result)
end)
it('returns nil for context-only hunks', function()
local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'default')
assert.is_nil(result)
end)
it('returns spans for single word change', function()
local result = diff.compute_intra_hunks({
'-local x = 1',
'+local x = 2',
}, 'default')
assert.is_not_nil(result)
assert.is_true(#result.del_spans > 0)
assert.is_true(#result.add_spans > 0)
end)
it('identifies correct byte offsets for word change', function()
local result = diff.compute_intra_hunks({
'-local x = 1',
'+local x = 2',
}, 'default')
assert.is_not_nil(result)
assert.are.equal(1, #result.del_spans)
assert.are.equal(1, #result.add_spans)
local del_span = result.del_spans[1]
local add_span = result.add_spans[1]
local del_text = ('local x = 1'):sub(del_span.col_start, del_span.col_end - 1)
local add_text = ('local x = 2'):sub(add_span.col_start, add_span.col_end - 1)
assert.are.equal('1', del_text)
assert.are.equal('2', add_text)
end)
it('handles multiple change groups separated by context', function()
local result = diff.compute_intra_hunks({
'-local a = 1',
'+local a = 2',
' local b = 3',
'-local c = 4',
'+local c = 5',
}, 'default')
assert.is_not_nil(result)
assert.is_true(#result.del_spans >= 2)
assert.is_true(#result.add_spans >= 2)
end)
it('handles uneven line counts (2 old, 1 new)', function()
local result = diff.compute_intra_hunks({
'-line one',
'-line two',
'+line combined',
}, 'default')
assert.is_not_nil(result)
end)
it('handles multi-byte UTF-8 content', function()
local result = diff.compute_intra_hunks({
'-local x = "héllo"',
'+local x = "wörld"',
}, 'default')
assert.is_not_nil(result)
assert.is_true(#result.del_spans > 0)
assert.is_true(#result.add_spans > 0)
end)
it('returns nil when del and add are identical', function()
local result = diff.compute_intra_hunks({
'-local x = 1',
'+local x = 1',
}, 'default')
assert.is_nil(result)
end)
end)
describe('has_vscode', function()
it('returns false in test environment', function()
assert.is_false(diff.has_vscode())
end)
end)
end)

View file

@ -1,480 +0,0 @@
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)

View file

@ -1,54 +0,0 @@
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,8 +2,6 @@ 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
@ -12,7 +10,6 @@ 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,290 +215,5 @@ 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)

View file

@ -1,400 +0,0 @@
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)

View file

@ -1,135 +0,0 @@
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)