Compare commits

..

1 commit

Author SHA1 Message Date
f3a72926d2 doc: add plug mappings for merge conflict resolution 2026-02-08 16:28:18 -05:00
35 changed files with 1224 additions and 6744 deletions

View file

@ -25,7 +25,6 @@ jobs:
- '*.lua'
- '.luarc.json'
- '*.toml'
- 'vim.yaml'
markdown:
- '*.md'
@ -36,8 +35,11 @@ jobs:
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
- run: nix develop --command stylua --check .
- uses: JohnnyMorganz/stylua-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: 2.1.0
args: --check .
lua-lint:
name: Lua Lint Check
@ -46,8 +48,11 @@ jobs:
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
- run: nix develop --command selene --display-style quiet .
- name: Lint with Selene
uses: NTBBloodbath/selene-action@v1.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --display-style quiet .
lua-typecheck:
name: Lua Type Check
@ -70,5 +75,15 @@ jobs:
if: ${{ needs.changes.outputs.markdown == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
- run: nix develop --command prettier --check .
- 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 .

6
.gitignore vendored
View file

@ -5,10 +5,4 @@ doc/tags
CLAUDE.md
.claude/
bench/
node_modules/
result
result-*
.direnv/
.envrc

View file

@ -1,15 +1,8 @@
{
"runtime.version": "LuaJIT",
"runtime.version": "Lua 5.1",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim", "jit"],
"workspace.library": [
"$VIMRUNTIME/lua",
"${3rd}/luv/library",
"${3rd}/busted/library",
"${3rd}/luassert/library"
],
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
"workspace.checkThirdParty": false,
"diagnostics.libraryFiles": "Disable",
"workspace.ignoreDir": [".direnv"],
"completion.callSnippet": "Replace"
}

View file

@ -1 +0,0 @@
.direnv/

View file

@ -2,24 +2,25 @@
**Syntax highlighting for diffs in Neovim**
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.
Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware
syntax highlighting.
<video src="https://github.com/user-attachments/assets/24574916-ecb2-478e-a0ea-e4cdc971e310" width="100%" controls></video>
![diffs.nvim preview](https://github.com/user-attachments/assets/d3d64c96-b824-4fcb-af7f-4aef3f7f498a)
## Features
- 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
- 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
## Requirements
@ -40,58 +41,18 @@ 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.
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.
To improve accuracy, `diffs.nvim` reads lines from disk before and after each
hunk for parsing context (`highlights.context`, enabled by default with 25
lines). This resolves most boundary issues. Set
`highlights.context.enabled = false` to disable.
- **Syntax "flashing"**: `diffs.nvim` hooks into the `FileType fugitive` event
- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event
triggered by `vim-fugitive`, at which point the buffer is preliminarily
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.
painted. The buffer is then re-painted after `debounce_ms` milliseconds,
causing an unavoidable visual "flash" even when `debounce_ms = 0`.
- **Conflicting diff plugins**: `diffs.nvim` may not interact well with other
plugins that modify diff highlighting. Known plugins that may conflict:
@ -109,14 +70,11 @@ See the documentation for more information.
# Acknowledgements
- [`vim-fugitive`](https://github.com/tpope/vim-fugitive)
- [@esmuellert](https://github.com/esmuellert) /
[`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - vscode-diff
algorithm FFI backend for word-level intra-line accuracy
- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim)
- [`diffview.nvim`](https://github.com/sindrets/diffview.nvim)
- [`difftastic`](https://github.com/Wilfred/difftastic)
- [`mini.diff`](https://github.com/echasnovski/mini.diff)
- [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim)
- [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim)
- [@phanen](https://github.com/phanen) - diff header highlighting, unknown
filetype fix, shebang/modeline detection, treesitter injection support,
decoration provider highlighting architecture
filetype fix, shebang/modeline detection, treesitter injection support

View file

@ -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, Neogit, and
Neovim's built-in diff mode.
highlights on top of default diff highlighting in vim-fugitive and Neovim's
built-in diff mode.
Features: ~
- Syntax highlighting in |:Git| summary diffs and commit detail views
@ -22,26 +22,6 @@ 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*
@ -56,7 +36,7 @@ etc.) works without vim-fugitive.
==============================================================================
SETUP *diffs-setup*
Install with lazy.nvim: >lua
Using lazy.nvim: >lua
{
'barrettruth/diffs.nvim',
dependencies = { 'tpope/vim-fugitive' },
@ -72,10 +52,8 @@ 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,
@ -97,26 +75,23 @@ 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 = ']c',
prev = '[c',
next = ']x',
prev = '[x',
},
},
}
@ -127,6 +102,11 @@ 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
@ -134,45 +114,14 @@ 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.
@ -211,10 +160,6 @@ 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
@ -225,42 +170,16 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
*diffs.ContextConfig*
Context config fields: ~
{enabled} (boolean, default: true)
Read surrounding code from the working tree
file and feed it into the treesitter string
parser. Uses the hunk's `@@ +start,count @@`
line numbers to read lines before and after
the hunk from disk. Improves syntax accuracy
when the hunk is inside an incomplete construct
(e.g., a table literal or function body whose
opening is not visible in the hunk's own
context lines).
Read lines from disk before and after each hunk
to provide surrounding syntax context. Improves
accuracy at hunk boundaries where incomplete
constructs (e.g., a function definition with no
body) would otherwise confuse the parser.
{lines} (integer, default: 25)
Max context lines to read in each direction.
Files are read once per parse and cached across
hunks in the same file.
*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.
Number of context lines to read in each
direction. Lines are read with early exit —
cost scales with this value, not file size.
*diffs.TreesitterConfig*
Treesitter config fields: ~
@ -392,36 +311,10 @@ Example configuration: >lua
vim.keymap.set('n', 'ct', '<Plug>(diffs-conflict-theirs)')
vim.keymap.set('n', 'cb', '<Plug>(diffs-conflict-both)')
vim.keymap.set('n', 'cn', '<Plug>(diffs-conflict-none)')
vim.keymap.set('n', ']c', '<Plug>(diffs-conflict-next)')
vim.keymap.set('n', '[c', '<Plug>(diffs-conflict-prev)')
vim.keymap.set('n', ']x', '<Plug>(diffs-conflict-next)')
vim.keymap.set('n', '[x', '<Plug>(diffs-conflict-prev)')
<
*<Plug>(diffs-merge-ours)*
<Plug>(diffs-merge-ours)
Accept ours in a merge diff view. Resolves the
conflict in the working file with ours content.
*<Plug>(diffs-merge-theirs)*
<Plug>(diffs-merge-theirs)
Accept theirs in a merge diff view.
*<Plug>(diffs-merge-both)*
<Plug>(diffs-merge-both)
Accept both (ours then theirs) in a merge diff view.
*<Plug>(diffs-merge-none)*
<Plug>(diffs-merge-none)
Reject both in a merge diff view.
*<Plug>(diffs-merge-next)*
<Plug>(diffs-merge-next)
Jump to next unresolved conflict hunk in merge diff.
*<Plug>(diffs-merge-prev)*
<Plug>(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://`
@ -452,7 +345,6 @@ 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
@ -469,9 +361,6 @@ 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.
@ -500,15 +389,13 @@ 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 = ']c',
prev = '[c',
next = ']x',
prev = '[x',
},
},
}
@ -528,37 +415,9 @@ Configuration: ~
diagnostics alone.
{show_virtual_text} (boolean, default: true)
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.
Show virtual text labels (" current" and
" incoming") at the end of `<<<<<<<` and
`>>>>>>>` marker lines.
{keymaps} (table, default: see above)
Buffer-local keymaps for conflict resolution
@ -579,10 +438,10 @@ Configuration: ~
{none} (string|false, default: 'don')
Reject both changes (delete entire block).
{next} (string|false, default: ']c')
{next} (string|false, default: ']x')
Jump to next conflict marker. Wraps around.
{prev} (string|false, default: '[c')
{prev} (string|false, default: '[x')
Jump to previous conflict marker. Wraps
around.
@ -599,52 +458,6 @@ 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*
@ -666,8 +479,8 @@ refresh({bufnr}) *diffs.refresh()*
IMPLEMENTATION *diffs-implementation*
Summary / commit detail views: ~
1. `FileType` autocmd for computed filetypes (see |diffs-config|) triggers
|diffs.attach()|. For `git` buffers, only `fugitive://` URIs are attached.
1. `FileType fugitive` or `FileType git` (for `fugitive://` buffers)
triggers |diffs.attach()|
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:
@ -676,14 +489,13 @@ 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()|
- `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
- `Normal` extmarks at priority 198 clear underlying diff foreground
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199
- Syntax highlights are applied as extmarks at priority 200
- Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at
priority 201 overlay changed characters with an intense background
- Conceal extmarks hide diff prefixes when `hide_prefix` is enabled
- All priorities are configurable via |diffs.PrioritiesConfig|
4. A decoration provider re-highlights visible hunks on each redraw
4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events
Diff mode views: ~
1. `OptionSet diff` detects when any window enters diff mode
@ -697,14 +509,15 @@ KNOWN LIMITATIONS *diffs-limitations*
Incomplete Syntax Context ~
*diffs-syntax-context*
Treesitter parses each diff hunk in isolation. When `highlights.context` is
enabled (the default), surrounding code is read from the working tree file
and fed into the parser to improve accuracy at hunk boundaries. This helps
when a hunk is inside a table, function body, or loop whose opening is
beyond the hunk's own context lines. Requires `repo_root` and
`file_new_start` to be available on the hunk (true for standard unified
diffs). In rare cases, hunks that start or end mid-expression may still
produce imperfect highlights due to treesitter error recovery.
Treesitter parses each diff hunk in isolation. To provide surrounding code
context, diffs.nvim reads lines from disk before and after each hunk
(see |diffs.ContextConfig|, enabled by default). This resolves most boundary
issues where incomplete constructs (e.g., a function definition at the edge
of a hunk with no body) would confuse the parser.
Set `highlights.context.enabled = false` to disable context padding. In rare
cases, context padding may not help if the relevant surrounding code is very
far from the hunk boundaries.
Syntax Highlighting Flash ~
*diffs-flash*
@ -713,8 +526,14 @@ 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. The
decoration provider applies highlights on the next redraw cycle.
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,
}
<
Conflicting Diff Plugins ~
*diffs-plugin-conflicts*
@ -816,10 +635,6 @@ 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.

43
flake.lock generated
View file

@ -1,43 +0,0 @@
{
"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
}

View file

@ -1,53 +0,0 @@
{
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
];
};
});
};
}

View file

@ -36,24 +36,6 @@ 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
@ -87,33 +69,6 @@ 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)
@ -183,7 +138,6 @@ 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 }
@ -203,17 +157,7 @@ function M.gdiff_file(filepath, opts)
local old_lines, new_lines, err
local diff_label
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
if opts.untracked then
old_lines = {}
new_lines, err = git.get_working_content(filepath)
if not new_lines then
@ -292,14 +236,6 @@ 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()
@ -327,8 +263,6 @@ 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
@ -391,8 +325,6 @@ 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
@ -402,10 +334,7 @@ function M.read_buffer(bufnr)
local old_lines, new_lines
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
if label == 'untracked' then
old_lines = {}
new_lines = git.get_working_content(abs_path) or {}
elseif label == 'staged' then

View file

@ -20,6 +20,8 @@ local attached_buffers = {}
---@type table<integer, boolean>
local diagnostics_suppressed = {}
local PRIORITY_LINE_BG = 200
---@param lines string[]
---@return diffs.ConflictRegion[]
function M.parse(lines)
@ -90,17 +92,6 @@ 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
@ -112,41 +103,14 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_ours + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
priority = config.priority,
priority = PRIORITY_LINE_BG,
})
if config.show_virtual_text then
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
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
virt_text = { { ' (current)', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
end
for line = region.ours_start, region.ours_end - 1 do
@ -154,11 +118,11 @@ local function apply_highlights(bufnr, regions, config)
end_row = line + 1,
hl_group = 'DiffsConflictOurs',
hl_eol = true,
priority = config.priority,
priority = PRIORITY_LINE_BG,
})
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictOursNr',
priority = config.priority,
priority = PRIORITY_LINE_BG,
})
end
@ -167,7 +131,7 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_base + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
priority = config.priority,
priority = PRIORITY_LINE_BG,
})
for line = region.base_start, region.base_end - 1 do
@ -175,11 +139,11 @@ local function apply_highlights(bufnr, regions, config)
end_row = line + 1,
hl_group = 'DiffsConflictBase',
hl_eol = true,
priority = config.priority,
priority = PRIORITY_LINE_BG,
})
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictBaseNr',
priority = config.priority,
priority = PRIORITY_LINE_BG,
})
end
end
@ -188,7 +152,7 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_sep + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
priority = config.priority,
priority = PRIORITY_LINE_BG,
})
for line = region.theirs_start, region.theirs_end - 1 do
@ -196,11 +160,11 @@ local function apply_highlights(bufnr, regions, config)
end_row = line + 1,
hl_group = 'DiffsConflictTheirs',
hl_eol = true,
priority = config.priority,
priority = PRIORITY_LINE_BG,
})
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictTheirsNr',
priority = config.priority,
priority = PRIORITY_LINE_BG,
})
end
@ -208,17 +172,14 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_theirs + 1,
hl_group = 'DiffsConflictMarker',
hl_eol = true,
priority = config.priority,
priority = PRIORITY_LINE_BG,
})
if config.show_virtual_text then
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
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, {
virt_text = { { ' (incoming)', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
end
end
end
@ -238,7 +199,7 @@ end
---@param bufnr integer
---@param region diffs.ConflictRegion
---@param replacement string[]
function M.replace_region(bufnr, region, replacement)
local function replace_region(bufnr, region, replacement)
vim.api.nvim_buf_set_lines(
bufnr,
region.marker_ours,
@ -250,7 +211,7 @@ end
---@param bufnr integer
---@param config diffs.ConflictConfig
function M.refresh(bufnr, config)
local function refresh(bufnr, config)
local regions = parse_buffer(bufnr)
if #regions == 0 then
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
@ -283,8 +244,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)
M.replace_region(bufnr, region, lines)
M.refresh(bufnr, config)
replace_region(bufnr, region, lines)
refresh(bufnr, config)
end
---@param bufnr integer
@ -301,8 +262,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)
M.replace_region(bufnr, region, lines)
M.refresh(bufnr, config)
replace_region(bufnr, region, lines)
refresh(bufnr, config)
end
---@param bufnr integer
@ -327,8 +288,8 @@ function M.resolve_both(bufnr, config)
for _, l in ipairs(theirs) do
table.insert(combined, l)
end
M.replace_region(bufnr, region, combined)
M.refresh(bufnr, config)
replace_region(bufnr, region, combined)
refresh(bufnr, config)
end
---@param bufnr integer
@ -344,8 +305,8 @@ function M.resolve_none(bufnr, config)
if not region then
return
end
M.replace_region(bufnr, region, {})
M.refresh(bufnr, config)
replace_region(bufnr, region, {})
refresh(bufnr, config)
end
---@param bufnr integer
@ -362,7 +323,6 @@ 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
@ -380,7 +340,6 @@ 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
@ -458,7 +417,7 @@ function M.attach(bufnr, config)
if not attached_buffers[bufnr] then
return true
end
M.refresh(bufnr, config)
refresh(bufnr, config)
end,
})

View file

@ -26,65 +26,20 @@ 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?, string?
---@return string?, string?
local function parse_file_line(line)
local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$')
if old and new then
return unquote(vim.trim(new)), unquote(vim.trim(old)), 'R'
return vim.trim(new), vim.trim(old)
end
local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$')
if status and filename then
return unquote(vim.trim(filename)), nil, status
local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$')
if filename then
return vim.trim(filename), nil
end
return nil, nil, nil
return nil, nil
end
---@param line string
@ -102,34 +57,34 @@ end
---@param bufnr integer
---@param lnum integer
---@return string?, diffs.FugitiveSection, boolean, string?, string?
---@return string?, diffs.FugitiveSection, boolean, string?
function M.get_file_at_line(bufnr, lnum)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local current_line = lines[lnum]
if not current_line then
return nil, nil, false, nil, nil
return nil, nil, false, nil
end
local section_header = parse_section_header(current_line)
if section_header then
return nil, section_header, true, nil, nil
return nil, section_header, true, nil
end
local filename, old_filename, status = parse_file_line(current_line)
local filename, old_filename = parse_file_line(current_line)
if filename then
local section = M.get_section_at_line(bufnr, lnum)
return filename, section, false, old_filename, status
return filename, section, false, old_filename
end
local prefix = current_line:sub(1, 1)
if prefix == '+' or prefix == '-' or prefix == ' ' then
for i = lnum - 1, 1, -1 do
local prev_line = lines[i]
filename, old_filename, status = parse_file_line(prev_line)
filename, old_filename = parse_file_line(prev_line)
if filename then
local section = M.get_section_at_line(bufnr, i)
return filename, section, false, old_filename, status
return filename, section, false, old_filename
end
if prev_line:match('^%w+ %(') or prev_line == '' then
break
@ -137,7 +92,7 @@ function M.get_file_at_line(bufnr, lnum)
end
end
return nil, nil, false, nil, nil
return nil, nil, false, nil
end
---@class diffs.HunkPosition
@ -195,7 +150,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, status = M.get_file_at_line(bufnr, lnum)
local filename, section, is_header, old_filename = M.get_file_at_line(bufnr, lnum)
local repo_root = get_repo_root_from_fugitive(bufnr)
if not repo_root then
@ -237,7 +192,6 @@ 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,
})

View file

@ -1,19 +1,13 @@
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

View file

@ -3,6 +3,38 @@ 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
@ -10,9 +42,8 @@ local diff = require('diffs.diff')
---@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, priorities)
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines)
local parse_text = text
if context_lines and #context_lines > 0 then
parse_text = text .. '\n' .. table.concat(context_lines, '\n')
@ -46,7 +77,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 priorities.syntax
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
@ -64,12 +95,6 @@ end
---@class diffs.HunkOpts
---@field hide_prefix boolean
---@field highlights diffs.Highlights
---@field defer_vim_syntax? boolean
---@field syntax_only? boolean
---@class diffs.TSContext
---@field before string[]?
---@field after string[]?
---@param bufnr integer
---@param ns integer
@ -78,9 +103,6 @@ end
---@param line_map table<integer, integer>
---@param col_offset integer
---@param covered_lines? table<integer, true>
---@param priorities diffs.PrioritiesConfig
---@param force_high_priority? boolean
---@param context? diffs.TSContext
---@return integer
local function highlight_treesitter(
bufnr,
@ -89,36 +111,9 @@ local function highlight_treesitter(
lang,
line_map,
col_offset,
covered_lines,
priorities,
force_high_priority,
context
covered_lines
)
local prefix_count = 0
local parse_lines = code_lines
if context then
local before = context.before
local after = context.after
if (before and #before > 0) or (after and #after > 0) then
parse_lines = {}
if before then
prefix_count = #before
for _, l in ipairs(before) do
parse_lines[#parse_lines + 1] = l
end
end
for _, l in ipairs(code_lines) do
parse_lines[#parse_lines + 1] = l
end
if after then
for _, l in ipairs(after) do
parse_lines[#parse_lines + 1] = l
end
end
end
end
local code = table.concat(parse_lines, '\n')
local code = table.concat(code_lines, '\n')
if code == '' then
return 0
end
@ -148,8 +143,6 @@ local function highlight_treesitter(
if capture ~= 'spell' and capture ~= 'nospell' then
local capture_name = '@' .. capture .. '.' .. tree_lang
local sr, sc, er, ec = node:range()
sr = sr - prefix_count
er = er - prefix_count
local buf_sr = line_map[sr]
if buf_sr then
@ -158,10 +151,8 @@ local function highlight_treesitter(
local buf_sc = sc + col_offset
local buf_ec = ec + col_offset
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
local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100)
or PRIORITY_SYNTAX
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
@ -228,17 +219,8 @@ end
---@param code_lines string[]
---@param covered_lines? table<integer, true>
---@param leading_offset? integer
---@param priorities diffs.PrioritiesConfig
---@return integer
local function highlight_vim_syntax(
bufnr,
ns,
hunk,
code_lines,
covered_lines,
leading_offset,
priorities
)
local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset)
local ft = hunk.ft
if not ft then
return 0
@ -256,9 +238,9 @@ local function highlight_vim_syntax(
local spans = {}
pcall(vim.api.nvim_buf_call, scratch, function()
vim.api.nvim_buf_call(scratch, function()
vim.cmd('setlocal syntax=' .. ft)
vim.cmd.redraw()
vim.cmd('redraw')
---@param line integer
---@param col integer
@ -274,19 +256,18 @@ local function highlight_vim_syntax(
spans = M.coalesce_syntax_spans(query_fn, code_lines)
end)
pcall(vim.api.nvim_buf_delete, scratch, { force = true })
vim.api.nvim_buf_delete(scratch, { force = true })
local hunk_line_count = #hunk.lines
local col_off = (hunk.prefix_width or 1) + (hunk.quote_width or 0) - 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 + col_off, {
end_col = span.col_end + col_off,
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end,
hl_group = span.hl_name,
priority = priorities.syntax,
priority = PRIORITY_SYNTAX,
})
extmark_count = extmark_count + 1
if covered_lines then
@ -303,9 +284,6 @@ 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 qw = hunk.quote_width or 0
local use_ts = hunk.lang and opts.highlights.treesitter.enabled
local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled
@ -322,18 +300,28 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
use_vim = false
end
if use_vim and opts.defer_vim_syntax then
use_vim = false
end
---@type table<integer, true>
local covered_lines = {}
local extmark_count = 0
---@type string[]
local new_code = {}
local ctx_cfg = opts.highlights.context
local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0
local leading = {}
local trailing = {}
if (use_ts or use_vim) and context > 0 and hunk.file_new_start and hunk.repo_root then
local filepath = vim.fs.joinpath(hunk.repo_root, hunk.filename)
local lead_from = math.max(1, hunk.file_new_start - context)
local lead_count = hunk.file_new_start - lead_from
if lead_count > 0 then
leading = read_line_range(filepath, lead_from, lead_count)
end
local trail_from = hunk.file_new_start + (hunk.file_new_count or 0)
trailing = read_line_range(filepath, trail_from, context)
end
local extmark_count = 0
if use_ts then
---@type string[]
local new_code = {}
---@type table<integer, integer>
local new_map = {}
---@type string[]
@ -341,17 +329,20 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
---@type table<integer, integer>
local old_map = {}
for i, line in ipairs(hunk.lines) do
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
for _, pad_line in ipairs(leading) do
table.insert(new_code, pad_line)
table.insert(old_code, pad_line)
end
if has_add and not has_del then
for i, line in ipairs(hunk.lines) do
local prefix = line:sub(1, 1)
local stripped = line:sub(2)
local buf_line = hunk.start_line + i - 1
if prefix == '+' then
new_map[#new_code] = buf_line
table.insert(new_code, stripped)
elseif has_del and not has_add then
elseif prefix == '-' then
old_map[#old_code] = buf_line
table.insert(old_code, stripped)
else
@ -361,38 +352,22 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
end
local ts_context = nil
if opts.highlights.context.enabled and (hunk.context_before or hunk.context_after) then
ts_context = { before = hunk.context_before, after = hunk.context_after }
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,
pw + qw,
covered_lines,
p,
nil,
ts_context
)
extmark_count = highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines)
extmark_count = extmark_count
+ highlight_treesitter(
bufnr,
ns,
old_code,
hunk.lang,
old_map,
pw + qw,
covered_lines,
p,
nil,
ts_context
)
+ highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines)
if hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
end_col = hunk.header_context_col + #hunk.header_context,
hl_group = 'DiffsClear',
priority = PRIORITY_CLEAR,
})
local header_extmarks = highlight_text(
bufnr,
ns,
@ -400,8 +375,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
hunk.header_context_col,
hunk.header_context,
hunk.lang,
new_code,
p
new_code
)
if header_extmarks > 0 then
dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks)
@ -411,10 +385,16 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
elseif use_vim then
---@type string[]
local code_lines = {}
for _, line in ipairs(hunk.lines) do
table.insert(code_lines, line:sub(pw + 1))
for _, pad_line in ipairs(leading) do
table.insert(code_lines, pad_line)
end
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p)
for _, line in ipairs(hunk.lines) do
table.insert(code_lines, line:sub(2))
end
for _, pad_line in ipairs(trailing) do
table.insert(code_lines, pad_line)
end
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, #leading)
end
if
@ -429,35 +409,13 @@ 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,
qw,
nil,
p,
qw > 0 or pw > 1
)
end
local at_raw_line
if (qw > 0 or 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]
+ highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0)
end
---@type diffs.IntraChanges?
local intra = nil
local intra_cfg = opts.highlights.intra
if
not opts.syntax_only
and intra_cfg
and intra_cfg.enabled
and pw == 1
and #hunk.lines <= intra_cfg.max_lines
then
if intra_cfg and intra_cfg.enabled and #hunk.lines <= intra_cfg.max_lines then
dbg('computing intra for hunk %s:%d (%d lines)', hunk.filename, hunk.start_line, #hunk.lines)
intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm)
if intra then
@ -488,181 +446,69 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
end
if
(qw > 0 or 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] + qw,
hl_group = 'DiffsClear',
priority = p.clear,
})
if pw > 1 then
local hline = hunk.header_lines[i + 1]
if hline:match('^index ') then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, qw, {
end_col = 5 + qw,
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 + qw, {
end_col = dot_pos + 1 + #hash + qw,
hl_group = '@constant.diff',
priority = p.syntax,
})
end
end
end
end
end
end
if (qw > 0 or 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, qw, {
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
local raw_body_lines
if qw > 0 then
raw_body_lines =
vim.api.nvim_buf_get_lines(bufnr, hunk.start_line, hunk.start_line + #hunk.lines, false)
end
for i, line in ipairs(hunk.lines) do
local buf_line = hunk.start_line + i - 1
local line_len = #line
local raw_len = raw_body_lines and #raw_body_lines[i] or nil
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 prefix = line:sub(1, 1)
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
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
if not opts.syntax_only then
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 = { { string.rep(' ', pw + qw), virt_hl } },
virt_text_pos = 'overlay',
})
end
if qw > 0 or pw > 1 then
local prefix_end = pw + qw
if raw_len and prefix_end > raw_len then
prefix_end = raw_len
end
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
end_col = prefix_end,
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
local char_col = ci + qw
if raw_len and char_col >= raw_len then
break
end
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, char_col, {
end_col = char_col + 1,
hl_group = ch == '+' and '@diff.plus' or '@diff.minus',
priority = p.syntax,
})
end
end
end
if opts.highlights.background and is_diff_line then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
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 + qw, {
end_col = line_len + qw,
hl_group = 'DiffsConflictMarker',
priority = p.char_bg,
})
end
if char_spans_by_line[i] then
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"',
i,
buf_line,
span.col_start,
span.col_end,
char_hl,
line:sub(span.col_start + 1, span.col_end)
)
local ok, err =
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + qw, {
end_col = span.col_end + qw,
hl_group = char_hl,
priority = p.char_bg,
})
if not ok then
dbg('char extmark FAILED: %s', err)
end
extmark_count = extmark_count + 1
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 + qw, {
end_col = line_len + qw,
hl_group = 'DiffsClear',
priority = p.clear,
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_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, {
end_col = line_len,
hl_group = 'DiffsClear',
priority = PRIORITY_CLEAR,
})
end
if opts.highlights.background and is_diff_line then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
end_row = buf_line + 1,
hl_group = line_hl,
hl_eol = true,
priority = PRIORITY_LINE_BG,
})
if opts.highlights.gutter then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
number_hl_group = number_hl,
priority = PRIORITY_LINE_BG,
})
end
end
if char_spans_by_line[i] then
local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText'
for _, span in ipairs(char_spans_by_line[i]) do
dbg(
'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"',
i,
buf_line,
span.col_start,
span.col_end,
char_hl,
line:sub(span.col_start + 1, span.col_end)
)
local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end,
hl_group = char_hl,
priority = PRIORITY_CHAR_BG,
})
if not ok then
dbg('char extmark FAILED: %s', err)
end
extmark_count = extmark_count + 1
end
end
end
dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count)

View file

@ -15,12 +15,6 @@
---@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
@ -30,14 +24,11 @@
---@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
@ -50,18 +41,14 @@
---@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|string
---@field debug boolean
---@field debounce_ms integer
---@field hide_prefix boolean
---@field extra_filetypes string[]
---@field highlights diffs.Highlights
---@field fugitive diffs.FugitiveConfig|false
---@field neogit diffs.NeogitConfig|false
---@field fugitive diffs.FugitiveConfig
---@field conflict diffs.ConflictConfig
---@class diffs
@ -110,8 +97,8 @@ end
---@type diffs.Config
local default_config = {
debug = false,
debounce_ms = 0,
hide_prefix = false,
extra_filetypes = {},
highlights = {
background = true,
gutter = true,
@ -132,28 +119,22 @@ local default_config = {
algorithm = 'default',
max_lines = 500,
},
priorities = {
clear = 198,
syntax = 199,
line_bg = 200,
char_bg = 201,
},
},
fugitive = false,
neogit = false,
fugitive = {
horizontal = 'du',
vertical = 'dU',
},
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 = ']c',
prev = '[c',
next = ']x',
prev = '[x',
},
},
}
@ -163,304 +144,66 @@ local config = vim.deepcopy(default_config)
local initialized = false
---@diagnostic disable-next-line: missing-fields
local fast_hl_opts = {} ---@type diffs.HunkOpts
---@type table<integer, boolean>
local attached_buffers = {}
---@type table<integer, boolean>
local ft_retry_pending = {}
---@type table<integer, boolean>
local diff_windows = {}
---@class diffs.HunkCacheEntry
---@field hunks diffs.Hunk[]
---@field tick integer
---@field highlighted table<integer, true>
---@field pending_clear boolean
---@field line_count integer
---@field byte_count integer
---@type table<integer, diffs.HunkCacheEntry>
local hunk_cache = {}
---@param bufnr integer
---@return boolean
function M.is_fugitive_buffer(bufnr)
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 invalidate_cache(bufnr)
local entry = hunk_cache[bufnr]
if entry then
entry.tick = -1
entry.pending_clear = true
end
end
---@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
end
return true
end
---@param old_entry diffs.HunkCacheEntry
---@param new_hunks diffs.Hunk[]
---@return table<integer, true>?
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
local function highlight_buffer(bufnr)
if not vim.api.nvim_buf_is_valid(bufnr) then
return
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 path string
---@return string[]?
local function read_file_lines(path)
local f = io.open(path, 'r')
if not f then
return nil
end
local lines = {}
for line in f:lines() do
lines[#lines + 1] = line
end
f:close()
return lines
end
---@param hunks diffs.Hunk[]
---@param max_lines integer
local function compute_hunk_context(hunks, max_lines)
---@type table<string, string[]|false>
local file_cache = {}
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
if not hunk.repo_root or not hunk.filename or not hunk.file_new_start then
goto continue
end
local path = vim.fs.joinpath(hunk.repo_root, hunk.filename)
local file_lines = file_cache[path]
if file_lines == nil then
file_lines = read_file_lines(path) or false
file_cache[path] = file_lines
end
if not file_lines then
goto continue
end
local new_start = hunk.file_new_start
local new_count = hunk.file_new_count or 0
local total = #file_lines
local before_start = math.max(1, new_start - max_lines)
if before_start < new_start then
local before = {}
for i = before_start, new_start - 1 do
before[#before + 1] = file_lines[i]
end
hunk.context_before = before
end
local after_start = new_start + new_count
local after_end = math.min(total, after_start + max_lines - 1)
if after_start <= total then
local after = {}
for i = after_start, after_end do
after[#after + 1] = file_lines[i]
end
hunk.context_after = after
end
::continue::
highlight.highlight_hunk(bufnr, ns, hunk, {
hide_prefix = config.hide_prefix,
highlights = config.highlights,
})
end
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 fun()
local function create_debounced_highlight(bufnr)
local timer = nil ---@type table?
return function()
if timer then
timer:stop() ---@diagnostic disable-line: undefined-field
timer:close() ---@diagnostic disable-line: undefined-field
timer = nil
end
local t = vim.uv.new_timer()
if not t then
highlight_buffer(bufnr)
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)
if config.highlights.context.enabled then
compute_hunk_context(hunks, config.highlights.context.lines)
end
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()
@ -483,7 +226,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, bg = bg })
vim.api.nvim_set_hl(0, 'DiffsClear', { default = true, fg = normal.fg or 0xc0c0c0 })
vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add })
vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del })
vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add })
@ -531,7 +274,6 @@ 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',
@ -563,41 +305,10 @@ 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,
function(v)
return v == nil or type(v) == 'boolean' or type(v) == 'string'
end,
'boolean or string (file path)',
},
debug = { opts.debug, 'boolean', true },
debounce_ms = { opts.debounce_ms, 'number', true },
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 },
})
@ -611,7 +322,6 @@ 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
@ -652,32 +362,21 @@ 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 type(opts.fugitive) == 'table' then
---@type diffs.FugitiveConfig
local fug = opts.fugitive
if opts.fugitive then
vim.validate({
['fugitive.horizontal'] = {
fug.horizontal,
opts.fugitive.horizontal,
function(v)
return v == nil or v == false or type(v) == 'string'
return v == false or type(v) == 'string'
end,
'string or false',
},
['fugitive.vertical'] = {
fug.vertical,
opts.fugitive.vertical,
function(v)
return v == nil or v == false or type(v) == 'string'
return v == false or type(v) == 'string'
end,
'string or false',
},
@ -689,9 +388,6 @@ 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 },
})
@ -711,6 +407,9 @@ 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
@ -750,132 +449,17 @@ 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
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 syntax_opts = {
hide_prefix = config.hide_prefix,
highlights = config.highlights,
syntax_only = true,
}
for _, hunk in ipairs(deferred_syntax) do
highlight.highlight_hunk(bufnr, ns, hunk, syntax_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
)
highlight_buffer(bufnr)
end
end,
})
@ -900,34 +484,37 @@ 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)
ensure_cache(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,
})
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
@ -935,7 +522,7 @@ end
---@param bufnr? integer
function M.refresh(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
invalidate_cache(bufnr)
highlight_buffer(bufnr)
end
local DIFF_WINHIGHLIGHT = table.concat({
@ -978,7 +565,7 @@ function M.detach_diff()
end
end
---@return diffs.FugitiveConfig|false
---@return diffs.FugitiveConfig
function M.get_fugitive_config()
init()
return config.fugitive
@ -990,24 +577,4 @@ 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,
compute_hunk_context = compute_hunk_context,
}
return M

View file

@ -8,9 +8,6 @@ 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()
@ -167,10 +164,9 @@ function M.ensure(callback)
return
end
table.insert(pending_callbacks, callback)
if download_in_progress then
dbg('download already in progress, queued callback')
dbg('download already in progress')
callback(nil)
return
end
@ -196,25 +192,21 @@ 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 '')
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()
callback(nil)
return
end
local cbs = pending_callbacks
pending_callbacks = {}
for _, cb in ipairs(cbs) do
cb(handle)
local f = io.open(version_path(), 'w')
if f then
f:write(EXPECTED_VERSION)
f:close()
end
vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO)
callback(M.load())
end)
end)
end

View file

@ -1,17 +1,10 @@
local M = {}
local enabled = false
local log_file = nil
---@param val boolean|string
---@param val boolean
function M.set_enabled(val)
if type(val) == 'string' then
enabled = true
log_file = val
else
enabled = val
log_file = nil
end
enabled = val
end
---@param msg string
@ -20,16 +13,7 @@ function M.dbg(msg, ...)
if not enabled then
return
end
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
vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG)
end
return M

View file

@ -1,418 +0,0 @@
local M = {}
local conflict = require('diffs.conflict')
local ns = vim.api.nvim_create_namespace('diffs-merge')
---@type table<integer, table<integer, true>>
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, '<Plug>(diffs-merge-ours)' },
{ km.theirs, '<Plug>(diffs-merge-theirs)' },
{ km.both, '<Plug>(diffs-merge-both)' },
{ km.none, '<Plug>(diffs-merge-none)' },
{ km.next, '<Plug>(diffs-merge-next)' },
{ km.prev, '<Plug>(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

View file

@ -12,19 +12,12 @@
---@field file_old_count integer?
---@field file_new_start integer?
---@field file_new_count integer?
---@field prefix_width integer
---@field quote_width integer
---@field repo_root string?
---@field context_before string[]?
---@field context_after string[]?
local M = {}
local dbg = require('diffs.log').dbg
---@type table<string, {ft: string?, lang: string?}>
local ft_lang_cache = {}
---@param filepath string
---@param n integer
---@return string[]?
@ -63,15 +56,6 @@ 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
@ -122,14 +106,7 @@ local function get_repo_root(bufnr)
return vim.fn.fnamemodify(git_dir, ':h')
end
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 .. '/.')
return nil
end
---@param bufnr integer
@ -137,18 +114,6 @@ 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)
local quote_prefix = nil
local quote_width = 0
for _, l in ipairs(lines) do
local qp = l:match('^(>+ )diff %-%-') or l:match('^(>+ )@@ %-')
if qp then
quote_prefix = qp
quote_width = #qp
break
end
end
---@type diffs.Hunk[]
local hunks = {}
@ -168,8 +133,6 @@ 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[]
@ -182,11 +145,6 @@ 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 current_quote_width = 0
local function flush_hunk()
if hunk_start and #hunk_lines > 0 then
@ -198,8 +156,6 @@ 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,
quote_width = current_quote_width,
file_old_start = file_old_start,
file_old_count = file_old_count,
file_new_start = file_new_start,
@ -220,121 +176,51 @@ 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 logical = line
if quote_prefix then
if line:sub(1, quote_width) == quote_prefix then
logical = line:sub(quote_width + 1)
elseif line:match('^>+$') then
logical = ''
end
end
local diff_git_file = logical:match('^diff %-%-git a/.+ b/(.+)$')
or logical:match('^diff %-%-combined (.+)$')
or logical:match('^diff %-%-cc (.+)$')
local neogit_file = logical:match('^modified%s+(.+)$')
or (not logical:match('^new file mode') and logical:match('^new file%s+(.+)$'))
or (not logical:match('^deleted file mode') and logical:match('^deleted%s+(.+)$'))
or logical:match('^renamed%s+(.+)$')
or logical:match('^copied%s+(.+)$')
local bare_file = not hunk_start and logical:match('^([^%s]+%.[^%s]+)$')
local filename = logical:match('^[MADRCU%?!]%s+(.+)$')
or diff_git_file
or neogit_file
or bare_file
local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$')
if filename then
flush_hunk()
current_filename = filename
current_quote_width = (logical ~= line) and quote_width or 0
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
current_ft = get_ft_from_filename(filename, repo_root)
current_lang = current_ft and get_lang_from_ft(current_ft) or nil
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 logical:match('^@@+') then
elseif line:match('^@@.-@@') then
flush_hunk()
hunk_start = i
local at_prefix = logical:match('^(@@+)')
hunk_prefix_width = #at_prefix - 1
if #at_prefix == 2 then
local hs, hc, hs2, hc2 = logical: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 = logical: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 = logical:match('%+(%d+),?(%d*) @@')
if hs2 then
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
new_remaining = file_new_count
end
local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
if hs then
file_old_start = tonumber(hs)
file_old_count = tonumber(hc) or 1
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
end
local at_end, context = logical:match('^(@@+.-@@+%s*)(.*)')
local prefix, context = line:match('^(@@.-@@%s*)(.*)')
if context and context ~= '' then
hunk_header_context = context
hunk_header_context_col = #at_end + current_quote_width
hunk_header_context_col = #prefix
end
if hunk_count then
hunk_count = hunk_count + 1
end
elseif hunk_start then
local prefix = logical:sub(1, 1)
local prefix = line:sub(1, 1)
if prefix == ' ' or prefix == '+' or prefix == '-' then
table.insert(hunk_lines, logical)
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
table.insert(hunk_lines, line)
elseif
logical == ''
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
logical == ''
or logical:match('^[MADRC%?!]%s+')
or logical:match('^diff ')
or logical:match('^index ')
or logical:match('^Binary ')
line == ''
or line:match('^[MADRC%?!]%s+')
or line:match('^diff ')
or line:match('^index ')
or line:match('^Binary ')
then
flush_hunk()
current_filename = nil
@ -344,7 +230,7 @@ function M.parse_buffer(bufnr)
end
end
if header_start and not hunk_start then
table.insert(header_lines, logical)
table.insert(header_lines, line)
end
end
@ -353,8 +239,4 @@ function M.parse_buffer(bufnr)
return hunks
end
M._test = {
ft_lang_cache = ft_lang_cache,
}
return M

View file

@ -6,7 +6,7 @@ vim.g.loaded_diffs = 1
require('diffs.commands').setup()
vim.api.nvim_create_autocmd('FileType', {
pattern = require('diffs').compute_filetypes(vim.g.diffs or {}),
pattern = { 'fugitive', 'git' },
callback = function(args)
local diffs = require('diffs')
if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then
@ -82,28 +82,3 @@ end, { desc = 'Jump to next conflict' })
vim.keymap.set('n', '<Plug>(diffs-conflict-prev)', function()
require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to previous conflict' })
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', '<Plug>(diffs-merge-ours)', function()
merge_action(require('diffs.merge').resolve_ours)
end, { desc = 'Accept ours in merge diff' })
vim.keymap.set('n', '<Plug>(diffs-merge-theirs)', function()
merge_action(require('diffs.merge').resolve_theirs)
end, { desc = 'Accept theirs in merge diff' })
vim.keymap.set('n', '<Plug>(diffs-merge-both)', function()
merge_action(require('diffs.merge').resolve_both)
end, { desc = 'Accept both in merge diff' })
vim.keymap.set('n', '<Plug>(diffs-merge-none)', function()
merge_action(require('diffs.merge').resolve_none)
end, { desc = 'Reject both in merge diff' })
vim.keymap.set('n', '<Plug>(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', '<Plug>(diffs-merge-prev)', function()
require('diffs.merge').goto_prev(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to previous conflict hunk' })

View file

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

View file

@ -1,4 +1 @@
std = 'vim'
[lints]
bad_string_escape = 'allow'

View file

@ -40,78 +40,6 @@ 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 = {

View file

@ -6,14 +6,13 @@ 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 = ']c',
prev = '[c',
next = ']x',
prev = '[x',
},
}
if overrides then
@ -235,6 +234,29 @@ 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',
@ -509,33 +531,6 @@ 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',
@ -580,33 +575,6 @@ 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)
@ -717,158 +685,4 @@ 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)

View file

@ -1,382 +0,0 @@
require('spec.helpers')
local diffs = require('diffs')
local highlight = require('diffs.highlight')
local compute_hunk_context = diffs._test.compute_hunk_context
describe('context', function()
describe('compute_hunk_context', function()
local tmpdir
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
end)
local function write_file(filename, lines)
local path = vim.fs.joinpath(tmpdir, filename)
local dir = vim.fn.fnamemodify(path, ':h')
if vim.fn.isdirectory(dir) == 0 then
vim.fn.mkdir(dir, 'p')
end
local f = io.open(path, 'w')
f:write(table.concat(lines, '\n') .. '\n')
f:close()
end
local function make_hunk(filename, opts)
return {
filename = filename,
ft = 'lua',
lang = 'lua',
start_line = opts.start_line or 1,
lines = opts.lines,
prefix_width = opts.prefix_width or 1,
quote_width = 0,
repo_root = tmpdir,
file_new_start = opts.file_new_start,
file_new_count = opts.file_new_count,
}
end
it('reads context_before from file lines preceding the hunk', function()
write_file('a.lua', {
'local M = {}',
'function M.foo()',
' local x = 1',
' local y = 2',
'end',
'return M',
})
local hunks = {
make_hunk('a.lua', {
file_new_start = 3,
file_new_count = 3,
lines = { ' local x = 1', '+local new = true', ' local y = 2' },
}),
}
compute_hunk_context(hunks, 25)
assert.same({ 'local M = {}', 'function M.foo()' }, hunks[1].context_before)
end)
it('reads context_after from file lines following the hunk', function()
write_file('a.lua', {
'local M = {}',
'function M.foo()',
' local x = 1',
'end',
'return M',
})
local hunks = {
make_hunk('a.lua', {
file_new_start = 2,
file_new_count = 2,
lines = { ' function M.foo()', '+ local x = 1' },
}),
}
compute_hunk_context(hunks, 25)
assert.same({ 'end', 'return M' }, hunks[1].context_after)
end)
it('caps context_before to max_lines', function()
write_file('a.lua', {
'line1',
'line2',
'line3',
'line4',
'line5',
'target',
})
local hunks = {
make_hunk('a.lua', {
file_new_start = 6,
file_new_count = 1,
lines = { '+target' },
}),
}
compute_hunk_context(hunks, 2)
assert.same({ 'line4', 'line5' }, hunks[1].context_before)
end)
it('caps context_after to max_lines', function()
write_file('a.lua', {
'target',
'after1',
'after2',
'after3',
'after4',
})
local hunks = {
make_hunk('a.lua', {
file_new_start = 1,
file_new_count = 1,
lines = { '+target' },
}),
}
compute_hunk_context(hunks, 2)
assert.same({ 'after1', 'after2' }, hunks[1].context_after)
end)
it('skips hunks without file_new_start', function()
write_file('a.lua', { 'line1', 'line2' })
local hunks = {
make_hunk('a.lua', {
file_new_start = nil,
file_new_count = nil,
lines = { '+something' },
}),
}
compute_hunk_context(hunks, 25)
assert.is_nil(hunks[1].context_before)
assert.is_nil(hunks[1].context_after)
end)
it('skips hunks without repo_root', function()
local hunks = {
{
filename = 'a.lua',
ft = 'lua',
lang = 'lua',
start_line = 1,
lines = { '+x' },
prefix_width = 1,
quote_width = 0,
repo_root = nil,
file_new_start = 1,
file_new_count = 1,
},
}
compute_hunk_context(hunks, 25)
assert.is_nil(hunks[1].context_before)
assert.is_nil(hunks[1].context_after)
end)
it('skips when file does not exist on disk', function()
local hunks = {
make_hunk('nonexistent.lua', {
file_new_start = 1,
file_new_count = 1,
lines = { '+x' },
}),
}
compute_hunk_context(hunks, 25)
assert.is_nil(hunks[1].context_before)
assert.is_nil(hunks[1].context_after)
end)
it('returns nil context_before for hunk at line 1', function()
write_file('a.lua', { 'first', 'second' })
local hunks = {
make_hunk('a.lua', {
file_new_start = 1,
file_new_count = 1,
lines = { '+first' },
}),
}
compute_hunk_context(hunks, 25)
assert.is_nil(hunks[1].context_before)
end)
it('returns nil context_after for hunk at end of file', function()
write_file('a.lua', { 'first', 'last' })
local hunks = {
make_hunk('a.lua', {
file_new_start = 1,
file_new_count = 2,
lines = { ' first', '+last' },
}),
}
compute_hunk_context(hunks, 25)
assert.is_nil(hunks[1].context_after)
end)
it('reads file once for multiple hunks in same file', function()
write_file('a.lua', {
'local M = {}',
'function M.foo()',
' return 1',
'end',
'function M.bar()',
' return 2',
'end',
'return M',
})
local hunks = {
make_hunk('a.lua', {
file_new_start = 2,
file_new_count = 3,
lines = { ' function M.foo()', '+ return 1', ' end' },
}),
make_hunk('a.lua', {
file_new_start = 5,
file_new_count = 3,
lines = { ' function M.bar()', '+ return 2', ' end' },
}),
}
compute_hunk_context(hunks, 25)
assert.same({ 'local M = {}' }, hunks[1].context_before)
assert.same({ 'function M.bar()', ' return 2', 'end', 'return M' }, hunks[1].context_after)
assert.same({
'local M = {}',
'function M.foo()',
' return 1',
'end',
}, hunks[2].context_before)
assert.same({ 'return M' }, hunks[2].context_after)
end)
end)
describe('highlight_treesitter with context', function()
local ns
before_each(function()
ns = vim.api.nvim_create_namespace('diffs_context_test')
local normal = vim.api.nvim_get_hl(0, { name = 'Normal' })
vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 })
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)
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_extmarks(bufnr)
return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })
end
local function default_opts(overrides)
local opts = {
hide_prefix = false,
highlights = {
background = false,
gutter = false,
context = { enabled = true, lines = 25 },
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 },
},
}
if overrides then
if overrides.highlights then
opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights)
end
end
return opts
end
it('applies extmarks only to hunk lines, not context lines', function()
local bufnr = create_buffer({
'@@ -1,2 +1,3 @@',
' local x = 1',
' local y = 2',
'+local z = 3',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 2,
lines = { ' local x = 1', ' local y = 2', '+local z = 3' },
prefix_width = 1,
quote_width = 0,
context_before = { 'local function foo()' },
context_after = { 'end' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local row = mark[2]
assert.is_true(row >= 1 and row <= 3, 'extmark row ' .. row .. ' outside hunk range')
end
assert.is_true(#extmarks > 0)
delete_buffer(bufnr)
end)
it('does not pass context when context.enabled = false', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 2,
lines = { ' local x = 1', '+local y = 2' },
prefix_width = 1,
quote_width = 0,
context_before = { 'local function foo()' },
context_after = { 'end' },
}
local opts_enabled = default_opts({ highlights = { context = { enabled = true } } })
highlight.highlight_hunk(bufnr, ns, hunk, opts_enabled)
local extmarks_with = get_extmarks(bufnr)
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
local opts_disabled = default_opts({ highlights = { context = { enabled = false } } })
highlight.highlight_hunk(bufnr, ns, hunk, opts_disabled)
local extmarks_without = get_extmarks(bufnr)
assert.is_true(#extmarks_with > 0)
assert.is_true(#extmarks_without > 0)
delete_buffer(bufnr)
end)
it('skips context fields that are nil', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 2,
lines = { ' local x = 1', '+local y = 2' },
prefix_width = 1,
quote_width = 0,
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
assert.is_true(#extmarks > 0)
delete_buffer(bufnr)
end)
end)
end)

View file

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

View file

@ -1,477 +0,0 @@
require('spec.helpers')
local highlight = require('diffs.highlight')
local parser = require('diffs.parser')
describe('email-quoted diffs', 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)
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('parser', function()
it('parses a fully email-quoted unified diff', function()
local bufnr = create_buffer({
'> diff --git a/foo.py b/foo.py',
'> index abc1234..def5678 100644',
'> --- a/foo.py',
'> +++ b/foo.py',
'> @@ -0,0 +1,3 @@',
'> +from typing import Annotated, final',
'> +',
'> +class Foo:',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('foo.py', hunks[1].filename)
assert.are.equal(3, #hunks[1].lines)
assert.are.equal('+from typing import Annotated, final', hunks[1].lines[1])
assert.are.equal(2, hunks[1].quote_width)
delete_buffer(bufnr)
end)
it('parses a quoted diff embedded in an email reply', function()
local bufnr = create_buffer({
'Looks good, one nit:',
'',
'> diff --git a/foo.py b/foo.py',
'> @@ -0,0 +1,3 @@',
'> +from typing import Annotated, final',
'> +',
'> +class Foo:',
'',
'Maybe rename Foo to Bar?',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('foo.py', hunks[1].filename)
assert.are.equal(3, #hunks[1].lines)
assert.are.equal(2, hunks[1].quote_width)
delete_buffer(bufnr)
end)
it('sets quote_width = 0 on normal (unquoted) diffs', function()
local bufnr = create_buffer({
'diff --git a/bar.lua b/bar.lua',
'@@ -1,2 +1,2 @@',
'-old_line',
'+new_line',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(0, hunks[1].quote_width)
delete_buffer(bufnr)
end)
it('treats bare > lines as empty quoted lines', function()
local bufnr = create_buffer({
'> diff --git a/foo.py b/foo.py',
'> @@ -1,3 +1,3 @@',
'> -old',
'>',
'> +new',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(3, #hunks[1].lines)
assert.are.equal('-old', hunks[1].lines[1])
assert.are.equal(' ', hunks[1].lines[2])
assert.are.equal('+new', hunks[1].lines[3])
delete_buffer(bufnr)
end)
it('handles deeply nested quotes', function()
local bufnr = create_buffer({
'>> diff --git a/foo.py b/foo.py',
'>> @@ -0,0 +1,2 @@',
'>> +line1',
'>> +line2',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(3, hunks[1].quote_width)
assert.are.equal('+line1', hunks[1].lines[1])
delete_buffer(bufnr)
end)
it('adjusts header_context_col for quote width', function()
local bufnr = create_buffer({
'> diff --git a/foo.py b/foo.py',
'> @@ -1,2 +1,2 @@ def hello():',
'> -old',
'> +new',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('def hello():', hunks[1].header_context)
assert.are.equal(#'@@ -1,2 +1,2 @@ ' + 2, hunks[1].header_context_col)
delete_buffer(bufnr)
end)
it('does not false-positive on prose containing > diff', function()
local bufnr = create_buffer({
'> diff between approaches is small',
'> I think we should go with option A',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(0, #hunks)
delete_buffer(bufnr)
end)
it('stores header lines stripped of quote prefix', function()
local bufnr = create_buffer({
'> diff --git a/foo.lua b/foo.lua',
'> index abc1234..def5678 100644',
'> --- a/foo.lua',
'> +++ b/foo.lua',
'> @@ -1,1 +1,1 @@',
'> -old',
'> +new',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.is_not_nil(hunks[1].header_lines)
for _, hline in ipairs(hunks[1].header_lines) do
assert.is_nil(hline:match('^> '))
end
delete_buffer(bufnr)
end)
end)
describe('highlight', function()
local ns
before_each(function()
ns = vim.api.nvim_create_namespace('diffs_email_test')
vim.api.nvim_set_hl(0, 'DiffsClear', { fg = 0xc0c0c0, bg = 0x1e1e1e })
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = 0x1a3a1a })
vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = 0x3a1a1a })
vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { fg = 0x808080, bold = true })
end)
local function get_extmarks(bufnr)
return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })
end
local function default_opts(overrides)
local opts = {
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,
},
},
}
if overrides then
if overrides.highlights then
opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights)
end
for k, v in pairs(overrides) do
if k ~= 'highlights' then
opts[k] = v
end
end
end
return opts
end
it('applies DiffsClear on email-quoted header lines covering full buffer width', function()
local buf_lines = {
'> diff --git a/foo.lua b/foo.lua',
'> index abc1234..def5678 100644',
'> --- a/foo.lua',
'> +++ b/foo.lua',
'> @@ -1,1 +1,1 @@',
'> -old',
'> +new',
}
local bufnr = create_buffer(buf_lines)
local hunk = {
filename = 'foo.lua',
lang = 'lua',
ft = 'lua',
start_line = 5,
lines = { '-old', '+new' },
prefix_width = 1,
quote_width = 2,
header_start_line = 1,
header_lines = {
'diff --git a/foo.lua b/foo.lua',
'index abc1234..def5678 100644',
'--- a/foo.lua',
'+++ b/foo.lua',
},
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local header_clears = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.hl_group == 'DiffsClear' and mark[2] < 4 then
table.insert(header_clears, { row = mark[2], col = mark[3], end_col = d.end_col })
end
end
assert.is_true(#header_clears > 0)
for _, c in ipairs(header_clears) do
assert.are.equal(0, c.col)
local buf_line_len = #buf_lines[c.row + 1]
assert.are.equal(buf_line_len, c.end_col)
end
delete_buffer(bufnr)
end)
it('applies body prefix DiffsClear covering [0, pw+qw)', function()
local bufnr = create_buffer({
'> @@ -1,1 +1,1 @@',
'> -old',
'> +new',
})
local hunk = {
filename = 'foo.lua',
lang = 'lua',
ft = 'lua',
start_line = 1,
lines = { '-old', '+new' },
prefix_width = 1,
quote_width = 2,
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local prefix_clears = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.hl_group == 'DiffsClear' and d.end_col == 3 and mark[3] == 0 then
table.insert(prefix_clears, { row = mark[2] })
end
end
assert.are.equal(2, #prefix_clears)
delete_buffer(bufnr)
end)
it('clamps body prefix DiffsClear on bare > lines (1-byte buffer line)', function()
local bufnr = create_buffer({
'> @@ -1,3 +1,3 @@',
'> -old',
'>',
'> +new',
})
local hunk = {
filename = 'foo.lua',
ft = 'lua',
lang = 'lua',
start_line = 1,
lines = { '-old', ' ', '+new' },
prefix_width = 1,
quote_width = 2,
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local bare_line_row = 2
local bare_clears = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.hl_group == 'DiffsClear' and mark[2] == bare_line_row and mark[3] == 0 then
table.insert(bare_clears, { end_col = d.end_col })
end
end
assert.are.equal(1, #bare_clears)
assert.are.equal(1, bare_clears[1].end_col)
delete_buffer(bufnr)
end)
it('applies per-char @diff.plus/@diff.minus at ci + qw', function()
local bufnr = create_buffer({
'> @@ -1,1 +1,1 @@',
'> -old',
'> +new',
})
local hunk = {
filename = 'foo.lua',
lang = 'lua',
ft = 'lua',
start_line = 1,
lines = { '-old', '+new' },
prefix_width = 1,
quote_width = 2,
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local diff_marks = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and (d.hl_group == '@diff.plus' or d.hl_group == '@diff.minus') then
table.insert(
diff_marks,
{ row = mark[2], col = mark[3], end_col = d.end_col, hl = d.hl_group }
)
end
end
assert.is_true(#diff_marks >= 2)
for _, dm in ipairs(diff_marks) do
assert.are.equal(2, dm.col)
assert.are.equal(3, dm.end_col)
end
delete_buffer(bufnr)
end)
it('offsets treesitter extmarks by pw + qw', function()
local bufnr = create_buffer({
'> @@ -1,1 +1,2 @@',
'> local x = 1',
'> +local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
ft = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
prefix_width = 1,
quote_width = 2,
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local ts_marks = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.hl_group and d.hl_group:match('^@.*%.lua$') then
table.insert(ts_marks, { row = mark[2], col = mark[3] })
end
end
assert.is_true(#ts_marks > 0)
for _, tm in ipairs(ts_marks) do
assert.is_true(tm.col >= 3)
end
delete_buffer(bufnr)
end)
it('offsets intra-line char span extmarks by qw', function()
local bufnr = create_buffer({
'> @@ -1,1 +1,1 @@',
'> -hello world',
'> +hello earth',
})
local hunk = {
filename = 'test.txt',
ft = nil,
lang = nil,
start_line = 1,
lines = { '-hello world', '+hello earth' },
prefix_width = 1,
quote_width = 2,
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({
highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } },
})
)
local extmarks = get_extmarks(bufnr)
local char_marks = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then
table.insert(char_marks, { row = mark[2], col = mark[3], end_col = d.end_col })
end
end
if #char_marks > 0 then
for _, cm in ipairs(char_marks) do
assert.is_true(cm.col >= 2)
end
end
delete_buffer(bufnr)
end)
it('does not produce duplicate extmarks with syntax_only + qw', function()
local bufnr = create_buffer({
'> @@ -1,1 +1,2 @@',
'> local x = 1',
'> +local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
ft = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
prefix_width = 1,
quote_width = 2,
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ syntax_only = true }))
local extmarks = get_extmarks(bufnr)
local line_bg_count = 0
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.line_hl_group == 'DiffsAdd' then
line_bg_count = line_bg_count + 1
end
end
assert.are.equal(1, line_bg_count)
delete_buffer(bufnr)
end)
end)
end)

View file

@ -87,6 +87,28 @@ 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)',
@ -135,6 +157,28 @@ 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)',
@ -146,54 +190,77 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true })
end)
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()
it('handles double extensions', function()
local buf = create_status_buffer({
'Staged (1)',
'R100 "old name.lua" -> "new name.lua"',
'M test.spec.lua',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('new name.lua', filename)
assert.equals('old name.lua', old_filename)
assert.equals('test.spec.lua', filename)
assert.is_nil(old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles hyphenated filenames', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M my-component-test.lua',
})
local filename, section = fugitive.get_file_at_line(buf, 2)
assert.equals('my-component-test.lua', filename)
assert.equals('unstaged', section)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles underscores and numbers', function()
local buf = create_status_buffer({
'Staged (1)',
'A test_file_123.lua',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('test_file_123.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles dotfiles', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M .gitignore',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('.gitignore', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles renamed with complex names', function()
local buf = create_status_buffer({
'Staged (1)',
'R src/old-file.spec.lua -> src/new-file.spec.lua',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('src/new-file.spec.lua', filename)
assert.equals('src/old-file.spec.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles deeply nested paths', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M lua/diffs/ui/components/diff-view.lua',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('lua/diffs/ui/components/diff-view.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('parses untracked file', function()
local buf = create_status_buffer({
'Untracked (1)',
'? untracked.lua',
})
local filename, section = fugitive.get_file_at_line(buf, 2)
assert.equals('untracked.lua', filename)
assert.equals('untracked', section)
vim.api.nvim_buf_delete(buf, { force = true })
end)
@ -254,6 +321,30 @@ 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)',
@ -315,6 +406,22 @@ 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)',

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@ describe('diffs', function()
it('accepts full config', function()
vim.g.diffs = {
debug = true,
debounce_ms = 100,
hide_prefix = false,
highlights = {
background = true,
@ -45,7 +46,7 @@ describe('diffs', function()
it('accepts partial config', function()
vim.g.diffs = {
hide_prefix = true,
debounce_ms = 25,
}
assert.has_no.errors(function()
diffs.attach()
@ -151,247 +152,6 @@ 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')

View file

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

View file

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

View file

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

View file

@ -163,10 +163,10 @@ describe('parser', function()
end
end)
it('stops hunk at blank line when remaining counts exhausted', function()
it('stops hunk at blank line', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,1 +1,2 @@',
'@@ -1,2 +1,3 @@',
' local x = 1',
'+local y = 2',
'',
@ -391,27 +391,35 @@ describe('parser', function()
vim.fn.delete(repo_root, 'rf')
end)
it('detects filetype for .sh files when did_filetype() is non-zero', function()
rawset(vim.fn, 'did_filetype', function()
return 1
end)
it('detects python from shebang without open buffer', function()
local repo_root = '/tmp/diffs-test-shebang-py'
vim.fn.mkdir(repo_root, 'p')
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..."',
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")',
})
local hunks = parser.parse_buffer(bufnr)
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
local hunks = parser.parse_buffer(diff_buf)
assert.are.equal(1, #hunks)
assert.are.equal('test.sh', hunks[1].filename)
assert.are.equal('sh', hunks[1].ft)
delete_buffer(bufnr)
rawset(vim.fn, 'did_filetype', nil)
assert.are.equal('deploy', hunks[1].filename)
assert.are.equal('python', hunks[1].ft)
delete_buffer(diff_buf)
os.remove(file_path)
vim.fn.delete(repo_root, 'rf')
end)
it('extracts file line numbers from @@ header', function()
@ -432,6 +440,22 @@ 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',
@ -448,154 +472,6 @@ 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',
@ -612,388 +488,16 @@ describe('parser', function()
delete_buffer(bufnr)
end)
it('detects neogit modified prefix', function()
it('repo_root is nil when not available', function()
local bufnr = create_buffer({
'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 @@',
'M lua/test.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
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)
assert.is_nil(hunks[1].repo_root)
delete_buffer(bufnr)
end)
end)

33
vim.toml Normal file
View file

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

View file

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