diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml
index 77049fd..c0b7770 100644
--- a/.github/workflows/quality.yaml
+++ b/.github/workflows/quality.yaml
@@ -25,6 +25,7 @@ jobs:
- '*.lua'
- '.luarc.json'
- '*.toml'
+ - 'vim.yaml'
markdown:
- '*.md'
@@ -35,11 +36,8 @@ jobs:
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- - uses: JohnnyMorganz/stylua-action@v4
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- version: 2.1.0
- args: --check .
+ - uses: cachix/install-nix-action@v31
+ - run: nix develop --command stylua --check .
lua-lint:
name: Lua Lint Check
@@ -48,11 +46,8 @@ jobs:
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- - name: Lint with Selene
- uses: NTBBloodbath/selene-action@v1.0.0
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- args: --display-style quiet .
+ - uses: cachix/install-nix-action@v31
+ - run: nix develop --command selene --display-style quiet .
lua-typecheck:
name: Lua Type Check
@@ -75,15 +70,5 @@ jobs:
if: ${{ needs.changes.outputs.markdown == 'true' }}
steps:
- uses: actions/checkout@v4
- - name: Setup pnpm
- uses: pnpm/action-setup@v4
- with:
- version: 8
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20'
- - name: Install prettier
- run: pnpm add -g prettier@3.1.0
- - name: Check markdown formatting with prettier
- run: prettier --check .
+ - uses: cachix/install-nix-action@v31
+ - run: nix develop --command prettier --check .
diff --git a/.gitignore b/.gitignore
index d14787c..b7a91ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,10 @@ doc/tags
CLAUDE.md
.claude/
+bench/
node_modules/
+
+result
+result-*
+.direnv/
+.envrc
diff --git a/.luarc.json b/.luarc.json
index b438cce..bfbf500 100644
--- a/.luarc.json
+++ b/.luarc.json
@@ -1,8 +1,15 @@
{
- "runtime.version": "Lua 5.1",
+ "runtime.version": "LuaJIT",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim", "jit"],
- "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
+ "workspace.library": [
+ "$VIMRUNTIME/lua",
+ "${3rd}/luv/library",
+ "${3rd}/busted/library",
+ "${3rd}/luassert/library"
+ ],
"workspace.checkThirdParty": false,
+ "diagnostics.libraryFiles": "Disable",
+ "workspace.ignoreDir": [".direnv"],
"completion.callSnippet": "Replace"
}
diff --git a/.styluaignore b/.styluaignore
new file mode 100644
index 0000000..9b42106
--- /dev/null
+++ b/.styluaignore
@@ -0,0 +1 @@
+.direnv/
diff --git a/README.md b/README.md
index f0a04a8..eee728d 100644
--- a/README.md
+++ b/README.md
@@ -2,25 +2,24 @@
**Syntax highlighting for diffs in Neovim**
-Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware
-syntax highlighting.
+Enhance [vim-fugitive](https://github.com/tpope/vim-fugitive),
+[Neogit](https://github.com/NeogitOrg/neogit), and Neovim's built-in diff mode
+with language-aware syntax highlighting.
-
+
## Features
-- Treesitter syntax highlighting in `:Git` diffs and commit views
-- Diff header highlighting (`diff --git`, `index`, `---`, `+++`)
-- `: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`)
-- Vim syntax fallback for languages without a treesitter parser
-- Hunk header context highlighting (`@@ ... @@ function foo()`)
-- Character-level (intra-line) diff highlighting for changed characters
-- Inline merge conflict detection, highlighting, and resolution keymaps
-- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and
- highlight overrides
+- Treesitter syntax highlighting in vim-fugitive, Neogit, and `diff` filetype
+- Character-level intra-line diff highlighting (with optional
+ [vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for
+ word-level accuracy)
+- `:Gdiff` unified diff against any revision
+- Background-only diff colors for `&diff` buffers
+- Inline merge conflict detection, highlighting, and resolution
+- Email-quoted diff highlighting (`> diff ...` prefixes, arbitrary nesting
+ depth)
+- Vim syntax fallback, configurable blend/priorities
## Requirements
@@ -41,18 +40,58 @@ luarocks install diffs.nvim
:help diffs.nvim
```
+## FAQ
+
+**Q: How do I install with lazy.nvim?**
+
+```lua
+{
+ 'barrettruth/diffs.nvim',
+ init = function()
+ vim.g.diffs = {
+ ...
+ }
+ end,
+}
+```
+
+Do not lazy load `diffs.nvim` with `event`, `lazy`, `ft`, `config`, or `keys` to
+control loading - `diffs.nvim` lazy-loads itself.
+
+**Q: Does diffs.nvim support vim-fugitive/Neogit?**
+
+Yes. Enable it in your config:
+
+```lua
+vim.g.diffs = {
+ fugitive = true,
+ neogit = true,
+}
+```
+
+See the documentation for more information.
+
## Known Limitations
- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation.
- To improve accuracy, `diffs.nvim` reads lines from disk before and after each
- hunk for parsing context (`highlights.context`, enabled by default with 25
- lines). This resolves most boundary issues. Set
- `highlights.context.enabled = false` to disable.
+ Context lines within the hunk provide syntactic context for the parser. In
+ rare cases, hunks that start or end mid-expression may produce imperfect
+ highlights due to treesitter error recovery.
-- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event
+- **Syntax "flashing"**: `diffs.nvim` hooks into the `FileType fugitive` event
triggered by `vim-fugitive`, at which point the buffer is preliminarily
- painted. The buffer is then re-painted after `debounce_ms` milliseconds,
- causing an unavoidable visual "flash" even when `debounce_ms = 0`.
+ painted. The decoration provider applies highlights on the next redraw cycle,
+ causing a brief visual "flash".
+
+- **Cold Start**: Treesitter grammar loading (~10ms) and query compilation
+ (~4ms) are one-time costs per language per Neovim session. Each language pays
+ this cost on first encounter, which may cause a brief stutter when a diff
+ containing a new language first enters the viewport.
+
+- **Vim syntax fallback is deferred**: The vim syntax fallback (for languages
+ without a treesitter parser) cannot run inside the decoration provider's
+ redraw cycle due to Neovim's restriction on buffer mutations. Vim syntax
+ highlights for these hunks appear slightly delayed.
- **Conflicting diff plugins**: `diffs.nvim` may not interact well with other
plugins that modify diff highlighting. Known plugins that may conflict:
@@ -70,11 +109,14 @@ luarocks install diffs.nvim
# Acknowledgements
- [`vim-fugitive`](https://github.com/tpope/vim-fugitive)
-- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim)
+- [@esmuellert](https://github.com/esmuellert) /
+ [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - vscode-diff
+ algorithm FFI backend for word-level intra-line accuracy
- [`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
+ filetype fix, shebang/modeline detection, treesitter injection support,
+ decoration provider highlighting architecture
diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt
index 028d8ba..3bb5a09 100644
--- a/doc/diffs.nvim.txt
+++ b/doc/diffs.nvim.txt
@@ -7,8 +7,8 @@ License: MIT
INTRODUCTION *diffs.nvim*
diffs.nvim adds syntax highlighting to diff views. It overlays language-aware
-highlights on top of default diff highlighting in vim-fugitive and Neovim's
-built-in diff mode.
+highlights on top of default diff highlighting in vim-fugitive, Neogit, and
+Neovim's built-in diff mode.
Features: ~
- Syntax highlighting in |:Git| summary diffs and commit detail views
@@ -22,6 +22,26 @@ Features: ~
- Gutter (line number) highlighting
- Inline merge conflict marker detection, highlighting, and resolution
+==============================================================================
+CONTENTS *diffs-contents*
+
+ 1. Introduction ............................................... |diffs.nvim|
+ 2. Requirements ....................................... |diffs-requirements|
+ 3. Setup ..................................................... |diffs-setup|
+ 4. Configuration ............................................ |diffs-config|
+ 5. Commands ............................................... |diffs-commands|
+ 6. Mappings ............................................... |diffs-mappings|
+ 7. Fugitive Status Keymaps ................................ |diffs-fugitive|
+ 8. Conflict Resolution .................................... |diffs-conflict|
+ 9. Merge Diff Resolution ..................................... |diffs-merge|
+ 10. Neogit ................................................... |diffs-neogit|
+ 11. API ......................................................... |diffs-api|
+ 12. Implementation ................................... |diffs-implementation|
+ 13. Known Limitations ................................... |diffs-limitations|
+ 14. Highlight Groups ..................................... |diffs-highlights|
+ 15. Health Check ............................................. |diffs-health|
+ 16. Acknowledgements ............................... |diffs-acknowledgements|
+
==============================================================================
REQUIREMENTS *diffs-requirements*
@@ -36,7 +56,7 @@ etc.) works without vim-fugitive.
==============================================================================
SETUP *diffs-setup*
-Using lazy.nvim: >lua
+Install with lazy.nvim: >lua
{
'barrettruth/diffs.nvim',
dependencies = { 'tpope/vim-fugitive' },
@@ -52,8 +72,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
>lua
vim.g.diffs = {
debug = false,
- debounce_ms = 0,
hide_prefix = false,
+ fugitive = false,
+ neogit = false,
+ extra_filetypes = {},
highlights = {
background = true,
gutter = true,
@@ -75,23 +97,26 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
algorithm = 'default',
max_lines = 500,
},
+ priorities = {
+ clear = 198,
+ syntax = 199,
+ line_bg = 200,
+ char_bg = 201,
+ },
overrides = {},
},
- fugitive = {
- horizontal = 'du',
- vertical = 'dU',
- },
conflict = {
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
+ show_actions = false,
keymaps = {
ours = 'doo',
theirs = 'dot',
both = 'dob',
none = 'don',
- next = ']x',
- prev = '[x',
+ next = ']c',
+ prev = '[c',
},
},
}
@@ -102,11 +127,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
Enable debug logging to |:messages| with
`[diffs]` prefix.
- {debounce_ms} (integer, default: 0)
- Debounce delay in milliseconds for re-highlighting
- after buffer changes. Lower values feel snappier
- but use more CPU.
-
{hide_prefix} (boolean, default: false)
Hide diff prefixes (`+`/`-`/` `) using virtual
text overlay. Makes code appear without the
@@ -114,14 +134,45 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
is also enabled, the overlay inherits the line's
background color.
+ {fugitive} (boolean|table, default: false)
+ Enable vim-fugitive integration. Pass `true`
+ for defaults, `false` to disable, or a table
+ with sub-options (see |diffs.FugitiveConfig|).
+ Passing a table implicitly enables the
+ integration — no `enabled` field needed.
+ When active, the `fugitive` filetype is
+ registered and status buffer keymaps are set. >lua
+ vim.g.diffs = { fugitive = true }
+ vim.g.diffs = {
+ fugitive = { horizontal = 'dd' },
+ }
+<
+
+ {neogit} (boolean|table, default: false)
+ Enable Neogit integration. Pass `true` or
+ `{}` to enable, `false` to disable. When
+ active, `NeogitStatus`, `NeogitCommitView`,
+ and `NeogitDiffView` filetypes are registered
+ and Neogit highlight overrides are applied.
+ See |diffs-neogit|. >lua
+ vim.g.diffs = { neogit = true }
+<
+
+ {extra_filetypes} (table, default: {})
+ Additional filetypes to attach to, beyond the
+ built-in `git`, `gitcommit`, and any enabled
+ integration filetypes. Use this to enable
+ highlighting in plain `.diff` / `.patch`
+ files: >lua
+ vim.g.diffs = {
+ extra_filetypes = { 'diff' },
+ }
+<
+
{highlights} (table, default: see below)
Controls which highlight features are enabled.
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.
@@ -160,6 +211,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
Character-level (intra-line) diff highlighting.
See |diffs.IntraConfig| for fields.
+ {priorities} (table, default: see below)
+ Extmark priority values.
+ See |diffs.PrioritiesConfig| for fields.
+
{overrides} (table, default: {})
Map of highlight group names to highlight
definitions (see |nvim_set_hl()|). Applied
@@ -181,6 +236,28 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
direction. Lines are read with early exit —
cost scales with this value, not file size.
+ *diffs.PrioritiesConfig*
+ Priorities config fields: ~
+ {clear} (integer, default: 198)
+ Priority for `DiffsClear` extmarks that reset
+ underlying diff foreground colors. Must be
+ below {syntax}.
+
+ {syntax} (integer, default: 199)
+ Priority for treesitter and vim syntax extmarks.
+ Must be below {line_bg} so that colorscheme
+ backgrounds on syntax groups do not obscure
+ line-level diff backgrounds.
+
+ {line_bg} (integer, default: 200)
+ Priority for `DiffsAdd`/`DiffsDelete` line
+ background extmarks. Must be below {char_bg}.
+
+ {char_bg} (integer, default: 201)
+ Priority for `DiffsAddText`/`DiffsDeleteText`
+ character-level background extmarks. Highest
+ priority so changed characters stand out.
+
*diffs.TreesitterConfig*
Treesitter config fields: ~
{enabled} (boolean, default: true)
@@ -280,6 +357,67 @@ Example configuration: >lua
vim.keymap.set('n', 'gD', '(diffs-gvdiff)')
<
+ *(diffs-conflict-ours)*
+(diffs-conflict-ours)
+ Accept current (ours) change. Replaces the
+ conflict block with ours content.
+
+ *(diffs-conflict-theirs)*
+(diffs-conflict-theirs)
+ Accept incoming (theirs) change. Replaces the
+ conflict block with theirs content.
+
+ *(diffs-conflict-both)*
+(diffs-conflict-both)
+ Accept both changes (ours then theirs).
+
+ *(diffs-conflict-none)*
+(diffs-conflict-none)
+ Reject both changes (delete entire block).
+
+ *(diffs-conflict-next)*
+(diffs-conflict-next)
+ Jump to next conflict marker. Wraps around.
+
+ *(diffs-conflict-prev)*
+(diffs-conflict-prev)
+ Jump to previous conflict marker. Wraps around.
+
+Example configuration: >lua
+ vim.keymap.set('n', 'co', '(diffs-conflict-ours)')
+ vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)')
+ vim.keymap.set('n', 'cb', '(diffs-conflict-both)')
+ vim.keymap.set('n', 'cn', '(diffs-conflict-none)')
+ vim.keymap.set('n', ']c', '(diffs-conflict-next)')
+ vim.keymap.set('n', '[c', '(diffs-conflict-prev)')
+<
+
+ *(diffs-merge-ours)*
+(diffs-merge-ours)
+ Accept ours in a merge diff view. Resolves the
+ conflict in the working file with ours content.
+
+ *(diffs-merge-theirs)*
+(diffs-merge-theirs)
+ Accept theirs in a merge diff view.
+
+ *(diffs-merge-both)*
+(diffs-merge-both)
+ Accept both (ours then theirs) in a merge diff view.
+
+ *(diffs-merge-none)*
+(diffs-merge-none)
+ Reject both in a merge diff view.
+
+ *(diffs-merge-next)*
+(diffs-merge-next)
+ Jump to next unresolved conflict hunk in merge diff.
+
+ *(diffs-merge-prev)*
+(diffs-merge-prev)
+ Jump to previous unresolved conflict hunk in merge
+ diff.
+
Diff buffer mappings: ~
*diffs-q*
q Close the diff window. Available in all `diffs://`
@@ -310,6 +448,7 @@ Behavior by file status: ~
A Staged (empty) index file as all-added
D Staged HEAD (empty) file as all-removed
R Staged HEAD:oldname index:newname content diff
+ U Unstaged :2: (ours) :3: (theirs) merge diff
? Untracked (empty) working tree file as all-added
On section headers, the keymap runs `git diff` (or `git diff --cached` for
@@ -326,6 +465,9 @@ Configuration: ~
},
}
<
+ Passing a table enables fugitive integration. Use `fugitive = false`
+ to disable. There is no `enabled` field; table presence is sufficient.
+
Fields: ~
{horizontal} (string|false, default: 'du')
Keymap for unified diff in horizontal split.
@@ -354,13 +496,15 @@ Configuration: ~
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
+ show_actions = false,
+ priority = 200,
keymaps = {
ours = 'doo',
theirs = 'dot',
both = 'dob',
none = 'don',
- next = ']x',
- prev = '[x',
+ next = ']c',
+ prev = '[c',
},
},
}
@@ -380,9 +524,37 @@ Configuration: ~
diagnostics alone.
{show_virtual_text} (boolean, default: true)
- Show virtual text labels (" current" and
- " incoming") at the end of `<<<<<<<` and
- `>>>>>>>` marker lines.
+ Show `(current)` and `(incoming)` labels at
+ the end of `<<<<<<<` and `>>>>>>>` marker
+ lines. Also controls hunk hints in merge
+ diff views.
+
+ {format_virtual_text} (function|nil, default: nil)
+ Custom formatter for virtual text labels.
+ Receives `(side, keymap)` where `side` is
+ `"ours"` or `"theirs"` and `keymap` is the
+ configured keymap string or `false`. Return
+ a string (label text without parens) or
+ `nil` to hide the label. Example: >lua
+ format_virtual_text = function(side, keymap)
+ if keymap then
+ return side .. ' [' .. keymap .. ']'
+ end
+ return side
+ end
+<
+
+ {show_actions} (boolean, default: false)
+ Show a codelens-style action line above each
+ `<<<<<<<` marker listing available resolution
+ keymaps. Renders as virtual lines using the
+ `DiffsConflictActions` highlight group.
+ Only keymaps that are not `false` appear.
+
+ {priority} (integer, default: 200)
+ Extmark priority for conflict region
+ backgrounds and markers. Adjust if other
+ plugins use the same priority range.
{keymaps} (table, default: see above)
Buffer-local keymaps for conflict resolution
@@ -403,10 +575,10 @@ Configuration: ~
{none} (string|false, default: 'don')
Reject both changes (delete entire block).
- {next} (string|false, default: ']x')
+ {next} (string|false, default: ']c')
Jump to next conflict marker. Wraps around.
- {prev} (string|false, default: '[x')
+ {prev} (string|false, default: '[c')
Jump to previous conflict marker. Wraps
around.
@@ -423,6 +595,52 @@ User events: ~
})
<
+==============================================================================
+MERGE DIFF RESOLUTION *diffs-merge*
+
+When pressing `du`/`dU` on an unmerged (`U`) file in the fugitive status
+buffer, diffs.nvim opens a unified diff of ours (`git show :2:path`) vs
+theirs (`git show :3:path`) with full treesitter and intra-line highlighting.
+
+The same conflict resolution keymaps (`doo`/`dot`/`dob`/`don`/`]c`/`[c`)
+are available on the diff buffer. They resolve conflicts in the working
+file by matching diff hunks to conflict markers:
+
+- `doo` replaces the conflict region with ours content
+- `dot` replaces the conflict region with theirs content
+- `dob` replaces with both (ours then theirs)
+- `don` removes the conflict region entirely
+- `]c`/`[c` navigate between unresolved conflict hunks
+
+Resolved hunks are marked with `(resolved)` virtual text. Hunks that
+correspond to auto-merged content (no conflict markers) show an
+informational notification and are left unchanged.
+
+The working file buffer is modified in place; save it when ready.
+Phase 1 inline conflict highlights (see |diffs-conflict|) are refreshed
+automatically after each resolution.
+
+==============================================================================
+NEOGIT *diffs-neogit*
+
+diffs.nvim works with Neogit (https://github.com/NeogitOrg/neogit) out of
+the box. Enable Neogit support in your config: >lua
+ vim.g.diffs = { neogit = true }
+<
+
+When a diff is expanded in a Neogit buffer (e.g., via TAB on a file in the
+status view), diffs.nvim applies treesitter syntax highlighting and
+intra-line diffs to the hunk lines, just as it does for fugitive.
+
+Neogit highlight overrides: ~
+On first attach to a Neogit buffer, diffs.nvim overrides Neogit's diff
+highlight groups (`NeogitDiffAdd*`, `NeogitDiffDelete*`,
+`NeogitDiffContext*`, `NeogitHunkHeader*`, `NeogitDiffHeader*`, etc.) by
+setting them to empty (`{}`). This gives diffs.nvim sole control of diff
+line visuals. The overrides are reapplied on `ColorScheme` since Neogit
+re-defines its groups then. When `neogit = false`, no highlight overrides
+are applied.
+
==============================================================================
API *diffs-api*
@@ -444,8 +662,8 @@ refresh({bufnr}) *diffs.refresh()*
IMPLEMENTATION *diffs-implementation*
Summary / commit detail views: ~
-1. `FileType fugitive` or `FileType git` (for `fugitive://` buffers)
- triggers |diffs.attach()|
+1. `FileType` autocmd for computed filetypes (see |diffs-config|) triggers
+ |diffs.attach()|. For `git` buffers, only `fugitive://` URIs are attached.
2. The buffer is parsed to detect file headers (`M path/to/file`,
`diff --git a/... b/...`) and hunk headers (`@@ -10,3 +10,4 @@`)
3. For each hunk:
@@ -454,13 +672,14 @@ Summary / commit detail views: ~
- Code is parsed with |vim.treesitter.get_string_parser()|
- If no treesitter parser and `vim.enabled`: vim syntax fallback via
scratch buffer and |synID()|
- - `Normal` extmarks at priority 198 clear underlying diff foreground
- - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199
- - Syntax highlights are applied as extmarks at priority 200
+ - `DiffsClear` extmarks at priority 198 clear underlying diff foreground
+ - Syntax highlights are applied as extmarks at priority 199
+ - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 200
- Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at
priority 201 overlay changed characters with an intense background
- Conceal extmarks hide diff prefixes when `hide_prefix` is enabled
-4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events
+ - All priorities are configurable via |diffs.PrioritiesConfig|
+4. A decoration provider re-highlights visible hunks on each redraw
Diff mode views: ~
1. `OptionSet diff` detects when any window enters diff mode
@@ -474,15 +693,10 @@ KNOWN LIMITATIONS *diffs-limitations*
Incomplete Syntax Context ~
*diffs-syntax-context*
-Treesitter parses each diff hunk in isolation. To provide surrounding code
-context, diffs.nvim reads lines from disk before and after each hunk
-(see |diffs.ContextConfig|, enabled by default). This resolves most boundary
-issues where incomplete constructs (e.g., a function definition at the edge
-of a hunk with no body) would confuse the parser.
-
-Set `highlights.context.enabled = false` to disable context padding. In rare
-cases, context padding may not help if the relevant surrounding code is very
-far from the hunk boundaries.
+Treesitter parses each diff hunk in isolation. Context lines within the hunk
+(lines with a ` ` prefix) provide syntactic context for the parser. In rare
+cases, hunks that start or end mid-expression may produce imperfect highlights
+due to treesitter error recovery.
Syntax Highlighting Flash ~
*diffs-flash*
@@ -491,14 +705,8 @@ the buffer briefly shows fugitive's default diff highlighting before
diffs.nvim applies treesitter highlights.
This occurs because diffs.nvim hooks into the `FileType fugitive` event,
-which fires after vim-fugitive has already painted the buffer. Even with
-`debounce_ms = 0`, the re-painting goes through Neovim's event loop.
-
-To minimize the flash, use a low debounce value: >lua
- vim.g.diffs = {
- debounce_ms = 0,
- }
-<
+which fires after vim-fugitive has already painted the buffer. The
+decoration provider applies highlights on the next redraw cycle.
Conflicting Diff Plugins ~
*diffs-plugin-conflicts*
@@ -600,6 +808,10 @@ Conflict highlights: ~
*DiffsConflictBaseNr*
DiffsConflictBaseNr Line number for base content lines (diff3).
+ *DiffsConflictActions*
+ DiffsConflictActions Dimmed foreground (no bold) for the codelens-style
+ action line shown when `show_actions` is true.
+
Diff mode window highlights: ~
These are used for |winhighlight| remapping in `&diff` windows.
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..0c2cb09
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,43 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1770812194,
+ "narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs",
+ "systems": "systems"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..2221bdf
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,53 @@
+{
+ description = "diffs.nvim — syntax highlighting for diffs in Neovim";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+ systems.url = "github:nix-systems/default";
+ };
+
+ outputs =
+ {
+ nixpkgs,
+ systems,
+ ...
+ }:
+ let
+ forEachSystem =
+ f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
+ in
+ {
+ formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
+
+ devShells = forEachSystem (pkgs: {
+ default =
+ let
+ ts-plugin = pkgs.vimPlugins.nvim-treesitter.withPlugins (p: [ p.diff ]);
+ diff-grammar = pkgs.vimPlugins.nvim-treesitter-parsers.diff;
+ luaEnv = pkgs.luajit.withPackages (
+ ps: with ps; [
+ busted
+ nlua
+ ]
+ );
+ busted-with-grammar = pkgs.writeShellScriptBin "busted" ''
+ nvim_bin=$(which nvim)
+ tmpdir=$(mktemp -d)
+ trap 'rm -rf "$tmpdir"' EXIT
+ printf '#!/bin/sh\nexec "%s" --cmd "set rtp+=${ts-plugin}/runtime" --cmd "set rtp+=${diff-grammar}" "$@"\n' "$nvim_bin" > "$tmpdir/nvim"
+ chmod +x "$tmpdir/nvim"
+ PATH="$tmpdir:$PATH" exec ${luaEnv}/bin/busted "$@"
+ '';
+ in
+ pkgs.mkShell {
+ packages = [
+ busted-with-grammar
+ pkgs.prettier
+ pkgs.stylua
+ pkgs.selene
+ pkgs.lua-language-server
+ ];
+ };
+ });
+ };
+}
diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua
index dc6cfe2..01f1d3a 100644
--- a/lua/diffs/commands.lua
+++ b/lua/diffs/commands.lua
@@ -36,6 +36,24 @@ function M.find_hunk_line(diff_lines, hunk_position)
return nil
end
+---@param lines string[]
+---@return string[]
+function M.filter_combined_diffs(lines)
+ local result = {}
+ local skip = false
+ for _, line in ipairs(lines) do
+ if line:match('^diff %-%-cc ') then
+ skip = true
+ elseif line:match('^diff %-%-git ') then
+ skip = false
+ end
+ if not skip then
+ table.insert(result, line)
+ end
+ end
+ return result
+end
+
---@param old_lines string[]
---@param new_lines string[]
---@param old_name string
@@ -69,6 +87,33 @@ local function generate_unified_diff(old_lines, new_lines, old_name, new_name)
return result
end
+---@param raw_lines string[]
+---@param repo_root string
+---@return string[]
+local function replace_combined_diffs(raw_lines, repo_root)
+ local unmerged_files = {}
+ for _, line in ipairs(raw_lines) do
+ local cc_file = line:match('^diff %-%-cc (.+)$')
+ if cc_file then
+ table.insert(unmerged_files, cc_file)
+ end
+ end
+
+ local result = M.filter_combined_diffs(raw_lines)
+
+ for _, filename in ipairs(unmerged_files) do
+ local filepath = repo_root .. '/' .. filename
+ local old_lines = git.get_file_content(':2', filepath) or {}
+ local new_lines = git.get_file_content(':3', filepath) or {}
+ local diff_lines = generate_unified_diff(old_lines, new_lines, filename, filename)
+ for _, dl in ipairs(diff_lines) do
+ table.insert(result, dl)
+ end
+ end
+
+ return result
+end
+
---@param revision? string
---@param vertical? boolean
function M.gdiff(revision, vertical)
@@ -138,6 +183,7 @@ end
---@field vertical? boolean
---@field staged? boolean
---@field untracked? boolean
+---@field unmerged? boolean
---@field old_filepath? string
---@field hunk_position? { hunk_header: string, offset: integer }
@@ -157,7 +203,17 @@ function M.gdiff_file(filepath, opts)
local old_lines, new_lines, err
local diff_label
- if opts.untracked then
+ if opts.unmerged then
+ old_lines = git.get_file_content(':2', filepath)
+ if not old_lines then
+ old_lines = {}
+ end
+ new_lines = git.get_file_content(':3', filepath)
+ if not new_lines then
+ new_lines = {}
+ end
+ diff_label = 'unmerged'
+ elseif opts.untracked then
old_lines = {}
new_lines, err = git.get_working_content(filepath)
if not new_lines then
@@ -236,6 +292,14 @@ function M.gdiff_file(filepath, opts)
end
M.setup_diff_buf(diff_buf)
+
+ if diff_label == 'unmerged' then
+ vim.api.nvim_buf_set_var(diff_buf, 'diffs_unmerged', true)
+ vim.api.nvim_buf_set_var(diff_buf, 'diffs_working_path', filepath)
+ local conflict_config = require('diffs').get_conflict_config()
+ require('diffs.merge').setup_keymaps(diff_buf, conflict_config)
+ end
+
dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label)
vim.schedule(function()
@@ -263,6 +327,8 @@ function M.gdiff_section(repo_root, opts)
return
end
+ result = replace_combined_diffs(result, repo_root)
+
if #result == 0 then
vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO)
return
@@ -325,6 +391,8 @@ function M.read_buffer(bufnr)
if vim.v.shell_error ~= 0 then
diff_lines = {}
end
+
+ diff_lines = replace_combined_diffs(diff_lines, repo_root)
else
local abs_path = repo_root .. '/' .. path
@@ -334,7 +402,10 @@ function M.read_buffer(bufnr)
local old_lines, new_lines
- if label == 'untracked' then
+ if label == 'unmerged' then
+ old_lines = git.get_file_content(':2', abs_path) or {}
+ new_lines = git.get_file_content(':3', abs_path) or {}
+ elseif label == 'untracked' then
old_lines = {}
new_lines = git.get_working_content(abs_path) or {}
elseif label == 'staged' then
diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua
index f4468d2..5ed8009 100644
--- a/lua/diffs/conflict.lua
+++ b/lua/diffs/conflict.lua
@@ -20,8 +20,6 @@ local attached_buffers = {}
---@type table
local diagnostics_suppressed = {}
-local PRIORITY_LINE_BG = 200
-
---@param lines string[]
---@return diffs.ConflictRegion[]
function M.parse(lines)
@@ -92,6 +90,17 @@ local function parse_buffer(bufnr)
return M.parse(lines)
end
+---@param side string
+---@param config diffs.ConflictConfig
+---@return string?
+local function get_virtual_text_label(side, config)
+ if config.format_virtual_text then
+ local keymap = side == 'ours' and config.keymaps.ours or config.keymaps.theirs
+ return config.format_virtual_text(side, keymap)
+ end
+ return side == 'ours' and 'current' or 'incoming'
+end
+
---@param bufnr integer
---@param regions diffs.ConflictRegion[]
---@param config diffs.ConflictConfig
@@ -103,14 +112,41 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_ours + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
- priority = PRIORITY_LINE_BG,
+ priority = config.priority,
})
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',
- })
+ local ours_label = get_virtual_text_label('ours', config)
+ if ours_label then
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
+ virt_text = { { ' (' .. ours_label .. ')', 'DiffsConflictMarker' } },
+ virt_text_pos = 'eol',
+ })
+ end
+ end
+
+ if config.show_actions then
+ local parts = {}
+ local actions = {
+ { 'Current', config.keymaps.ours },
+ { 'Incoming', config.keymaps.theirs },
+ { 'Both', config.keymaps.both },
+ { 'None', config.keymaps.none },
+ }
+ for _, action in ipairs(actions) do
+ if action[2] then
+ if #parts > 0 then
+ table.insert(parts, { ' \226\148\130 ', 'DiffsConflictActions' })
+ end
+ table.insert(parts, { ('%s (%s)'):format(action[1], action[2]), 'DiffsConflictActions' })
+ end
+ end
+ if #parts > 0 then
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
+ virt_lines = { parts },
+ virt_lines_above = true,
+ })
+ end
end
for line = region.ours_start, region.ours_end - 1 do
@@ -118,11 +154,11 @@ local function apply_highlights(bufnr, regions, config)
end_row = line + 1,
hl_group = 'DiffsConflictOurs',
hl_eol = true,
- priority = PRIORITY_LINE_BG,
+ priority = config.priority,
})
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictOursNr',
- priority = PRIORITY_LINE_BG,
+ priority = config.priority,
})
end
@@ -131,7 +167,7 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_base + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
- priority = PRIORITY_LINE_BG,
+ priority = config.priority,
})
for line = region.base_start, region.base_end - 1 do
@@ -139,11 +175,11 @@ local function apply_highlights(bufnr, regions, config)
end_row = line + 1,
hl_group = 'DiffsConflictBase',
hl_eol = true,
- priority = PRIORITY_LINE_BG,
+ priority = config.priority,
})
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictBaseNr',
- priority = PRIORITY_LINE_BG,
+ priority = config.priority,
})
end
end
@@ -152,7 +188,7 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_sep + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
- priority = PRIORITY_LINE_BG,
+ priority = config.priority,
})
for line = region.theirs_start, region.theirs_end - 1 do
@@ -160,11 +196,11 @@ local function apply_highlights(bufnr, regions, config)
end_row = line + 1,
hl_group = 'DiffsConflictTheirs',
hl_eol = true,
- priority = PRIORITY_LINE_BG,
+ priority = config.priority,
})
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictTheirsNr',
- priority = PRIORITY_LINE_BG,
+ priority = config.priority,
})
end
@@ -172,14 +208,17 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_theirs + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
- priority = PRIORITY_LINE_BG,
+ priority = config.priority,
})
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',
- })
+ local theirs_label = get_virtual_text_label('theirs', config)
+ if theirs_label then
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, {
+ virt_text = { { ' (' .. theirs_label .. ')', 'DiffsConflictMarker' } },
+ virt_text_pos = 'eol',
+ })
+ end
end
end
end
@@ -199,7 +238,7 @@ end
---@param bufnr integer
---@param region diffs.ConflictRegion
---@param replacement string[]
-local function replace_region(bufnr, region, replacement)
+function M.replace_region(bufnr, region, replacement)
vim.api.nvim_buf_set_lines(
bufnr,
region.marker_ours,
@@ -211,7 +250,7 @@ end
---@param bufnr integer
---@param config diffs.ConflictConfig
-local function refresh(bufnr, config)
+function M.refresh(bufnr, config)
local regions = parse_buffer(bufnr)
if #regions == 0 then
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
@@ -244,8 +283,8 @@ function M.resolve_ours(bufnr, config)
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)
+ M.replace_region(bufnr, region, lines)
+ M.refresh(bufnr, config)
end
---@param bufnr integer
@@ -262,8 +301,8 @@ function M.resolve_theirs(bufnr, config)
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)
+ M.replace_region(bufnr, region, lines)
+ M.refresh(bufnr, config)
end
---@param bufnr integer
@@ -288,8 +327,8 @@ function M.resolve_both(bufnr, config)
for _, l in ipairs(theirs) do
table.insert(combined, l)
end
- replace_region(bufnr, region, combined)
- refresh(bufnr, config)
+ M.replace_region(bufnr, region, combined)
+ M.refresh(bufnr, config)
end
---@param bufnr integer
@@ -305,8 +344,8 @@ function M.resolve_none(bufnr, config)
if not region then
return
end
- replace_region(bufnr, region, {})
- refresh(bufnr, config)
+ M.replace_region(bufnr, region, {})
+ M.refresh(bufnr, config)
end
---@param bufnr integer
@@ -323,6 +362,7 @@ function M.goto_next(bufnr)
return
end
end
+ vim.notify('[diffs.nvim]: wrapped to first conflict', vim.log.levels.INFO)
vim.api.nvim_win_set_cursor(0, { regions[1].marker_ours + 1, 0 })
end
@@ -340,6 +380,7 @@ function M.goto_prev(bufnr)
return
end
end
+ vim.notify('[diffs.nvim]: wrapped to last conflict', vim.log.levels.INFO)
vim.api.nvim_win_set_cursor(0, { regions[#regions].marker_ours + 1, 0 })
end
@@ -348,35 +389,19 @@ end
local function setup_keymaps(bufnr, config)
local km = config.keymaps
- if km.ours then
- vim.keymap.set('n', km.ours, function()
- M.resolve_ours(bufnr, config)
- end, { buffer = bufnr })
- end
- if km.theirs then
- vim.keymap.set('n', km.theirs, function()
- M.resolve_theirs(bufnr, config)
- end, { buffer = bufnr })
- end
- if km.both then
- vim.keymap.set('n', km.both, function()
- M.resolve_both(bufnr, config)
- end, { buffer = bufnr })
- end
- if km.none then
- vim.keymap.set('n', km.none, function()
- M.resolve_none(bufnr, config)
- end, { buffer = bufnr })
- end
- if km.next then
- vim.keymap.set('n', km.next, function()
- M.goto_next(bufnr)
- end, { buffer = bufnr })
- end
- if km.prev then
- vim.keymap.set('n', km.prev, function()
- M.goto_prev(bufnr)
- end, { buffer = bufnr })
+ local maps = {
+ { km.ours, '(diffs-conflict-ours)' },
+ { km.theirs, '(diffs-conflict-theirs)' },
+ { km.both, '(diffs-conflict-both)' },
+ { km.none, '(diffs-conflict-none)' },
+ { km.next, '(diffs-conflict-next)' },
+ { km.prev, '(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
@@ -433,7 +458,7 @@ function M.attach(bufnr, config)
if not attached_buffers[bufnr] then
return true
end
- refresh(bufnr, config)
+ M.refresh(bufnr, config)
end,
})
diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua
index a588a22..d4c3782 100644
--- a/lua/diffs/fugitive.lua
+++ b/lua/diffs/fugitive.lua
@@ -26,20 +26,65 @@ function M.get_section_at_line(bufnr, lnum)
return nil
end
+---@param s string
+---@return string
+local function unquote(s)
+ if s:sub(1, 1) ~= '"' then
+ return s
+ end
+ local inner = s:sub(2, -2)
+ local result = {}
+ local i = 1
+ while i <= #inner do
+ if inner:sub(i, i) == '\\' and i < #inner then
+ local next_char = inner:sub(i + 1, i + 1)
+ if next_char == 'n' then
+ table.insert(result, '\n')
+ i = i + 2
+ elseif next_char == 't' then
+ table.insert(result, '\t')
+ i = i + 2
+ elseif next_char == '"' then
+ table.insert(result, '"')
+ i = i + 2
+ elseif next_char == '\\' then
+ table.insert(result, '\\')
+ i = i + 2
+ elseif next_char:match('%d') then
+ local oct = inner:match('^(%d%d%d)', i + 1)
+ if oct then
+ table.insert(result, string.char(tonumber(oct, 8)))
+ i = i + 4
+ else
+ table.insert(result, next_char)
+ i = i + 2
+ end
+ else
+ table.insert(result, next_char)
+ i = i + 2
+ end
+ else
+ table.insert(result, inner:sub(i, i))
+ i = i + 1
+ end
+ end
+ return table.concat(result)
+end
+
---@param line string
----@return string?, string?
+---@return string?, 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)
+ return unquote(vim.trim(new)), unquote(vim.trim(old)), 'R'
end
- local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$')
- if filename then
- return vim.trim(filename), nil
+ local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$')
+ if status and filename then
+ return unquote(vim.trim(filename)), nil, status
end
- return nil, nil
+ return nil, nil, nil
end
---@param line string
@@ -57,34 +102,34 @@ end
---@param bufnr integer
---@param lnum integer
----@return string?, diffs.FugitiveSection, boolean, string?
+---@return string?, diffs.FugitiveSection, boolean, string?, 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
+ return nil, nil, false, nil, nil
end
local section_header = parse_section_header(current_line)
if section_header then
- return nil, section_header, true, nil
+ return nil, section_header, true, nil, nil
end
- local filename, old_filename = parse_file_line(current_line)
+ local filename, old_filename, status = parse_file_line(current_line)
if filename then
local section = M.get_section_at_line(bufnr, lnum)
- return filename, section, false, old_filename
+ return filename, section, false, old_filename, status
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)
+ filename, old_filename, status = parse_file_line(prev_line)
if filename then
local section = M.get_section_at_line(bufnr, i)
- return filename, section, false, old_filename
+ return filename, section, false, old_filename, status
end
if prev_line:match('^%w+ %(') or prev_line == '' then
break
@@ -92,7 +137,7 @@ function M.get_file_at_line(bufnr, lnum)
end
end
- return nil, nil, false, nil
+ return nil, nil, false, nil, nil
end
---@class diffs.HunkPosition
@@ -150,7 +195,7 @@ 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 filename, section, is_header, old_filename, status = M.get_file_at_line(bufnr, lnum)
local repo_root = get_repo_root_from_fugitive(bufnr)
if not repo_root then
@@ -192,6 +237,7 @@ function M.diff_file_under_cursor(vertical)
vertical = vertical,
staged = section == 'staged',
untracked = section == 'untracked',
+ unmerged = status == 'U',
old_filepath = old_filepath,
hunk_position = hunk_position,
})
diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua
index 7695283..1aa6328 100644
--- a/lua/diffs/git.lua
+++ b/lua/diffs/git.lua
@@ -1,13 +1,19 @@
local M = {}
+local repo_root_cache = {}
+
---@param filepath string
---@return string?
function M.get_repo_root(filepath)
local dir = vim.fn.fnamemodify(filepath, ':h')
+ if repo_root_cache[dir] ~= nil then
+ return repo_root_cache[dir]
+ end
local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' })
if vim.v.shell_error ~= 0 then
return nil
end
+ repo_root_cache[dir] = result[1]
return result[1]
end
diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua
index 306b0e5..806fb0a 100644
--- a/lua/diffs/highlight.lua
+++ b/lua/diffs/highlight.lua
@@ -3,38 +3,6 @@ local M = {}
local dbg = require('diffs.log').dbg
local diff = require('diffs.diff')
----@param filepath string
----@param from_line integer
----@param count integer
----@return string[]
-local function read_line_range(filepath, from_line, count)
- if count <= 0 then
- return {}
- end
- local f = io.open(filepath, 'r')
- if not f then
- return {}
- end
- local result = {}
- local line_num = 0
- for line in f:lines() do
- line_num = line_num + 1
- if line_num >= from_line then
- table.insert(result, line)
- if #result >= count then
- break
- end
- end
- end
- f:close()
- return result
-end
-
-local PRIORITY_CLEAR = 198
-local PRIORITY_SYNTAX = 199
-local PRIORITY_LINE_BG = 200
-local PRIORITY_CHAR_BG = 201
-
---@param bufnr integer
---@param ns integer
---@param hunk diffs.Hunk
@@ -42,8 +10,9 @@ local PRIORITY_CHAR_BG = 201
---@param text string
---@param lang string
---@param context_lines? string[]
+---@param priorities diffs.PrioritiesConfig
---@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, context_lines, priorities)
local parse_text = text
if context_lines and #context_lines > 0 then
parse_text = text .. '\n' .. table.concat(context_lines, '\n')
@@ -77,7 +46,7 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_l
local buf_sc = col_offset + sc
local buf_ec = col_offset + ec
- local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX
+ local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or priorities.syntax
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
@@ -95,6 +64,7 @@ end
---@class diffs.HunkOpts
---@field hide_prefix boolean
---@field highlights diffs.Highlights
+---@field defer_vim_syntax? boolean
---@param bufnr integer
---@param ns integer
@@ -103,6 +73,8 @@ end
---@param line_map table
---@param col_offset integer
---@param covered_lines? table
+---@param priorities diffs.PrioritiesConfig
+---@param force_high_priority? boolean
---@return integer
local function highlight_treesitter(
bufnr,
@@ -111,7 +83,9 @@ local function highlight_treesitter(
lang,
line_map,
col_offset,
- covered_lines
+ covered_lines,
+ priorities,
+ force_high_priority
)
local code = table.concat(code_lines, '\n')
if code == '' then
@@ -151,8 +125,10 @@ local function highlight_treesitter(
local buf_sc = sc + col_offset
local buf_ec = ec + col_offset
- local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100)
- or PRIORITY_SYNTAX
+ local meta_prio = tonumber(metadata.priority) or 100
+ local priority = tree_lang == 'diff'
+ and ((col_offset > 0 or force_high_priority) and (priorities.syntax + meta_prio - 100) or meta_prio)
+ or priorities.syntax
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
@@ -219,8 +195,17 @@ end
---@param code_lines string[]
---@param covered_lines? table
---@param leading_offset? integer
+---@param priorities diffs.PrioritiesConfig
---@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,
+ covered_lines,
+ leading_offset,
+ priorities
+)
local ft = hunk.ft
if not ft then
return 0
@@ -238,9 +223,9 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines,
local spans = {}
- vim.api.nvim_buf_call(scratch, function()
+ pcall(vim.api.nvim_buf_call, scratch, function()
vim.cmd('setlocal syntax=' .. ft)
- vim.cmd('redraw')
+ vim.cmd.redraw()
---@param line integer
---@param col integer
@@ -256,18 +241,19 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines,
spans = M.coalesce_syntax_spans(query_fn, code_lines)
end)
- vim.api.nvim_buf_delete(scratch, { force = true })
+ pcall(vim.api.nvim_buf_delete, scratch, { force = true })
local hunk_line_count = #hunk.lines
+ local col_off = (hunk.prefix_width or 1) - 1
local extmark_count = 0
for _, span in ipairs(spans) do
local adj = span.line - leading_offset
if adj >= 1 and adj <= hunk_line_count then
local buf_line = hunk.start_line + adj - 1
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
- end_col = span.col_end,
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + col_off, {
+ end_col = span.col_end + col_off,
hl_group = span.hl_name,
- priority = PRIORITY_SYNTAX,
+ priority = priorities.syntax,
})
extmark_count = extmark_count + 1
if covered_lines then
@@ -284,6 +270,8 @@ end
---@param hunk diffs.Hunk
---@param opts diffs.HunkOpts
function M.highlight_hunk(bufnr, ns, hunk, opts)
+ local p = opts.highlights.priorities
+ local pw = hunk.prefix_width or 1
local use_ts = hunk.lang and opts.highlights.treesitter.enabled
local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled
@@ -300,28 +288,18 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
use_vim = false
end
+ if use_vim and opts.defer_vim_syntax then
+ use_vim = false
+ end
+
---@type table
local covered_lines = {}
- local ctx_cfg = opts.highlights.context
- local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0
- local leading = {}
- local trailing = {}
- if (use_ts or use_vim) and context > 0 and hunk.file_new_start and hunk.repo_root then
- local filepath = vim.fs.joinpath(hunk.repo_root, hunk.filename)
- local lead_from = math.max(1, hunk.file_new_start - context)
- local lead_count = hunk.file_new_start - lead_from
- if lead_count > 0 then
- leading = read_line_range(filepath, lead_from, lead_count)
- end
- local trail_from = hunk.file_new_start + (hunk.file_new_count or 0)
- trailing = read_line_range(filepath, trail_from, context)
- end
-
local extmark_count = 0
+ ---@type string[]
+ local new_code = {}
+
if use_ts then
- ---@type string[]
- local new_code = {}
---@type table
local new_map = {}
---@type string[]
@@ -329,20 +307,17 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
---@type table
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 prefix = line:sub(1, pw)
+ local stripped = line:sub(pw + 1)
local buf_line = hunk.start_line + i - 1
+ local has_add = prefix:find('+', 1, true) ~= nil
+ local has_del = prefix:find('-', 1, true) ~= nil
- if prefix == '+' then
+ if has_add and not has_del then
new_map[#new_code] = buf_line
table.insert(new_code, stripped)
- elseif prefix == '-' then
+ elseif has_del and not has_add then
old_map[#old_code] = buf_line
table.insert(old_code, stripped)
else
@@ -352,22 +327,12 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
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 =
+ highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, pw, covered_lines, p)
extmark_count = extmark_count
- + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines)
+ + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, pw, covered_lines, p)
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,
@@ -375,7 +340,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
hunk.header_context_col,
hunk.header_context,
hunk.lang,
- new_code
+ new_code,
+ p
)
if header_extmarks > 0 then
dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks)
@@ -385,16 +351,10 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
elseif use_vim then
---@type string[]
local code_lines = {}
- for _, pad_line in ipairs(leading) do
- table.insert(code_lines, pad_line)
- end
for _, line in ipairs(hunk.lines) do
- table.insert(code_lines, line:sub(2))
+ table.insert(code_lines, line:sub(pw + 1))
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)
+ extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p)
end
if
@@ -409,13 +369,19 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
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)
+ + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0, nil, p, pw > 1)
+ end
+
+ local at_raw_line
+ if pw > 1 and opts.highlights.treesitter.enabled then
+ local at_buf_line = hunk.start_line - 1
+ at_raw_line = vim.api.nvim_buf_get_lines(bufnr, at_buf_line, at_buf_line + 1, false)[1]
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
+ if intra_cfg and intra_cfg.enabled and pw == 1 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
@@ -446,48 +412,140 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
end
+ if
+ pw > 1
+ and hunk.header_start_line
+ and hunk.header_lines
+ and #hunk.header_lines > 0
+ and opts.highlights.treesitter.enabled
+ then
+ for i = 0, #hunk.header_lines - 1 do
+ local buf_line = hunk.header_start_line - 1 + i
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ end_col = #hunk.header_lines[i + 1],
+ hl_group = 'DiffsClear',
+ priority = p.clear,
+ })
+
+ local hline = hunk.header_lines[i + 1]
+ if hline:match('^index ') then
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ end_col = 5,
+ hl_group = '@keyword.diff',
+ priority = p.syntax,
+ })
+ local dot_pos = hline:find('%.%.', 1, false)
+ if dot_pos then
+ local rest = hline:sub(dot_pos + 2)
+ local hash = rest:match('^(%x+)')
+ if hash then
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, dot_pos + 1, {
+ end_col = dot_pos + 1 + #hash,
+ hl_group = '@constant.diff',
+ priority = p.syntax,
+ })
+ end
+ end
+ end
+ end
+ end
+
+ if pw > 1 and at_raw_line then
+ local at_buf_line = hunk.start_line - 1
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, {
+ end_col = #at_raw_line,
+ hl_group = 'DiffsClear',
+ priority = p.clear,
+ })
+ if opts.highlights.treesitter.enabled then
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, {
+ end_col = #at_raw_line,
+ hl_group = '@attribute.diff',
+ priority = p.syntax,
+ })
+ end
+ end
+
+ if use_ts and 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 = p.clear,
+ })
+ end
+
for i, line in ipairs(hunk.lines) do
local buf_line = hunk.start_line + i - 1
local line_len = #line
- local prefix = line:sub(1, 1)
+ local prefix = line:sub(1, pw)
+ local has_add = prefix:find('+', 1, true) ~= nil
+ local has_del = prefix:find('-', 1, true) ~= nil
+ local is_diff_line = has_add or has_del
+ local line_hl = is_diff_line and (has_add and 'DiffsAdd' or 'DiffsDelete') or nil
+ local number_hl = is_diff_line and (has_add and 'DiffsAddNr' or 'DiffsDeleteNr') or nil
- local is_diff_line = prefix == '+' or prefix == '-'
- local line_hl = is_diff_line and (prefix == '+' and 'DiffsAdd' or 'DiffsDelete') or nil
- local number_hl = is_diff_line and (prefix == '+' and 'DiffsAddNr' or 'DiffsDeleteNr') or nil
+ local is_marker = false
+ if pw > 1 and line_hl and not prefix:find('[^+]') then
+ local content = line:sub(pw + 1)
+ is_marker = content:match('^<<<<<<<')
+ or content:match('^=======')
+ or content:match('^>>>>>>>')
+ or content:match('^|||||||')
+ end
if opts.hide_prefix then
local virt_hl = (opts.highlights.background and line_hl) or nil
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
- virt_text = { { ' ', virt_hl } },
+ virt_text = { { string.rep(' ', pw), virt_hl } },
virt_text_pos = 'overlay',
})
end
- if line_len > 1 and covered_lines[buf_line] then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
+ if pw > 1 then
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ end_col = pw,
+ hl_group = 'DiffsClear',
+ priority = p.clear,
+ })
+ for ci = 0, pw - 1 do
+ local ch = line:sub(ci + 1, ci + 1)
+ if ch == '+' or ch == '-' then
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, ci, {
+ end_col = ci + 1,
+ hl_group = ch == '+' and '@diff.plus' or '@diff.minus',
+ priority = p.syntax,
+ })
+ end
+ end
+ end
+
+ if line_len > pw and covered_lines[buf_line] then
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, {
end_col = line_len,
hl_group = 'DiffsClear',
- priority = PRIORITY_CLEAR,
+ priority = p.clear,
})
end
if opts.highlights.background and is_diff_line then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
- end_row = buf_line + 1,
- hl_group = line_hl,
- hl_eol = true,
- priority = PRIORITY_LINE_BG,
+ line_hl_group = line_hl,
+ number_hl_group = opts.highlights.gutter and number_hl or nil,
+ priority = p.line_bg,
+ })
+ end
+
+ if is_marker and line_len > pw then
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, {
+ end_col = line_len,
+ hl_group = 'DiffsConflictMarker',
+ priority = p.char_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'
+ local char_hl = has_add 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"',
@@ -501,7 +559,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
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,
+ priority = p.char_bg,
})
if not ok then
dbg('char extmark FAILED: %s', err)
diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua
index cdc29d1..0bd5388 100644
--- a/lua/diffs/init.lua
+++ b/lua/diffs/init.lua
@@ -15,6 +15,12 @@
---@field enabled boolean
---@field lines integer
+---@class diffs.PrioritiesConfig
+---@field clear integer
+---@field syntax integer
+---@field line_bg integer
+---@field char_bg integer
+
---@class diffs.Highlights
---@field background boolean
---@field gutter boolean
@@ -24,11 +30,14 @@
---@field treesitter diffs.TreesitterConfig
---@field vim diffs.VimConfig
---@field intra diffs.IntraConfig
+---@field priorities diffs.PrioritiesConfig
---@class diffs.FugitiveConfig
---@field horizontal string|false
---@field vertical string|false
+---@class diffs.NeogitConfig
+
---@class diffs.ConflictKeymaps
---@field ours string|false
---@field theirs string|false
@@ -41,14 +50,18 @@
---@field enabled boolean
---@field disable_diagnostics boolean
---@field show_virtual_text boolean
+---@field format_virtual_text? fun(side: string, keymap: string|false): string?
+---@field show_actions boolean
+---@field priority integer
---@field keymaps diffs.ConflictKeymaps
---@class diffs.Config
----@field debug boolean
----@field debounce_ms integer
+---@field debug boolean|string
---@field hide_prefix boolean
+---@field extra_filetypes string[]
---@field highlights diffs.Highlights
----@field fugitive diffs.FugitiveConfig
+---@field fugitive diffs.FugitiveConfig|false
+---@field neogit diffs.NeogitConfig|false
---@field conflict diffs.ConflictConfig
---@class diffs
@@ -97,8 +110,8 @@ end
---@type diffs.Config
local default_config = {
debug = false,
- debounce_ms = 0,
hide_prefix = false,
+ extra_filetypes = {},
highlights = {
background = true,
gutter = true,
@@ -119,22 +132,28 @@ local default_config = {
algorithm = 'default',
max_lines = 500,
},
+ priorities = {
+ clear = 198,
+ syntax = 199,
+ line_bg = 200,
+ char_bg = 201,
+ },
},
- fugitive = {
- horizontal = 'du',
- vertical = 'dU',
- },
+ fugitive = false,
+ neogit = false,
conflict = {
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
+ show_actions = false,
+ priority = 200,
keymaps = {
ours = 'doo',
theirs = 'dot',
both = 'dob',
none = 'don',
- next = ']x',
- prev = '[x',
+ next = ']c',
+ prev = '[c',
},
},
}
@@ -144,66 +163,238 @@ local config = vim.deepcopy(default_config)
local initialized = false
+---@diagnostic disable-next-line: missing-fields
+local fast_hl_opts = {} ---@type diffs.HunkOpts
+
---@type table
local attached_buffers = {}
+---@type table
+local ft_retry_pending = {}
+
---@type table
local diff_windows = {}
+---@class diffs.HunkCacheEntry
+---@field hunks diffs.Hunk[]
+---@field tick integer
+---@field highlighted table
+---@field pending_clear boolean
+---@field line_count integer
+---@field byte_count integer
+
+---@type table
+local hunk_cache = {}
+
---@param bufnr integer
---@return boolean
function M.is_fugitive_buffer(bufnr)
return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil
end
+---@param opts table
+---@return string[]
+function M.compute_filetypes(opts)
+ local fts = { 'git', 'gitcommit' }
+ local fug = opts.fugitive
+ if fug == true or type(fug) == 'table' then
+ table.insert(fts, 'fugitive')
+ end
+ local neo = opts.neogit
+ if neo == true or type(neo) == 'table' then
+ table.insert(fts, 'NeogitStatus')
+ table.insert(fts, 'NeogitCommitView')
+ table.insert(fts, 'NeogitDiffView')
+ end
+ if type(opts.extra_filetypes) == 'table' then
+ for _, ft in ipairs(opts.extra_filetypes) do
+ table.insert(fts, ft)
+ end
+ end
+ return fts
+end
+
local dbg = log.dbg
---@param bufnr integer
-local function highlight_buffer(bufnr)
- if not vim.api.nvim_buf_is_valid(bufnr) then
- return
- end
-
- vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
-
- local hunks = parser.parse_buffer(bufnr)
- dbg('found %d hunks in buffer %d', #hunks, bufnr)
- for _, hunk in ipairs(hunks) do
- highlight.highlight_hunk(bufnr, ns, hunk, {
- hide_prefix = config.hide_prefix,
- highlights = config.highlights,
- })
+local function invalidate_cache(bufnr)
+ local entry = hunk_cache[bufnr]
+ if entry then
+ entry.tick = -1
+ entry.pending_clear = true
end
end
----@param bufnr integer
----@return fun()
-local function create_debounced_highlight(bufnr)
- local timer = nil ---@type table?
- return function()
- if timer then
- timer:stop() ---@diagnostic disable-line: undefined-field
- timer:close() ---@diagnostic disable-line: undefined-field
- timer = nil
+---@param a diffs.Hunk
+---@param b diffs.Hunk
+---@return boolean
+local function hunks_eq(a, b)
+ local n = #a.lines
+ if n ~= #b.lines or a.filename ~= b.filename then
+ return false
+ end
+ if a.lines[1] ~= b.lines[1] then
+ return false
+ end
+ if n > 1 and a.lines[n] ~= b.lines[n] then
+ return false
+ end
+ if n > 2 then
+ local mid = math.floor(n / 2) + 1
+ if a.lines[mid] ~= b.lines[mid] then
+ return false
end
- local t = vim.uv.new_timer()
- if not t then
- highlight_buffer(bufnr)
+ end
+ return true
+end
+
+---@param old_entry diffs.HunkCacheEntry
+---@param new_hunks diffs.Hunk[]
+---@return table?
+local function carry_forward_highlighted(old_entry, new_hunks)
+ local old_hunks = old_entry.hunks
+ local old_hl = old_entry.highlighted
+ local old_n = #old_hunks
+ local new_n = #new_hunks
+ local highlighted = {}
+
+ local prefix_len = 0
+ local limit = math.min(old_n, new_n)
+ for i = 1, limit do
+ if not hunks_eq(old_hunks[i], new_hunks[i]) then
+ break
+ end
+ if old_hl[i] then
+ highlighted[i] = true
+ end
+ prefix_len = i
+ end
+
+ local suffix_len = 0
+ local max_suffix = limit - prefix_len
+ for j = 0, max_suffix - 1 do
+ local old_idx = old_n - j
+ local new_idx = new_n - j
+ if not hunks_eq(old_hunks[old_idx], new_hunks[new_idx]) then
+ break
+ end
+ if old_hl[old_idx] then
+ highlighted[new_idx] = true
+ end
+ suffix_len = j + 1
+ end
+
+ dbg(
+ 'carry_forward: %d prefix + %d suffix of %d old -> %d new hunks',
+ prefix_len,
+ suffix_len,
+ old_n,
+ new_n
+ )
+ if next(highlighted) == nil then
+ return nil
+ end
+ return highlighted
+end
+
+---@param bufnr integer
+local function ensure_cache(bufnr)
+ if not vim.api.nvim_buf_is_valid(bufnr) then
+ return
+ end
+ local tick = vim.api.nvim_buf_get_changedtick(bufnr)
+ local entry = hunk_cache[bufnr]
+ if entry and entry.tick == tick then
+ return
+ end
+ if entry and not entry.pending_clear then
+ local lc = vim.api.nvim_buf_line_count(bufnr)
+ local bc = vim.api.nvim_buf_get_offset(bufnr, lc)
+ if lc == entry.line_count and bc == entry.byte_count then
+ entry.tick = tick
+ entry.pending_clear = true
+ dbg('content unchanged in buffer %d (tick %d), skipping reparse', bufnr, tick)
return
end
- timer = t
- t:start(
- config.debounce_ms,
- 0,
- vim.schedule_wrap(function()
- if timer == t then
- timer = nil
- t:close()
- end
- highlight_buffer(bufnr)
- end)
- )
end
+ local hunks = parser.parse_buffer(bufnr)
+ local lc = vim.api.nvim_buf_line_count(bufnr)
+ local bc = vim.api.nvim_buf_get_offset(bufnr, lc)
+ dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick)
+ local carried = entry and not entry.pending_clear and carry_forward_highlighted(entry, hunks)
+ hunk_cache[bufnr] = {
+ hunks = hunks,
+ tick = tick,
+ highlighted = carried or {},
+ pending_clear = not carried,
+ line_count = lc,
+ byte_count = bc,
+ }
+
+ local has_nil_ft = false
+ for _, hunk in ipairs(hunks) do
+ if not has_nil_ft and not hunk.ft and hunk.filename then
+ has_nil_ft = true
+ end
+ end
+ if has_nil_ft and vim.fn.did_filetype() ~= 0 and not ft_retry_pending[bufnr] then
+ ft_retry_pending[bufnr] = true
+ vim.schedule(function()
+ if vim.api.nvim_buf_is_valid(bufnr) and hunk_cache[bufnr] then
+ dbg('retrying filetype detection for buffer %d (was blocked by did_filetype)', bufnr)
+ invalidate_cache(bufnr)
+ vim.cmd('redraw!')
+ end
+ ft_retry_pending[bufnr] = nil
+ end)
+ end
+end
+
+---@param hunks diffs.Hunk[]
+---@param toprow integer
+---@param botrow integer
+---@return integer first
+---@return integer last
+local function find_visible_hunks(hunks, toprow, botrow)
+ local n = #hunks
+ if n == 0 then
+ return 0, 0
+ end
+
+ local lo, hi = 1, n + 1
+ while lo < hi do
+ local mid = math.floor((lo + hi) / 2)
+ local h = hunks[mid]
+ local bottom = h.start_line - 1 + #h.lines - 1
+ if bottom < toprow then
+ lo = mid + 1
+ else
+ hi = mid
+ end
+ end
+
+ if lo > n then
+ return 0, 0
+ end
+
+ local first = lo
+ local h = hunks[first]
+ local top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1)
+ if top >= botrow then
+ return 0, 0
+ end
+
+ local last = first
+ for i = first + 1, n do
+ h = hunks[i]
+ top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1)
+ if top >= botrow then
+ break
+ end
+ last = i
+ end
+
+ return first, last
end
local function compute_highlight_groups()
@@ -226,7 +417,7 @@ local function compute_highlight_groups()
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, 'DiffsClear', { default = true, fg = normal.fg or 0xc0c0c0, bg = bg })
vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add })
vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del })
vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add })
@@ -274,6 +465,7 @@ local function compute_highlight_groups()
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, 'DiffsConflictActions', { default = true, fg = 0x808080 })
vim.api.nvim_set_hl(
0,
'DiffsConflictOursNr',
@@ -305,10 +497,41 @@ local function init()
local opts = vim.g.diffs or {}
+ local fugitive_defaults = { horizontal = 'du', vertical = 'dU' }
+ if opts.fugitive == true then
+ opts.fugitive = vim.deepcopy(fugitive_defaults)
+ elseif type(opts.fugitive) == 'table' then
+ opts.fugitive = vim.tbl_extend('keep', opts.fugitive, fugitive_defaults)
+ end
+
+ if opts.neogit == true then
+ opts.neogit = {}
+ end
+
vim.validate({
- debug = { opts.debug, 'boolean', true },
- debounce_ms = { opts.debounce_ms, 'number', true },
+ debug = {
+ opts.debug,
+ function(v)
+ return v == nil or type(v) == 'boolean' or type(v) == 'string'
+ end,
+ 'boolean or string (file path)',
+ },
hide_prefix = { opts.hide_prefix, 'boolean', true },
+ fugitive = {
+ opts.fugitive,
+ function(v)
+ return v == nil or v == false or type(v) == 'table'
+ end,
+ 'table or false',
+ },
+ neogit = {
+ opts.neogit,
+ function(v)
+ return v == nil or v == false or type(v) == 'table'
+ end,
+ 'table or false',
+ },
+ extra_filetypes = { opts.extra_filetypes, 'table', true },
highlights = { opts.highlights, 'table', true },
})
@@ -322,6 +545,7 @@ local function init()
['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true },
['highlights.vim'] = { opts.highlights.vim, 'table', true },
['highlights.intra'] = { opts.highlights.intra, 'table', true },
+ ['highlights.priorities'] = { opts.highlights.priorities, 'table', true },
})
if opts.highlights.context then
@@ -362,21 +586,32 @@ local function init()
['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true },
})
end
+
+ if opts.highlights.priorities then
+ vim.validate({
+ ['highlights.priorities.clear'] = { opts.highlights.priorities.clear, 'number', true },
+ ['highlights.priorities.syntax'] = { opts.highlights.priorities.syntax, 'number', true },
+ ['highlights.priorities.line_bg'] = { opts.highlights.priorities.line_bg, 'number', true },
+ ['highlights.priorities.char_bg'] = { opts.highlights.priorities.char_bg, 'number', true },
+ })
+ end
end
- if opts.fugitive then
+ if type(opts.fugitive) == 'table' then
+ ---@type diffs.FugitiveConfig
+ local fug = opts.fugitive
vim.validate({
['fugitive.horizontal'] = {
- opts.fugitive.horizontal,
+ fug.horizontal,
function(v)
- return v == false or type(v) == 'string'
+ return v == nil or v == false or type(v) == 'string'
end,
'string or false',
},
['fugitive.vertical'] = {
- opts.fugitive.vertical,
+ fug.vertical,
function(v)
- return v == false or type(v) == 'string'
+ return v == nil or v == false or type(v) == 'string'
end,
'string or false',
},
@@ -388,6 +623,9 @@ local function init()
['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.format_virtual_text'] = { opts.conflict.format_virtual_text, 'function', true },
+ ['conflict.show_actions'] = { opts.conflict.show_actions, 'boolean', true },
+ ['conflict.priority'] = { opts.conflict.priority, 'number', true },
['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true },
})
@@ -407,9 +645,6 @@ local function init()
end
end
- if opts.debounce_ms and opts.debounce_ms < 0 then
- error('diffs: debounce_ms must be >= 0')
- end
if
opts.highlights
and opts.highlights.context
@@ -449,17 +684,137 @@ local function init()
then
error('diffs: highlights.blend_alpha must be >= 0 and <= 1')
end
+ if opts.highlights and opts.highlights.priorities then
+ for _, key in ipairs({ 'clear', 'syntax', 'line_bg', 'char_bg' }) do
+ local v = opts.highlights.priorities[key]
+ if v and v < 0 then
+ error('diffs: highlights.priorities.' .. key .. ' must be >= 0')
+ end
+ end
+ end
+ if opts.conflict and opts.conflict.priority and opts.conflict.priority < 0 then
+ error('diffs: conflict.priority must be >= 0')
+ end
config = vim.tbl_deep_extend('force', default_config, opts)
log.set_enabled(config.debug)
+ fast_hl_opts = {
+ hide_prefix = config.hide_prefix,
+ highlights = vim.tbl_deep_extend('force', config.highlights, {
+ treesitter = { enabled = false },
+ }),
+ defer_vim_syntax = true,
+ }
+
compute_highlight_groups()
vim.api.nvim_create_autocmd('ColorScheme', {
callback = function()
compute_highlight_groups()
for bufnr, _ in pairs(attached_buffers) do
- highlight_buffer(bufnr)
+ invalidate_cache(bufnr)
+ end
+ end,
+ })
+
+ vim.api.nvim_set_decoration_provider(ns, {
+ on_buf = function(_, bufnr)
+ if not attached_buffers[bufnr] then
+ return false
+ end
+ local t0 = config.debug and vim.uv.hrtime() or nil
+ ensure_cache(bufnr)
+ local entry = hunk_cache[bufnr]
+ if entry and entry.pending_clear then
+ vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
+ entry.highlighted = {}
+ entry.pending_clear = false
+ end
+ if t0 then
+ dbg('on_buf %d: %.2fms', bufnr, (vim.uv.hrtime() - t0) / 1e6)
+ end
+ end,
+ on_win = function(_, _, bufnr, toprow, botrow)
+ if not attached_buffers[bufnr] then
+ return false
+ end
+ local entry = hunk_cache[bufnr]
+ if not entry then
+ return
+ end
+ local first, last = find_visible_hunks(entry.hunks, toprow, botrow)
+ if first == 0 then
+ return
+ end
+ local t0 = config.debug and vim.uv.hrtime() or nil
+ local deferred_syntax = {}
+ local count = 0
+ for i = first, last do
+ if not entry.highlighted[i] then
+ local hunk = entry.hunks[i]
+ local clear_start = hunk.start_line - 1
+ local clear_end = hunk.start_line + #hunk.lines
+ if hunk.header_start_line then
+ clear_start = hunk.header_start_line - 1
+ end
+ vim.api.nvim_buf_clear_namespace(bufnr, ns, clear_start, clear_end)
+ highlight.highlight_hunk(bufnr, ns, hunk, fast_hl_opts)
+ entry.highlighted[i] = true
+ count = count + 1
+ local has_syntax = hunk.lang and config.highlights.treesitter.enabled
+ local needs_vim = not hunk.lang and hunk.ft and config.highlights.vim.enabled
+ if has_syntax or needs_vim then
+ table.insert(deferred_syntax, hunk)
+ end
+ end
+ end
+ if #deferred_syntax > 0 then
+ local tick = entry.tick
+ dbg('deferred syntax scheduled: %d hunks tick=%d', #deferred_syntax, tick)
+ vim.schedule(function()
+ if not vim.api.nvim_buf_is_valid(bufnr) then
+ return
+ end
+ local cur = hunk_cache[bufnr]
+ if not cur or cur.tick ~= tick then
+ dbg(
+ 'deferred syntax stale: cur.tick=%s captured=%d',
+ cur and tostring(cur.tick) or 'nil',
+ tick
+ )
+ return
+ end
+ local t1 = config.debug and vim.uv.hrtime() or nil
+ local full_opts = {
+ hide_prefix = config.hide_prefix,
+ highlights = config.highlights,
+ }
+ for _, hunk in ipairs(deferred_syntax) do
+ local start_row = hunk.start_line - 1
+ local end_row = hunk.start_line + #hunk.lines
+ if hunk.header_start_line then
+ start_row = hunk.header_start_line - 1
+ end
+ vim.api.nvim_buf_clear_namespace(bufnr, ns, start_row, end_row)
+ highlight.highlight_hunk(bufnr, ns, hunk, full_opts)
+ end
+ if t1 then
+ dbg('deferred pass: %d hunks in %.2fms', #deferred_syntax, (vim.uv.hrtime() - t1) / 1e6)
+ end
+ end)
+ end
+ if t0 and count > 0 then
+ dbg(
+ 'on_win %d: %d hunks [%d..%d] in %.2fms (viewport %d-%d)',
+ bufnr,
+ count,
+ first,
+ last,
+ (vim.uv.hrtime() - t0) / 1e6,
+ toprow,
+ botrow
+ )
end
end,
})
@@ -484,37 +839,34 @@ function M.attach(bufnr)
end
attached_buffers[bufnr] = true
+ local neogit_augroup = nil
+ if config.neogit and vim.bo[bufnr].filetype:match('^Neogit') then
+ vim.b[bufnr].neogit_disable_hunk_highlight = true
+ neogit_augroup = vim.api.nvim_create_augroup('diffs_neogit_' .. bufnr, { clear = true })
+ vim.api.nvim_create_autocmd('User', {
+ pattern = 'NeogitDiffLoaded',
+ group = neogit_augroup,
+ callback = function()
+ if vim.api.nvim_buf_is_valid(bufnr) and attached_buffers[bufnr] then
+ M.refresh(bufnr)
+ end
+ end,
+ })
+ end
+
dbg('attaching to buffer %d', bufnr)
- local debounced = create_debounced_highlight(bufnr)
-
- highlight_buffer(bufnr)
-
- vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
- buffer = bufnr,
- callback = debounced,
- })
-
- vim.api.nvim_create_autocmd('Syntax', {
- buffer = bufnr,
- callback = function()
- dbg('syntax event, re-highlighting buffer %d', bufnr)
- highlight_buffer(bufnr)
- end,
- })
-
- vim.api.nvim_create_autocmd('BufReadPost', {
- buffer = bufnr,
- callback = function()
- dbg('BufReadPost event, re-highlighting buffer %d', bufnr)
- highlight_buffer(bufnr)
- end,
- })
+ ensure_cache(bufnr)
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
callback = function()
attached_buffers[bufnr] = nil
+ hunk_cache[bufnr] = nil
+ ft_retry_pending[bufnr] = nil
+ if neogit_augroup then
+ pcall(vim.api.nvim_del_augroup_by_id, neogit_augroup)
+ end
end,
})
end
@@ -522,7 +874,7 @@ end
---@param bufnr? integer
function M.refresh(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
- highlight_buffer(bufnr)
+ invalidate_cache(bufnr)
end
local DIFF_WINHIGHLIGHT = table.concat({
@@ -565,7 +917,7 @@ function M.detach_diff()
end
end
----@return diffs.FugitiveConfig
+---@return diffs.FugitiveConfig|false
function M.get_fugitive_config()
init()
return config.fugitive
@@ -577,4 +929,23 @@ function M.get_conflict_config()
return config.conflict
end
+local function process_pending_clear(bufnr)
+ local entry = hunk_cache[bufnr]
+ if entry and entry.pending_clear then
+ vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
+ entry.highlighted = {}
+ entry.pending_clear = false
+ end
+end
+
+M._test = {
+ find_visible_hunks = find_visible_hunks,
+ hunk_cache = hunk_cache,
+ ensure_cache = ensure_cache,
+ invalidate_cache = invalidate_cache,
+ hunks_eq = hunks_eq,
+ process_pending_clear = process_pending_clear,
+ ft_retry_pending = ft_retry_pending,
+}
+
return M
diff --git a/lua/diffs/lib.lua b/lua/diffs/lib.lua
index 5b3254b..8376925 100644
--- a/lua/diffs/lib.lua
+++ b/lua/diffs/lib.lua
@@ -8,6 +8,9 @@ local cached_handle = nil
---@type boolean
local download_in_progress = false
+---@type fun(handle: table?)[]
+local pending_callbacks = {}
+
---@return string
local function get_os()
local os_name = jit.os:lower()
@@ -164,9 +167,10 @@ function M.ensure(callback)
return
end
+ table.insert(pending_callbacks, callback)
+
if download_in_progress then
- dbg('download already in progress')
- callback(nil)
+ dbg('download already in progress, queued callback')
return
end
@@ -192,21 +196,25 @@ function M.ensure(callback)
vim.system(cmd, {}, function(result)
download_in_progress = false
vim.schedule(function()
+ local handle = nil
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
+ else
+ 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)
+ handle = M.load()
end
- local f = io.open(version_path(), 'w')
- if f then
- f:write(EXPECTED_VERSION)
- f:close()
+ local cbs = pending_callbacks
+ pending_callbacks = {}
+ for _, cb in ipairs(cbs) do
+ cb(handle)
end
-
- vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO)
- callback(M.load())
end)
end)
end
diff --git a/lua/diffs/log.lua b/lua/diffs/log.lua
index 08abcc6..525f578 100644
--- a/lua/diffs/log.lua
+++ b/lua/diffs/log.lua
@@ -1,10 +1,17 @@
local M = {}
local enabled = false
+local log_file = nil
----@param val boolean
+---@param val boolean|string
function M.set_enabled(val)
- enabled = val
+ if type(val) == 'string' then
+ enabled = true
+ log_file = val
+ else
+ enabled = val
+ log_file = nil
+ end
end
---@param msg string
@@ -13,7 +20,16 @@ function M.dbg(msg, ...)
if not enabled then
return
end
- vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG)
+ local formatted = '[diffs.nvim]: ' .. string.format(msg, ...)
+ if log_file then
+ local f = io.open(log_file, 'a')
+ if f then
+ f:write(string.format('%.6fs', vim.uv.hrtime() / 1e9) .. ' ' .. formatted .. '\n')
+ f:close()
+ end
+ else
+ vim.notify(formatted, vim.log.levels.DEBUG)
+ end
end
return M
diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua
new file mode 100644
index 0000000..daf72ac
--- /dev/null
+++ b/lua/diffs/merge.lua
@@ -0,0 +1,418 @@
+local M = {}
+
+local conflict = require('diffs.conflict')
+
+local ns = vim.api.nvim_create_namespace('diffs-merge')
+
+---@type table>
+local resolved_hunks = {}
+
+---@class diffs.MergeHunkInfo
+---@field index integer
+---@field start_line integer
+---@field end_line integer
+---@field del_lines string[]
+---@field add_lines string[]
+
+---@param bufnr integer
+---@return diffs.MergeHunkInfo[]
+function M.parse_hunks(bufnr)
+ local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
+ local hunks = {}
+ local current = nil
+
+ for i, line in ipairs(lines) do
+ local idx = i - 1
+ if line:match('^@@') then
+ if current then
+ current.end_line = idx - 1
+ table.insert(hunks, current)
+ end
+ current = {
+ index = #hunks + 1,
+ start_line = idx,
+ end_line = idx,
+ del_lines = {},
+ add_lines = {},
+ }
+ elseif current then
+ local prefix = line:sub(1, 1)
+ if prefix == '-' then
+ table.insert(current.del_lines, line:sub(2))
+ elseif prefix == '+' then
+ table.insert(current.add_lines, line:sub(2))
+ elseif prefix ~= ' ' and prefix ~= '\\' then
+ current.end_line = idx - 1
+ table.insert(hunks, current)
+ current = nil
+ end
+ if current then
+ current.end_line = idx
+ end
+ end
+ end
+
+ if current then
+ table.insert(hunks, current)
+ end
+
+ return hunks
+end
+
+---@param bufnr integer
+---@return diffs.MergeHunkInfo?
+function M.find_hunk_at_cursor(bufnr)
+ local hunks = M.parse_hunks(bufnr)
+ local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1
+
+ for _, hunk in ipairs(hunks) do
+ if cursor_line >= hunk.start_line and cursor_line <= hunk.end_line then
+ return hunk
+ end
+ end
+
+ return nil
+end
+
+---@param hunk diffs.MergeHunkInfo
+---@param working_bufnr integer
+---@return diffs.ConflictRegion?
+function M.match_hunk_to_conflict(hunk, working_bufnr)
+ local working_lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
+ local regions = conflict.parse(working_lines)
+
+ for _, region in ipairs(regions) do
+ local ours_lines = {}
+ for line = region.ours_start + 1, region.ours_end do
+ table.insert(ours_lines, working_lines[line])
+ end
+
+ if #ours_lines == #hunk.del_lines then
+ local match = true
+ for j = 1, #ours_lines do
+ if ours_lines[j] ~= hunk.del_lines[j] then
+ match = false
+ break
+ end
+ end
+ if match then
+ return region
+ end
+ end
+ end
+
+ return nil
+end
+
+---@param diff_bufnr integer
+---@return integer?
+function M.get_or_load_working_buf(diff_bufnr)
+ local ok, working_path = pcall(vim.api.nvim_buf_get_var, diff_bufnr, 'diffs_working_path')
+ if not ok or not working_path then
+ return nil
+ end
+
+ local existing = vim.fn.bufnr(working_path)
+ if existing ~= -1 then
+ return existing
+ end
+
+ local bufnr = vim.fn.bufadd(working_path)
+ vim.fn.bufload(bufnr)
+ return bufnr
+end
+
+---@param diff_bufnr integer
+---@param hunk_index integer
+local function mark_resolved(diff_bufnr, hunk_index)
+ if not resolved_hunks[diff_bufnr] then
+ resolved_hunks[diff_bufnr] = {}
+ end
+ resolved_hunks[diff_bufnr][hunk_index] = true
+end
+
+---@param diff_bufnr integer
+---@param hunk_index integer
+---@return boolean
+function M.is_resolved(diff_bufnr, hunk_index)
+ return resolved_hunks[diff_bufnr] and resolved_hunks[diff_bufnr][hunk_index] or false
+end
+
+---@param diff_bufnr integer
+---@param hunk diffs.MergeHunkInfo
+local function add_resolved_virtual_text(diff_bufnr, hunk)
+ pcall(vim.api.nvim_buf_set_extmark, diff_bufnr, ns, hunk.start_line, 0, {
+ virt_text = { { ' (resolved)', 'Comment' } },
+ virt_text_pos = 'eol',
+ })
+end
+
+---@param bufnr integer
+---@param config diffs.ConflictConfig
+function M.resolve_ours(bufnr, config)
+ local hunk = M.find_hunk_at_cursor(bufnr)
+ if not hunk then
+ return
+ end
+ if M.is_resolved(bufnr, hunk.index) then
+ vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO)
+ return
+ end
+ local working_bufnr = M.get_or_load_working_buf(bufnr)
+ if not working_bufnr then
+ return
+ end
+ local region = M.match_hunk_to_conflict(hunk, working_bufnr)
+ if not region then
+ vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO)
+ return
+ end
+ local lines = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false)
+ conflict.replace_region(working_bufnr, region, lines)
+ conflict.refresh(working_bufnr, config)
+ mark_resolved(bufnr, hunk.index)
+ add_resolved_virtual_text(bufnr, hunk)
+end
+
+---@param bufnr integer
+---@param config diffs.ConflictConfig
+function M.resolve_theirs(bufnr, config)
+ local hunk = M.find_hunk_at_cursor(bufnr)
+ if not hunk then
+ return
+ end
+ if M.is_resolved(bufnr, hunk.index) then
+ vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO)
+ return
+ end
+ local working_bufnr = M.get_or_load_working_buf(bufnr)
+ if not working_bufnr then
+ return
+ end
+ local region = M.match_hunk_to_conflict(hunk, working_bufnr)
+ if not region then
+ vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO)
+ return
+ end
+ local lines =
+ vim.api.nvim_buf_get_lines(working_bufnr, region.theirs_start, region.theirs_end, false)
+ conflict.replace_region(working_bufnr, region, lines)
+ conflict.refresh(working_bufnr, config)
+ mark_resolved(bufnr, hunk.index)
+ add_resolved_virtual_text(bufnr, hunk)
+end
+
+---@param bufnr integer
+---@param config diffs.ConflictConfig
+function M.resolve_both(bufnr, config)
+ local hunk = M.find_hunk_at_cursor(bufnr)
+ if not hunk then
+ return
+ end
+ if M.is_resolved(bufnr, hunk.index) then
+ vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO)
+ return
+ end
+ local working_bufnr = M.get_or_load_working_buf(bufnr)
+ if not working_bufnr then
+ return
+ end
+ local region = M.match_hunk_to_conflict(hunk, working_bufnr)
+ if not region then
+ vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO)
+ return
+ end
+ local ours = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false)
+ local theirs =
+ vim.api.nvim_buf_get_lines(working_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
+ conflict.replace_region(working_bufnr, region, combined)
+ conflict.refresh(working_bufnr, config)
+ mark_resolved(bufnr, hunk.index)
+ add_resolved_virtual_text(bufnr, hunk)
+end
+
+---@param bufnr integer
+---@param config diffs.ConflictConfig
+function M.resolve_none(bufnr, config)
+ local hunk = M.find_hunk_at_cursor(bufnr)
+ if not hunk then
+ return
+ end
+ if M.is_resolved(bufnr, hunk.index) then
+ vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO)
+ return
+ end
+ local working_bufnr = M.get_or_load_working_buf(bufnr)
+ if not working_bufnr then
+ return
+ end
+ local region = M.match_hunk_to_conflict(hunk, working_bufnr)
+ if not region then
+ vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO)
+ return
+ end
+ conflict.replace_region(working_bufnr, region, {})
+ conflict.refresh(working_bufnr, config)
+ mark_resolved(bufnr, hunk.index)
+ add_resolved_virtual_text(bufnr, hunk)
+end
+
+---@param bufnr integer
+function M.goto_next(bufnr)
+ local hunks = M.parse_hunks(bufnr)
+ if #hunks == 0 then
+ return
+ end
+
+ local working_bufnr = M.get_or_load_working_buf(bufnr)
+ if not working_bufnr then
+ return
+ end
+
+ local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1
+
+ local candidates = {}
+ for _, hunk in ipairs(hunks) do
+ if not M.is_resolved(bufnr, hunk.index) then
+ if M.match_hunk_to_conflict(hunk, working_bufnr) then
+ table.insert(candidates, hunk)
+ end
+ end
+ end
+
+ if #candidates == 0 then
+ return
+ end
+
+ for _, hunk in ipairs(candidates) do
+ if hunk.start_line > cursor_line then
+ vim.api.nvim_win_set_cursor(0, { hunk.start_line + 1, 0 })
+ return
+ end
+ end
+
+ vim.notify('[diffs.nvim]: wrapped to first hunk', vim.log.levels.INFO)
+ vim.api.nvim_win_set_cursor(0, { candidates[1].start_line + 1, 0 })
+end
+
+---@param bufnr integer
+function M.goto_prev(bufnr)
+ local hunks = M.parse_hunks(bufnr)
+ if #hunks == 0 then
+ return
+ end
+
+ local working_bufnr = M.get_or_load_working_buf(bufnr)
+ if not working_bufnr then
+ return
+ end
+
+ local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1
+
+ local candidates = {}
+ for _, hunk in ipairs(hunks) do
+ if not M.is_resolved(bufnr, hunk.index) then
+ if M.match_hunk_to_conflict(hunk, working_bufnr) then
+ table.insert(candidates, hunk)
+ end
+ end
+ end
+
+ if #candidates == 0 then
+ return
+ end
+
+ for i = #candidates, 1, -1 do
+ if candidates[i].start_line < cursor_line then
+ vim.api.nvim_win_set_cursor(0, { candidates[i].start_line + 1, 0 })
+ return
+ end
+ end
+
+ vim.notify('[diffs.nvim]: wrapped to last hunk', vim.log.levels.INFO)
+ vim.api.nvim_win_set_cursor(0, { candidates[#candidates].start_line + 1, 0 })
+end
+
+---@param bufnr integer
+---@param config diffs.ConflictConfig
+local function apply_hunk_hints(bufnr, config)
+ if not config.show_virtual_text then
+ return
+ end
+
+ local hunks = M.parse_hunks(bufnr)
+ for _, hunk in ipairs(hunks) do
+ if M.is_resolved(bufnr, hunk.index) then
+ add_resolved_virtual_text(bufnr, hunk)
+ else
+ local parts = {}
+ local actions = {
+ { 'current', config.keymaps.ours },
+ { 'incoming', config.keymaps.theirs },
+ { 'both', config.keymaps.both },
+ { 'none', config.keymaps.none },
+ }
+ for _, action in ipairs(actions) do
+ if action[2] then
+ if #parts > 0 then
+ table.insert(parts, { ' | ', 'Comment' })
+ end
+ table.insert(parts, { ('%s: %s'):format(action[2], action[1]), 'Comment' })
+ end
+ end
+ if #parts > 0 then
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, hunk.start_line, 0, {
+ virt_text = parts,
+ virt_text_pos = 'eol',
+ })
+ end
+ end
+ end
+end
+
+---@param bufnr integer
+---@param config diffs.ConflictConfig
+function M.setup_keymaps(bufnr, config)
+ resolved_hunks[bufnr] = nil
+ vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
+
+ local km = config.keymaps
+
+ local maps = {
+ { km.ours, '(diffs-merge-ours)' },
+ { km.theirs, '(diffs-merge-theirs)' },
+ { km.both, '(diffs-merge-both)' },
+ { km.none, '(diffs-merge-none)' },
+ { km.next, '(diffs-merge-next)' },
+ { km.prev, '(diffs-merge-prev)' },
+ }
+
+ for _, map in ipairs(maps) do
+ if map[1] then
+ vim.keymap.set('n', map[1], map[2], { buffer = bufnr })
+ end
+ end
+
+ apply_hunk_hints(bufnr, config)
+
+ vim.api.nvim_create_autocmd('BufWipeout', {
+ buffer = bufnr,
+ callback = function()
+ resolved_hunks[bufnr] = nil
+ end,
+ })
+end
+
+---@return integer
+function M.get_namespace()
+ return ns
+end
+
+return M
diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua
index 43eb1f6..a32e563 100644
--- a/lua/diffs/parser.lua
+++ b/lua/diffs/parser.lua
@@ -12,12 +12,16 @@
---@field file_old_count integer?
---@field file_new_start integer?
---@field file_new_count integer?
+---@field prefix_width integer
---@field repo_root string?
local M = {}
local dbg = require('diffs.log').dbg
+---@type table
+local ft_lang_cache = {}
+
---@param filepath string
---@param n integer
---@return string[]?
@@ -56,6 +60,15 @@ local function get_ft_from_filename(filename, repo_root)
end
local ft = vim.filetype.match({ filename = filename })
+ if not ft and vim.fn.did_filetype() ~= 0 then
+ dbg('retrying filetype match for %s (clearing did_filetype)', filename)
+ local saved = rawget(vim.fn, 'did_filetype')
+ rawset(vim.fn, 'did_filetype', function()
+ return 0
+ end)
+ ft = vim.filetype.match({ filename = filename })
+ rawset(vim.fn, 'did_filetype', saved)
+ end
if ft then
dbg('filetype from filename: %s', ft)
return ft
@@ -106,7 +119,14 @@ local function get_repo_root(bufnr)
return vim.fn.fnamemodify(git_dir, ':h')
end
- return nil
+ local ok3, neogit_git_dir = pcall(vim.api.nvim_buf_get_var, bufnr, 'neogit_git_dir')
+ if ok3 and neogit_git_dir then
+ return vim.fn.fnamemodify(neogit_git_dir, ':h')
+ end
+
+ local cwd = vim.fn.getcwd()
+ local git = require('diffs.git')
+ return git.get_repo_root(cwd .. '/.')
end
---@param bufnr integer
@@ -114,6 +134,7 @@ end
function M.parse_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local repo_root = get_repo_root(bufnr)
+
---@type diffs.Hunk[]
local hunks = {}
@@ -133,6 +154,8 @@ function M.parse_buffer(bufnr)
local hunk_lines = {}
---@type integer?
local hunk_count = nil
+ ---@type integer
+ local hunk_prefix_width = 1
---@type integer?
local header_start = nil
---@type string[]
@@ -145,6 +168,10 @@ function M.parse_buffer(bufnr)
local file_new_start = nil
---@type integer?
local file_new_count = nil
+ ---@type integer?
+ local old_remaining = nil
+ ---@type integer?
+ local new_remaining = nil
local function flush_hunk()
if hunk_start and #hunk_lines > 0 then
@@ -156,6 +183,7 @@ function M.parse_buffer(bufnr)
header_context = hunk_header_context,
header_context_col = hunk_header_context_col,
lines = hunk_lines,
+ prefix_width = hunk_prefix_width,
file_old_start = file_old_start,
file_old_count = file_old_count,
file_new_start = file_new_start,
@@ -176,37 +204,78 @@ function M.parse_buffer(bufnr)
file_old_count = nil
file_new_start = nil
file_new_count = nil
+ old_remaining = nil
+ new_remaining = nil
end
for i, line in ipairs(lines) do
- local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$')
+ local diff_git_file = line:match('^diff %-%-git a/.+ b/(.+)$')
+ or line:match('^diff %-%-combined (.+)$')
+ or line:match('^diff %-%-cc (.+)$')
+ local neogit_file = line:match('^modified%s+(.+)$')
+ or (not line:match('^new file mode') and line:match('^new file%s+(.+)$'))
+ or (not line:match('^deleted file mode') and line:match('^deleted%s+(.+)$'))
+ or line:match('^renamed%s+(.+)$')
+ or line:match('^copied%s+(.+)$')
+ local bare_file = not hunk_start and line:match('^([^%s]+%.[^%s]+)$')
+ local filename = line:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file or neogit_file or bare_file
if filename then
flush_hunk()
current_filename = filename
- current_ft = get_ft_from_filename(filename, repo_root)
- current_lang = current_ft and get_lang_from_ft(current_ft) or nil
+ local cache_key = (repo_root or '') .. '\0' .. filename
+ local cached = ft_lang_cache[cache_key]
+ if cached then
+ current_ft = cached.ft
+ current_lang = cached.lang
+ else
+ current_ft = get_ft_from_filename(filename, repo_root)
+ current_lang = current_ft and get_lang_from_ft(current_ft) or nil
+ if current_ft or vim.fn.did_filetype() == 0 then
+ ft_lang_cache[cache_key] = { ft = current_ft, lang = current_lang }
+ end
+ end
if current_lang then
dbg('file: %s -> lang: %s', filename, current_lang)
elseif current_ft then
dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft)
end
hunk_count = 0
+ hunk_prefix_width = 1
header_start = i
header_lines = {}
- elseif line:match('^@@.-@@') then
+ elseif line:match('^@@+') then
flush_hunk()
hunk_start = i
- local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
- if hs then
- file_old_start = tonumber(hs)
- file_old_count = tonumber(hc) or 1
- file_new_start = tonumber(hs2)
- file_new_count = tonumber(hc2) or 1
+ local at_prefix = line:match('^(@@+)')
+ hunk_prefix_width = #at_prefix - 1
+ if #at_prefix == 2 then
+ 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
+ old_remaining = file_old_count
+ new_remaining = file_new_count
+ end
+ else
+ local hs, hc = line:match('%-(%d+),?(%d*)')
+ if hs then
+ file_old_start = tonumber(hs)
+ file_old_count = tonumber(hc) or 1
+ old_remaining = file_old_count
+ end
+ local hs2, hc2 = line:match('%+(%d+),?(%d*) @@')
+ if hs2 then
+ file_new_start = tonumber(hs2)
+ file_new_count = tonumber(hc2) or 1
+ new_remaining = file_new_count
+ end
end
- local prefix, context = line:match('^(@@.-@@%s*)(.*)')
+ local at_end, context = line:match('^(@@+.-@@+%s*)(.*)')
if context and context ~= '' then
hunk_header_context = context
- hunk_header_context_col = #prefix
+ hunk_header_context_col = #at_end
end
if hunk_count then
hunk_count = hunk_count + 1
@@ -215,6 +284,22 @@ function M.parse_buffer(bufnr)
local prefix = line:sub(1, 1)
if prefix == ' ' or prefix == '+' or prefix == '-' then
table.insert(hunk_lines, line)
+ if old_remaining and (prefix == ' ' or prefix == '-') then
+ old_remaining = old_remaining - 1
+ end
+ if new_remaining and (prefix == ' ' or prefix == '+') then
+ new_remaining = new_remaining - 1
+ end
+ elseif
+ line == ''
+ and old_remaining
+ and old_remaining > 0
+ and new_remaining
+ and new_remaining > 0
+ then
+ table.insert(hunk_lines, string.rep(' ', hunk_prefix_width))
+ old_remaining = old_remaining - 1
+ new_remaining = new_remaining - 1
elseif
line == ''
or line:match('^[MADRC%?!]%s+')
@@ -239,4 +324,8 @@ function M.parse_buffer(bufnr)
return hunks
end
+M._test = {
+ ft_lang_cache = ft_lang_cache,
+}
+
return M
diff --git a/plugin/diffs.lua b/plugin/diffs.lua
index e4fc690..e572cf5 100644
--- a/plugin/diffs.lua
+++ b/plugin/diffs.lua
@@ -6,7 +6,7 @@ vim.g.loaded_diffs = 1
require('diffs.commands').setup()
vim.api.nvim_create_autocmd('FileType', {
- pattern = { 'fugitive', 'git' },
+ pattern = require('diffs').compute_filetypes(vim.g.diffs or {}),
callback = function(args)
local diffs = require('diffs')
if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then
@@ -57,3 +57,53 @@ end, { desc = 'Unified diff (horizontal)' })
vim.keymap.set('n', '(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', '(diffs-conflict-ours)', function()
+ conflict_action(require('diffs.conflict').resolve_ours)
+end, { desc = 'Accept current (ours) change' })
+vim.keymap.set('n', '(diffs-conflict-theirs)', function()
+ conflict_action(require('diffs.conflict').resolve_theirs)
+end, { desc = 'Accept incoming (theirs) change' })
+vim.keymap.set('n', '(diffs-conflict-both)', function()
+ conflict_action(require('diffs.conflict').resolve_both)
+end, { desc = 'Accept both changes' })
+vim.keymap.set('n', '(diffs-conflict-none)', function()
+ conflict_action(require('diffs.conflict').resolve_none)
+end, { desc = 'Reject both changes' })
+vim.keymap.set('n', '(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', '(diffs-conflict-prev)', function()
+ require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf())
+end, { desc = 'Jump to previous conflict' })
+
+local function merge_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', '(diffs-merge-ours)', function()
+ merge_action(require('diffs.merge').resolve_ours)
+end, { desc = 'Accept ours in merge diff' })
+vim.keymap.set('n', '(diffs-merge-theirs)', function()
+ merge_action(require('diffs.merge').resolve_theirs)
+end, { desc = 'Accept theirs in merge diff' })
+vim.keymap.set('n', '(diffs-merge-both)', function()
+ merge_action(require('diffs.merge').resolve_both)
+end, { desc = 'Accept both in merge diff' })
+vim.keymap.set('n', '(diffs-merge-none)', function()
+ merge_action(require('diffs.merge').resolve_none)
+end, { desc = 'Reject both in merge diff' })
+vim.keymap.set('n', '(diffs-merge-next)', function()
+ require('diffs.merge').goto_next(vim.api.nvim_get_current_buf())
+end, { desc = 'Jump to next conflict hunk' })
+vim.keymap.set('n', '(diffs-merge-prev)', function()
+ require('diffs.merge').goto_prev(vim.api.nvim_get_current_buf())
+end, { desc = 'Jump to previous conflict hunk' })
diff --git a/scripts/ci.sh b/scripts/ci.sh
new file mode 100755
index 0000000..e06bf09
--- /dev/null
+++ b/scripts/ci.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -eu
+
+nix develop --command stylua --check .
+git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet
+nix develop --command prettier --check .
+nix fmt
+git diff --exit-code -- '*.nix'
+nix develop --command lua-language-server --check . --checklevel=Warning
+nix develop --command busted
diff --git a/selene.toml b/selene.toml
index 96cf5ab..f2ada4b 100644
--- a/selene.toml
+++ b/selene.toml
@@ -1 +1,4 @@
std = 'vim'
+
+[lints]
+bad_string_escape = 'allow'
diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua
index daba5d5..1bf9e61 100644
--- a/spec/commands_spec.lua
+++ b/spec/commands_spec.lua
@@ -40,6 +40,78 @@ describe('commands', function()
end)
end)
+ describe('filter_combined_diffs', function()
+ it('strips diff --cc entries entirely', function()
+ local lines = {
+ 'diff --cc main.lua',
+ 'index d13ab94,b113aee..0000000',
+ '--- a/main.lua',
+ '+++ b/main.lua',
+ '@@@ -1,7 -1,7 +1,11 @@@',
+ ' local M = {}',
+ '++<<<<<<< HEAD',
+ ' + return 1',
+ '++=======',
+ '+ return 2',
+ '++>>>>>>> theirs',
+ ' end',
+ }
+ local result = commands.filter_combined_diffs(lines)
+ assert.are.equal(0, #result)
+ end)
+
+ it('preserves diff --git entries', function()
+ local lines = {
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,3 +1,3 @@',
+ ' local M = {}',
+ '-local x = 1',
+ '+local x = 2',
+ ' return M',
+ }
+ local result = commands.filter_combined_diffs(lines)
+ assert.are.equal(8, #result)
+ assert.are.same(lines, result)
+ end)
+
+ it('strips combined but keeps unified in mixed output', function()
+ local lines = {
+ 'diff --cc conflict.lua',
+ 'index aaa,bbb..000',
+ '@@@ -1,1 -1,1 +1,5 @@@',
+ '++<<<<<<< HEAD',
+ 'diff --git a/clean.lua b/clean.lua',
+ '--- a/clean.lua',
+ '+++ b/clean.lua',
+ '@@ -1,1 +1,1 @@',
+ '-old',
+ '+new',
+ }
+ local result = commands.filter_combined_diffs(lines)
+ assert.are.equal(6, #result)
+ assert.are.equal('diff --git a/clean.lua b/clean.lua', result[1])
+ assert.are.equal('+new', result[6])
+ end)
+
+ it('returns empty for empty input', function()
+ local result = commands.filter_combined_diffs({})
+ assert.are.equal(0, #result)
+ end)
+
+ it('returns empty when all entries are combined', function()
+ local lines = {
+ 'diff --cc a.lua',
+ 'some content',
+ 'diff --cc b.lua',
+ 'more content',
+ }
+ local result = commands.filter_combined_diffs(lines)
+ assert.are.equal(0, #result)
+ end)
+ end)
+
describe('find_hunk_line', function()
it('finds matching @@ header and returns target line', function()
local diff_lines = {
diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua
index 75eac23..9f3df5d 100644
--- a/spec/conflict_spec.lua
+++ b/spec/conflict_spec.lua
@@ -6,13 +6,14 @@ local function default_config(overrides)
enabled = true,
disable_diagnostics = false,
show_virtual_text = true,
+ show_actions = false,
keymaps = {
ours = 'doo',
theirs = 'dot',
both = 'dob',
none = 'don',
- next = ']x',
- prev = '[x',
+ next = ']c',
+ prev = '[c',
},
}
if overrides then
@@ -234,29 +235,6 @@ describe('conflict', function()
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',
@@ -531,6 +509,33 @@ describe('conflict', function()
helpers.delete_buffer(bufnr)
end)
+ it('goto_next notifies on wrap-around', 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 })
+
+ local notified = false
+ local orig_notify = vim.notify
+ vim.notify = function(msg)
+ if msg:match('wrapped to first conflict') then
+ notified = true
+ end
+ end
+
+ conflict.goto_next(bufnr)
+ vim.notify = orig_notify
+
+ assert.is_true(notified)
+
+ helpers.delete_buffer(bufnr)
+ end)
+
it('goto_prev jumps to previous conflict', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
@@ -575,6 +580,33 @@ describe('conflict', function()
helpers.delete_buffer(bufnr)
end)
+ it('goto_prev notifies on wrap-around', 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 })
+
+ local notified = false
+ local orig_notify = vim.notify
+ vim.notify = function(msg)
+ if msg:match('wrapped to last conflict') then
+ notified = true
+ end
+ end
+
+ conflict.goto_prev(bufnr)
+ vim.notify = orig_notify
+
+ assert.is_true(notified)
+
+ 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)
@@ -685,4 +717,158 @@ describe('conflict', function()
helpers.delete_buffer(bufnr)
end)
end)
+
+ describe('virtual text formatting', function()
+ after_each(function()
+ conflict.detach(vim.api.nvim_get_current_buf())
+ end)
+
+ it('default labels show current and incoming without keymaps', 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 labels = {}
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].virt_text then
+ table.insert(labels, mark[4].virt_text[1][1])
+ end
+ end
+ assert.are.equal(2, #labels)
+ assert.are.equal(' (current)', labels[1])
+ assert.are.equal(' (incoming)', labels[2])
+
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('uses custom format_virtual_text function', function()
+ local bufnr = create_file_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ })
+
+ conflict.attach(
+ bufnr,
+ default_config({
+ format_virtual_text = function(side)
+ return side == 'ours' and 'OURS' or 'THEIRS'
+ end,
+ })
+ )
+
+ local extmarks = get_extmarks(bufnr)
+ local labels = {}
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].virt_text then
+ table.insert(labels, mark[4].virt_text[1][1])
+ end
+ end
+ assert.are.equal(2, #labels)
+ assert.are.equal(' (OURS)', labels[1])
+ assert.are.equal(' (THEIRS)', labels[2])
+
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('hides label when format_virtual_text returns nil', function()
+ local bufnr = create_file_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ })
+
+ conflict.attach(
+ bufnr,
+ default_config({
+ format_virtual_text = function()
+ return nil
+ end,
+ })
+ )
+
+ 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)
+ end)
+
+ describe('action lines', function()
+ after_each(function()
+ conflict.detach(vim.api.nvim_get_current_buf())
+ end)
+
+ it('adds virt_lines when show_actions is true', function()
+ local bufnr = create_file_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ })
+
+ conflict.attach(bufnr, default_config({ show_actions = true }))
+
+ local extmarks = get_extmarks(bufnr)
+ local virt_lines_count = 0
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].virt_lines then
+ virt_lines_count = virt_lines_count + 1
+ end
+ end
+ assert.are.equal(1, virt_lines_count)
+
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('omits disabled keymaps from action line', function()
+ local bufnr = create_file_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ })
+
+ conflict.attach(
+ bufnr,
+ default_config({ show_actions = true, keymaps = { both = false, none = false } })
+ )
+
+ local extmarks = get_extmarks(bufnr)
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].virt_lines then
+ local line = mark[4].virt_lines[1]
+ local text = ''
+ for _, chunk in ipairs(line) do
+ text = text .. chunk[1]
+ end
+ assert.is_truthy(text:find('Current'))
+ assert.is_truthy(text:find('Incoming'))
+ assert.is_falsy(text:find('Both'))
+ assert.is_falsy(text:find('None'))
+ end
+ end
+
+ helpers.delete_buffer(bufnr)
+ end)
+ end)
end)
diff --git a/spec/decoration_provider_spec.lua b/spec/decoration_provider_spec.lua
new file mode 100644
index 0000000..172ca11
--- /dev/null
+++ b/spec/decoration_provider_spec.lua
@@ -0,0 +1,329 @@
+require('spec.helpers')
+local diffs = require('diffs')
+
+describe('decoration_provider', function()
+ local function create_buffer(lines)
+ local bufnr = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
+ return bufnr
+ end
+
+ local function delete_buffer(bufnr)
+ if vim.api.nvim_buf_is_valid(bufnr) then
+ vim.api.nvim_buf_delete(bufnr, { force = true })
+ end
+ end
+
+ describe('ensure_cache', function()
+ it('populates hunk cache for a buffer with diff content', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,2 +1,3 @@',
+ ' local x = 1',
+ '-local y = 2',
+ '+local y = 3',
+ ' local z = 4',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_not_nil(entry)
+ assert.is_table(entry.hunks)
+ assert.is_true(#entry.hunks > 0)
+ delete_buffer(bufnr)
+ end)
+
+ it('cache tick matches buffer changedtick after attach', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ local tick = vim.api.nvim_buf_get_changedtick(bufnr)
+ assert.are.equal(tick, entry.tick)
+ delete_buffer(bufnr)
+ end)
+
+ it('re-parses and advances tick when buffer content changes', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ local tick_before = diffs._test.hunk_cache[bufnr].tick
+ vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' })
+ diffs._test.ensure_cache(bufnr)
+ local tick_after = diffs._test.hunk_cache[bufnr].tick
+ assert.is_true(tick_after > tick_before)
+ delete_buffer(bufnr)
+ end)
+
+ it('skips reparse when fingerprint unchanged but sets pending_clear', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ local original_hunks = entry.hunks
+ entry.pending_clear = false
+
+ local lc = vim.api.nvim_buf_line_count(bufnr)
+ local bc = vim.api.nvim_buf_get_offset(bufnr, lc)
+ entry.line_count = lc
+ entry.byte_count = bc
+ entry.tick = -1
+
+ diffs._test.ensure_cache(bufnr)
+
+ local updated = diffs._test.hunk_cache[bufnr]
+ local current_tick = vim.api.nvim_buf_get_changedtick(bufnr)
+ assert.are.equal(original_hunks, updated.hunks)
+ assert.are.equal(current_tick, updated.tick)
+ assert.is_true(updated.pending_clear)
+ delete_buffer(bufnr)
+ end)
+
+ it('does nothing for invalid buffer', function()
+ local bufnr = create_buffer({})
+ diffs.attach(bufnr)
+ vim.api.nvim_buf_delete(bufnr, { force = true })
+ assert.has_no.errors(function()
+ diffs._test.ensure_cache(bufnr)
+ end)
+ end)
+ end)
+
+ describe('pending_clear', function()
+ it('is true after invalidate_cache', function()
+ local bufnr = create_buffer({})
+ diffs.attach(bufnr)
+ diffs._test.invalidate_cache(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_true(entry.pending_clear)
+ delete_buffer(bufnr)
+ end)
+
+ it('is true immediately after fresh ensure_cache', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_true(entry.pending_clear)
+ delete_buffer(bufnr)
+ end)
+
+ it('clears namespace extmarks when on_buf processes pending_clear', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ local ns_id = vim.api.nvim_create_namespace('diffs')
+ vim.api.nvim_buf_set_extmark(bufnr, ns_id, 0, 0, { line_hl_group = 'DiffAdd' })
+ assert.are.equal(1, #vim.api.nvim_buf_get_extmarks(bufnr, ns_id, 0, -1, {}))
+
+ diffs._test.invalidate_cache(bufnr)
+ diffs._test.ensure_cache(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_true(entry.pending_clear)
+
+ diffs._test.process_pending_clear(bufnr)
+
+ entry = diffs._test.hunk_cache[bufnr]
+ assert.is_false(entry.pending_clear)
+ assert.are.same({}, vim.api.nvim_buf_get_extmarks(bufnr, ns_id, 0, -1, {}))
+ delete_buffer(bufnr)
+ end)
+ end)
+
+ describe('BufWipeout cleanup', function()
+ it('removes hunk_cache entry after buffer wipeout', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ assert.is_not_nil(diffs._test.hunk_cache[bufnr])
+ vim.api.nvim_buf_delete(bufnr, { force = true })
+ assert.is_nil(diffs._test.hunk_cache[bufnr])
+ end)
+ end)
+
+ describe('hunk stability', function()
+ it('carries forward highlighted for stable hunks on section expansion', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,2 +1,2 @@',
+ ' local x = 1',
+ '-local y = 2',
+ '+local y = 3',
+ '@@ -10,2 +10,3 @@',
+ ' function M.foo()',
+ '+ return true',
+ ' end',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.are.equal(2, #entry.hunks)
+
+ entry.pending_clear = false
+ entry.highlighted = { [1] = true, [2] = true }
+
+ vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, {
+ '@@ -5,1 +5,2 @@',
+ ' local z = 4',
+ '+local w = 5',
+ })
+ diffs._test.ensure_cache(bufnr)
+
+ local updated = diffs._test.hunk_cache[bufnr]
+ assert.are.equal(3, #updated.hunks)
+ assert.is_true(updated.highlighted[1] == true)
+ assert.is_nil(updated.highlighted[2])
+ assert.is_true(updated.highlighted[3] == true)
+ assert.is_false(updated.pending_clear)
+ delete_buffer(bufnr)
+ end)
+
+ it('carries forward highlighted for stable hunks on section collapse', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,2 +1,2 @@',
+ ' local x = 1',
+ '-local y = 2',
+ '+local y = 3',
+ '@@ -5,1 +5,2 @@',
+ ' local z = 4',
+ '+local w = 5',
+ '@@ -10,2 +10,3 @@',
+ ' function M.foo()',
+ '+ return true',
+ ' end',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.are.equal(3, #entry.hunks)
+
+ entry.pending_clear = false
+ entry.highlighted = { [1] = true, [2] = true, [3] = true }
+
+ vim.api.nvim_buf_set_lines(bufnr, 5, 8, false, {})
+ diffs._test.ensure_cache(bufnr)
+
+ local updated = diffs._test.hunk_cache[bufnr]
+ assert.are.equal(2, #updated.hunks)
+ assert.is_true(updated.highlighted[1] == true)
+ assert.is_true(updated.highlighted[2] == true)
+ assert.is_false(updated.pending_clear)
+ delete_buffer(bufnr)
+ end)
+
+ it('bypasses carry-forward when pending_clear was true', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,2 +1,2 @@',
+ ' local x = 1',
+ '-local y = 2',
+ '+local y = 3',
+ '@@ -10,2 +10,3 @@',
+ ' function M.foo()',
+ '+ return true',
+ ' end',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ entry.highlighted = { [1] = true, [2] = true }
+ entry.pending_clear = true
+
+ vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, {
+ '@@ -5,1 +5,2 @@',
+ ' local z = 4',
+ '+local w = 5',
+ })
+ diffs._test.ensure_cache(bufnr)
+
+ local updated = diffs._test.hunk_cache[bufnr]
+ assert.are.same({}, updated.highlighted)
+ assert.is_true(updated.pending_clear)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not carry forward when all hunks changed', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,2 +1,2 @@',
+ ' local x = 1',
+ '-local y = 2',
+ '+local y = 3',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+
+ entry.pending_clear = false
+ entry.highlighted = { [1] = true }
+
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
+ 'M other.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local a = 1',
+ '+local b = 2',
+ })
+ diffs._test.ensure_cache(bufnr)
+
+ local updated = diffs._test.hunk_cache[bufnr]
+ assert.is_nil(updated.highlighted[1])
+ assert.is_true(updated.pending_clear)
+ delete_buffer(bufnr)
+ end)
+ end)
+
+ describe('multiple hunks in cache', function()
+ it('stores all parsed hunks for a multi-hunk buffer', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,2 +1,2 @@',
+ ' local x = 1',
+ '-local y = 2',
+ '+local y = 3',
+ '@@ -10,2 +10,3 @@',
+ ' function M.foo()',
+ '+ return true',
+ ' end',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_not_nil(entry)
+ assert.are.equal(2, #entry.hunks)
+ delete_buffer(bufnr)
+ end)
+
+ it('stores empty hunks table for buffer with no diff content', function()
+ local bufnr = create_buffer({
+ 'Head: main',
+ 'Help: g?',
+ '',
+ 'Nothing to see here',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_not_nil(entry)
+ assert.are.same({}, entry.hunks)
+ delete_buffer(bufnr)
+ end)
+ end)
+end)
diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua
index 244e1b7..95b40a3 100644
--- a/spec/fugitive_spec.lua
+++ b/spec/fugitive_spec.lua
@@ -87,28 +87,6 @@ describe('fugitive', function()
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)',
@@ -157,28 +135,6 @@ describe('fugitive', function()
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)',
@@ -190,77 +146,54 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true })
end)
- it('handles double extensions', function()
+ it('unquotes git-quoted filenames with spaces', function()
+ local buf = create_status_buffer({
+ 'Unstaged (1)',
+ 'M "path with spaces/file.lua"',
+ })
+ local filename = fugitive.get_file_at_line(buf, 2)
+ assert.equals('path with spaces/file.lua', filename)
+ vim.api.nvim_buf_delete(buf, { force = true })
+ end)
+
+ it('unquotes escaped quotes in filenames', function()
+ local buf = create_status_buffer({
+ 'Unstaged (1)',
+ 'M "file\\"name.lua"',
+ })
+ local filename = fugitive.get_file_at_line(buf, 2)
+ assert.equals('file"name.lua', filename)
+ vim.api.nvim_buf_delete(buf, { force = true })
+ end)
+
+ it('unquotes octal escapes in filenames', function()
+ local buf = create_status_buffer({
+ 'Unstaged (1)',
+ 'M "\\303\\251le.lua"',
+ })
+ local filename = fugitive.get_file_at_line(buf, 2)
+ assert.equals('\195\169le.lua', filename)
+ vim.api.nvim_buf_delete(buf, { force = true })
+ end)
+
+ it('passes through unquoted filenames unchanged', function()
+ local buf = create_status_buffer({
+ 'Unstaged (1)',
+ 'M normal.lua',
+ })
+ local filename = fugitive.get_file_at_line(buf, 2)
+ assert.equals('normal.lua', filename)
+ vim.api.nvim_buf_delete(buf, { force = true })
+ end)
+
+ it('unquotes renamed files with quotes', function()
local buf = create_status_buffer({
'Staged (1)',
- 'M test.spec.lua',
+ 'R100 "old name.lua" -> "new name.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)
+ assert.equals('new name.lua', filename)
+ assert.equals('old name.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
@@ -321,30 +254,6 @@ describe('fugitive', function()
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)',
@@ -406,22 +315,6 @@ describe('fugitive', function()
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)',
diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua
index 0b9051e..5d4b94e 100644
--- a/spec/highlight_spec.lua
+++ b/spec/highlight_spec.lua
@@ -13,6 +13,7 @@ describe('highlight', function()
vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 })
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg })
vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg })
+ vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { fg = 0x808080, bold = true })
end)
local function create_buffer(lines)
@@ -51,6 +52,12 @@ describe('highlight', function()
algorithm = 'default',
max_lines = 500,
},
+ priorities = {
+ clear = 198,
+ syntax = 199,
+ line_bg = 200,
+ char_bg = 201,
+ },
},
}
if overrides then
@@ -64,27 +71,6 @@ describe('highlight', function()
return opts
end
- it('applies extmarks for lua code', function()
- local bufnr = create_buffer({
- '@@ -1,1 +1,2 @@',
- ' local x = 1',
- '+local y = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { ' local x = 1', '+local y = 2' },
- }
-
- highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
-
- local extmarks = get_extmarks(bufnr)
- assert.is_true(#extmarks > 0)
- delete_buffer(bufnr)
- end)
-
it('applies DiffsClear extmarks to clear diff colors', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@@ -190,36 +176,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('highlights header context when enabled', function()
- local bufnr = create_buffer({
- '@@ -10,3 +10,4 @@ function hello()',
- ' local x = 1',
- '+local y = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- header_context = 'function hello()',
- header_context_col = 18,
- lines = { ' local x = 1', '+local y = 2' },
- }
-
- highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
-
- local extmarks = get_extmarks(bufnr)
- local has_header_extmark = false
- for _, mark in ipairs(extmarks) do
- if mark[2] == 0 then
- has_header_extmark = true
- break
- end
- end
- assert.is_true(has_header_extmark)
- delete_buffer(bufnr)
- end)
-
it('highlights function keyword in header context', function()
local bufnr = create_buffer({
'@@ -5,3 +5,4 @@ function M.setup()',
@@ -280,44 +236,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('handles empty hunk lines', function()
- local bufnr = create_buffer({
- '@@ -1,0 +1,0 @@',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = {},
- }
-
- assert.has_no.errors(function()
- highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
- end)
- delete_buffer(bufnr)
- end)
-
- it('handles code that is just whitespace', function()
- local bufnr = create_buffer({
- '@@ -1,1 +1,2 @@',
- ' ',
- '+ ',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { ' ', '+ ' },
- }
-
- assert.has_no.errors(function()
- highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
- end)
- delete_buffer(bufnr)
- end)
-
it('applies overlay extmarks when hide_prefix enabled', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@@ -345,33 +263,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('does not apply overlay extmarks when hide_prefix disabled', function()
- local bufnr = create_buffer({
- '@@ -1,1 +1,2 @@',
- ' local x = 1',
- '+local y = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { ' local x = 1', '+local y = 2' },
- }
-
- highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = false }))
-
- local extmarks = get_extmarks(bufnr)
- local overlay_count = 0
- for _, mark in ipairs(extmarks) do
- if mark[4] and mark[4].virt_text_pos == 'overlay' then
- overlay_count = overlay_count + 1
- end
- end
- assert.are.equal(0, overlay_count)
- delete_buffer(bufnr)
- end)
-
it('applies DiffAdd background to + lines when background enabled', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@@ -396,7 +287,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_add = false
for _, mark in ipairs(extmarks) do
- if mark[4] and mark[4].hl_group == 'DiffsAdd' then
+ if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
has_diff_add = true
break
end
@@ -429,7 +320,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_delete = false
for _, mark in ipairs(extmarks) do
- if mark[4] and mark[4].hl_group == 'DiffsDelete' then
+ if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then
has_diff_delete = true
break
end
@@ -438,39 +329,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('does not apply background when background disabled', function()
- local bufnr = create_buffer({
- '@@ -1,1 +1,2 @@',
- ' local x = 1',
- '+local y = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { ' local x = 1', '+local y = 2' },
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({ highlights = { background = false } })
- )
-
- local extmarks = get_extmarks(bufnr)
- local has_line_hl = false
- for _, mark in ipairs(extmarks) do
- if mark[4] and (mark[4].hl_group == 'DiffsAdd' or mark[4].hl_group == 'DiffsDelete') then
- has_line_hl = true
- break
- end
- end
- assert.is_false(has_line_hl)
- delete_buffer(bufnr)
- end)
-
it('applies number_hl_group when gutter enabled', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@@ -504,7 +362,7 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('does not apply number_hl_group when gutter disabled', function()
+ it('line bg uses line_hl_group not hl_group with end_row', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
@@ -522,51 +380,101 @@ describe('highlight', function()
bufnr,
ns,
hunk,
- default_opts({ highlights = { background = true, gutter = false } })
+ default_opts({ highlights = { background = true } })
)
local extmarks = get_extmarks(bufnr)
- local has_number_hl = false
for _, mark in ipairs(extmarks) do
- if mark[4] and mark[4].number_hl_group then
- has_number_hl = true
- break
- end
+ local d = mark[4]
+ assert.is_not_equal('DiffsAdd', d and d.hl_group)
+ assert.is_not_equal('DiffsDelete', d and d.hl_group)
end
- assert.is_false(has_number_hl)
delete_buffer(bufnr)
end)
- it('skips treesitter highlights when treesitter disabled', function()
+ it('line bg extmark survives adjacent clear_namespace starting at next row', function()
local bufnr = create_buffer({
- '@@ -1,1 +1,2 @@',
- ' local x = 1',
- '+local y = 2',
+ 'diff --git a/foo.py b/foo.py',
+ '@@ -1,2 +1,2 @@',
+ '-old',
+ '+new',
})
local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { ' local x = 1', '+local y = 2' },
+ filename = 'foo.py',
+ header_start_line = 1,
+ start_line = 2,
+ lines = { '-old', '+new' },
+ prefix_width = 1,
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
- default_opts({ highlights = { treesitter = { enabled = false }, background = true } })
+ default_opts({ highlights = { background = true, treesitter = { enabled = false } } })
)
- local extmarks = get_extmarks(bufnr)
- local has_ts_highlight = false
- for _, mark in ipairs(extmarks) do
- if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then
- has_ts_highlight = true
- break
+ local last_body_row = hunk.start_line + #hunk.lines - 1
+ vim.api.nvim_buf_clear_namespace(bufnr, ns, last_body_row + 1, last_body_row + 10)
+
+ local marks = vim.api.nvim_buf_get_extmarks(
+ bufnr,
+ ns,
+ { last_body_row, 0 },
+ { last_body_row, -1 },
+ { details = true }
+ )
+ local has_line_bg = false
+ for _, mark in ipairs(marks) do
+ if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
+ has_line_bg = true
end
end
- assert.is_false(has_ts_highlight)
+ assert.is_true(has_line_bg)
+ delete_buffer(bufnr)
+ end)
+
+ it('clear range covers last body line of hunk with header', function()
+ local bufnr = create_buffer({
+ 'diff --git a/foo.py b/foo.py',
+ 'index abc..def 100644',
+ '--- a/foo.py',
+ '+++ b/foo.py',
+ '@@ -1,3 +1,3 @@',
+ ' ctx',
+ '-old',
+ '+new',
+ })
+
+ local hunk = {
+ filename = 'foo.py',
+ header_start_line = 1,
+ start_line = 5,
+ lines = { ' ctx', '-old', '+new' },
+ prefix_width = 1,
+ }
+
+ highlight.highlight_hunk(
+ bufnr,
+ ns,
+ hunk,
+ default_opts({ highlights = { background = true, treesitter = { enabled = false } } })
+ )
+
+ local last_body_row = hunk.start_line + #hunk.lines - 1
+ local clear_start = hunk.header_start_line - 1
+ local clear_end = hunk.start_line + #hunk.lines
+ vim.api.nvim_buf_clear_namespace(bufnr, ns, clear_start, clear_end)
+
+ local marks = vim.api.nvim_buf_get_extmarks(
+ bufnr,
+ ns,
+ { last_body_row, 0 },
+ { last_body_row, -1 },
+ { details = false }
+ )
+ assert.are.equal(0, #marks)
delete_buffer(bufnr)
end)
@@ -594,7 +502,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_add = false
for _, mark in ipairs(extmarks) do
- if mark[4] and mark[4].hl_group == 'DiffsAdd' then
+ if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
has_diff_add = true
break
end
@@ -654,40 +562,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('skips vim fallback when vim.enabled is false', function()
- local bufnr = create_buffer({
- '@@ -1,1 +1,2 @@',
- ' local x = 1',
- '+local y = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- ft = 'abap',
- lang = nil,
- start_line = 1,
- lines = { ' local x = 1', '+local y = 2' },
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({ highlights = { vim = { enabled = false } } })
- )
-
- local extmarks = get_extmarks(bufnr)
- local has_syntax_hl = false
- for _, mark in ipairs(extmarks) do
- if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then
- has_syntax_hl = true
- break
- end
- end
- assert.is_false(has_syntax_hl)
- delete_buffer(bufnr)
- end)
-
it('respects vim.max_lines', function()
local lines = { '@@ -1,100 +1,101 @@' }
local hunk_lines = {}
@@ -742,7 +616,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_add = false
for _, mark in ipairs(extmarks) do
- if mark[4] and mark[4].hl_group == 'DiffsAdd' then
+ if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
has_diff_add = true
break
end
@@ -802,7 +676,7 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('uses hl_group not line_hl_group for line backgrounds', function()
+ it('uses hl_group with hl_eol for line backgrounds', function()
local bufnr = create_buffer({
'@@ -1,2 +1,1 @@',
'-local x = 1',
@@ -824,44 +698,14 @@ describe('highlight', function()
)
local extmarks = get_extmarks(bufnr)
+ local found = false
for _, mark in ipairs(extmarks) do
local d = mark[4]
- if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
- assert.is_true(d.hl_eol == true)
- assert.is_nil(d.line_hl_group)
- end
- end
- delete_buffer(bufnr)
- end)
-
- it('hl_eol background extmarks are multiline so hl_eol takes effect', function()
- local bufnr = create_buffer({
- '@@ -1,2 +1,1 @@',
- '-local x = 1',
- '+local y = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { '-local x = 1', '+local y = 2' },
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({ highlights = { background = true } })
- )
-
- local extmarks = get_extmarks(bufnr)
- for _, mark in ipairs(extmarks) do
- local d = mark[4]
- if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
- assert.is_true(d.end_row > mark[2])
+ if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then
+ found = true
end
end
+ assert.is_true(found)
delete_buffer(bufnr)
end)
@@ -900,92 +744,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('line bg priority > DiffsClear priority', function()
- local bufnr = create_buffer({
- '@@ -1,2 +1,1 @@',
- '-local x = 1',
- '+local y = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { '-local x = 1', '+local y = 2' },
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({ highlights = { background = true } })
- )
-
- local extmarks = get_extmarks(bufnr)
- local clear_priority = nil
- local line_bg_priority = nil
- for _, mark in ipairs(extmarks) do
- local d = mark[4]
- if d and d.hl_group == 'DiffsClear' then
- clear_priority = d.priority
- end
- if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
- line_bg_priority = d.priority
- end
- end
- assert.is_not_nil(clear_priority)
- assert.is_not_nil(line_bg_priority)
- assert.is_true(line_bg_priority > clear_priority)
- delete_buffer(bufnr)
- end)
-
- it('char-level extmarks have higher priority than line bg', function()
- vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
- vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
-
- local bufnr = create_buffer({
- '@@ -1,2 +1,2 @@',
- '-local x = 1',
- '+local x = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { '-local x = 1', '+local x = 2' },
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({
- highlights = {
- background = true,
- intra = { enabled = true, algorithm = 'default', max_lines = 500 },
- },
- })
- )
-
- local extmarks = get_extmarks(bufnr)
- local line_bg_priority = nil
- local char_bg_priority = nil
- for _, mark in ipairs(extmarks) do
- local d = mark[4]
- if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
- line_bg_priority = d.priority
- end
- if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then
- char_bg_priority = d.priority
- end
- end
- assert.is_not_nil(line_bg_priority)
- assert.is_not_nil(char_bg_priority)
- assert.is_true(char_bg_priority > line_bg_priority)
- delete_buffer(bufnr)
- end)
-
it('creates char-level extmarks for changed characters', function()
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
@@ -1029,38 +787,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('does not create char-level extmarks when intra disabled', function()
- local bufnr = create_buffer({
- '@@ -1,2 +1,2 @@',
- '-local x = 1',
- '+local x = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { '-local x = 1', '+local x = 2' },
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({
- highlights = { intra = { enabled = false, algorithm = 'default', max_lines = 500 } },
- })
- )
-
- local extmarks = get_extmarks(bufnr)
- for _, mark in ipairs(extmarks) do
- local d = mark[4]
- assert.is_not_equal('DiffsAddText', d and d.hl_group)
- assert.is_not_equal('DiffsDeleteText', d and d.hl_group)
- end
- delete_buffer(bufnr)
- end)
-
it('does not create char-level extmarks for pure additions', function()
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
@@ -1131,7 +857,7 @@ describe('highlight', function()
if d then
if d.hl_group == 'DiffsClear' then
table.insert(priorities.clear, d.priority)
- elseif d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete' then
+ elseif d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete' then
table.insert(priorities.line_bg, d.priority)
elseif d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then
table.insert(priorities.char_bg, d.priority)
@@ -1157,142 +883,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('context padding produces no extmarks on padding lines', function()
- local repo_root = '/tmp/diffs-test-context'
- vim.fn.mkdir(repo_root, 'p')
-
- local f = io.open(repo_root .. '/test.lua', 'w')
- f:write('local M = {}\n')
- f:write('function M.hello()\n')
- f:write(' return "hi"\n')
- f:write('end\n')
- f:write('return M\n')
- f:close()
-
- local bufnr = create_buffer({
- '@@ -3,1 +3,2 @@',
- ' return "hi"',
- '+"bye"',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { ' return "hi"', '+"bye"' },
- file_old_start = 3,
- file_old_count = 1,
- file_new_start = 3,
- file_new_count = 2,
- repo_root = repo_root,
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({ highlights = { context = { enabled = true, lines = 25 } } })
- )
-
- local extmarks = get_extmarks(bufnr)
- for _, mark in ipairs(extmarks) do
- local row = mark[2]
- assert.is_true(row >= 1 and row <= 2)
- end
-
- delete_buffer(bufnr)
- os.remove(repo_root .. '/test.lua')
- vim.fn.delete(repo_root, 'rf')
- end)
-
- it('context disabled matches behavior without padding', function()
- local bufnr = create_buffer({
- '@@ -1,1 +1,2 @@',
- ' local x = 1',
- '+local y = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { ' local x = 1', '+local y = 2' },
- file_new_start = 1,
- file_new_count = 2,
- repo_root = '/nonexistent',
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({ highlights = { context = { enabled = false, lines = 0 } } })
- )
-
- local extmarks = get_extmarks(bufnr)
- assert.is_true(#extmarks > 0)
- delete_buffer(bufnr)
- end)
-
- it('gracefully handles missing file for context padding', function()
- local bufnr = create_buffer({
- '@@ -1,1 +1,2 @@',
- ' local x = 1',
- '+local y = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { ' local x = 1', '+local y = 2' },
- file_new_start = 1,
- file_new_count = 2,
- repo_root = '/nonexistent/path',
- }
-
- assert.has_no.errors(function()
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({ highlights = { context = { enabled = true, lines = 25 } } })
- )
- end)
-
- local extmarks = get_extmarks(bufnr)
- assert.is_true(#extmarks > 0)
- delete_buffer(bufnr)
- end)
-
- it('highlights treesitter injections', function()
- local bufnr = create_buffer({
- '@@ -1,1 +1,2 @@',
- ' local x = 1',
- '+vim.cmd([[ echo 1 ]])',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { ' local x = 1', '+vim.cmd([[ echo 1 ]])' },
- }
-
- highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
-
- local extmarks = get_extmarks(bufnr)
- local has_vim_capture = false
- for _, mark in ipairs(extmarks) do
- if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.vim$') then
- has_vim_capture = true
- break
- end
- end
- assert.is_true(has_vim_capture)
- delete_buffer(bufnr)
- end)
-
it('includes captures from both base and injected languages', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@@ -1327,6 +917,313 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
+ it('classifies all combined diff prefix types for background', function()
+ local bufnr = create_buffer({
+ '@@@ -1,5 -1,5 +1,9 @@@',
+ ' local M = {}',
+ '++<<<<<<< HEAD',
+ ' + return 1',
+ '+ local greeting = "hi"',
+ '++=======',
+ '+ return 2',
+ '++>>>>>>> feature',
+ ' end',
+ })
+
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ prefix_width = 2,
+ lines = {
+ ' local M = {}',
+ '++<<<<<<< HEAD',
+ ' + return 1',
+ '+ local greeting = "hi"',
+ '++=======',
+ '+ return 2',
+ '++>>>>>>> feature',
+ ' end',
+ },
+ }
+
+ highlight.highlight_hunk(
+ bufnr,
+ ns,
+ hunk,
+ default_opts({ highlights = { background = true } })
+ )
+
+ local extmarks = get_extmarks(bufnr)
+ local line_bgs = {}
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then
+ line_bgs[mark[2]] = d.line_hl_group
+ end
+ end
+ assert.is_nil(line_bgs[1])
+ assert.are.equal('DiffsAdd', line_bgs[2])
+ assert.are.equal('DiffsAdd', line_bgs[3])
+ assert.are.equal('DiffsAdd', line_bgs[4])
+ assert.are.equal('DiffsAdd', line_bgs[5])
+ assert.are.equal('DiffsAdd', line_bgs[6])
+ assert.are.equal('DiffsAdd', line_bgs[7])
+ assert.is_nil(line_bgs[8])
+ delete_buffer(bufnr)
+ end)
+
+ it('conceals full 2-char prefix for all combined diff line types', function()
+ local bufnr = create_buffer({
+ '@@@ -1,3 -1,3 +1,5 @@@',
+ ' local M = {}',
+ '++<<<<<<< HEAD',
+ ' + return 1',
+ '+ local x = 2',
+ ' end',
+ })
+
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ prefix_width = 2,
+ lines = {
+ ' local M = {}',
+ '++<<<<<<< HEAD',
+ ' + return 1',
+ '+ local x = 2',
+ ' end',
+ },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = true }))
+
+ local extmarks = get_extmarks(bufnr)
+ local overlays = {}
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].virt_text_pos == 'overlay' then
+ overlays[mark[2]] = mark[4].virt_text[1][1]
+ end
+ end
+ assert.are.equal(5, vim.tbl_count(overlays))
+ for _, text in pairs(overlays) do
+ assert.are.equal(' ', text)
+ end
+ delete_buffer(bufnr)
+ end)
+
+ it('places treesitter captures at col_offset 2 for combined diffs', function()
+ local bufnr = create_buffer({
+ '@@@ -1,2 -1,2 +1,2 @@@',
+ ' local x = 1',
+ ' +local y = 2',
+ })
+
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ prefix_width = 2,
+ lines = { ' local x = 1', ' +local y = 2' },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local ts_marks = {}
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then
+ table.insert(ts_marks, mark)
+ end
+ end
+ assert.is_true(#ts_marks > 0)
+ for _, mark in ipairs(ts_marks) do
+ assert.is_true(mark[3] >= 2)
+ end
+ delete_buffer(bufnr)
+ end)
+
+ it('applies DiffsClear starting at col 2 for combined diffs', function()
+ local bufnr = create_buffer({
+ '@@@ -1,1 -1,1 +1,2 @@@',
+ ' local x = 1',
+ ' +local y = 2',
+ })
+
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ prefix_width = 2,
+ lines = { ' local x = 1', ' +local y = 2' },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local content_clear_count = 0
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].hl_group == 'DiffsClear' then
+ assert.is_true(mark[3] == 0 or mark[3] == 2, 'DiffsClear at unexpected col ' .. mark[3])
+ if mark[3] == 2 then
+ content_clear_count = content_clear_count + 1
+ end
+ end
+ end
+ assert.are.equal(2, content_clear_count)
+ delete_buffer(bufnr)
+ end)
+
+ it('skips intra-line diffing for combined diffs', function()
+ vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
+ vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
+
+ local bufnr = create_buffer({
+ '@@@ -1,2 -1,2 +1,3 @@@',
+ ' local x = 1',
+ ' +local y = 2',
+ '+ local y = 3',
+ })
+
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ prefix_width = 2,
+ lines = { ' local x = 1', ' +local y = 2', '+ local y = 3' },
+ }
+
+ highlight.highlight_hunk(
+ bufnr,
+ ns,
+ hunk,
+ default_opts({
+ highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } },
+ })
+ )
+
+ local extmarks = get_extmarks(bufnr)
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ assert.is_not_equal('DiffsAddText', d and d.hl_group)
+ assert.is_not_equal('DiffsDeleteText', d and d.hl_group)
+ end
+ delete_buffer(bufnr)
+ end)
+
+ it('applies DiffsConflictMarker text on markers with DiffsAdd bg', function()
+ local bufnr = create_buffer({
+ '@@@ -1,5 -1,5 +1,9 @@@',
+ ' local M = {}',
+ '++<<<<<<< HEAD',
+ '+ local x = 1',
+ '++||||||| base',
+ '++=======',
+ ' +local y = 2',
+ '++>>>>>>> feature',
+ ' return M',
+ })
+
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ prefix_width = 2,
+ lines = {
+ ' local M = {}',
+ '++<<<<<<< HEAD',
+ '+ local x = 1',
+ '++||||||| base',
+ '++=======',
+ ' +local y = 2',
+ '++>>>>>>> feature',
+ ' return M',
+ },
+ }
+
+ highlight.highlight_hunk(
+ bufnr,
+ ns,
+ hunk,
+ default_opts({ highlights = { background = true, gutter = true } })
+ )
+
+ local extmarks = get_extmarks(bufnr)
+ local line_bgs = {}
+ local gutter_hls = {}
+ local marker_text = {}
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then
+ line_bgs[mark[2]] = d.line_hl_group
+ end
+ if d and d.number_hl_group then
+ gutter_hls[mark[2]] = d.number_hl_group
+ end
+ if d and d.hl_group == 'DiffsConflictMarker' then
+ marker_text[mark[2]] = true
+ end
+ end
+
+ assert.is_nil(line_bgs[1])
+ assert.are.equal('DiffsAdd', line_bgs[2])
+ assert.are.equal('DiffsAdd', line_bgs[3])
+ assert.are.equal('DiffsAdd', line_bgs[4])
+ assert.are.equal('DiffsAdd', line_bgs[5])
+ assert.are.equal('DiffsAdd', line_bgs[6])
+ assert.are.equal('DiffsAdd', line_bgs[7])
+ assert.is_nil(line_bgs[8])
+
+ assert.is_nil(gutter_hls[1])
+ assert.are.equal('DiffsAddNr', gutter_hls[2])
+ assert.are.equal('DiffsAddNr', gutter_hls[3])
+ assert.are.equal('DiffsAddNr', gutter_hls[4])
+ assert.are.equal('DiffsAddNr', gutter_hls[5])
+ assert.are.equal('DiffsAddNr', gutter_hls[6])
+ assert.are.equal('DiffsAddNr', gutter_hls[7])
+ assert.is_nil(gutter_hls[8])
+
+ assert.is_true(marker_text[2] ~= nil)
+ assert.is_nil(marker_text[3])
+ assert.is_true(marker_text[4] ~= nil)
+ assert.is_true(marker_text[5] ~= nil)
+ assert.is_nil(marker_text[6])
+ assert.is_true(marker_text[7] ~= nil)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not apply DiffsConflictMarker in unified diffs', function()
+ local bufnr = create_buffer({
+ '@@ -1,1 +1,4 @@',
+ ' local M = {}',
+ '+<<<<<<< HEAD',
+ '+local x = 1',
+ '+=======',
+ })
+
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ lines = { ' local M = {}', '+<<<<<<< HEAD', '+local x = 1', '+=======' },
+ }
+
+ highlight.highlight_hunk(
+ bufnr,
+ ns,
+ hunk,
+ default_opts({ highlights = { background = true } })
+ )
+
+ local extmarks = get_extmarks(bufnr)
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ assert.is_not_equal('DiffsConflictMarker', d and d.hl_group)
+ end
+ delete_buffer(bufnr)
+ end)
+
it('filters @spell and @nospell captures from injections', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@@ -1386,6 +1283,7 @@ describe('highlight', function()
context = { enabled = false, lines = 0 },
treesitter = { enabled = true, max_lines = 500 },
vim = { enabled = false, max_lines = 200 },
+ priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 },
},
}
end
@@ -1469,7 +1367,7 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('does not apply header highlights when treesitter disabled', function()
+ it('does not apply DiffsClear to header lines for non-quoted diffs', function()
local bufnr = create_buffer({
'diff --git a/parser.lua b/parser.lua',
'index 3e8afa0..018159c 100644',
@@ -1494,19 +1392,439 @@ describe('highlight', function()
},
}
- local opts = default_opts()
- opts.highlights.treesitter.enabled = false
-
- highlight.highlight_hunk(bufnr, ns, hunk, opts)
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
- local header_extmarks = 0
for _, mark in ipairs(extmarks) do
- if mark[2] < 4 and mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then
- header_extmarks = header_extmarks + 1
+ local d = mark[4]
+ if d and d.hl_group == 'DiffsClear' and mark[3] == 0 and mark[2] < 4 then
+ error('unexpected DiffsClear on header row ' .. mark[2] .. ' for non-quoted diff')
end
end
- assert.are.equal(0, header_extmarks)
+ delete_buffer(bufnr)
+ end)
+
+ it('preserves diff grammar treesitter on headers for non-quoted diffs', function()
+ local bufnr = create_buffer({
+ 'diff --git a/parser.lua b/parser.lua',
+ '--- a/parser.lua',
+ '+++ b/parser.lua',
+ '@@ -1,2 +1,3 @@',
+ ' local M = {}',
+ '+local x = 1',
+ })
+
+ local hunk = {
+ filename = 'parser.lua',
+ lang = 'lua',
+ start_line = 4,
+ lines = { ' local M = {}', '+local x = 1' },
+ header_start_line = 1,
+ header_lines = {
+ 'diff --git a/parser.lua b/parser.lua',
+ '--- a/parser.lua',
+ '+++ b/parser.lua',
+ },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local header_ts_count = 0
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if mark[2] < 3 and d and d.hl_group and d.hl_group:match('^@.*%.diff$') then
+ header_ts_count = header_ts_count + 1
+ end
+ end
+ assert.is_true(header_ts_count > 0, 'expected diff grammar treesitter on header lines')
+ delete_buffer(bufnr)
+ end)
+
+ it('applies syntax extmarks to combined diff body lines', function()
+ local bufnr = create_buffer({
+ '@@@ -1,2 -1,2 +1,3 @@@',
+ ' local M = {}',
+ '+ local x = 1',
+ ' -local y = 2',
+ })
+
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ prefix_width = 2,
+ start_line = 1,
+ lines = { ' local M = {}', '+ local x = 1', ' -local y = 2' },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local syntax_on_body = 0
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if mark[2] >= 1 and d and d.hl_group and d.hl_group:match('^@.*%.lua$') then
+ syntax_on_body = syntax_on_body + 1
+ end
+ end
+ assert.is_true(syntax_on_body > 0, 'expected lua treesitter syntax on combined diff body')
+ delete_buffer(bufnr)
+ end)
+
+ it('applies DiffsClear and per-char diff fg to combined diff body prefixes', function()
+ local bufnr = create_buffer({
+ '@@@',
+ ' unchanged',
+ '+ added',
+ ' -removed',
+ '++both',
+ })
+
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ prefix_width = 2,
+ start_line = 1,
+ lines = { ' unchanged', '+ added', ' -removed', '++both' },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local prefix_clears = {}
+ local plus_marks = {}
+ local minus_marks = {}
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if mark[2] >= 1 and d then
+ if d.hl_group == 'DiffsClear' and mark[3] == 0 and d.end_col == 2 then
+ prefix_clears[mark[2]] = true
+ end
+ if d.hl_group == '@diff.plus' and d.priority == 199 then
+ if not plus_marks[mark[2]] then
+ plus_marks[mark[2]] = {}
+ end
+ table.insert(plus_marks[mark[2]], mark[3])
+ end
+ if d.hl_group == '@diff.minus' and d.priority == 199 then
+ if not minus_marks[mark[2]] then
+ minus_marks[mark[2]] = {}
+ end
+ table.insert(minus_marks[mark[2]], mark[3])
+ end
+ end
+ end
+
+ assert.is_true(prefix_clears[1] ~= nil, 'DiffsClear on context prefix')
+ assert.is_true(prefix_clears[2] ~= nil, 'DiffsClear on add prefix')
+ assert.is_true(prefix_clears[3] ~= nil, 'DiffsClear on del prefix')
+ assert.is_true(prefix_clears[4] ~= nil, 'DiffsClear on both-add prefix')
+
+ assert.is_true(plus_marks[2] ~= nil, '@diff.plus on + in "+ added"')
+ assert.are.equal(0, plus_marks[2][1])
+
+ assert.is_true(minus_marks[3] ~= nil, '@diff.minus on - in " -removed"')
+ assert.are.equal(1, minus_marks[3][1])
+
+ assert.is_true(plus_marks[4] ~= nil, '@diff.plus on ++ in "++both"')
+ assert.are.equal(2, #plus_marks[4])
+
+ assert.is_nil(plus_marks[1], 'no @diff.plus on context " unchanged"')
+ assert.is_nil(minus_marks[1], 'no @diff.minus on context " unchanged"')
+ delete_buffer(bufnr)
+ end)
+
+ it('applies DiffsClear to headers for combined diffs', function()
+ local bufnr = create_buffer({
+ 'diff --combined lua/merge/target.lua',
+ 'index abc1234,def5678..ghi9012',
+ '--- a/lua/merge/target.lua',
+ '+++ b/lua/merge/target.lua',
+ '@@@ -1,2 -1,2 +1,3 @@@',
+ ' local M = {}',
+ '+ local x = 1',
+ })
+
+ local hunk = {
+ filename = 'lua/merge/target.lua',
+ lang = 'lua',
+ prefix_width = 2,
+ start_line = 5,
+ lines = { ' local M = {}', '+ local x = 1' },
+ header_start_line = 1,
+ header_lines = {
+ 'diff --combined lua/merge/target.lua',
+ 'index abc1234,def5678..ghi9012',
+ '--- a/lua/merge/target.lua',
+ '+++ b/lua/merge/target.lua',
+ },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local clear_lines = {}
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if d and d.hl_group == 'DiffsClear' and mark[3] == 0 and mark[2] < 4 then
+ clear_lines[mark[2]] = true
+ end
+ end
+ assert.is_true(clear_lines[0] ~= nil, 'DiffsClear on diff --combined line')
+ assert.is_true(clear_lines[1] ~= nil, 'DiffsClear on index line')
+ assert.is_true(clear_lines[2] ~= nil, 'DiffsClear on --- line')
+ assert.is_true(clear_lines[3] ~= nil, 'DiffsClear on +++ line')
+ delete_buffer(bufnr)
+ end)
+
+ it('applies @attribute.diff at syntax priority to @@@ line for combined diffs', function()
+ local bufnr = create_buffer({
+ '@@@ -1,2 -1,2 +1,3 @@@',
+ ' local M = {}',
+ '+ local x = 1',
+ })
+
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ prefix_width = 2,
+ start_line = 1,
+ lines = { ' local M = {}', '+ local x = 1' },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local has_attr = false
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if mark[2] == 0 and d and d.hl_group == '@attribute.diff' and (d.priority or 0) >= 199 then
+ has_attr = true
+ end
+ end
+ assert.is_true(has_attr, '@attribute.diff at p>=199 on @@@ line')
+ delete_buffer(bufnr)
+ end)
+
+ it('applies DiffsClear to @@@ line for combined diffs', function()
+ local bufnr = create_buffer({
+ '@@@ -1,2 -1,2 +1,3 @@@',
+ ' local M = {}',
+ '+ local x = 1',
+ })
+
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ prefix_width = 2,
+ start_line = 1,
+ lines = { ' local M = {}', '+ local x = 1' },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local has_at_clear = false
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if mark[2] == 0 and d and d.hl_group == 'DiffsClear' and mark[3] == 0 then
+ has_at_clear = true
+ end
+ end
+ assert.is_true(has_at_clear, 'DiffsClear on @@@ line')
+ delete_buffer(bufnr)
+ end)
+
+ it('applies header diff grammar at syntax priority for combined diffs', function()
+ local bufnr = create_buffer({
+ 'diff --combined lua/merge/target.lua',
+ 'index abc1234,def5678..ghi9012',
+ '--- a/lua/merge/target.lua',
+ '+++ b/lua/merge/target.lua',
+ '@@@ -1,2 -1,2 +1,3 @@@',
+ ' local M = {}',
+ '+ local x = 1',
+ })
+
+ local hunk = {
+ filename = 'lua/merge/target.lua',
+ lang = 'lua',
+ prefix_width = 2,
+ start_line = 5,
+ lines = { ' local M = {}', '+ local x = 1' },
+ header_start_line = 1,
+ header_lines = {
+ 'diff --combined lua/merge/target.lua',
+ 'index abc1234,def5678..ghi9012',
+ '--- a/lua/merge/target.lua',
+ '+++ b/lua/merge/target.lua',
+ },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local high_prio_diff = {}
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if
+ mark[2] < 4
+ and d
+ and d.hl_group
+ and d.hl_group:match('^@.*%.diff$')
+ and (d.priority or 0) >= 199
+ then
+ high_prio_diff[mark[2]] = true
+ end
+ end
+ assert.is_true(high_prio_diff[2] ~= nil, 'diff grammar at p>=199 on --- line')
+ assert.is_true(high_prio_diff[3] ~= nil, 'diff grammar at p>=199 on +++ line')
+ delete_buffer(bufnr)
+ end)
+
+ it('@diff.minus wins over @punctuation.special on combined diff headers', function()
+ local bufnr = create_buffer({
+ 'diff --combined lua/merge/target.lua',
+ 'index abc1234,def5678..ghi9012',
+ '--- a/lua/merge/target.lua',
+ '+++ b/lua/merge/target.lua',
+ '@@@ -1,2 -1,2 +1,3 @@@',
+ ' local M = {}',
+ '+ local x = 1',
+ })
+
+ local hunk = {
+ filename = 'lua/merge/target.lua',
+ lang = 'lua',
+ prefix_width = 2,
+ start_line = 5,
+ lines = { ' local M = {}', '+ local x = 1' },
+ header_start_line = 1,
+ header_lines = {
+ 'diff --combined lua/merge/target.lua',
+ 'index abc1234,def5678..ghi9012',
+ '--- a/lua/merge/target.lua',
+ '+++ b/lua/merge/target.lua',
+ },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local minus_prio, punct_prio_minus = 0, 0
+ local plus_prio, punct_prio_plus = 0, 0
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if d and d.hl_group then
+ if mark[2] == 2 then
+ if d.hl_group == '@diff.minus.diff' then
+ minus_prio = math.max(minus_prio, d.priority or 0)
+ elseif d.hl_group == '@punctuation.special.diff' then
+ punct_prio_minus = math.max(punct_prio_minus, d.priority or 0)
+ end
+ elseif mark[2] == 3 then
+ if d.hl_group == '@diff.plus.diff' then
+ plus_prio = math.max(plus_prio, d.priority or 0)
+ elseif d.hl_group == '@punctuation.special.diff' then
+ punct_prio_plus = math.max(punct_prio_plus, d.priority or 0)
+ end
+ end
+ end
+ end
+ assert.is_true(
+ minus_prio > punct_prio_minus,
+ '@diff.minus.diff should beat @punctuation.special.diff on --- line'
+ )
+ assert.is_true(
+ plus_prio > punct_prio_plus,
+ '@diff.plus.diff should beat @punctuation.special.diff on +++ line'
+ )
+ delete_buffer(bufnr)
+ end)
+
+ it('applies @keyword.diff on index word for combined diffs', function()
+ local bufnr = create_buffer({
+ 'diff --combined lua/merge/target.lua',
+ 'index abc1234,def5678..ghi9012',
+ '--- a/lua/merge/target.lua',
+ '+++ b/lua/merge/target.lua',
+ '@@@ -1,2 -1,2 +1,3 @@@',
+ ' local M = {}',
+ '+ local x = 1',
+ })
+
+ local hunk = {
+ filename = 'lua/merge/target.lua',
+ lang = 'lua',
+ prefix_width = 2,
+ start_line = 5,
+ lines = { ' local M = {}', '+ local x = 1' },
+ header_start_line = 1,
+ header_lines = {
+ 'diff --combined lua/merge/target.lua',
+ 'index abc1234,def5678..ghi9012',
+ '--- a/lua/merge/target.lua',
+ '+++ b/lua/merge/target.lua',
+ },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local has_keyword = false
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if
+ mark[2] == 1
+ and d
+ and d.hl_group == '@keyword.diff'
+ and mark[3] == 0
+ and (d.end_col or 0) == 5
+ then
+ has_keyword = true
+ end
+ end
+ assert.is_true(has_keyword, '@keyword.diff at row 1, cols 0-5')
+ delete_buffer(bufnr)
+ end)
+
+ it('applies @constant.diff on result hash for combined diffs', function()
+ local bufnr = create_buffer({
+ 'diff --combined lua/merge/target.lua',
+ 'index abc1234,def5678..ghi9012',
+ '--- a/lua/merge/target.lua',
+ '+++ b/lua/merge/target.lua',
+ '@@@ -1,2 -1,2 +1,3 @@@',
+ ' local M = {}',
+ '+ local x = 1',
+ })
+
+ local hunk = {
+ filename = 'lua/merge/target.lua',
+ lang = 'lua',
+ prefix_width = 2,
+ start_line = 5,
+ lines = { ' local M = {}', '+ local x = 1' },
+ header_start_line = 1,
+ header_lines = {
+ 'diff --combined lua/merge/target.lua',
+ 'index abc1234,def5678..ghi9012',
+ '--- a/lua/merge/target.lua',
+ '+++ b/lua/merge/target.lua',
+ },
+ }
+
+ highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
+
+ local extmarks = get_extmarks(bufnr)
+ local has_constant = false
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if mark[2] == 1 and d and d.hl_group == '@constant.diff' and (d.priority or 0) >= 199 then
+ has_constant = true
+ end
+ end
+ assert.is_true(has_constant, '@constant.diff on result hash')
delete_buffer(bufnr)
end)
end)
@@ -1543,40 +1861,11 @@ describe('highlight', function()
context = { enabled = false, lines = 0 },
treesitter = { enabled = true, max_lines = 500 },
vim = { enabled = false, max_lines = 200 },
+ priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 },
},
}
end
- it('uses priority 199 for code languages', function()
- local bufnr = create_buffer({
- '@@ -1,1 +1,2 @@',
- ' local x = 1',
- '+local y = 2',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { ' local x = 1', '+local y = 2' },
- }
-
- highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
-
- local extmarks = get_extmarks(bufnr)
- local has_priority_199 = false
- for _, mark in ipairs(extmarks) do
- if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then
- if mark[4].priority == 199 then
- has_priority_199 = true
- break
- end
- end
- end
- assert.is_true(has_priority_199)
- delete_buffer(bufnr)
- end)
-
it('uses treesitter priority for diff language', function()
local bufnr = create_buffer({
'diff --git a/test.lua b/test.lua',
diff --git a/spec/init_spec.lua b/spec/init_spec.lua
index 1bf71d1..2e1952e 100644
--- a/spec/init_spec.lua
+++ b/spec/init_spec.lua
@@ -24,7 +24,6 @@ describe('diffs', function()
it('accepts full config', function()
vim.g.diffs = {
debug = true,
- debounce_ms = 100,
hide_prefix = false,
highlights = {
background = true,
@@ -46,7 +45,7 @@ describe('diffs', function()
it('accepts partial config', function()
vim.g.diffs = {
- debounce_ms = 25,
+ hide_prefix = true,
}
assert.has_no.errors(function()
diffs.attach()
@@ -152,6 +151,247 @@ describe('diffs', function()
end)
end)
+ describe('find_visible_hunks', function()
+ local find_visible_hunks = diffs._test.find_visible_hunks
+
+ local function make_hunk(start_row, end_row, opts)
+ local lines = {}
+ for i = 1, end_row - start_row + 1 do
+ lines[i] = 'line' .. i
+ end
+ local h = { start_line = start_row + 1, lines = lines }
+ if opts and opts.header_start_line then
+ h.header_start_line = opts.header_start_line
+ end
+ return h
+ end
+
+ it('returns (0, 0) for empty hunk list', function()
+ local first, last = find_visible_hunks({}, 0, 50)
+ assert.are.equal(0, first)
+ assert.are.equal(0, last)
+ end)
+
+ it('finds single hunk fully inside viewport', function()
+ local h = make_hunk(5, 10)
+ local first, last = find_visible_hunks({ h }, 0, 50)
+ assert.are.equal(1, first)
+ assert.are.equal(1, last)
+ end)
+
+ it('returns (0, 0) for single hunk fully above viewport', function()
+ local h = make_hunk(5, 10)
+ local first, last = find_visible_hunks({ h }, 20, 50)
+ assert.are.equal(0, first)
+ assert.are.equal(0, last)
+ end)
+
+ it('returns (0, 0) for single hunk fully below viewport', function()
+ local h = make_hunk(50, 60)
+ local first, last = find_visible_hunks({ h }, 0, 20)
+ assert.are.equal(0, first)
+ assert.are.equal(0, last)
+ end)
+
+ it('finds single hunk partially visible at top edge', function()
+ local h = make_hunk(5, 15)
+ local first, last = find_visible_hunks({ h }, 10, 30)
+ assert.are.equal(1, first)
+ assert.are.equal(1, last)
+ end)
+
+ it('finds single hunk partially visible at bottom edge', function()
+ local h = make_hunk(25, 35)
+ local first, last = find_visible_hunks({ h }, 10, 30)
+ assert.are.equal(1, first)
+ assert.are.equal(1, last)
+ end)
+
+ it('finds subset of visible hunks', function()
+ local h1 = make_hunk(5, 10)
+ local h2 = make_hunk(25, 30)
+ local h3 = make_hunk(55, 60)
+ local first, last = find_visible_hunks({ h1, h2, h3 }, 20, 40)
+ assert.are.equal(2, first)
+ assert.are.equal(2, last)
+ end)
+
+ it('finds all hunks when all are visible', function()
+ local h1 = make_hunk(5, 10)
+ local h2 = make_hunk(15, 20)
+ local h3 = make_hunk(25, 30)
+ local first, last = find_visible_hunks({ h1, h2, h3 }, 0, 50)
+ assert.are.equal(1, first)
+ assert.are.equal(3, last)
+ end)
+
+ it('returns (0, 0) when no hunks are visible', function()
+ local h1 = make_hunk(5, 10)
+ local h2 = make_hunk(15, 20)
+ local first, last = find_visible_hunks({ h1, h2 }, 30, 50)
+ assert.are.equal(0, first)
+ assert.are.equal(0, last)
+ end)
+
+ it('uses header_start_line for top boundary', function()
+ local h = make_hunk(5, 10, { header_start_line = 4 })
+ local first, last = find_visible_hunks({ h }, 0, 50)
+ assert.are.equal(1, first)
+ assert.are.equal(1, last)
+ end)
+
+ it('finds both adjacent hunks at viewport edge', function()
+ local h1 = make_hunk(10, 20)
+ local h2 = make_hunk(20, 30)
+ local first, last = find_visible_hunks({ h1, h2 }, 15, 25)
+ assert.are.equal(1, first)
+ assert.are.equal(2, last)
+ end)
+ end)
+
+ describe('hunk_cache', function()
+ local function create_buffer(lines)
+ local bufnr = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
+ return bufnr
+ end
+
+ local function delete_buffer(bufnr)
+ if vim.api.nvim_buf_is_valid(bufnr) then
+ vim.api.nvim_buf_delete(bufnr, { force = true })
+ end
+ end
+
+ it('creates entry on attach', function()
+ local bufnr = create_buffer({
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_not_nil(entry)
+ assert.is_table(entry.hunks)
+ assert.is_number(entry.tick)
+ assert.is_true(entry.tick >= 0)
+ delete_buffer(bufnr)
+ end)
+
+ it('is idempotent on repeated attach', function()
+ local bufnr = create_buffer({
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ local entry1 = diffs._test.hunk_cache[bufnr]
+ local tick1 = entry1.tick
+ local hunks1 = entry1.hunks
+ diffs._test.ensure_cache(bufnr)
+ local entry2 = diffs._test.hunk_cache[bufnr]
+ assert.are.equal(tick1, entry2.tick)
+ assert.are.equal(hunks1, entry2.hunks)
+ delete_buffer(bufnr)
+ end)
+
+ it('marks stale on invalidate', function()
+ local bufnr = create_buffer({})
+ diffs.attach(bufnr)
+ diffs._test.invalidate_cache(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.are.equal(-1, entry.tick)
+ assert.is_true(entry.pending_clear)
+ delete_buffer(bufnr)
+ end)
+
+ it('evicts on buffer wipeout', function()
+ local bufnr = create_buffer({})
+ diffs.attach(bufnr)
+ assert.is_not_nil(diffs._test.hunk_cache[bufnr])
+ vim.api.nvim_buf_delete(bufnr, { force = true })
+ assert.is_nil(diffs._test.hunk_cache[bufnr])
+ end)
+
+ it('detects content change via tick', function()
+ local bufnr = create_buffer({
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ local tick_before = diffs._test.hunk_cache[bufnr].tick
+ vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' })
+ diffs._test.ensure_cache(bufnr)
+ local tick_after = diffs._test.hunk_cache[bufnr].tick
+ assert.is_true(tick_after > tick_before)
+ delete_buffer(bufnr)
+ end)
+ end)
+
+ describe('compute_filetypes', function()
+ local compute = diffs.compute_filetypes
+
+ it('returns core filetypes with empty config', function()
+ local fts = compute({})
+ assert.are.same({ 'git', 'gitcommit' }, fts)
+ end)
+
+ it('includes fugitive when fugitive = true', function()
+ local fts = compute({ fugitive = true })
+ assert.is_true(vim.tbl_contains(fts, 'fugitive'))
+ end)
+
+ it('includes fugitive when fugitive is a table', function()
+ local fts = compute({ fugitive = { horizontal = 'dd' } })
+ assert.is_true(vim.tbl_contains(fts, 'fugitive'))
+ end)
+
+ it('excludes fugitive when fugitive = false', function()
+ local fts = compute({ fugitive = false })
+ assert.is_false(vim.tbl_contains(fts, 'fugitive'))
+ end)
+
+ it('excludes fugitive when fugitive is nil', function()
+ local fts = compute({})
+ assert.is_false(vim.tbl_contains(fts, 'fugitive'))
+ end)
+
+ it('includes neogit filetypes when neogit = true', function()
+ local fts = compute({ neogit = true })
+ assert.is_true(vim.tbl_contains(fts, 'NeogitStatus'))
+ assert.is_true(vim.tbl_contains(fts, 'NeogitCommitView'))
+ assert.is_true(vim.tbl_contains(fts, 'NeogitDiffView'))
+ end)
+
+ it('includes neogit filetypes when neogit is a table', function()
+ local fts = compute({ neogit = {} })
+ assert.is_true(vim.tbl_contains(fts, 'NeogitStatus'))
+ end)
+
+ it('excludes neogit when neogit = false', function()
+ local fts = compute({ neogit = false })
+ assert.is_false(vim.tbl_contains(fts, 'NeogitStatus'))
+ end)
+
+ it('excludes neogit when neogit is nil', function()
+ local fts = compute({})
+ assert.is_false(vim.tbl_contains(fts, 'NeogitStatus'))
+ end)
+
+ it('includes extra_filetypes', function()
+ local fts = compute({ extra_filetypes = { 'diff' } })
+ assert.is_true(vim.tbl_contains(fts, 'diff'))
+ end)
+
+ it('combines fugitive, neogit, and extra_filetypes', function()
+ local fts = compute({ fugitive = true, neogit = true, extra_filetypes = { 'diff' } })
+ assert.is_true(vim.tbl_contains(fts, 'git'))
+ assert.is_true(vim.tbl_contains(fts, 'fugitive'))
+ assert.is_true(vim.tbl_contains(fts, 'NeogitStatus'))
+ assert.is_true(vim.tbl_contains(fts, 'diff'))
+ end)
+ end)
+
describe('diff mode', function()
local function create_diff_window()
vim.cmd('new')
diff --git a/spec/integration_spec.lua b/spec/integration_spec.lua
new file mode 100644
index 0000000..23e870b
--- /dev/null
+++ b/spec/integration_spec.lua
@@ -0,0 +1,434 @@
+require('spec.helpers')
+local diffs = require('diffs')
+local highlight = require('diffs.highlight')
+
+local function setup_highlight_groups()
+ local normal = vim.api.nvim_get_hl(0, { name = 'Normal' })
+ local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' })
+ local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' })
+ vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 })
+ vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg or 0x2e4a3a })
+ vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg or 0x4a2e3a })
+ vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
+ vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
+end
+
+local function create_buffer(lines)
+ local bufnr = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
+ return bufnr
+end
+
+local function delete_buffer(bufnr)
+ if vim.api.nvim_buf_is_valid(bufnr) then
+ vim.api.nvim_buf_delete(bufnr, { force = true })
+ end
+end
+
+local function get_diffs_ns()
+ return vim.api.nvim_get_namespaces()['diffs']
+end
+
+local function get_extmarks(bufnr, ns)
+ return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })
+end
+
+local function highlight_opts_with_background()
+ return {
+ hide_prefix = false,
+ highlights = {
+ background = true,
+ gutter = false,
+ context = { enabled = false, lines = 0 },
+ treesitter = { enabled = true, max_lines = 500 },
+ vim = { enabled = false, max_lines = 200 },
+ intra = { enabled = false, algorithm = 'default', max_lines = 500 },
+ priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 },
+ },
+ }
+end
+
+describe('integration', function()
+ before_each(function()
+ setup_highlight_groups()
+ end)
+
+ describe('attach and parse', function()
+ it('attach populates hunk cache for unified diff buffer', function()
+ local bufnr = create_buffer({
+ 'diff --git a/foo.lua b/foo.lua',
+ 'index abc..def 100644',
+ '--- a/foo.lua',
+ '+++ b/foo.lua',
+ '@@ -1,3 +1,3 @@',
+ ' local x = 1',
+ '-local y = 2',
+ '+local y = 3',
+ ' local z = 4',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_not_nil(entry)
+ assert.are.equal(1, #entry.hunks)
+ assert.are.equal('foo.lua', entry.hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('attach parses multiple hunks across multiple files', function()
+ local bufnr = create_buffer({
+ 'M foo.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ 'M bar.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local a = 1',
+ '+local b = 2',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_not_nil(entry)
+ assert.are.equal(2, #entry.hunks)
+ delete_buffer(bufnr)
+ end)
+
+ it('re-attach on same buffer is idempotent', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ local entry_before = diffs._test.hunk_cache[bufnr]
+ local tick_before = entry_before.tick
+ diffs.attach(bufnr)
+ local entry_after = diffs._test.hunk_cache[bufnr]
+ assert.are.equal(tick_before, entry_after.tick)
+ delete_buffer(bufnr)
+ end)
+
+ it('refresh after content change invalidates cache', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ local tick_before = diffs._test.hunk_cache[bufnr].tick
+ vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' })
+ diffs.refresh(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.are.equal(-1, entry.tick)
+ assert.is_true(entry.pending_clear)
+ assert.is_true(tick_before >= 0)
+ delete_buffer(bufnr)
+ end)
+ end)
+
+ describe('ft_retry_pending', function()
+ before_each(function()
+ rawset(vim.fn, 'did_filetype', function()
+ return 1
+ end)
+ require('diffs.parser')._test.ft_lang_cache = {}
+ end)
+
+ after_each(function()
+ rawset(vim.fn, 'did_filetype', nil)
+ end)
+
+ it('sets ft_retry_pending when nil-ft hunks detected under did_filetype', function()
+ local bufnr = create_buffer({
+ 'diff --git a/app.conf b/app.conf',
+ '@@ -1,2 +1,2 @@',
+ ' server {',
+ '- listen 80;',
+ '+ listen 8080;',
+ })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_not_nil(entry)
+ assert.is_nil(entry.hunks[1].ft)
+ assert.is_true(diffs._test.ft_retry_pending[bufnr] == true)
+ delete_buffer(bufnr)
+ end)
+
+ it('clears ft_retry_pending after scheduled callback fires', function()
+ local bufnr = create_buffer({
+ 'diff --git a/app.conf b/app.conf',
+ '@@ -1,2 +1,2 @@',
+ ' server {',
+ '- listen 80;',
+ '+ listen 8080;',
+ })
+ diffs.attach(bufnr)
+ assert.is_true(diffs._test.ft_retry_pending[bufnr] == true)
+
+ local done = false
+ vim.schedule(function()
+ done = true
+ end)
+ vim.wait(1000, function()
+ return done
+ end)
+
+ assert.is_nil(diffs._test.ft_retry_pending[bufnr])
+ delete_buffer(bufnr)
+ end)
+
+ it('invalidates cache after scheduled callback fires', function()
+ local bufnr = create_buffer({
+ 'diff --git a/app.conf b/app.conf',
+ '@@ -1,2 +1,2 @@',
+ ' server {',
+ '- listen 80;',
+ '+ listen 8080;',
+ })
+ diffs.attach(bufnr)
+ local tick_after_attach = diffs._test.hunk_cache[bufnr].tick
+ assert.is_true(tick_after_attach >= 0)
+
+ local done = false
+ vim.schedule(function()
+ done = true
+ end)
+ vim.wait(1000, function()
+ return done
+ end)
+
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.are.equal(-1, entry.tick)
+ assert.is_true(entry.pending_clear)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not set ft_retry_pending when did_filetype() is zero', function()
+ rawset(vim.fn, 'did_filetype', nil)
+ local bufnr = create_buffer({
+ 'diff --git a/test.sh b/test.sh',
+ '@@ -1,2 +1,3 @@',
+ ' #!/usr/bin/env bash',
+ '-old line',
+ '+new line',
+ })
+ diffs.attach(bufnr)
+ assert.is_falsy(diffs._test.ft_retry_pending[bufnr])
+ delete_buffer(bufnr)
+ end)
+
+ it('does not set ft_retry_pending for files with resolvable ft', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ assert.is_falsy(diffs._test.ft_retry_pending[bufnr])
+ delete_buffer(bufnr)
+ end)
+ end)
+
+ describe('extmarks from highlight pipeline', function()
+ it('DiffsAdd background applied to + lines', function()
+ local bufnr = create_buffer({
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ local ns = vim.api.nvim_create_namespace('diffs_integration_test_add')
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ lines = { ' local x = 1', '+local y = 2' },
+ }
+ highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background())
+ local extmarks = get_extmarks(bufnr, ns)
+ local has_diff_add = false
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
+ has_diff_add = true
+ break
+ end
+ end
+ assert.is_true(has_diff_add)
+ delete_buffer(bufnr)
+ end)
+
+ it('DiffsDelete background applied to - lines', function()
+ local bufnr = create_buffer({
+ '@@ -1,2 +1,1 @@',
+ ' local x = 1',
+ '-local y = 2',
+ })
+ local ns = vim.api.nvim_create_namespace('diffs_integration_test_del')
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ lines = { ' local x = 1', '-local y = 2' },
+ }
+ highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background())
+ local extmarks = get_extmarks(bufnr, ns)
+ local has_diff_delete = false
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then
+ has_diff_delete = true
+ break
+ end
+ end
+ assert.is_true(has_diff_delete)
+ delete_buffer(bufnr)
+ end)
+
+ it('mixed hunk produces both DiffsAdd and DiffsDelete backgrounds', function()
+ local bufnr = create_buffer({
+ '@@ -1,2 +1,2 @@',
+ '-local x = 1',
+ '+local x = 2',
+ })
+ local ns = vim.api.nvim_create_namespace('diffs_integration_test_mixed')
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ lines = { '-local x = 1', '+local x = 2' },
+ }
+ highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background())
+ local extmarks = get_extmarks(bufnr, ns)
+ local has_add = false
+ local has_delete = false
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
+ has_add = true
+ end
+ if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then
+ has_delete = true
+ end
+ end
+ assert.is_true(has_add)
+ assert.is_true(has_delete)
+ delete_buffer(bufnr)
+ end)
+
+ it('no background extmarks for context lines', function()
+ local bufnr = create_buffer({
+ '@@ -1,3 +1,3 @@',
+ ' local x = 1',
+ '-local y = 2',
+ '+local y = 3',
+ ' local z = 4',
+ })
+ local ns = vim.api.nvim_create_namespace('diffs_integration_test_ctx')
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ lines = { ' local x = 1', '-local y = 2', '+local y = 3', ' local z = 4' },
+ }
+ highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background())
+ local extmarks = get_extmarks(bufnr, ns)
+ local line_bgs = {}
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then
+ line_bgs[mark[2]] = d.line_hl_group
+ end
+ end
+ assert.is_nil(line_bgs[1])
+ assert.is_nil(line_bgs[4])
+ assert.are.equal('DiffsDelete', line_bgs[2])
+ assert.are.equal('DiffsAdd', line_bgs[3])
+ delete_buffer(bufnr)
+ end)
+
+ it('treesitter extmarks applied for lua hunks', function()
+ local bufnr = create_buffer({
+ '@@ -1,2 +1,3 @@',
+ ' local x = 1',
+ '+local y = 2',
+ ' return x',
+ })
+ local ns = vim.api.nvim_create_namespace('diffs_integration_test_ts')
+ local hunk = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ lines = { ' local x = 1', '+local y = 2', ' return x' },
+ }
+ highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background())
+ local extmarks = get_extmarks(bufnr, ns)
+ local has_ts = false
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then
+ has_ts = true
+ break
+ end
+ end
+ assert.is_true(has_ts)
+ delete_buffer(bufnr)
+ end)
+
+ it('diffs namespace exists after attach', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ diffs.attach(bufnr)
+ local ns = get_diffs_ns()
+ assert.is_not_nil(ns)
+ assert.is_number(ns)
+ delete_buffer(bufnr)
+ end)
+ end)
+
+ describe('multiple hunks highlighting', function()
+ it('both hunks in multi-hunk buffer get background extmarks', function()
+ local bufnr = create_buffer({
+ '@@ -1,2 +1,2 @@',
+ '-local x = 1',
+ '+local x = 10',
+ '@@ -10,2 +10,2 @@',
+ '-local y = 2',
+ '+local y = 20',
+ })
+ local ns = vim.api.nvim_create_namespace('diffs_integration_test_multi')
+ local hunk1 = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 1,
+ lines = { '-local x = 1', '+local x = 10' },
+ }
+ local hunk2 = {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 4,
+ lines = { '-local y = 2', '+local y = 20' },
+ }
+ highlight.highlight_hunk(bufnr, ns, hunk1, highlight_opts_with_background())
+ highlight.highlight_hunk(bufnr, ns, hunk2, highlight_opts_with_background())
+ local extmarks = get_extmarks(bufnr, ns)
+ local add_lines = {}
+ local del_lines = {}
+ for _, mark in ipairs(extmarks) do
+ local d = mark[4]
+ if d and d.line_hl_group == 'DiffsAdd' then
+ add_lines[mark[2]] = true
+ end
+ if d and d.line_hl_group == 'DiffsDelete' then
+ del_lines[mark[2]] = true
+ end
+ end
+ assert.is_true(del_lines[1] ~= nil)
+ assert.is_true(add_lines[2] ~= nil)
+ assert.is_true(del_lines[4] ~= nil)
+ assert.is_true(add_lines[5] ~= nil)
+ delete_buffer(bufnr)
+ end)
+ end)
+end)
diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua
new file mode 100644
index 0000000..5e4b854
--- /dev/null
+++ b/spec/merge_spec.lua
@@ -0,0 +1,815 @@
+local helpers = require('spec.helpers')
+local merge = require('diffs.merge')
+
+local function default_config(overrides)
+ local cfg = {
+ enabled = true,
+ disable_diagnostics = false,
+ show_virtual_text = true,
+ show_actions = false,
+ keymaps = {
+ ours = 'doo',
+ theirs = 'dot',
+ both = 'dob',
+ none = 'don',
+ next = ']c',
+ prev = '[c',
+ },
+ }
+ if overrides then
+ cfg = vim.tbl_deep_extend('force', cfg, overrides)
+ end
+ return cfg
+end
+
+local function create_diff_buffer(lines, working_path)
+ local bufnr = helpers.create_buffer(lines)
+ if working_path then
+ vim.api.nvim_buf_set_var(bufnr, 'diffs_working_path', working_path)
+ end
+ return bufnr
+end
+
+local function create_working_buffer(lines, name)
+ local bufnr = vim.api.nvim_create_buf(true, false)
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
+ if name then
+ vim.api.nvim_buf_set_name(bufnr, name)
+ end
+ return bufnr
+end
+
+describe('merge', function()
+ describe('parse_hunks', function()
+ it('parses a single hunk', function()
+ local bufnr = helpers.create_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,3 +1,3 @@',
+ ' local M = {}',
+ '-local x = 1',
+ '+local x = 2',
+ ' return M',
+ })
+
+ local hunks = merge.parse_hunks(bufnr)
+ assert.are.equal(1, #hunks)
+ assert.are.equal(3, hunks[1].start_line)
+ assert.are.equal(7, hunks[1].end_line)
+ assert.are.same({ 'local x = 1' }, hunks[1].del_lines)
+ assert.are.same({ 'local x = 2' }, hunks[1].add_lines)
+
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('parses multiple hunks', function()
+ local bufnr = helpers.create_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,3 +1,3 @@',
+ ' local M = {}',
+ '-local x = 1',
+ '+local x = 2',
+ ' return M',
+ '@@ -10,3 +10,3 @@',
+ ' function M.foo()',
+ '- return 1',
+ '+ return 2',
+ ' end',
+ })
+
+ local hunks = merge.parse_hunks(bufnr)
+ assert.are.equal(2, #hunks)
+ assert.are.equal(3, hunks[1].start_line)
+ assert.are.equal(8, hunks[2].start_line)
+
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('parses add-only hunk', function()
+ local bufnr = helpers.create_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,2 +1,3 @@',
+ ' local M = {}',
+ '+local new = true',
+ ' return M',
+ })
+
+ local hunks = merge.parse_hunks(bufnr)
+ assert.are.equal(1, #hunks)
+ assert.are.same({}, hunks[1].del_lines)
+ assert.are.same({ 'local new = true' }, hunks[1].add_lines)
+
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('parses delete-only hunk', function()
+ local bufnr = helpers.create_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,3 +1,2 @@',
+ ' local M = {}',
+ '-local old = false',
+ ' return M',
+ })
+
+ local hunks = merge.parse_hunks(bufnr)
+ assert.are.equal(1, #hunks)
+ assert.are.same({ 'local old = false' }, hunks[1].del_lines)
+ assert.are.same({}, hunks[1].add_lines)
+
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('returns empty for buffer with no hunks', function()
+ local bufnr = helpers.create_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ })
+
+ local hunks = merge.parse_hunks(bufnr)
+ assert.are.equal(0, #hunks)
+
+ helpers.delete_buffer(bufnr)
+ end)
+ end)
+
+ describe('match_hunk_to_conflict', function()
+ it('matches hunk to conflict region', function()
+ local working_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ }, '/tmp/diffs_test_match.lua')
+
+ local hunk = {
+ index = 1,
+ start_line = 3,
+ end_line = 7,
+ del_lines = { 'local x = 1' },
+ add_lines = { 'local x = 2' },
+ }
+
+ local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
+ assert.is_not_nil(region)
+ assert.are.equal(0, region.marker_ours)
+
+ helpers.delete_buffer(working_bufnr)
+ end)
+
+ it('returns nil for auto-merged content', function()
+ local working_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ }, '/tmp/diffs_test_auto.lua')
+
+ local hunk = {
+ index = 1,
+ start_line = 3,
+ end_line = 7,
+ del_lines = { 'local y = 3' },
+ add_lines = { 'local y = 4' },
+ }
+
+ local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
+ assert.is_nil(region)
+
+ helpers.delete_buffer(working_bufnr)
+ end)
+
+ it('matches with empty ours section', function()
+ local working_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ }, '/tmp/diffs_test_empty_ours.lua')
+
+ local hunk = {
+ index = 1,
+ start_line = 3,
+ end_line = 5,
+ del_lines = {},
+ add_lines = { 'local x = 2' },
+ }
+
+ local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
+ assert.is_not_nil(region)
+
+ helpers.delete_buffer(working_bufnr)
+ end)
+
+ it('matches correct region among multiple conflicts', function()
+ local working_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local a = 1',
+ '=======',
+ 'local a = 2',
+ '>>>>>>> feature',
+ 'middle',
+ '<<<<<<< HEAD',
+ 'local b = 3',
+ '=======',
+ 'local b = 4',
+ '>>>>>>> feature',
+ }, '/tmp/diffs_test_multi.lua')
+
+ local hunk = {
+ index = 2,
+ start_line = 8,
+ end_line = 12,
+ del_lines = { 'local b = 3' },
+ add_lines = { 'local b = 4' },
+ }
+
+ local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
+ assert.is_not_nil(region)
+ assert.are.equal(6, region.marker_ours)
+
+ helpers.delete_buffer(working_bufnr)
+ end)
+
+ it('matches with diff3 format', function()
+ local working_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '||||||| base',
+ 'local x = 0',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ }, '/tmp/diffs_test_diff3.lua')
+
+ local hunk = {
+ index = 1,
+ start_line = 3,
+ end_line = 7,
+ del_lines = { 'local x = 1' },
+ add_lines = { 'local x = 2' },
+ }
+
+ local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
+ assert.is_not_nil(region)
+ assert.are.equal(2, region.marker_base)
+
+ helpers.delete_buffer(working_bufnr)
+ end)
+ end)
+
+ describe('resolution', function()
+ local diff_bufnr, working_bufnr
+
+ local function setup_buffers()
+ local working_path = '/tmp/diffs_test_resolve.lua'
+ working_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ }, working_path)
+
+ diff_bufnr = create_diff_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,1 +1,1 @@',
+ '-local x = 1',
+ '+local x = 2',
+ }, working_path)
+ vim.api.nvim_set_current_buf(diff_bufnr)
+ end
+
+ local function cleanup()
+ helpers.delete_buffer(diff_bufnr)
+ helpers.delete_buffer(working_bufnr)
+ end
+
+ it('resolve_ours keeps ours content in working file', function()
+ setup_buffers()
+ vim.api.nvim_win_set_cursor(0, { 5, 0 })
+
+ merge.resolve_ours(diff_bufnr, default_config())
+
+ local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
+ assert.are.equal(1, #lines)
+ assert.are.equal('local x = 1', lines[1])
+
+ cleanup()
+ end)
+
+ it('resolve_theirs keeps theirs content in working file', function()
+ setup_buffers()
+ vim.api.nvim_win_set_cursor(0, { 5, 0 })
+
+ merge.resolve_theirs(diff_bufnr, default_config())
+
+ local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
+ assert.are.equal(1, #lines)
+ assert.are.equal('local x = 2', lines[1])
+
+ cleanup()
+ end)
+
+ it('resolve_both keeps ours then theirs in working file', function()
+ setup_buffers()
+ vim.api.nvim_win_set_cursor(0, { 5, 0 })
+
+ merge.resolve_both(diff_bufnr, default_config())
+
+ local lines = vim.api.nvim_buf_get_lines(working_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])
+
+ cleanup()
+ end)
+
+ it('resolve_none removes entire block from working file', function()
+ setup_buffers()
+ vim.api.nvim_win_set_cursor(0, { 5, 0 })
+
+ merge.resolve_none(diff_bufnr, default_config())
+
+ local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
+ assert.are.equal(1, #lines)
+ assert.are.equal('', lines[1])
+
+ cleanup()
+ end)
+
+ it('tracks resolved hunks', function()
+ setup_buffers()
+ vim.api.nvim_win_set_cursor(0, { 5, 0 })
+
+ assert.is_false(merge.is_resolved(diff_bufnr, 1))
+ merge.resolve_ours(diff_bufnr, default_config())
+ assert.is_true(merge.is_resolved(diff_bufnr, 1))
+
+ cleanup()
+ end)
+
+ it('adds virtual text for resolved hunks', function()
+ setup_buffers()
+ vim.api.nvim_win_set_cursor(0, { 5, 0 })
+
+ merge.resolve_ours(diff_bufnr, default_config())
+
+ local extmarks =
+ vim.api.nvim_buf_get_extmarks(diff_bufnr, merge.get_namespace(), 0, -1, { details = true })
+ local has_resolved_text = false
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].virt_text then
+ for _, chunk in ipairs(mark[4].virt_text) do
+ if chunk[1]:match('resolved') then
+ has_resolved_text = true
+ end
+ end
+ end
+ end
+ assert.is_true(has_resolved_text)
+
+ cleanup()
+ end)
+
+ it('notifies when hunk is already resolved', function()
+ setup_buffers()
+ vim.api.nvim_win_set_cursor(0, { 5, 0 })
+
+ merge.resolve_ours(diff_bufnr, default_config())
+
+ local notified = false
+ local orig_notify = vim.notify
+ vim.notify = function(msg)
+ if msg:match('already resolved') then
+ notified = true
+ end
+ end
+
+ merge.resolve_ours(diff_bufnr, default_config())
+ vim.notify = orig_notify
+
+ assert.is_true(notified)
+
+ cleanup()
+ end)
+
+ it('notifies when hunk does not match a conflict', function()
+ local working_path = '/tmp/diffs_test_no_conflict.lua'
+ local w_bufnr = create_working_buffer({
+ 'local y = 1',
+ }, working_path)
+
+ local d_bufnr = create_diff_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,1 +1,1 @@',
+ '-local x = 1',
+ '+local x = 2',
+ }, working_path)
+ vim.api.nvim_set_current_buf(d_bufnr)
+ vim.api.nvim_win_set_cursor(0, { 5, 0 })
+
+ local notified = false
+ local orig_notify = vim.notify
+ vim.notify = function(msg)
+ if msg:match('does not correspond') then
+ notified = true
+ end
+ end
+
+ merge.resolve_ours(d_bufnr, default_config())
+ vim.notify = orig_notify
+
+ assert.is_true(notified)
+
+ helpers.delete_buffer(d_bufnr)
+ helpers.delete_buffer(w_bufnr)
+ end)
+ end)
+
+ describe('navigation', function()
+ it('goto_next jumps to next conflict hunk', function()
+ local working_path = '/tmp/diffs_test_nav.lua'
+ local w_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local a = 1',
+ '=======',
+ 'local a = 2',
+ '>>>>>>> feature',
+ 'middle',
+ '<<<<<<< HEAD',
+ 'local b = 3',
+ '=======',
+ 'local b = 4',
+ '>>>>>>> feature',
+ }, working_path)
+
+ local d_bufnr = create_diff_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,1 +1,1 @@',
+ '-local a = 1',
+ '+local a = 2',
+ '@@ -5,1 +5,1 @@',
+ '-local b = 3',
+ '+local b = 4',
+ }, working_path)
+ vim.api.nvim_set_current_buf(d_bufnr)
+ vim.api.nvim_win_set_cursor(0, { 1, 0 })
+
+ merge.goto_next(d_bufnr)
+ assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
+
+ merge.goto_next(d_bufnr)
+ assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
+
+ helpers.delete_buffer(d_bufnr)
+ helpers.delete_buffer(w_bufnr)
+ end)
+
+ it('goto_next wraps around', function()
+ local working_path = '/tmp/diffs_test_wrap.lua'
+ local w_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ }, working_path)
+
+ local d_bufnr = create_diff_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,1 +1,1 @@',
+ '-local x = 1',
+ '+local x = 2',
+ }, working_path)
+ vim.api.nvim_set_current_buf(d_bufnr)
+ vim.api.nvim_win_set_cursor(0, { 6, 0 })
+
+ merge.goto_next(d_bufnr)
+ assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
+
+ helpers.delete_buffer(d_bufnr)
+ helpers.delete_buffer(w_bufnr)
+ end)
+
+ it('goto_next notifies on wrap-around', function()
+ local working_path = '/tmp/diffs_test_wrap_notify.lua'
+ local w_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ }, working_path)
+
+ local d_bufnr = create_diff_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,1 +1,1 @@',
+ '-local x = 1',
+ '+local x = 2',
+ }, working_path)
+ vim.api.nvim_set_current_buf(d_bufnr)
+ vim.api.nvim_win_set_cursor(0, { 6, 0 })
+
+ local notified = false
+ local orig_notify = vim.notify
+ vim.notify = function(msg)
+ if msg:match('wrapped to first hunk') then
+ notified = true
+ end
+ end
+
+ merge.goto_next(d_bufnr)
+ vim.notify = orig_notify
+
+ assert.is_true(notified)
+
+ helpers.delete_buffer(d_bufnr)
+ helpers.delete_buffer(w_bufnr)
+ end)
+
+ it('goto_prev jumps to previous conflict hunk', function()
+ local working_path = '/tmp/diffs_test_prev.lua'
+ local w_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local a = 1',
+ '=======',
+ 'local a = 2',
+ '>>>>>>> feature',
+ 'middle',
+ '<<<<<<< HEAD',
+ 'local b = 3',
+ '=======',
+ 'local b = 4',
+ '>>>>>>> feature',
+ }, working_path)
+
+ local d_bufnr = create_diff_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,1 +1,1 @@',
+ '-local a = 1',
+ '+local a = 2',
+ '@@ -5,1 +5,1 @@',
+ '-local b = 3',
+ '+local b = 4',
+ }, working_path)
+ vim.api.nvim_set_current_buf(d_bufnr)
+ vim.api.nvim_win_set_cursor(0, { 9, 0 })
+
+ merge.goto_prev(d_bufnr)
+ assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
+
+ merge.goto_prev(d_bufnr)
+ assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
+
+ helpers.delete_buffer(d_bufnr)
+ helpers.delete_buffer(w_bufnr)
+ end)
+
+ it('goto_prev wraps around', function()
+ local working_path = '/tmp/diffs_test_prev_wrap.lua'
+ local w_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ }, working_path)
+
+ local d_bufnr = create_diff_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,1 +1,1 @@',
+ '-local x = 1',
+ '+local x = 2',
+ }, working_path)
+ vim.api.nvim_set_current_buf(d_bufnr)
+ vim.api.nvim_win_set_cursor(0, { 1, 0 })
+
+ merge.goto_prev(d_bufnr)
+ assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
+
+ helpers.delete_buffer(d_bufnr)
+ helpers.delete_buffer(w_bufnr)
+ end)
+
+ it('goto_prev notifies on wrap-around', function()
+ local working_path = '/tmp/diffs_test_prev_wrap_notify.lua'
+ local w_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ }, working_path)
+
+ local d_bufnr = create_diff_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,1 +1,1 @@',
+ '-local x = 1',
+ '+local x = 2',
+ }, working_path)
+ vim.api.nvim_set_current_buf(d_bufnr)
+ vim.api.nvim_win_set_cursor(0, { 1, 0 })
+
+ local notified = false
+ local orig_notify = vim.notify
+ vim.notify = function(msg)
+ if msg:match('wrapped to last hunk') then
+ notified = true
+ end
+ end
+
+ merge.goto_prev(d_bufnr)
+ vim.notify = orig_notify
+
+ assert.is_true(notified)
+
+ helpers.delete_buffer(d_bufnr)
+ helpers.delete_buffer(w_bufnr)
+ end)
+
+ it('skips resolved hunks', function()
+ local working_path = '/tmp/diffs_test_skip_resolved.lua'
+ local w_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local a = 1',
+ '=======',
+ 'local a = 2',
+ '>>>>>>> feature',
+ 'middle',
+ '<<<<<<< HEAD',
+ 'local b = 3',
+ '=======',
+ 'local b = 4',
+ '>>>>>>> feature',
+ }, working_path)
+
+ local d_bufnr = create_diff_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,1 +1,1 @@',
+ '-local a = 1',
+ '+local a = 2',
+ '@@ -5,1 +5,1 @@',
+ '-local b = 3',
+ '+local b = 4',
+ }, working_path)
+ vim.api.nvim_set_current_buf(d_bufnr)
+ vim.api.nvim_win_set_cursor(0, { 5, 0 })
+
+ merge.resolve_ours(d_bufnr, default_config())
+
+ vim.api.nvim_win_set_cursor(0, { 1, 0 })
+ merge.goto_next(d_bufnr)
+ assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
+
+ helpers.delete_buffer(d_bufnr)
+ helpers.delete_buffer(w_bufnr)
+ end)
+ end)
+
+ describe('hunk hints', function()
+ it('adds keymap hints on hunk header lines', function()
+ local d_bufnr = create_diff_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,1 +1,1 @@',
+ '-local x = 1',
+ '+local x = 2',
+ })
+
+ merge.setup_keymaps(d_bufnr, default_config())
+
+ local extmarks =
+ vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true })
+ local hint_marks = {}
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].virt_text then
+ local text = ''
+ for _, chunk in ipairs(mark[4].virt_text) do
+ text = text .. chunk[1]
+ end
+ table.insert(hint_marks, { line = mark[2], text = text })
+ end
+ end
+ assert.are.equal(1, #hint_marks)
+ assert.are.equal(3, hint_marks[1].line)
+ assert.is_truthy(hint_marks[1].text:find('doo'))
+ assert.is_truthy(hint_marks[1].text:find('dot'))
+
+ helpers.delete_buffer(d_bufnr)
+ end)
+ end)
+
+ describe('setup_keymaps', function()
+ it('clears resolved state on re-init', function()
+ local working_path = '/tmp/diffs_test_reinit.lua'
+ local w_bufnr = create_working_buffer({
+ '<<<<<<< HEAD',
+ 'local x = 1',
+ '=======',
+ 'local x = 2',
+ '>>>>>>> feature',
+ }, working_path)
+
+ local d_bufnr = create_diff_buffer({
+ 'diff --git a/file.lua b/file.lua',
+ '--- a/file.lua',
+ '+++ b/file.lua',
+ '@@ -1,1 +1,1 @@',
+ '-local x = 1',
+ '+local x = 2',
+ }, working_path)
+ vim.api.nvim_set_current_buf(d_bufnr)
+ vim.api.nvim_win_set_cursor(0, { 5, 0 })
+
+ local cfg = default_config()
+ merge.resolve_ours(d_bufnr, cfg)
+ assert.is_true(merge.is_resolved(d_bufnr, 1))
+
+ local extmarks =
+ vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true })
+ assert.is_true(#extmarks > 0)
+
+ merge.setup_keymaps(d_bufnr, cfg)
+
+ assert.is_false(merge.is_resolved(d_bufnr, 1))
+ extmarks =
+ vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true })
+ local resolved_count = 0
+ for _, mark in ipairs(extmarks) do
+ if mark[4] and mark[4].virt_text then
+ for _, chunk in ipairs(mark[4].virt_text) do
+ if chunk[1]:match('resolved') then
+ resolved_count = resolved_count + 1
+ end
+ end
+ end
+ end
+ assert.are.equal(0, resolved_count)
+
+ helpers.delete_buffer(d_bufnr)
+ helpers.delete_buffer(w_bufnr)
+ end)
+ end)
+
+ describe('fugitive integration', function()
+ it('parse_file_line returns status for unmerged files', function()
+ local fugitive = require('diffs.fugitive')
+ local buf = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
+ 'Unstaged (1)',
+ 'U conflict.lua',
+ })
+ local filename, section, is_header, old_filename, status = fugitive.get_file_at_line(buf, 2)
+ assert.are.equal('conflict.lua', filename)
+ assert.are.equal('unstaged', section)
+ assert.is_false(is_header)
+ assert.is_nil(old_filename)
+ assert.are.equal('U', status)
+ vim.api.nvim_buf_delete(buf, { force = true })
+ end)
+
+ it('walkback from hunk line propagates status', function()
+ local fugitive = require('diffs.fugitive')
+ local buf = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
+ 'Unstaged (1)',
+ 'U conflict.lua',
+ '@@ -1,3 +1,4 @@',
+ ' local M = {}',
+ '+local new = true',
+ })
+ local _, _, _, _, status = fugitive.get_file_at_line(buf, 5)
+ assert.are.equal('U', status)
+ vim.api.nvim_buf_delete(buf, { force = true })
+ end)
+ end)
+end)
diff --git a/spec/neogit_integration_spec.lua b/spec/neogit_integration_spec.lua
new file mode 100644
index 0000000..ef33049
--- /dev/null
+++ b/spec/neogit_integration_spec.lua
@@ -0,0 +1,126 @@
+require('spec.helpers')
+
+vim.g.diffs = { neogit = true }
+
+local diffs = require('diffs')
+local parser = require('diffs.parser')
+
+local function create_buffer(lines)
+ local bufnr = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
+ return bufnr
+end
+
+local function delete_buffer(bufnr)
+ if vim.api.nvim_buf_is_valid(bufnr) then
+ vim.api.nvim_buf_delete(bufnr, { force = true })
+ end
+end
+
+describe('neogit_integration', function()
+ describe('neogit_disable_hunk_highlight', function()
+ it('sets neogit_disable_hunk_highlight on NeogitStatus buffer after attach', function()
+ local bufnr = create_buffer({
+ 'modified test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ vim.api.nvim_set_option_value('filetype', 'NeogitStatus', { buf = bufnr })
+ diffs.attach(bufnr)
+
+ assert.is_true(vim.b[bufnr].neogit_disable_hunk_highlight)
+
+ delete_buffer(bufnr)
+ end)
+
+ it('does not set neogit_disable_hunk_highlight on non-Neogit buffer', function()
+ local bufnr = create_buffer({})
+ vim.api.nvim_set_option_value('filetype', 'git', { buf = bufnr })
+ diffs.attach(bufnr)
+
+ assert.is_not_true(vim.b[bufnr].neogit_disable_hunk_highlight)
+
+ delete_buffer(bufnr)
+ end)
+ end)
+
+ describe('NeogitStatus buffer attach', function()
+ it('populates hunk_cache for NeogitStatus buffer with diff content', function()
+ local bufnr = create_buffer({
+ 'modified hello.lua',
+ '@@ -1,2 +1,3 @@',
+ ' local M = {}',
+ '+local x = 1',
+ ' return M',
+ })
+ vim.api.nvim_set_option_value('filetype', 'NeogitStatus', { buf = bufnr })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_not_nil(entry)
+ assert.is_table(entry.hunks)
+ assert.are.equal(1, #entry.hunks)
+ assert.are.equal('hello.lua', entry.hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('populates hunk_cache for NeogitDiffView buffer', function()
+ local bufnr = create_buffer({
+ 'new file newmod.lua',
+ '@@ -0,0 +1,2 @@',
+ '+local M = {}',
+ '+return M',
+ })
+ vim.api.nvim_set_option_value('filetype', 'NeogitDiffView', { buf = bufnr })
+ diffs.attach(bufnr)
+ local entry = diffs._test.hunk_cache[bufnr]
+ assert.is_not_nil(entry)
+ assert.is_table(entry.hunks)
+ assert.are.equal(1, #entry.hunks)
+ delete_buffer(bufnr)
+ end)
+ end)
+
+ describe('parser neogit patterns', function()
+ it('detects renamed prefix via parser', function()
+ local bufnr = create_buffer({
+ 'renamed old.lua',
+ '@@ -1,2 +1,3 @@',
+ ' local M = {}',
+ '+local x = 1',
+ ' return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+ assert.are.equal(1, #hunks)
+ assert.are.equal('old.lua', hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('detects copied prefix via parser', function()
+ local bufnr = create_buffer({
+ 'copied orig.lua',
+ '@@ -1,2 +1,3 @@',
+ ' local M = {}',
+ '+local x = 1',
+ ' return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+ assert.are.equal(1, #hunks)
+ assert.are.equal('orig.lua', hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('detects deleted prefix via parser', function()
+ local bufnr = create_buffer({
+ 'deleted gone.lua',
+ '@@ -1,2 +0,0 @@',
+ '-local M = {}',
+ '-return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+ assert.are.equal(1, #hunks)
+ assert.are.equal('gone.lua', hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+ end)
+end)
diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua
index 11ac3be..adbbd37 100644
--- a/spec/parser_spec.lua
+++ b/spec/parser_spec.lua
@@ -163,10 +163,10 @@ describe('parser', function()
end
end)
- it('stops hunk at blank line', function()
+ it('stops hunk at blank line when remaining counts exhausted', function()
local bufnr = create_buffer({
'M test.lua',
- '@@ -1,2 +1,3 @@',
+ '@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
'',
@@ -391,35 +391,27 @@ describe('parser', function()
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')
+ it('detects filetype for .sh files when did_filetype() is non-zero', function()
+ rawset(vim.fn, 'did_filetype', function()
+ return 1
+ end)
- 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")',
+ parser._test.ft_lang_cache = {}
+ local bufnr = create_buffer({
+ 'diff --git a/test.sh b/test.sh',
+ '@@ -1,3 +1,4 @@',
+ ' #!/usr/bin/env bash',
+ ' set -euo pipefail',
+ '-echo "running tests..."',
+ '+echo "running tests with coverage..."',
})
- vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
-
- local hunks = parser.parse_buffer(diff_buf)
+ local hunks = parser.parse_buffer(bufnr)
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')
+ assert.are.equal('test.sh', hunks[1].filename)
+ assert.are.equal('sh', hunks[1].ft)
+ delete_buffer(bufnr)
+ rawset(vim.fn, 'did_filetype', nil)
end)
it('extracts file line numbers from @@ header', function()
@@ -440,22 +432,6 @@ describe('parser', function()
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',
@@ -472,6 +448,154 @@ describe('parser', function()
delete_buffer(bufnr)
end)
+ it('recognizes U prefix for unmerged files', function()
+ local bufnr = create_buffer({
+ 'U merge_me.lua',
+ '@@@ -1,3 -1,5 +1,9 @@@',
+ ' local M = {}',
+ '++<<<<<<< HEAD',
+ ' + return 1',
+ '++=======',
+ '+ return 2',
+ '++>>>>>>> feature',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('merge_me.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ delete_buffer(bufnr)
+ end)
+
+ it('sets prefix_width 2 from @@@ combined diff header', function()
+ local bufnr = create_buffer({
+ 'U test.lua',
+ '@@@ -1,3 -1,5 +1,9 @@@',
+ ' local M = {}',
+ '++<<<<<<< HEAD',
+ ' + return 1',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal(2, hunks[1].prefix_width)
+ delete_buffer(bufnr)
+ end)
+
+ it('sets prefix_width 1 for standard @@ unified diff', function()
+ local bufnr = create_buffer({
+ 'M test.lua',
+ '@@ -1,2 +1,3 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal(1, hunks[1].prefix_width)
+ delete_buffer(bufnr)
+ end)
+
+ it('collects all combined diff line types as hunk content', function()
+ local bufnr = create_buffer({
+ 'U test.lua',
+ '@@@ -1,3 -1,3 +1,5 @@@',
+ ' local M = {}',
+ '++<<<<<<< HEAD',
+ ' + return 1',
+ '+ local x = 2',
+ ' end',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal(5, #hunks[1].lines)
+ assert.are.equal(' local M = {}', hunks[1].lines[1])
+ assert.are.equal('++<<<<<<< HEAD', hunks[1].lines[2])
+ assert.are.equal(' + return 1', hunks[1].lines[3])
+ assert.are.equal('+ local x = 2', hunks[1].lines[4])
+ assert.are.equal(' end', hunks[1].lines[5])
+ delete_buffer(bufnr)
+ end)
+
+ it('extracts new range from combined diff header', function()
+ local bufnr = create_buffer({
+ 'U test.lua',
+ '@@@ -1,3 -1,5 +1,9 @@@',
+ ' local M = {}',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal(1, hunks[1].file_new_start)
+ assert.are.equal(9, hunks[1].file_new_count)
+ assert.are.equal(1, hunks[1].file_old_start)
+ assert.are.equal(3, hunks[1].file_old_count)
+ delete_buffer(bufnr)
+ end)
+
+ it('extracts header context from combined diff header', function()
+ local bufnr = create_buffer({
+ 'U test.lua',
+ '@@@ -1,3 -1,5 +1,9 @@@ function M.greet()',
+ ' local M = {}',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('function M.greet()', hunks[1].header_context)
+ delete_buffer(bufnr)
+ end)
+
+ it('resets prefix_width when switching from combined to unified diff', function()
+ local bufnr = create_buffer({
+ 'U merge.lua',
+ '@@@ -1,1 -1,1 +1,3 @@@',
+ ' local M = {}',
+ '++<<<<<<< HEAD',
+ 'M other.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(2, #hunks)
+ assert.are.equal(2, hunks[1].prefix_width)
+ assert.are.equal(1, hunks[2].prefix_width)
+ delete_buffer(bufnr)
+ end)
+
+ it('parses diff from gitcommit verbose buffer', function()
+ local bufnr = create_buffer({
+ '',
+ '# Please enter the commit message for your changes.',
+ '#',
+ '# On branch main',
+ '# Changes to be committed:',
+ '#\tmodified: test.lua',
+ '#',
+ '# ------------------------ >8 ------------------------',
+ '# Do not modify or remove the line above.',
+ 'diff --git a/test.lua b/test.lua',
+ 'index abc1234..def5678 100644',
+ '--- a/test.lua',
+ '+++ b/test.lua',
+ '@@ -1,3 +1,3 @@',
+ ' local function hello()',
+ '- print("hello world")',
+ '+ print("hello universe")',
+ ' return true',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('test.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ assert.are.equal(4, #hunks[1].lines)
+ delete_buffer(bufnr)
+ end)
+
it('stores repo_root on hunk when available', function()
local bufnr = create_buffer({
'M lua/test.lua',
@@ -488,16 +612,388 @@ describe('parser', function()
delete_buffer(bufnr)
end)
- it('repo_root is nil when not available', function()
+ it('detects neogit modified prefix', function()
local bufnr = create_buffer({
- 'M lua/test.lua',
- '@@ -1,3 +1,4 @@',
+ 'modified hello.lua',
+ '@@ -1,2 +1,3 @@',
+ ' local M = {}',
+ '+local x = 1',
+ ' return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('hello.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ assert.are.equal(3, #hunks[1].lines)
+ delete_buffer(bufnr)
+ end)
+
+ it('detects neogit new file prefix', function()
+ local bufnr = create_buffer({
+ 'new file hello.lua',
+ '@@ -0,0 +1,2 @@',
+ '+local M = {}',
+ '+return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('hello.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ assert.are.equal(2, #hunks[1].lines)
+ delete_buffer(bufnr)
+ end)
+
+ it('detects neogit deleted prefix', function()
+ local bufnr = create_buffer({
+ 'deleted hello.lua',
+ '@@ -1,2 +0,0 @@',
+ '-local M = {}',
+ '-return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('hello.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ assert.are.equal(2, #hunks[1].lines)
+ delete_buffer(bufnr)
+ end)
+
+ it('detects neogit renamed prefix', function()
+ local bufnr = create_buffer({
+ 'renamed old.lua',
+ '@@ -1,2 +1,3 @@',
+ ' local M = {}',
+ '+local x = 1',
+ ' return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('old.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ delete_buffer(bufnr)
+ end)
+
+ it('detects neogit copied prefix', function()
+ local bufnr = create_buffer({
+ 'copied orig.lua',
+ '@@ -1,2 +1,3 @@',
+ ' local M = {}',
+ '+local x = 1',
+ ' return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('orig.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not treat "new file mode" as a filename', function()
+ local bufnr = create_buffer({
+ 'diff --git a/src/new.lua b/src/new.lua',
+ 'new file mode 100644',
+ 'index 0000000..abc1234',
+ '--- /dev/null',
+ '+++ b/src/new.lua',
+ '@@ -0,0 +1,2 @@',
+ '+local M = {}',
+ '+return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('src/new.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not treat "new file mode 100755" as a filename', function()
+ local bufnr = create_buffer({
+ 'diff --git a/bin/run b/bin/run',
+ 'new file mode 100755',
+ 'index 0000000..abc1234',
+ '--- /dev/null',
+ '+++ b/bin/run',
+ '@@ -0,0 +1,2 @@',
+ '+#!/bin/bash',
+ '+echo hello',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('bin/run', hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not treat "deleted file mode" as a filename', function()
+ local bufnr = create_buffer({
+ 'diff --git a/src/old.lua b/src/old.lua',
+ 'deleted file mode 100644',
+ 'index abc1234..0000000',
+ '--- a/src/old.lua',
+ '+++ /dev/null',
+ '@@ -1,2 +0,0 @@',
+ '-local M = {}',
+ '-return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('src/old.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not treat "deleted file mode 100755" as a filename', function()
+ local bufnr = create_buffer({
+ 'diff --git a/bin/old b/bin/old',
+ 'deleted file mode 100755',
+ 'index abc1234..0000000',
+ '--- a/bin/old',
+ '+++ /dev/null',
+ '@@ -1,1 +0,0 @@',
+ '-#!/bin/bash',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('bin/old', hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not treat "old mode" or "new mode" as filenames', function()
+ local bufnr = create_buffer({
+ 'diff --git a/script.sh b/script.sh',
+ 'old mode 100644',
+ 'new mode 100755',
+ '@@ -1,1 +1,2 @@',
+ ' echo hello',
+ '+echo world',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('script.sh', hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not treat "rename from/to" as filenames', function()
+ local bufnr = create_buffer({
+ 'diff --git a/old.lua b/new.lua',
+ 'similarity index 95%',
+ 'rename from old.lua',
+ 'rename to new.lua',
+ '@@ -1,2 +1,2 @@',
+ ' local M = {}',
+ '-local x = 1',
+ '+local x = 2',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('new.lua', hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not treat "copy from/to" as filenames', function()
+ local bufnr = create_buffer({
+ 'diff --git a/orig.lua b/copy.lua',
+ 'similarity index 100%',
+ 'copy from orig.lua',
+ 'copy to copy.lua',
+ '@@ -1,1 +1,1 @@',
' local M = {}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
- assert.is_nil(hunks[1].repo_root)
+ assert.are.equal('copy.lua', hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not treat "similarity index" or "dissimilarity index" as filenames', function()
+ local bufnr = create_buffer({
+ 'diff --git a/foo.lua b/bar.lua',
+ 'similarity index 85%',
+ 'rename from foo.lua',
+ 'rename to bar.lua',
+ '@@ -1,2 +1,2 @@',
+ ' local M = {}',
+ '-return 1',
+ '+return 2',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('bar.lua', hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not treat "index" line as a filename', function()
+ local bufnr = create_buffer({
+ 'diff --git a/test.lua b/test.lua',
+ 'index abc1234..def5678 100644',
+ '--- a/test.lua',
+ '+++ b/test.lua',
+ '@@ -1,1 +1,2 @@',
+ ' local x = 1',
+ '+local y = 2',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('test.lua', hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('neogit new file with diff containing new file mode metadata', function()
+ local bufnr = create_buffer({
+ 'new file src/foo.lua',
+ 'diff --git a/src/foo.lua b/src/foo.lua',
+ 'new file mode 100644',
+ 'index 0000000..abc1234',
+ '--- /dev/null',
+ '+++ b/src/foo.lua',
+ '@@ -0,0 +1,3 @@',
+ '+local M = {}',
+ '+M.x = 1',
+ '+return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('src/foo.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ assert.are.equal(3, #hunks[1].lines)
+ delete_buffer(bufnr)
+ end)
+
+ it('neogit deleted with diff containing deleted file mode metadata', function()
+ local bufnr = create_buffer({
+ 'deleted src/old.lua',
+ 'diff --git a/src/old.lua b/src/old.lua',
+ 'deleted file mode 100644',
+ 'index abc1234..0000000',
+ '--- a/src/old.lua',
+ '+++ /dev/null',
+ '@@ -1,2 +0,0 @@',
+ '-local M = {}',
+ '-return M',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('src/old.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ assert.are.equal(2, #hunks[1].lines)
+ delete_buffer(bufnr)
+ end)
+
+ it('multiple new files with mode metadata do not corrupt filenames', function()
+ local bufnr = create_buffer({
+ 'diff --git a/a.lua b/a.lua',
+ 'new file mode 100644',
+ 'index 0000000..abc1234',
+ '--- /dev/null',
+ '+++ b/a.lua',
+ '@@ -0,0 +1,1 @@',
+ '+local a = 1',
+ 'diff --git a/b.lua b/b.lua',
+ 'new file mode 100644',
+ 'index 0000000..def5678',
+ '--- /dev/null',
+ '+++ b/b.lua',
+ '@@ -0,0 +1,1 @@',
+ '+local b = 2',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(2, #hunks)
+ assert.are.equal('a.lua', hunks[1].filename)
+ assert.are.equal('b.lua', hunks[2].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('fugitive status with new and deleted files containing mode metadata', function()
+ local bufnr = create_buffer({
+ 'Head: main',
+ '',
+ 'Staged (2)',
+ 'A src/new.lua',
+ 'diff --git a/src/new.lua b/src/new.lua',
+ 'new file mode 100644',
+ 'index 0000000..abc1234',
+ '--- /dev/null',
+ '+++ b/src/new.lua',
+ '@@ -0,0 +1,2 @@',
+ '+local M = {}',
+ '+return M',
+ 'D src/old.lua',
+ 'diff --git a/src/old.lua b/src/old.lua',
+ 'deleted file mode 100644',
+ 'index abc1234..0000000',
+ '--- a/src/old.lua',
+ '+++ /dev/null',
+ '@@ -1,1 +0,0 @@',
+ '-local x = 1',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(2, #hunks)
+ assert.are.equal('src/new.lua', hunks[1].filename)
+ assert.are.equal('lua', hunks[1].ft)
+ assert.are.equal('src/old.lua', hunks[2].filename)
+ assert.are.equal('lua', hunks[2].ft)
+ delete_buffer(bufnr)
+ end)
+
+ it('neogit new file with deep nested path', function()
+ local bufnr = create_buffer({
+ 'new file src/deep/nested/path/module.lua',
+ '@@ -0,0 +1,1 @@',
+ '+return {}',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('src/deep/nested/path/module.lua', hunks[1].filename)
+ delete_buffer(bufnr)
+ end)
+
+ it('detects bare filename for untracked files', function()
+ local bufnr = create_buffer({
+ 'newfile.rs',
+ '@@ -0,0 +1,3 @@',
+ '+fn main() {',
+ '+ println!("hello");',
+ '+}',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('newfile.rs', hunks[1].filename)
+ assert.are.equal(3, #hunks[1].lines)
+ delete_buffer(bufnr)
+ end)
+
+ it('does not match section headers as bare filenames', function()
+ local bufnr = create_buffer({
+ 'Untracked files (1)',
+ 'newfile.rs',
+ '@@ -0,0 +1,3 @@',
+ '+fn main() {',
+ '+ println!("hello");',
+ '+}',
+ })
+ local hunks = parser.parse_buffer(bufnr)
+
+ assert.are.equal(1, #hunks)
+ assert.are.equal('newfile.rs', hunks[1].filename)
delete_buffer(bufnr)
end)
end)
diff --git a/vim.toml b/vim.toml
deleted file mode 100644
index 3a84ac2..0000000
--- a/vim.toml
+++ /dev/null
@@ -1,33 +0,0 @@
-[selene]
-base = "lua51"
-name = "vim"
-
-[vim]
-any = true
-
-[jit]
-any = true
-
-[bit]
-any = true
-
-[assert]
-any = true
-
-[describe]
-any = true
-
-[it]
-any = true
-
-[before_each]
-any = true
-
-[after_each]
-any = true
-
-[spy]
-any = true
-
-[stub]
-any = true
diff --git a/vim.yaml b/vim.yaml
new file mode 100644
index 0000000..3821d25
--- /dev/null
+++ b/vim.yaml
@@ -0,0 +1,26 @@
+---
+base: lua51
+name: vim
+lua_versions:
+ - luajit
+globals:
+ vim:
+ any: true
+ jit:
+ any: true
+ assert:
+ any: true
+ describe:
+ any: true
+ it:
+ any: true
+ before_each:
+ any: true
+ after_each:
+ any: true
+ spy:
+ any: true
+ stub:
+ any: true
+ bit:
+ any: true