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
34 changed files with 1214 additions and 5282 deletions

View file

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

View file

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

View file

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

View file

@ -2,23 +2,25 @@
**Syntax highlighting for diffs in Neovim** **Syntax highlighting for diffs in Neovim**
Enhance [vim-fugitive](https://github.com/tpope/vim-fugitive), Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware
[Neogit](https://github.com/NeogitOrg/neogit), and Neovim's built-in diff mode syntax highlighting.
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 ## Features
- Treesitter syntax highlighting in vim-fugitive, Neogit, and `diff` filetype - Treesitter syntax highlighting in `:Git` diffs and commit views
- Character-level intra-line diff highlighting (with optional - Diff header highlighting (`diff --git`, `index`, `---`, `+++`)
[vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for - `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds
word-level accuracy) - `:Gdiff` unified diff against any git revision with syntax highlighting
- `:Gdiff` unified diff against any revision - Fugitive status buffer keymaps (`du`/`dU`) for unified diffs
- Background-only diff colors for `&diff` buffers - Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`)
- Inline merge conflict detection, highlighting, and resolution - Vim syntax fallback for languages without a treesitter parser
- Email-quoted diff highlighting (`> diff ...` prefixes, arbitrary nesting depth) - Hunk header context highlighting (`@@ ... @@ function foo()`)
- Vim syntax fallback, configurable blend/priorities - Character-level (intra-line) diff highlighting for changed characters
- Inline merge conflict detection, highlighting, and resolution keymaps
- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and
highlight overrides
## Requirements ## Requirements
@ -39,58 +41,18 @@ luarocks install diffs.nvim
:help 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 ## Known Limitations
- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation. - **Incomplete syntax context**: Treesitter parses each diff hunk in isolation.
Context lines within the hunk provide syntactic context for the parser. In To improve accuracy, `diffs.nvim` reads lines from disk before and after each
rare cases, hunks that start or end mid-expression may produce imperfect hunk for parsing context (`highlights.context`, enabled by default with 25
highlights due to treesitter error recovery. lines). This resolves most boundary issues. Set
`highlights.context.enabled = false` to disable.
- **Syntax "flashing"**: `diffs.nvim` hooks into the `FileType fugitive` event - **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event
triggered by `vim-fugitive`, at which point the buffer is preliminarily triggered by `vim-fugitive`, at which point the buffer is preliminarily
painted. The decoration provider applies highlights on the next redraw cycle, painted. The buffer is then re-painted after `debounce_ms` milliseconds,
causing a brief visual "flash". causing an unavoidable visual "flash" even when `debounce_ms = 0`.
- **Cold Start**: Treesitter grammar loading (~10ms) and query compilation
(~4ms) are one-time costs per language per Neovim session. Each language pays
this cost on first encounter, which may cause a brief stutter when a diff
containing a new language first enters the viewport.
- **Vim syntax fallback is deferred**: The vim syntax fallback (for languages
without a treesitter parser) cannot run inside the decoration provider's
redraw cycle due to Neovim's restriction on buffer mutations. Vim syntax
highlights for these hunks appear slightly delayed.
- **Conflicting diff plugins**: `diffs.nvim` may not interact well with other - **Conflicting diff plugins**: `diffs.nvim` may not interact well with other
plugins that modify diff highlighting. Known plugins that may conflict: plugins that modify diff highlighting. Known plugins that may conflict:
@ -108,14 +70,11 @@ See the documentation for more information.
# Acknowledgements # Acknowledgements
- [`vim-fugitive`](https://github.com/tpope/vim-fugitive) - [`vim-fugitive`](https://github.com/tpope/vim-fugitive)
- [@esmuellert](https://github.com/esmuellert) / - [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim)
[`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - vscode-diff
algorithm FFI backend for word-level intra-line accuracy
- [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) - [`diffview.nvim`](https://github.com/sindrets/diffview.nvim)
- [`difftastic`](https://github.com/Wilfred/difftastic) - [`difftastic`](https://github.com/Wilfred/difftastic)
- [`mini.diff`](https://github.com/echasnovski/mini.diff) - [`mini.diff`](https://github.com/echasnovski/mini.diff)
- [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim) - [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim)
- [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim)
- [@phanen](https://github.com/phanen) - diff header highlighting, unknown - [@phanen](https://github.com/phanen) - diff header highlighting, unknown
filetype fix, shebang/modeline detection, treesitter injection support, filetype fix, shebang/modeline detection, treesitter injection support
decoration provider highlighting architecture

View file

@ -7,8 +7,8 @@ License: MIT
INTRODUCTION *diffs.nvim* INTRODUCTION *diffs.nvim*
diffs.nvim adds syntax highlighting to diff views. It overlays language-aware diffs.nvim adds syntax highlighting to diff views. It overlays language-aware
highlights on top of default diff highlighting in vim-fugitive, Neogit, and highlights on top of default diff highlighting in vim-fugitive and Neovim's
Neovim's built-in diff mode. built-in diff mode.
Features: ~ Features: ~
- Syntax highlighting in |:Git| summary diffs and commit detail views - Syntax highlighting in |:Git| summary diffs and commit detail views
@ -36,7 +36,7 @@ etc.) works without vim-fugitive.
============================================================================== ==============================================================================
SETUP *diffs-setup* SETUP *diffs-setup*
Install with lazy.nvim: >lua Using lazy.nvim: >lua
{ {
'barrettruth/diffs.nvim', 'barrettruth/diffs.nvim',
dependencies = { 'tpope/vim-fugitive' }, dependencies = { 'tpope/vim-fugitive' },
@ -52,10 +52,8 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
>lua >lua
vim.g.diffs = { vim.g.diffs = {
debug = false, debug = false,
debounce_ms = 0,
hide_prefix = false, hide_prefix = false,
fugitive = false,
neogit = false,
extra_filetypes = {},
highlights = { highlights = {
background = true, background = true,
gutter = true, gutter = true,
@ -77,26 +75,23 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
algorithm = 'default', algorithm = 'default',
max_lines = 500, max_lines = 500,
}, },
priorities = {
clear = 198,
syntax = 199,
line_bg = 200,
char_bg = 201,
},
overrides = {}, overrides = {},
}, },
fugitive = {
horizontal = 'du',
vertical = 'dU',
},
conflict = { conflict = {
enabled = true, enabled = true,
disable_diagnostics = true, disable_diagnostics = true,
show_virtual_text = true, show_virtual_text = true,
show_actions = false,
keymaps = { keymaps = {
ours = 'doo', ours = 'doo',
theirs = 'dot', theirs = 'dot',
both = 'dob', both = 'dob',
none = 'don', none = 'don',
next = ']c', next = ']x',
prev = '[c', prev = '[x',
}, },
}, },
} }
@ -107,6 +102,11 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
Enable debug logging to |:messages| with Enable debug logging to |:messages| with
`[diffs]` prefix. `[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_prefix} (boolean, default: false)
Hide diff prefixes (`+`/`-`/` `) using virtual Hide diff prefixes (`+`/`-`/` `) using virtual
text overlay. Makes code appear without the text overlay. Makes code appear without the
@ -114,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 is also enabled, the overlay inherits the line's
background color. 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) {highlights} (table, default: see below)
Controls which highlight features are enabled. Controls which highlight features are enabled.
See |diffs.Highlights| for fields. See |diffs.Highlights| for fields.
{fugitive} (table, default: see below)
Fugitive status buffer keymap options.
See |diffs.FugitiveConfig| for fields.
{conflict} (table, default: see below) {conflict} (table, default: see below)
Inline merge conflict resolution options. Inline merge conflict resolution options.
See |diffs.ConflictConfig| for fields. See |diffs.ConflictConfig| for fields.
@ -191,10 +160,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
Character-level (intra-line) diff highlighting. Character-level (intra-line) diff highlighting.
See |diffs.IntraConfig| for fields. See |diffs.IntraConfig| for fields.
{priorities} (table, default: see below)
Extmark priority values.
See |diffs.PrioritiesConfig| for fields.
{overrides} (table, default: {}) {overrides} (table, default: {})
Map of highlight group names to highlight Map of highlight group names to highlight
definitions (see |nvim_set_hl()|). Applied definitions (see |nvim_set_hl()|). Applied
@ -216,28 +181,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
direction. Lines are read with early exit — direction. Lines are read with early exit —
cost scales with this value, not file size. cost scales with this value, not file size.
*diffs.PrioritiesConfig*
Priorities config fields: ~
{clear} (integer, default: 198)
Priority for `DiffsClear` extmarks that reset
underlying diff foreground colors. Must be
below {syntax}.
{syntax} (integer, default: 199)
Priority for treesitter and vim syntax extmarks.
Must be below {line_bg} so that colorscheme
backgrounds on syntax groups do not obscure
line-level diff backgrounds.
{line_bg} (integer, default: 200)
Priority for `DiffsAdd`/`DiffsDelete` line
background extmarks. Must be below {char_bg}.
{char_bg} (integer, default: 201)
Priority for `DiffsAddText`/`DiffsDeleteText`
character-level background extmarks. Highest
priority so changed characters stand out.
*diffs.TreesitterConfig* *diffs.TreesitterConfig*
Treesitter config fields: ~ Treesitter config fields: ~
{enabled} (boolean, default: true) {enabled} (boolean, default: true)
@ -368,36 +311,10 @@ Example configuration: >lua
vim.keymap.set('n', 'ct', '<Plug>(diffs-conflict-theirs)') vim.keymap.set('n', 'ct', '<Plug>(diffs-conflict-theirs)')
vim.keymap.set('n', 'cb', '<Plug>(diffs-conflict-both)') vim.keymap.set('n', 'cb', '<Plug>(diffs-conflict-both)')
vim.keymap.set('n', 'cn', '<Plug>(diffs-conflict-none)') vim.keymap.set('n', 'cn', '<Plug>(diffs-conflict-none)')
vim.keymap.set('n', ']c', '<Plug>(diffs-conflict-next)') vim.keymap.set('n', ']x', '<Plug>(diffs-conflict-next)')
vim.keymap.set('n', '[c', '<Plug>(diffs-conflict-prev)') 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: ~ Diff buffer mappings: ~
*diffs-q* *diffs-q*
q Close the diff window. Available in all `diffs://` q Close the diff window. Available in all `diffs://`
@ -428,7 +345,6 @@ Behavior by file status: ~
A Staged (empty) index file as all-added A Staged (empty) index file as all-added
D Staged HEAD (empty) file as all-removed D Staged HEAD (empty) file as all-removed
R Staged HEAD:oldname index:newname content diff R Staged HEAD:oldname index:newname content diff
U Unstaged :2: (ours) :3: (theirs) merge diff
? Untracked (empty) working tree file as all-added ? Untracked (empty) working tree file as all-added
On section headers, the keymap runs `git diff` (or `git diff --cached` for On section headers, the keymap runs `git diff` (or `git diff --cached` for
@ -445,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: ~ Fields: ~
{horizontal} (string|false, default: 'du') {horizontal} (string|false, default: 'du')
Keymap for unified diff in horizontal split. Keymap for unified diff in horizontal split.
@ -476,15 +389,13 @@ Configuration: ~
enabled = true, enabled = true,
disable_diagnostics = true, disable_diagnostics = true,
show_virtual_text = true, show_virtual_text = true,
show_actions = false,
priority = 200,
keymaps = { keymaps = {
ours = 'doo', ours = 'doo',
theirs = 'dot', theirs = 'dot',
both = 'dob', both = 'dob',
none = 'don', none = 'don',
next = ']c', next = ']x',
prev = '[c', prev = '[x',
}, },
}, },
} }
@ -504,37 +415,9 @@ Configuration: ~
diagnostics alone. diagnostics alone.
{show_virtual_text} (boolean, default: true) {show_virtual_text} (boolean, default: true)
Show `(current)` and `(incoming)` labels at Show virtual text labels (" current" and
the end of `<<<<<<<` and `>>>>>>>` marker " incoming") at the end of `<<<<<<<` and
lines. Also controls hunk hints in merge `>>>>>>>` marker lines.
diff views.
{format_virtual_text} (function|nil, default: nil)
Custom formatter for virtual text labels.
Receives `(side, keymap)` where `side` is
`"ours"` or `"theirs"` and `keymap` is the
configured keymap string or `false`. Return
a string (label text without parens) or
`nil` to hide the label. Example: >lua
format_virtual_text = function(side, keymap)
if keymap then
return side .. ' [' .. keymap .. ']'
end
return side
end
<
{show_actions} (boolean, default: false)
Show a codelens-style action line above each
`<<<<<<<` marker listing available resolution
keymaps. Renders as virtual lines using the
`DiffsConflictActions` highlight group.
Only keymaps that are not `false` appear.
{priority} (integer, default: 200)
Extmark priority for conflict region
backgrounds and markers. Adjust if other
plugins use the same priority range.
{keymaps} (table, default: see above) {keymaps} (table, default: see above)
Buffer-local keymaps for conflict resolution Buffer-local keymaps for conflict resolution
@ -555,10 +438,10 @@ Configuration: ~
{none} (string|false, default: 'don') {none} (string|false, default: 'don')
Reject both changes (delete entire block). Reject both changes (delete entire block).
{next} (string|false, default: ']c') {next} (string|false, default: ']x')
Jump to next conflict marker. Wraps around. Jump to next conflict marker. Wraps around.
{prev} (string|false, default: '[c') {prev} (string|false, default: '[x')
Jump to previous conflict marker. Wraps Jump to previous conflict marker. Wraps
around. around.
@ -575,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* API *diffs-api*
@ -642,8 +479,8 @@ refresh({bufnr}) *diffs.refresh()*
IMPLEMENTATION *diffs-implementation* IMPLEMENTATION *diffs-implementation*
Summary / commit detail views: ~ Summary / commit detail views: ~
1. `FileType` autocmd for computed filetypes (see |diffs-config|) triggers 1. `FileType fugitive` or `FileType git` (for `fugitive://` buffers)
|diffs.attach()|. For `git` buffers, only `fugitive://` URIs are attached. triggers |diffs.attach()|
2. The buffer is parsed to detect file headers (`M path/to/file`, 2. The buffer is parsed to detect file headers (`M path/to/file`,
`diff --git a/... b/...`) and hunk headers (`@@ -10,3 +10,4 @@`) `diff --git a/... b/...`) and hunk headers (`@@ -10,3 +10,4 @@`)
3. For each hunk: 3. For each hunk:
@ -652,14 +489,13 @@ Summary / commit detail views: ~
- Code is parsed with |vim.treesitter.get_string_parser()| - Code is parsed with |vim.treesitter.get_string_parser()|
- If no treesitter parser and `vim.enabled`: vim syntax fallback via - If no treesitter parser and `vim.enabled`: vim syntax fallback via
scratch buffer and |synID()| scratch buffer and |synID()|
- `DiffsClear` extmarks at priority 198 clear underlying diff foreground - `Normal` extmarks at priority 198 clear underlying diff foreground
- Syntax highlights are applied as extmarks at priority 199 - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 200 - Syntax highlights are applied as extmarks at priority 200
- Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at - Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at
priority 201 overlay changed characters with an intense background priority 201 overlay changed characters with an intense background
- Conceal extmarks hide diff prefixes when `hide_prefix` is enabled - Conceal extmarks hide diff prefixes when `hide_prefix` is enabled
- All priorities are configurable via |diffs.PrioritiesConfig| 4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events
4. A decoration provider re-highlights visible hunks on each redraw
Diff mode views: ~ Diff mode views: ~
1. `OptionSet diff` detects when any window enters diff mode 1. `OptionSet diff` detects when any window enters diff mode
@ -673,10 +509,15 @@ KNOWN LIMITATIONS *diffs-limitations*
Incomplete Syntax Context ~ Incomplete Syntax Context ~
*diffs-syntax-context* *diffs-syntax-context*
Treesitter parses each diff hunk in isolation. Context lines within the hunk Treesitter parses each diff hunk in isolation. To provide surrounding code
(lines with a ` ` prefix) provide syntactic context for the parser. In rare context, diffs.nvim reads lines from disk before and after each hunk
cases, hunks that start or end mid-expression may produce imperfect highlights (see |diffs.ContextConfig|, enabled by default). This resolves most boundary
due to treesitter error recovery. issues where incomplete constructs (e.g., a function definition at the edge
of a hunk with no body) would confuse the parser.
Set `highlights.context.enabled = false` to disable context padding. In rare
cases, context padding may not help if the relevant surrounding code is very
far from the hunk boundaries.
Syntax Highlighting Flash ~ Syntax Highlighting Flash ~
*diffs-flash* *diffs-flash*
@ -685,8 +526,14 @@ the buffer briefly shows fugitive's default diff highlighting before
diffs.nvim applies treesitter highlights. diffs.nvim applies treesitter highlights.
This occurs because diffs.nvim hooks into the `FileType fugitive` event, This occurs because diffs.nvim hooks into the `FileType fugitive` event,
which fires after vim-fugitive has already painted the buffer. The which fires after vim-fugitive has already painted the buffer. Even with
decoration provider applies highlights on the next redraw cycle. `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 ~ Conflicting Diff Plugins ~
*diffs-plugin-conflicts* *diffs-plugin-conflicts*
@ -788,10 +635,6 @@ Conflict highlights: ~
*DiffsConflictBaseNr* *DiffsConflictBaseNr*
DiffsConflictBaseNr Line number for base content lines (diff3). 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: ~ Diff mode window highlights: ~
These are used for |winhighlight| remapping in `&diff` windows. 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,47 +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 return nil
end 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 old_lines string[]
---@param new_lines string[] ---@param new_lines string[]
---@param old_name string ---@param old_name string
@ -87,33 +69,6 @@ local function generate_unified_diff(old_lines, new_lines, old_name, new_name)
return result return result
end 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 revision? string
---@param vertical? boolean ---@param vertical? boolean
function M.gdiff(revision, vertical) function M.gdiff(revision, vertical)
@ -183,7 +138,6 @@ end
---@field vertical? boolean ---@field vertical? boolean
---@field staged? boolean ---@field staged? boolean
---@field untracked? boolean ---@field untracked? boolean
---@field unmerged? boolean
---@field old_filepath? string ---@field old_filepath? string
---@field hunk_position? { hunk_header: string, offset: integer } ---@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 old_lines, new_lines, err
local diff_label local diff_label
if opts.unmerged then if opts.untracked then
old_lines = git.get_file_content(':2', filepath)
if not old_lines then
old_lines = {}
end
new_lines = git.get_file_content(':3', filepath)
if not new_lines then
new_lines = {}
end
diff_label = 'unmerged'
elseif opts.untracked then
old_lines = {} old_lines = {}
new_lines, err = git.get_working_content(filepath) new_lines, err = git.get_working_content(filepath)
if not new_lines then if not new_lines then
@ -292,14 +236,6 @@ function M.gdiff_file(filepath, opts)
end end
M.setup_diff_buf(diff_buf) 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) dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label)
vim.schedule(function() vim.schedule(function()
@ -327,8 +263,6 @@ function M.gdiff_section(repo_root, opts)
return return
end end
result = replace_combined_diffs(result, repo_root)
if #result == 0 then if #result == 0 then
vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO) vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO)
return return
@ -391,8 +325,6 @@ function M.read_buffer(bufnr)
if vim.v.shell_error ~= 0 then if vim.v.shell_error ~= 0 then
diff_lines = {} diff_lines = {}
end end
diff_lines = replace_combined_diffs(diff_lines, repo_root)
else else
local abs_path = repo_root .. '/' .. path local abs_path = repo_root .. '/' .. path
@ -402,10 +334,7 @@ function M.read_buffer(bufnr)
local old_lines, new_lines local old_lines, new_lines
if label == 'unmerged' then if label == 'untracked' then
old_lines = git.get_file_content(':2', abs_path) or {}
new_lines = git.get_file_content(':3', abs_path) or {}
elseif label == 'untracked' then
old_lines = {} old_lines = {}
new_lines = git.get_working_content(abs_path) or {} new_lines = git.get_working_content(abs_path) or {}
elseif label == 'staged' then elseif label == 'staged' then

View file

@ -20,6 +20,8 @@ local attached_buffers = {}
---@type table<integer, boolean> ---@type table<integer, boolean>
local diagnostics_suppressed = {} local diagnostics_suppressed = {}
local PRIORITY_LINE_BG = 200
---@param lines string[] ---@param lines string[]
---@return diffs.ConflictRegion[] ---@return diffs.ConflictRegion[]
function M.parse(lines) function M.parse(lines)
@ -90,17 +92,6 @@ local function parse_buffer(bufnr)
return M.parse(lines) return M.parse(lines)
end 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 bufnr integer
---@param regions diffs.ConflictRegion[] ---@param regions diffs.ConflictRegion[]
---@param config diffs.ConflictConfig ---@param config diffs.ConflictConfig
@ -112,41 +103,14 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_ours + 1, end_row = region.marker_ours + 1,
hl_group = 'DiffsConflictMarker', hl_group = 'DiffsConflictMarker',
hl_eol = true, hl_eol = true,
priority = config.priority, priority = PRIORITY_LINE_BG,
}) })
if config.show_virtual_text then if config.show_virtual_text then
local ours_label = get_virtual_text_label('ours', config) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
if ours_label then virt_text = { { ' (current)', 'DiffsConflictMarker' } },
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { virt_text_pos = 'eol',
virt_text = { { ' (' .. ours_label .. ')', 'DiffsConflictMarker' } }, })
virt_text_pos = 'eol',
})
end
end
if config.show_actions then
local parts = {}
local actions = {
{ 'Current', config.keymaps.ours },
{ 'Incoming', config.keymaps.theirs },
{ 'Both', config.keymaps.both },
{ 'None', config.keymaps.none },
}
for _, action in ipairs(actions) do
if action[2] then
if #parts > 0 then
table.insert(parts, { ' \226\148\130 ', 'DiffsConflictActions' })
end
table.insert(parts, { ('%s (%s)'):format(action[1], action[2]), 'DiffsConflictActions' })
end
end
if #parts > 0 then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
virt_lines = { parts },
virt_lines_above = true,
})
end
end end
for line = region.ours_start, region.ours_end - 1 do 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, end_row = line + 1,
hl_group = 'DiffsConflictOurs', hl_group = 'DiffsConflictOurs',
hl_eol = true, hl_eol = true,
priority = config.priority, priority = PRIORITY_LINE_BG,
}) })
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictOursNr', number_hl_group = 'DiffsConflictOursNr',
priority = config.priority, priority = PRIORITY_LINE_BG,
}) })
end end
@ -167,7 +131,7 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_base + 1, end_row = region.marker_base + 1,
hl_group = 'DiffsConflictMarker', hl_group = 'DiffsConflictMarker',
hl_eol = true, hl_eol = true,
priority = config.priority, priority = PRIORITY_LINE_BG,
}) })
for line = region.base_start, region.base_end - 1 do 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, end_row = line + 1,
hl_group = 'DiffsConflictBase', hl_group = 'DiffsConflictBase',
hl_eol = true, hl_eol = true,
priority = config.priority, priority = PRIORITY_LINE_BG,
}) })
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictBaseNr', number_hl_group = 'DiffsConflictBaseNr',
priority = config.priority, priority = PRIORITY_LINE_BG,
}) })
end end
end end
@ -188,7 +152,7 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_sep + 1, end_row = region.marker_sep + 1,
hl_group = 'DiffsConflictMarker', hl_group = 'DiffsConflictMarker',
hl_eol = true, hl_eol = true,
priority = config.priority, priority = PRIORITY_LINE_BG,
}) })
for line = region.theirs_start, region.theirs_end - 1 do 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, end_row = line + 1,
hl_group = 'DiffsConflictTheirs', hl_group = 'DiffsConflictTheirs',
hl_eol = true, hl_eol = true,
priority = config.priority, priority = PRIORITY_LINE_BG,
}) })
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
number_hl_group = 'DiffsConflictTheirsNr', number_hl_group = 'DiffsConflictTheirsNr',
priority = config.priority, priority = PRIORITY_LINE_BG,
}) })
end end
@ -208,17 +172,14 @@ local function apply_highlights(bufnr, regions, config)
end_row = region.marker_theirs + 1, end_row = region.marker_theirs + 1,
hl_group = 'DiffsConflictMarker', hl_group = 'DiffsConflictMarker',
hl_eol = true, hl_eol = true,
priority = config.priority, priority = PRIORITY_LINE_BG,
}) })
if config.show_virtual_text then if config.show_virtual_text then
local theirs_label = get_virtual_text_label('theirs', config) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, {
if theirs_label then virt_text = { { ' (incoming)', 'DiffsConflictMarker' } },
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { virt_text_pos = 'eol',
virt_text = { { ' (' .. theirs_label .. ')', 'DiffsConflictMarker' } }, })
virt_text_pos = 'eol',
})
end
end end
end end
end end
@ -238,7 +199,7 @@ end
---@param bufnr integer ---@param bufnr integer
---@param region diffs.ConflictRegion ---@param region diffs.ConflictRegion
---@param replacement string[] ---@param replacement string[]
function M.replace_region(bufnr, region, replacement) local function replace_region(bufnr, region, replacement)
vim.api.nvim_buf_set_lines( vim.api.nvim_buf_set_lines(
bufnr, bufnr,
region.marker_ours, region.marker_ours,
@ -250,7 +211,7 @@ end
---@param bufnr integer ---@param bufnr integer
---@param config diffs.ConflictConfig ---@param config diffs.ConflictConfig
function M.refresh(bufnr, config) local function refresh(bufnr, config)
local regions = parse_buffer(bufnr) local regions = parse_buffer(bufnr)
if #regions == 0 then if #regions == 0 then
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
@ -283,8 +244,8 @@ function M.resolve_ours(bufnr, config)
return return
end end
local lines = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false) local lines = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false)
M.replace_region(bufnr, region, lines) replace_region(bufnr, region, lines)
M.refresh(bufnr, config) refresh(bufnr, config)
end end
---@param bufnr integer ---@param bufnr integer
@ -301,8 +262,8 @@ function M.resolve_theirs(bufnr, config)
return return
end end
local lines = vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false) local lines = vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false)
M.replace_region(bufnr, region, lines) replace_region(bufnr, region, lines)
M.refresh(bufnr, config) refresh(bufnr, config)
end end
---@param bufnr integer ---@param bufnr integer
@ -327,8 +288,8 @@ function M.resolve_both(bufnr, config)
for _, l in ipairs(theirs) do for _, l in ipairs(theirs) do
table.insert(combined, l) table.insert(combined, l)
end end
M.replace_region(bufnr, region, combined) replace_region(bufnr, region, combined)
M.refresh(bufnr, config) refresh(bufnr, config)
end end
---@param bufnr integer ---@param bufnr integer
@ -344,8 +305,8 @@ function M.resolve_none(bufnr, config)
if not region then if not region then
return return
end end
M.replace_region(bufnr, region, {}) replace_region(bufnr, region, {})
M.refresh(bufnr, config) refresh(bufnr, config)
end end
---@param bufnr integer ---@param bufnr integer
@ -362,7 +323,6 @@ function M.goto_next(bufnr)
return return
end end
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 }) vim.api.nvim_win_set_cursor(0, { regions[1].marker_ours + 1, 0 })
end end
@ -380,7 +340,6 @@ function M.goto_prev(bufnr)
return return
end end
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 }) vim.api.nvim_win_set_cursor(0, { regions[#regions].marker_ours + 1, 0 })
end end
@ -458,7 +417,7 @@ function M.attach(bufnr, config)
if not attached_buffers[bufnr] then if not attached_buffers[bufnr] then
return true return true
end end
M.refresh(bufnr, config) refresh(bufnr, config)
end, end,
}) })

View file

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

View file

@ -1,19 +1,13 @@
local M = {} local M = {}
local repo_root_cache = {}
---@param filepath string ---@param filepath string
---@return string? ---@return string?
function M.get_repo_root(filepath) function M.get_repo_root(filepath)
local dir = vim.fn.fnamemodify(filepath, ':h') 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' }) local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' })
if vim.v.shell_error ~= 0 then if vim.v.shell_error ~= 0 then
return nil return nil
end end
repo_root_cache[dir] = result[1]
return result[1] return result[1]
end end

View file

@ -3,6 +3,38 @@ local M = {}
local dbg = require('diffs.log').dbg local dbg = require('diffs.log').dbg
local diff = require('diffs.diff') local diff = require('diffs.diff')
---@param filepath string
---@param from_line integer
---@param count integer
---@return string[]
local function read_line_range(filepath, from_line, count)
if count <= 0 then
return {}
end
local f = io.open(filepath, 'r')
if not f then
return {}
end
local result = {}
local line_num = 0
for line in f:lines() do
line_num = line_num + 1
if line_num >= from_line then
table.insert(result, line)
if #result >= count then
break
end
end
end
f:close()
return result
end
local PRIORITY_CLEAR = 198
local PRIORITY_SYNTAX = 199
local PRIORITY_LINE_BG = 200
local PRIORITY_CHAR_BG = 201
---@param bufnr integer ---@param bufnr integer
---@param ns integer ---@param ns integer
---@param hunk diffs.Hunk ---@param hunk diffs.Hunk
@ -10,9 +42,8 @@ local diff = require('diffs.diff')
---@param text string ---@param text string
---@param lang string ---@param lang string
---@param context_lines? string[] ---@param context_lines? string[]
---@param priorities diffs.PrioritiesConfig
---@return integer ---@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 local parse_text = text
if context_lines and #context_lines > 0 then if context_lines and #context_lines > 0 then
parse_text = text .. '\n' .. table.concat(context_lines, '\n') 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_sc = col_offset + sc
local buf_ec = col_offset + ec local buf_ec = col_offset + ec
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 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, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er, end_row = buf_er,
@ -64,7 +95,6 @@ end
---@class diffs.HunkOpts ---@class diffs.HunkOpts
---@field hide_prefix boolean ---@field hide_prefix boolean
---@field highlights diffs.Highlights ---@field highlights diffs.Highlights
---@field defer_vim_syntax? boolean
---@param bufnr integer ---@param bufnr integer
---@param ns integer ---@param ns integer
@ -73,7 +103,6 @@ end
---@param line_map table<integer, integer> ---@param line_map table<integer, integer>
---@param col_offset integer ---@param col_offset integer
---@param covered_lines? table<integer, true> ---@param covered_lines? table<integer, true>
---@param priorities diffs.PrioritiesConfig
---@return integer ---@return integer
local function highlight_treesitter( local function highlight_treesitter(
bufnr, bufnr,
@ -82,8 +111,7 @@ local function highlight_treesitter(
lang, lang,
line_map, line_map,
col_offset, col_offset,
covered_lines, covered_lines
priorities
) )
local code = table.concat(code_lines, '\n') local code = table.concat(code_lines, '\n')
if code == '' then if code == '' then
@ -123,9 +151,8 @@ local function highlight_treesitter(
local buf_sc = sc + col_offset local buf_sc = sc + col_offset
local buf_ec = ec + col_offset local buf_ec = ec + col_offset
local priority = tree_lang == 'diff' local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100)
and (col_offset > 0 and priorities.syntax or (tonumber(metadata.priority) or 100)) or PRIORITY_SYNTAX
or priorities.syntax
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er, end_row = buf_er,
@ -192,17 +219,8 @@ end
---@param code_lines string[] ---@param code_lines string[]
---@param covered_lines? table<integer, true> ---@param covered_lines? table<integer, true>
---@param leading_offset? integer ---@param leading_offset? integer
---@param priorities diffs.PrioritiesConfig
---@return integer ---@return integer
local function highlight_vim_syntax( local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset)
bufnr,
ns,
hunk,
code_lines,
covered_lines,
leading_offset,
priorities
)
local ft = hunk.ft local ft = hunk.ft
if not ft then if not ft then
return 0 return 0
@ -220,9 +238,9 @@ local function highlight_vim_syntax(
local spans = {} local spans = {}
pcall(vim.api.nvim_buf_call, scratch, function() vim.api.nvim_buf_call(scratch, function()
vim.cmd('setlocal syntax=' .. ft) vim.cmd('setlocal syntax=' .. ft)
vim.cmd.redraw() vim.cmd('redraw')
---@param line integer ---@param line integer
---@param col integer ---@param col integer
@ -238,19 +256,18 @@ local function highlight_vim_syntax(
spans = M.coalesce_syntax_spans(query_fn, code_lines) spans = M.coalesce_syntax_spans(query_fn, code_lines)
end) 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 hunk_line_count = #hunk.lines
local col_off = (hunk.prefix_width or 1) + (hunk.quote_width or 0) - 1
local extmark_count = 0 local extmark_count = 0
for _, span in ipairs(spans) do for _, span in ipairs(spans) do
local adj = span.line - leading_offset local adj = span.line - leading_offset
if adj >= 1 and adj <= hunk_line_count then if adj >= 1 and adj <= hunk_line_count then
local buf_line = hunk.start_line + adj - 1 local buf_line = hunk.start_line + adj - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + col_off, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end + col_off, end_col = span.col_end,
hl_group = span.hl_name, hl_group = span.hl_name,
priority = priorities.syntax, priority = PRIORITY_SYNTAX,
}) })
extmark_count = extmark_count + 1 extmark_count = extmark_count + 1
if covered_lines then if covered_lines then
@ -267,9 +284,6 @@ end
---@param hunk diffs.Hunk ---@param hunk diffs.Hunk
---@param opts diffs.HunkOpts ---@param opts diffs.HunkOpts
function M.highlight_hunk(bufnr, ns, hunk, opts) 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_ts = hunk.lang and opts.highlights.treesitter.enabled
local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled
@ -286,18 +300,28 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
use_vim = false use_vim = false
end end
if use_vim and opts.defer_vim_syntax then
use_vim = false
end
---@type table<integer, true> ---@type table<integer, true>
local covered_lines = {} local covered_lines = {}
local extmark_count = 0 local ctx_cfg = opts.highlights.context
---@type string[] local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0
local new_code = {} 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 if use_ts then
---@type string[]
local new_code = {}
---@type table<integer, integer> ---@type table<integer, integer>
local new_map = {} local new_map = {}
---@type string[] ---@type string[]
@ -305,17 +329,20 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
---@type table<integer, integer> ---@type table<integer, integer>
local old_map = {} local old_map = {}
for i, line in ipairs(hunk.lines) do for _, pad_line in ipairs(leading) do
local prefix = line:sub(1, pw) table.insert(new_code, pad_line)
local stripped = line:sub(pw + 1) table.insert(old_code, pad_line)
local buf_line = hunk.start_line + i - 1 end
local has_add = prefix:find('+', 1, true) ~= nil
local has_del = prefix:find('-', 1, true) ~= nil
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 new_map[#new_code] = buf_line
table.insert(new_code, stripped) table.insert(new_code, stripped)
elseif has_del and not has_add then elseif prefix == '-' then
old_map[#old_code] = buf_line old_map[#old_code] = buf_line
table.insert(old_code, stripped) table.insert(old_code, stripped)
else else
@ -325,12 +352,22 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end end
end end
extmark_count = for _, pad_line in ipairs(trailing) do
highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, pw + qw, covered_lines, p) table.insert(new_code, pad_line)
table.insert(old_code, pad_line)
end
extmark_count = highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines)
extmark_count = extmark_count extmark_count = extmark_count
+ highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, pw + qw, covered_lines, p) + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines)
if hunk.header_context and hunk.header_context_col then 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( local header_extmarks = highlight_text(
bufnr, bufnr,
ns, ns,
@ -338,8 +375,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
hunk.header_context_col, hunk.header_context_col,
hunk.header_context, hunk.header_context,
hunk.lang, hunk.lang,
new_code, new_code
p
) )
if header_extmarks > 0 then if header_extmarks > 0 then
dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks) dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks)
@ -349,10 +385,16 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
elseif use_vim then elseif use_vim then
---@type string[] ---@type string[]
local code_lines = {} local code_lines = {}
for _, line in ipairs(hunk.lines) do for _, pad_line in ipairs(leading) do
table.insert(code_lines, line:sub(pw + 1)) table.insert(code_lines, pad_line)
end 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 end
if if
@ -367,25 +409,13 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
header_map[i] = hunk.header_start_line - 1 + i header_map[i] = hunk.header_start_line - 1 + i
end end
extmark_count = extmark_count extmark_count = extmark_count
+ highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, qw, nil, p) + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0)
end
local at_raw_line
if qw > 0 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]
if at_raw_line then
local at_logical = at_raw_line:sub(qw + 1)
local at_map = { [0] = at_buf_line }
extmark_count = extmark_count
+ highlight_treesitter(bufnr, ns, { at_logical }, 'diff', at_map, qw, nil, p)
end
end end
---@type diffs.IntraChanges? ---@type diffs.IntraChanges?
local intra = nil local intra = nil
local intra_cfg = opts.highlights.intra local intra_cfg = opts.highlights.intra
if 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) 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) intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm)
if intra then if intra then
@ -416,102 +446,48 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end end
end end
if
qw > 0
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,
})
end
end
if qw > 0 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,
})
end
if use_ts and hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
end_col = hunk.header_context_col + #hunk.header_context,
hl_group = 'DiffsClear',
priority = p.clear,
})
end
for i, line in ipairs(hunk.lines) do for i, line in ipairs(hunk.lines) do
local buf_line = hunk.start_line + i - 1 local buf_line = hunk.start_line + i - 1
local line_len = #line local line_len = #line
local prefix = line:sub(1, pw) local prefix = line:sub(1, 1)
local has_add = prefix:find('+', 1, true) ~= nil
local has_del = prefix:find('-', 1, true) ~= nil
local is_diff_line = has_add or has_del
local line_hl = is_diff_line and (has_add and 'DiffsAdd' or 'DiffsDelete') or nil
local number_hl = is_diff_line and (has_add and 'DiffsAddNr' or 'DiffsDeleteNr') or nil
local is_marker = false local is_diff_line = prefix == '+' or prefix == '-'
if pw > 1 and line_hl and not prefix:find('[^+]') then local line_hl = is_diff_line and (prefix == '+' and 'DiffsAdd' or 'DiffsDelete') or nil
local content = line:sub(pw + 1) local number_hl = is_diff_line and (prefix == '+' and 'DiffsAddNr' or 'DiffsDeleteNr') or nil
is_marker = content:match('^<<<<<<<')
or content:match('^=======')
or content:match('^>>>>>>>')
or content:match('^|||||||')
end
if opts.hide_prefix then if opts.hide_prefix then
local virt_hl = (opts.highlights.background and line_hl) or nil local virt_hl = (opts.highlights.background and line_hl) or nil
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
virt_text = { { string.rep(' ', pw + qw), virt_hl } }, virt_text = { { ' ', virt_hl } },
virt_text_pos = 'overlay', virt_text_pos = 'overlay',
}) })
end end
if qw > 0 then if line_len > 1 and covered_lines[buf_line] then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
end_col = pw + qw, end_col = line_len,
hl_group = 'DiffsClear', hl_group = 'DiffsClear',
priority = p.clear, priority = PRIORITY_CLEAR,
})
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,
}) })
end end
if opts.highlights.background and is_diff_line then if opts.highlights.background and is_diff_line then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
line_hl_group = line_hl, end_row = buf_line + 1,
number_hl_group = opts.highlights.gutter and number_hl or nil, hl_group = line_hl,
priority = p.line_bg, hl_eol = true,
}) priority = PRIORITY_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,
}) })
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 end
if char_spans_by_line[i] then if char_spans_by_line[i] then
local char_hl = has_add and 'DiffsAddText' or 'DiffsDeleteText' local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText'
for _, span in ipairs(char_spans_by_line[i]) do for _, span in ipairs(char_spans_by_line[i]) do
dbg( dbg(
'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"', 'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"',
@ -522,10 +498,10 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
char_hl, char_hl,
line:sub(span.col_start + 1, span.col_end) 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, { local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end + qw, end_col = span.col_end,
hl_group = char_hl, hl_group = char_hl,
priority = p.char_bg, priority = PRIORITY_CHAR_BG,
}) })
if not ok then if not ok then
dbg('char extmark FAILED: %s', err) dbg('char extmark FAILED: %s', err)

View file

@ -15,12 +15,6 @@
---@field enabled boolean ---@field enabled boolean
---@field lines integer ---@field lines integer
---@class diffs.PrioritiesConfig
---@field clear integer
---@field syntax integer
---@field line_bg integer
---@field char_bg integer
---@class diffs.Highlights ---@class diffs.Highlights
---@field background boolean ---@field background boolean
---@field gutter boolean ---@field gutter boolean
@ -30,14 +24,11 @@
---@field treesitter diffs.TreesitterConfig ---@field treesitter diffs.TreesitterConfig
---@field vim diffs.VimConfig ---@field vim diffs.VimConfig
---@field intra diffs.IntraConfig ---@field intra diffs.IntraConfig
---@field priorities diffs.PrioritiesConfig
---@class diffs.FugitiveConfig ---@class diffs.FugitiveConfig
---@field horizontal string|false ---@field horizontal string|false
---@field vertical string|false ---@field vertical string|false
---@class diffs.NeogitConfig
---@class diffs.ConflictKeymaps ---@class diffs.ConflictKeymaps
---@field ours string|false ---@field ours string|false
---@field theirs string|false ---@field theirs string|false
@ -50,18 +41,14 @@
---@field enabled boolean ---@field enabled boolean
---@field disable_diagnostics boolean ---@field disable_diagnostics boolean
---@field show_virtual_text 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 ---@field keymaps diffs.ConflictKeymaps
---@class diffs.Config ---@class diffs.Config
---@field debug boolean|string ---@field debug boolean
---@field debounce_ms integer
---@field hide_prefix boolean ---@field hide_prefix boolean
---@field extra_filetypes string[]
---@field highlights diffs.Highlights ---@field highlights diffs.Highlights
---@field fugitive diffs.FugitiveConfig|false ---@field fugitive diffs.FugitiveConfig
---@field neogit diffs.NeogitConfig|false
---@field conflict diffs.ConflictConfig ---@field conflict diffs.ConflictConfig
---@class diffs ---@class diffs
@ -110,8 +97,8 @@ end
---@type diffs.Config ---@type diffs.Config
local default_config = { local default_config = {
debug = false, debug = false,
debounce_ms = 0,
hide_prefix = false, hide_prefix = false,
extra_filetypes = {},
highlights = { highlights = {
background = true, background = true,
gutter = true, gutter = true,
@ -132,28 +119,22 @@ local default_config = {
algorithm = 'default', algorithm = 'default',
max_lines = 500, max_lines = 500,
}, },
priorities = {
clear = 198,
syntax = 199,
line_bg = 200,
char_bg = 201,
},
}, },
fugitive = false, fugitive = {
neogit = false, horizontal = 'du',
vertical = 'dU',
},
conflict = { conflict = {
enabled = true, enabled = true,
disable_diagnostics = true, disable_diagnostics = true,
show_virtual_text = true, show_virtual_text = true,
show_actions = false,
priority = 200,
keymaps = { keymaps = {
ours = 'doo', ours = 'doo',
theirs = 'dot', theirs = 'dot',
both = 'dob', both = 'dob',
none = 'don', none = 'don',
next = ']c', next = ']x',
prev = '[c', prev = '[x',
}, },
}, },
} }
@ -163,238 +144,66 @@ local config = vim.deepcopy(default_config)
local initialized = false local initialized = false
---@diagnostic disable-next-line: missing-fields
local fast_hl_opts = {} ---@type diffs.HunkOpts
---@type table<integer, boolean> ---@type table<integer, boolean>
local attached_buffers = {} local attached_buffers = {}
---@type table<integer, boolean>
local ft_retry_pending = {}
---@type table<integer, boolean> ---@type table<integer, boolean>
local diff_windows = {} 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 ---@param bufnr integer
---@return boolean ---@return boolean
function M.is_fugitive_buffer(bufnr) function M.is_fugitive_buffer(bufnr)
return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil
end 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 local dbg = log.dbg
---@param bufnr integer ---@param bufnr integer
local function invalidate_cache(bufnr) local function highlight_buffer(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
end
local suffix_len = 0
local max_suffix = limit - prefix_len
for j = 0, max_suffix - 1 do
local old_idx = old_n - j
local new_idx = new_n - j
if not hunks_eq(old_hunks[old_idx], new_hunks[new_idx]) then
break
end
if old_hl[old_idx] then
highlighted[new_idx] = true
end
suffix_len = j + 1
end
dbg(
'carry_forward: %d prefix + %d suffix of %d old -> %d new hunks',
prefix_len,
suffix_len,
old_n,
new_n
)
if next(highlighted) == nil then
return nil
end
return highlighted
end
---@param bufnr integer
local function ensure_cache(bufnr)
if not vim.api.nvim_buf_is_valid(bufnr) then if not vim.api.nvim_buf_is_valid(bufnr) then
return return
end end
local tick = vim.api.nvim_buf_get_changedtick(bufnr)
local entry = hunk_cache[bufnr]
if entry and entry.tick == tick then
return
end
if entry and not entry.pending_clear then
local lc = vim.api.nvim_buf_line_count(bufnr)
local bc = vim.api.nvim_buf_get_offset(bufnr, lc)
if lc == entry.line_count and bc == entry.byte_count then
entry.tick = tick
entry.pending_clear = true
dbg('content unchanged in buffer %d (tick %d), skipping reparse', bufnr, tick)
return
end
end
local hunks = parser.parse_buffer(bufnr)
local lc = vim.api.nvim_buf_line_count(bufnr)
local bc = vim.api.nvim_buf_get_offset(bufnr, lc)
dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick)
local carried = entry and not entry.pending_clear and carry_forward_highlighted(entry, hunks)
hunk_cache[bufnr] = {
hunks = hunks,
tick = tick,
highlighted = carried or {},
pending_clear = not carried,
line_count = lc,
byte_count = bc,
}
local has_nil_ft = false 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 for _, hunk in ipairs(hunks) do
if not has_nil_ft and not hunk.ft and hunk.filename then highlight.highlight_hunk(bufnr, ns, hunk, {
has_nil_ft = true hide_prefix = config.hide_prefix,
end highlights = config.highlights,
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
end end
---@param hunks diffs.Hunk[] ---@param bufnr integer
---@param toprow integer ---@return fun()
---@param botrow integer local function create_debounced_highlight(bufnr)
---@return integer first local timer = nil ---@type table?
---@return integer last return function()
local function find_visible_hunks(hunks, toprow, botrow) if timer then
local n = #hunks timer:stop() ---@diagnostic disable-line: undefined-field
if n == 0 then timer:close() ---@diagnostic disable-line: undefined-field
return 0, 0 timer = nil
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
end local t = vim.uv.new_timer()
if not t then
if lo > n then highlight_buffer(bufnr)
return 0, 0 return
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 end
last = i 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 end
return first, last
end end
local function compute_highlight_groups() local function compute_highlight_groups()
@ -417,7 +226,7 @@ local function compute_highlight_groups()
local blended_add_text = blend_color(add_fg, bg, alpha) local blended_add_text = blend_color(add_fg, bg, alpha)
local blended_del_text = blend_color(del_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, 'DiffsAdd', { default = true, bg = blended_add })
vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del }) vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del })
vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add })
@ -465,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, 'DiffsConflictTheirs', { default = true, bg = blended_theirs })
vim.api.nvim_set_hl(0, 'DiffsConflictBase', { default = true, bg = blended_base }) 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, 'DiffsConflictMarker', { default = true, fg = 0x808080, bold = true })
vim.api.nvim_set_hl(0, 'DiffsConflictActions', { default = true, fg = 0x808080 })
vim.api.nvim_set_hl( vim.api.nvim_set_hl(
0, 0,
'DiffsConflictOursNr', 'DiffsConflictOursNr',
@ -489,14 +297,6 @@ local function compute_highlight_groups()
end end
end end
local neogit_context_hl_overridden = false
-- TODO: remove once NeogitOrg/neogit#1904 merges and is released (tracked in #135)
local function override_neogit_context_highlights()
vim.api.nvim_set_hl(0, 'NeogitDiffContextHighlight', {})
neogit_context_hl_overridden = true
end
local function init() local function init()
if initialized then if initialized then
return return
@ -505,41 +305,10 @@ local function init()
local opts = vim.g.diffs or {} 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({ vim.validate({
debug = { debug = { opts.debug, 'boolean', true },
opts.debug, debounce_ms = { opts.debounce_ms, 'number', true },
function(v)
return v == nil or type(v) == 'boolean' or type(v) == 'string'
end,
'boolean or string (file path)',
},
hide_prefix = { opts.hide_prefix, 'boolean', true }, 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 }, highlights = { opts.highlights, 'table', true },
}) })
@ -553,7 +322,6 @@ local function init()
['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true }, ['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true },
['highlights.vim'] = { opts.highlights.vim, 'table', true }, ['highlights.vim'] = { opts.highlights.vim, 'table', true },
['highlights.intra'] = { opts.highlights.intra, 'table', true }, ['highlights.intra'] = { opts.highlights.intra, 'table', true },
['highlights.priorities'] = { opts.highlights.priorities, 'table', true },
}) })
if opts.highlights.context then if opts.highlights.context then
@ -594,32 +362,21 @@ local function init()
['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true }, ['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true },
}) })
end 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 end
if type(opts.fugitive) == 'table' then if opts.fugitive then
---@type diffs.FugitiveConfig
local fug = opts.fugitive
vim.validate({ vim.validate({
['fugitive.horizontal'] = { ['fugitive.horizontal'] = {
fug.horizontal, opts.fugitive.horizontal,
function(v) function(v)
return v == nil or v == false or type(v) == 'string' return v == false or type(v) == 'string'
end, end,
'string or false', 'string or false',
}, },
['fugitive.vertical'] = { ['fugitive.vertical'] = {
fug.vertical, opts.fugitive.vertical,
function(v) function(v)
return v == nil or v == false or type(v) == 'string' return v == false or type(v) == 'string'
end, end,
'string or false', 'string or false',
}, },
@ -631,9 +388,6 @@ local function init()
['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true }, ['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true },
['conflict.disable_diagnostics'] = { opts.conflict.disable_diagnostics, 'boolean', true }, ['conflict.disable_diagnostics'] = { opts.conflict.disable_diagnostics, 'boolean', true },
['conflict.show_virtual_text'] = { opts.conflict.show_virtual_text, '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 }, ['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true },
}) })
@ -653,6 +407,9 @@ local function init()
end end
end end
if opts.debounce_ms and opts.debounce_ms < 0 then
error('diffs: debounce_ms must be >= 0')
end
if if
opts.highlights opts.highlights
and opts.highlights.context and opts.highlights.context
@ -692,136 +449,17 @@ local function init()
then then
error('diffs: highlights.blend_alpha must be >= 0 and <= 1') error('diffs: highlights.blend_alpha must be >= 0 and <= 1')
end 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) config = vim.tbl_deep_extend('force', default_config, opts)
log.set_enabled(config.debug) 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() compute_highlight_groups()
vim.api.nvim_create_autocmd('ColorScheme', { vim.api.nvim_create_autocmd('ColorScheme', {
callback = function() callback = function()
compute_highlight_groups() compute_highlight_groups()
if neogit_context_hl_overridden then
override_neogit_context_highlights()
end
for bufnr, _ in pairs(attached_buffers) do for bufnr, _ in pairs(attached_buffers) do
invalidate_cache(bufnr) highlight_buffer(bufnr)
end
end,
})
vim.api.nvim_set_decoration_provider(ns, {
on_buf = function(_, bufnr)
if not attached_buffers[bufnr] then
return false
end
local t0 = config.debug and vim.uv.hrtime() or nil
ensure_cache(bufnr)
local entry = hunk_cache[bufnr]
if entry and entry.pending_clear then
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
entry.highlighted = {}
entry.pending_clear = false
end
if t0 then
dbg('on_buf %d: %.2fms', bufnr, (vim.uv.hrtime() - t0) / 1e6)
end
end,
on_win = function(_, _, bufnr, toprow, botrow)
if not attached_buffers[bufnr] then
return false
end
local entry = hunk_cache[bufnr]
if not entry then
return
end
local first, last = find_visible_hunks(entry.hunks, toprow, botrow)
if first == 0 then
return
end
local t0 = config.debug and vim.uv.hrtime() or nil
local deferred_syntax = {}
local count = 0
for i = first, last do
if not entry.highlighted[i] then
local hunk = entry.hunks[i]
local clear_start = hunk.start_line - 1
local clear_end = hunk.start_line + #hunk.lines
if hunk.header_start_line then
clear_start = hunk.header_start_line - 1
end
vim.api.nvim_buf_clear_namespace(bufnr, ns, clear_start, clear_end)
highlight.highlight_hunk(bufnr, ns, hunk, fast_hl_opts)
entry.highlighted[i] = true
count = count + 1
local has_syntax = hunk.lang and config.highlights.treesitter.enabled
local needs_vim = not hunk.lang and hunk.ft and config.highlights.vim.enabled
if has_syntax or needs_vim then
table.insert(deferred_syntax, hunk)
end
end
end
if #deferred_syntax > 0 then
local tick = entry.tick
dbg('deferred syntax scheduled: %d hunks tick=%d', #deferred_syntax, tick)
vim.schedule(function()
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
local cur = hunk_cache[bufnr]
if not cur or cur.tick ~= tick then
dbg('deferred syntax stale: cur.tick=%s captured=%d', cur and tostring(cur.tick) or 'nil', tick)
return
end
local t1 = config.debug and vim.uv.hrtime() or nil
local full_opts = {
hide_prefix = config.hide_prefix,
highlights = config.highlights,
}
for _, hunk in ipairs(deferred_syntax) do
local start_row = hunk.start_line - 1
local end_row = hunk.start_line + #hunk.lines
if hunk.header_start_line then
start_row = hunk.header_start_line - 1
end
vim.api.nvim_buf_clear_namespace(bufnr, ns, start_row, end_row)
highlight.highlight_hunk(bufnr, ns, hunk, full_opts)
end
if t1 then
dbg('deferred pass: %d hunks in %.2fms', #deferred_syntax, (vim.uv.hrtime() - t1) / 1e6)
end
end)
end
if t0 and count > 0 then
dbg(
'on_win %d: %d hunks [%d..%d] in %.2fms (viewport %d-%d)',
bufnr,
count,
first,
last,
(vim.uv.hrtime() - t0) / 1e6,
toprow,
botrow
)
end end
end, end,
}) })
@ -846,35 +484,37 @@ function M.attach(bufnr)
end end
attached_buffers[bufnr] = true 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
override_neogit_context_highlights()
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) 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', { vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr, buffer = bufnr,
callback = function() callback = function()
attached_buffers[bufnr] = nil 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,
}) })
end end
@ -882,7 +522,7 @@ end
---@param bufnr? integer ---@param bufnr? integer
function M.refresh(bufnr) function M.refresh(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf() bufnr = bufnr or vim.api.nvim_get_current_buf()
invalidate_cache(bufnr) highlight_buffer(bufnr)
end end
local DIFF_WINHIGHLIGHT = table.concat({ local DIFF_WINHIGHLIGHT = table.concat({
@ -925,7 +565,7 @@ function M.detach_diff()
end end
end end
---@return diffs.FugitiveConfig|false ---@return diffs.FugitiveConfig
function M.get_fugitive_config() function M.get_fugitive_config()
init() init()
return config.fugitive return config.fugitive
@ -937,23 +577,4 @@ function M.get_conflict_config()
return config.conflict return config.conflict
end end
local function process_pending_clear(bufnr)
local entry = hunk_cache[bufnr]
if entry and entry.pending_clear then
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
entry.highlighted = {}
entry.pending_clear = false
end
end
M._test = {
find_visible_hunks = find_visible_hunks,
hunk_cache = hunk_cache,
ensure_cache = ensure_cache,
invalidate_cache = invalidate_cache,
hunks_eq = hunks_eq,
process_pending_clear = process_pending_clear,
ft_retry_pending = ft_retry_pending,
}
return M return M

View file

@ -8,9 +8,6 @@ local cached_handle = nil
---@type boolean ---@type boolean
local download_in_progress = false local download_in_progress = false
---@type fun(handle: table?)[]
local pending_callbacks = {}
---@return string ---@return string
local function get_os() local function get_os()
local os_name = jit.os:lower() local os_name = jit.os:lower()
@ -167,10 +164,9 @@ function M.ensure(callback)
return return
end end
table.insert(pending_callbacks, callback)
if download_in_progress then if download_in_progress then
dbg('download already in progress, queued callback') dbg('download already in progress')
callback(nil)
return return
end end
@ -196,25 +192,21 @@ function M.ensure(callback)
vim.system(cmd, {}, function(result) vim.system(cmd, {}, function(result)
download_in_progress = false download_in_progress = false
vim.schedule(function() vim.schedule(function()
local handle = nil
if result.code ~= 0 then if result.code ~= 0 then
vim.notify('[diffs] failed to download libvscode_diff', vim.log.levels.WARN) vim.notify('[diffs] failed to download libvscode_diff', vim.log.levels.WARN)
dbg('curl failed: %s', result.stderr or '') dbg('curl failed: %s', result.stderr or '')
else callback(nil)
local f = io.open(version_path(), 'w') return
if f then
f:write(EXPECTED_VERSION)
f:close()
end
vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO)
handle = M.load()
end end
local cbs = pending_callbacks local f = io.open(version_path(), 'w')
pending_callbacks = {} if f then
for _, cb in ipairs(cbs) do f:write(EXPECTED_VERSION)
cb(handle) f:close()
end end
vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO)
callback(M.load())
end) end)
end) end)
end end

View file

@ -1,17 +1,10 @@
local M = {} local M = {}
local enabled = false local enabled = false
local log_file = nil
---@param val boolean|string ---@param val boolean
function M.set_enabled(val) function M.set_enabled(val)
if type(val) == 'string' then enabled = val
enabled = true
log_file = val
else
enabled = val
log_file = nil
end
end end
---@param msg string ---@param msg string
@ -20,16 +13,7 @@ function M.dbg(msg, ...)
if not enabled then if not enabled then
return return
end end
local formatted = '[diffs.nvim]: ' .. string.format(msg, ...) vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG)
if log_file then
local f = io.open(log_file, 'a')
if f then
f:write(string.format('%.6fs', vim.uv.hrtime() / 1e9) .. ' ' .. formatted .. '\n')
f:close()
end
else
vim.notify(formatted, vim.log.levels.DEBUG)
end
end end
return M 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,17 +12,12 @@
---@field file_old_count integer? ---@field file_old_count integer?
---@field file_new_start integer? ---@field file_new_start integer?
---@field file_new_count integer? ---@field file_new_count integer?
---@field prefix_width integer
---@field quote_width integer
---@field repo_root string? ---@field repo_root string?
local M = {} local M = {}
local dbg = require('diffs.log').dbg local dbg = require('diffs.log').dbg
---@type table<string, {ft: string?, lang: string?}>
local ft_lang_cache = {}
---@param filepath string ---@param filepath string
---@param n integer ---@param n integer
---@return string[]? ---@return string[]?
@ -61,13 +56,6 @@ local function get_ft_from_filename(filename, repo_root)
end end
local ft = vim.filetype.match({ filename = filename }) 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 if ft then
dbg('filetype from filename: %s', ft) dbg('filetype from filename: %s', ft)
return ft return ft
@ -118,14 +106,7 @@ local function get_repo_root(bufnr)
return vim.fn.fnamemodify(git_dir, ':h') return vim.fn.fnamemodify(git_dir, ':h')
end end
local ok3, neogit_git_dir = pcall(vim.api.nvim_buf_get_var, bufnr, 'neogit_git_dir') return nil
if ok3 and neogit_git_dir then
return vim.fn.fnamemodify(neogit_git_dir, ':h')
end
local cwd = vim.fn.getcwd()
local git = require('diffs.git')
return git.get_repo_root(cwd .. '/.')
end end
---@param bufnr integer ---@param bufnr integer
@ -133,18 +114,6 @@ end
function M.parse_buffer(bufnr) function M.parse_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local repo_root = get_repo_root(bufnr) 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[] ---@type diffs.Hunk[]
local hunks = {} local hunks = {}
@ -164,8 +133,6 @@ function M.parse_buffer(bufnr)
local hunk_lines = {} local hunk_lines = {}
---@type integer? ---@type integer?
local hunk_count = nil local hunk_count = nil
---@type integer
local hunk_prefix_width = 1
---@type integer? ---@type integer?
local header_start = nil local header_start = nil
---@type string[] ---@type string[]
@ -178,12 +145,6 @@ function M.parse_buffer(bufnr)
local file_new_start = nil local file_new_start = nil
---@type integer? ---@type integer?
local file_new_count = nil local file_new_count = nil
---@type integer?
local old_remaining = nil
---@type integer?
local new_remaining = nil
local is_unified_diff = false
local current_quote_width = 0
local function flush_hunk() local function flush_hunk()
if hunk_start and #hunk_lines > 0 then if hunk_start and #hunk_lines > 0 then
@ -195,8 +156,6 @@ function M.parse_buffer(bufnr)
header_context = hunk_header_context, header_context = hunk_header_context,
header_context_col = hunk_header_context_col, header_context_col = hunk_header_context_col,
lines = hunk_lines, lines = hunk_lines,
prefix_width = hunk_prefix_width,
quote_width = current_quote_width,
file_old_start = file_old_start, file_old_start = file_old_start,
file_old_count = file_old_count, file_old_count = file_old_count,
file_new_start = file_new_start, file_new_start = file_new_start,
@ -217,114 +176,51 @@ function M.parse_buffer(bufnr)
file_old_count = nil file_old_count = nil
file_new_start = nil file_new_start = nil
file_new_count = nil file_new_count = nil
old_remaining = nil
new_remaining = nil
end end
for i, line in ipairs(lines) do for i, line in ipairs(lines) do
local logical = line local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$')
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/(.+)$')
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
if filename then if filename then
is_unified_diff = diff_git_file ~= nil
flush_hunk() flush_hunk()
current_filename = filename current_filename = filename
current_quote_width = (logical ~= line) and quote_width or 0 current_ft = get_ft_from_filename(filename, repo_root)
local cache_key = (repo_root or '') .. '\0' .. filename current_lang = current_ft and get_lang_from_ft(current_ft) or nil
local cached = ft_lang_cache[cache_key]
if cached then
current_ft = cached.ft
current_lang = cached.lang
else
current_ft = get_ft_from_filename(filename, repo_root)
current_lang = current_ft and get_lang_from_ft(current_ft) or nil
if current_ft or vim.fn.did_filetype() == 0 then
ft_lang_cache[cache_key] = { ft = current_ft, lang = current_lang }
end
end
if current_lang then if current_lang then
dbg('file: %s -> lang: %s', filename, current_lang) dbg('file: %s -> lang: %s', filename, current_lang)
elseif current_ft then elseif current_ft then
dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft) dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft)
end end
hunk_count = 0 hunk_count = 0
hunk_prefix_width = 1
header_start = i header_start = i
header_lines = {} header_lines = {}
elseif logical:match('^@@+') then elseif line:match('^@@.-@@') then
flush_hunk() flush_hunk()
hunk_start = i hunk_start = i
local at_prefix = logical:match('^(@@+)') local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
hunk_prefix_width = #at_prefix - 1 if hs then
if #at_prefix == 2 then file_old_start = tonumber(hs)
local hs, hc, hs2, hc2 = logical:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') file_old_count = tonumber(hc) or 1
if hs then file_new_start = tonumber(hs2)
file_old_start = tonumber(hs) file_new_count = tonumber(hc2) or 1
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 hs2, hc2 = logical:match('%+(%d+),?(%d*) @@')
if hs2 then
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
end
end end
local at_end, context = logical:match('^(@@+.-@@+%s*)(.*)') local prefix, context = line:match('^(@@.-@@%s*)(.*)')
if context and context ~= '' then if context and context ~= '' then
hunk_header_context = context hunk_header_context = context
hunk_header_context_col = #at_end + current_quote_width hunk_header_context_col = #prefix
end end
if hunk_count then if hunk_count then
hunk_count = hunk_count + 1 hunk_count = hunk_count + 1
end end
elseif hunk_start then elseif hunk_start then
local prefix = logical:sub(1, 1) local prefix = line:sub(1, 1)
if prefix == ' ' or prefix == '+' or prefix == '-' then if prefix == ' ' or prefix == '+' or prefix == '-' then
table.insert(hunk_lines, logical) table.insert(hunk_lines, line)
if old_remaining and (prefix == ' ' or prefix == '-') then
old_remaining = old_remaining - 1
end
if new_remaining and (prefix == ' ' or prefix == '+') then
new_remaining = new_remaining - 1
end
elseif elseif
logical == '' line == ''
and is_unified_diff or line:match('^[MADRC%?!]%s+')
and old_remaining or line:match('^diff ')
and old_remaining > 0 or line:match('^index ')
and new_remaining or line:match('^Binary ')
and new_remaining > 0
then
table.insert(hunk_lines, ' ')
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 ')
then then
flush_hunk() flush_hunk()
current_filename = nil current_filename = nil
@ -334,7 +230,7 @@ function M.parse_buffer(bufnr)
end end
end end
if header_start and not hunk_start then if header_start and not hunk_start then
table.insert(header_lines, logical) table.insert(header_lines, line)
end end
end end
@ -343,8 +239,4 @@ function M.parse_buffer(bufnr)
return hunks return hunks
end end
M._test = {
ft_lang_cache = ft_lang_cache,
}
return M return M

View file

@ -6,7 +6,7 @@ vim.g.loaded_diffs = 1
require('diffs.commands').setup() require('diffs.commands').setup()
vim.api.nvim_create_autocmd('FileType', { vim.api.nvim_create_autocmd('FileType', {
pattern = require('diffs').compute_filetypes(vim.g.diffs or {}), pattern = { 'fugitive', 'git' },
callback = function(args) callback = function(args)
local diffs = require('diffs') local diffs = require('diffs')
if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then 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() vim.keymap.set('n', '<Plug>(diffs-conflict-prev)', function()
require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf()) require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to previous conflict' }) 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' std = 'vim'
[lints]
bad_string_escape = 'allow'

View file

@ -40,78 +40,6 @@ describe('commands', function()
end) end)
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() describe('find_hunk_line', function()
it('finds matching @@ header and returns target line', function() it('finds matching @@ header and returns target line', function()
local diff_lines = { local diff_lines = {

View file

@ -6,14 +6,13 @@ local function default_config(overrides)
enabled = true, enabled = true,
disable_diagnostics = false, disable_diagnostics = false,
show_virtual_text = true, show_virtual_text = true,
show_actions = false,
keymaps = { keymaps = {
ours = 'doo', ours = 'doo',
theirs = 'dot', theirs = 'dot',
both = 'dob', both = 'dob',
none = 'don', none = 'don',
next = ']c', next = ']x',
prev = '[c', prev = '[x',
}, },
} }
if overrides then if overrides then
@ -235,6 +234,29 @@ describe('conflict', function()
helpers.delete_buffer(bufnr) helpers.delete_buffer(bufnr)
end) 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() it('applies number_hl_group to content lines', function()
local bufnr = create_file_buffer({ local bufnr = create_file_buffer({
'<<<<<<< HEAD', '<<<<<<< HEAD',
@ -509,33 +531,6 @@ describe('conflict', function()
helpers.delete_buffer(bufnr) helpers.delete_buffer(bufnr)
end) 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() it('goto_prev jumps to previous conflict', function()
local bufnr = create_file_buffer({ local bufnr = create_file_buffer({
'<<<<<<< HEAD', '<<<<<<< HEAD',
@ -580,33 +575,6 @@ describe('conflict', function()
helpers.delete_buffer(bufnr) helpers.delete_buffer(bufnr)
end) 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() it('goto_next does nothing with no conflicts', function()
local bufnr = create_file_buffer({ 'normal line' }) local bufnr = create_file_buffer({ 'normal line' })
vim.api.nvim_set_current_buf(bufnr) vim.api.nvim_set_current_buf(bufnr)
@ -717,158 +685,4 @@ describe('conflict', function()
helpers.delete_buffer(bufnr) helpers.delete_buffer(bufnr)
end) end)
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) 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,238 +0,0 @@
require('spec.helpers')
local parser = require('diffs.parser')
local highlight = require('diffs.highlight')
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, ns)
return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })
end
local function highlight_opts()
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('parser email-quoted diffs', 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('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('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)
end)
describe('email-quoted header highlight suppression', function()
before_each(function()
vim.api.nvim_set_hl(0, 'DiffsClear', { fg = 0xc0c0c0, bg = 0x1e1e2e })
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = 0x2e4a3a })
vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = 0x4a2e3a })
end)
it('applies DiffsClear to header lines when quote_width > 0', 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,2 @@',
'> +line1',
'> +line2',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
local ns = vim.api.nvim_create_namespace('diffs_email_clear_test')
highlight.highlight_hunk(bufnr, ns, hunks[1], highlight_opts())
local extmarks = get_extmarks(bufnr, ns)
local clear_lines = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.hl_group == 'DiffsClear' and mark[3] == 0 then
clear_lines[mark[2]] = true
end
end
assert.is_true(clear_lines[0] ~= nil, 'expected DiffsClear on diff --git line')
assert.is_true(clear_lines[1] ~= nil, 'expected DiffsClear on index line')
assert.is_true(clear_lines[2] ~= nil, 'expected DiffsClear on --- line')
assert.is_true(clear_lines[3] ~= nil, 'expected DiffsClear on +++ line')
delete_buffer(bufnr)
end)
it('applies DiffsClear and diff treesitter to @@ line when quote_width > 0', 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)
local ns = vim.api.nvim_create_namespace('diffs_email_at_test')
highlight.highlight_hunk(bufnr, ns, hunks[1], highlight_opts())
local extmarks = get_extmarks(bufnr, ns)
local has_at_clear = false
local has_at_ts = false
for _, mark in ipairs(extmarks) do
local d = mark[4]
if mark[2] == 1 and d then
if d.hl_group == 'DiffsClear' and mark[3] == 0 then
has_at_clear = true
end
if d.hl_group and d.hl_group:match('^@.*%.diff$') and d.priority == 199 then
has_at_ts = true
end
end
end
assert.is_true(has_at_clear, 'expected DiffsClear on @@ line')
assert.is_true(has_at_ts, 'expected diff treesitter capture on @@ line')
delete_buffer(bufnr)
end)
it('does not apply DiffsClear to header lines when quote_width = 0', 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,2 @@',
'+line1',
'+line2',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
local ns = vim.api.nvim_create_namespace('diffs_email_noclear_test')
highlight.highlight_hunk(bufnr, ns, hunks[1], highlight_opts())
local extmarks = get_extmarks(bufnr, ns)
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.hl_group == 'DiffsClear' and mark[3] == 0 and mark[2] < 5 then
error('unexpected DiffsClear at col 0 on header line ' .. mark[2] .. ' with quote_width=0')
end
end
delete_buffer(bufnr)
end)
end)

View file

@ -87,6 +87,28 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true }) vim.api.nvim_buf_delete(buf, { force = true })
end) 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() it('parses renamed file and returns both names', function()
local buf = create_status_buffer({ local buf = create_status_buffer({
'Staged (1)', 'Staged (1)',
@ -135,6 +157,28 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true }) vim.api.nvim_buf_delete(buf, { force = true })
end) 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() it('KNOWN LIMITATION: filename containing arrow parsed incorrectly', function()
local buf = create_status_buffer({ local buf = create_status_buffer({
'Staged (1)', 'Staged (1)',
@ -146,54 +190,77 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true }) vim.api.nvim_buf_delete(buf, { force = true })
end) end)
it('unquotes git-quoted filenames with spaces', function() it('handles double extensions', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M "path with spaces/file.lua"',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('path with spaces/file.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('unquotes escaped quotes in filenames', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M "file\\"name.lua"',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('file"name.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('unquotes octal escapes in filenames', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M "\\303\\251le.lua"',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('\195\169le.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('passes through unquoted filenames unchanged', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M normal.lua',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('normal.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('unquotes renamed files with quotes', function()
local buf = create_status_buffer({ local buf = create_status_buffer({
'Staged (1)', 'Staged (1)',
'R100 "old name.lua" -> "new name.lua"', 'M test.spec.lua',
}) })
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('new name.lua', filename) assert.equals('test.spec.lua', filename)
assert.equals('old name.lua', old_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 }) vim.api.nvim_buf_delete(buf, { force = true })
end) end)
@ -254,6 +321,30 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true }) vim.api.nvim_buf_delete(buf, { force = true })
end) 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() it('returns is_header=false for file lines', function()
local buf = create_status_buffer({ local buf = create_status_buffer({
'Staged (1)', 'Staged (1)',
@ -315,6 +406,22 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true }) vim.api.nvim_buf_delete(buf, { force = true })
end) 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() it('returns hunk header and offset for context line', function()
local buf = create_status_buffer({ local buf = create_status_buffer({
'Unstaged (1)', '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() it('accepts full config', function()
vim.g.diffs = { vim.g.diffs = {
debug = true, debug = true,
debounce_ms = 100,
hide_prefix = false, hide_prefix = false,
highlights = { highlights = {
background = true, background = true,
@ -45,7 +46,7 @@ describe('diffs', function()
it('accepts partial config', function() it('accepts partial config', function()
vim.g.diffs = { vim.g.diffs = {
hide_prefix = true, debounce_ms = 25,
} }
assert.has_no.errors(function() assert.has_no.errors(function()
diffs.attach() diffs.attach()
@ -151,247 +152,6 @@ describe('diffs', function()
end) end)
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() describe('diff mode', function()
local function create_diff_window() local function create_diff_window()
vim.cmd('new') vim.cmd('new')

View file

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

@ -391,25 +391,35 @@ describe('parser', function()
vim.fn.delete(repo_root, 'rf') vim.fn.delete(repo_root, 'rf')
end) end)
it('detects filetype for .sh files when did_filetype() is non-zero', function() it('detects python from shebang without open buffer', function()
rawset(vim.fn, 'did_filetype', function() return 1 end) local repo_root = '/tmp/diffs-test-shebang-py'
vim.fn.mkdir(repo_root, 'p')
parser._test.ft_lang_cache = {} local file_path = repo_root .. '/deploy'
local bufnr = create_buffer({ local f = io.open(file_path, 'w')
'diff --git a/test.sh b/test.sh', f:write('#!/usr/bin/env python3\n')
'@@ -1,3 +1,4 @@', f:write('import sys\n')
' #!/usr/bin/env bash', f:write('print("hi")\n')
' set -euo pipefail', f:close()
'-echo "running tests..."',
'+echo "running tests with coverage..."', 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(1, #hunks)
assert.are.equal('test.sh', hunks[1].filename) assert.are.equal('deploy', hunks[1].filename)
assert.are.equal('sh', hunks[1].ft) assert.are.equal('python', hunks[1].ft)
delete_buffer(bufnr)
rawset(vim.fn, 'did_filetype', nil) delete_buffer(diff_buf)
os.remove(file_path)
vim.fn.delete(repo_root, 'rf')
end) end)
it('extracts file line numbers from @@ header', function() it('extracts file line numbers from @@ header', function()
@ -430,6 +440,22 @@ describe('parser', function()
delete_buffer(bufnr) delete_buffer(bufnr)
end) 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() it('defaults count to 1 when omitted in @@ header', function()
local bufnr = create_buffer({ local bufnr = create_buffer({
'M lua/test.lua', 'M lua/test.lua',
@ -446,153 +472,6 @@ describe('parser', function()
delete_buffer(bufnr) delete_buffer(bufnr)
end) 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.is_nil(hunks[1].file_old_start)
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() it('stores repo_root on hunk when available', function()
local bufnr = create_buffer({ local bufnr = create_buffer({
'M lua/test.lua', 'M lua/test.lua',
@ -609,388 +488,16 @@ describe('parser', function()
delete_buffer(bufnr) delete_buffer(bufnr)
end) end)
it('detects neogit modified prefix', function() it('repo_root is nil when not available', function()
local bufnr = create_buffer({ local bufnr = create_buffer({
'modified hello.lua', 'M lua/test.lua',
'@@ -1,2 +1,3 @@', '@@ -1,3 +1,4 @@',
' local M = {}',
'+local x = 1',
' return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('hello.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
assert.are.equal(3, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('detects neogit new file prefix', function()
local bufnr = create_buffer({
'new file hello.lua',
'@@ -0,0 +1,2 @@',
'+local M = {}',
'+return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('hello.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
assert.are.equal(2, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('detects neogit deleted prefix', function()
local bufnr = create_buffer({
'deleted hello.lua',
'@@ -1,2 +0,0 @@',
'-local M = {}',
'-return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('hello.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
assert.are.equal(2, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('detects neogit renamed prefix', function()
local bufnr = create_buffer({
'renamed old.lua',
'@@ -1,2 +1,3 @@',
' local M = {}',
'+local x = 1',
' return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('old.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
delete_buffer(bufnr)
end)
it('detects neogit copied prefix', function()
local bufnr = create_buffer({
'copied orig.lua',
'@@ -1,2 +1,3 @@',
' local M = {}',
'+local x = 1',
' return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('orig.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
delete_buffer(bufnr)
end)
it('does not treat "new file mode" as a filename', function()
local bufnr = create_buffer({
'diff --git a/src/new.lua b/src/new.lua',
'new file mode 100644',
'index 0000000..abc1234',
'--- /dev/null',
'+++ b/src/new.lua',
'@@ -0,0 +1,2 @@',
'+local M = {}',
'+return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('src/new.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
delete_buffer(bufnr)
end)
it('does not treat "new file mode 100755" as a filename', function()
local bufnr = create_buffer({
'diff --git a/bin/run b/bin/run',
'new file mode 100755',
'index 0000000..abc1234',
'--- /dev/null',
'+++ b/bin/run',
'@@ -0,0 +1,2 @@',
'+#!/bin/bash',
'+echo hello',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('bin/run', hunks[1].filename)
delete_buffer(bufnr)
end)
it('does not treat "deleted file mode" as a filename', function()
local bufnr = create_buffer({
'diff --git a/src/old.lua b/src/old.lua',
'deleted file mode 100644',
'index abc1234..0000000',
'--- a/src/old.lua',
'+++ /dev/null',
'@@ -1,2 +0,0 @@',
'-local M = {}',
'-return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('src/old.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
delete_buffer(bufnr)
end)
it('does not treat "deleted file mode 100755" as a filename', function()
local bufnr = create_buffer({
'diff --git a/bin/old b/bin/old',
'deleted file mode 100755',
'index abc1234..0000000',
'--- a/bin/old',
'+++ /dev/null',
'@@ -1,1 +0,0 @@',
'-#!/bin/bash',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('bin/old', hunks[1].filename)
delete_buffer(bufnr)
end)
it('does not treat "old mode" or "new mode" as filenames', function()
local bufnr = create_buffer({
'diff --git a/script.sh b/script.sh',
'old mode 100644',
'new mode 100755',
'@@ -1,1 +1,2 @@',
' echo hello',
'+echo world',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('script.sh', hunks[1].filename)
delete_buffer(bufnr)
end)
it('does not treat "rename from/to" as filenames', function()
local bufnr = create_buffer({
'diff --git a/old.lua b/new.lua',
'similarity index 95%',
'rename from old.lua',
'rename to new.lua',
'@@ -1,2 +1,2 @@',
' local M = {}',
'-local x = 1',
'+local x = 2',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('new.lua', hunks[1].filename)
delete_buffer(bufnr)
end)
it('does not treat "copy from/to" as filenames', function()
local bufnr = create_buffer({
'diff --git a/orig.lua b/copy.lua',
'similarity index 100%',
'copy from orig.lua',
'copy to copy.lua',
'@@ -1,1 +1,1 @@',
' local M = {}', ' local M = {}',
}) })
local hunks = parser.parse_buffer(bufnr) local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks) assert.are.equal(1, #hunks)
assert.are.equal('copy.lua', hunks[1].filename) assert.is_nil(hunks[1].repo_root)
delete_buffer(bufnr)
end)
it('does not treat "similarity index" or "dissimilarity index" as filenames', function()
local bufnr = create_buffer({
'diff --git a/foo.lua b/bar.lua',
'similarity index 85%',
'rename from foo.lua',
'rename to bar.lua',
'@@ -1,2 +1,2 @@',
' local M = {}',
'-return 1',
'+return 2',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('bar.lua', hunks[1].filename)
delete_buffer(bufnr)
end)
it('does not treat "index" line as a filename', function()
local bufnr = create_buffer({
'diff --git a/test.lua b/test.lua',
'index abc1234..def5678 100644',
'--- a/test.lua',
'+++ b/test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('test.lua', hunks[1].filename)
delete_buffer(bufnr)
end)
it('neogit new file with diff containing new file mode metadata', function()
local bufnr = create_buffer({
'new file src/foo.lua',
'diff --git a/src/foo.lua b/src/foo.lua',
'new file mode 100644',
'index 0000000..abc1234',
'--- /dev/null',
'+++ b/src/foo.lua',
'@@ -0,0 +1,3 @@',
'+local M = {}',
'+M.x = 1',
'+return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('src/foo.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
assert.are.equal(3, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('neogit deleted with diff containing deleted file mode metadata', function()
local bufnr = create_buffer({
'deleted src/old.lua',
'diff --git a/src/old.lua b/src/old.lua',
'deleted file mode 100644',
'index abc1234..0000000',
'--- a/src/old.lua',
'+++ /dev/null',
'@@ -1,2 +0,0 @@',
'-local M = {}',
'-return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('src/old.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
assert.are.equal(2, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('multiple new files with mode metadata do not corrupt filenames', function()
local bufnr = create_buffer({
'diff --git a/a.lua b/a.lua',
'new file mode 100644',
'index 0000000..abc1234',
'--- /dev/null',
'+++ b/a.lua',
'@@ -0,0 +1,1 @@',
'+local a = 1',
'diff --git a/b.lua b/b.lua',
'new file mode 100644',
'index 0000000..def5678',
'--- /dev/null',
'+++ b/b.lua',
'@@ -0,0 +1,1 @@',
'+local b = 2',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(2, #hunks)
assert.are.equal('a.lua', hunks[1].filename)
assert.are.equal('b.lua', hunks[2].filename)
delete_buffer(bufnr)
end)
it('fugitive status with new and deleted files containing mode metadata', function()
local bufnr = create_buffer({
'Head: main',
'',
'Staged (2)',
'A src/new.lua',
'diff --git a/src/new.lua b/src/new.lua',
'new file mode 100644',
'index 0000000..abc1234',
'--- /dev/null',
'+++ b/src/new.lua',
'@@ -0,0 +1,2 @@',
'+local M = {}',
'+return M',
'D src/old.lua',
'diff --git a/src/old.lua b/src/old.lua',
'deleted file mode 100644',
'index abc1234..0000000',
'--- a/src/old.lua',
'+++ /dev/null',
'@@ -1,1 +0,0 @@',
'-local x = 1',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(2, #hunks)
assert.are.equal('src/new.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
assert.are.equal('src/old.lua', hunks[2].filename)
assert.are.equal('lua', hunks[2].ft)
delete_buffer(bufnr)
end)
it('neogit new file with deep nested path', function()
local bufnr = create_buffer({
'new file src/deep/nested/path/module.lua',
'@@ -0,0 +1,1 @@',
'+return {}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('src/deep/nested/path/module.lua', hunks[1].filename)
delete_buffer(bufnr)
end)
it('detects bare filename for untracked files', function()
local bufnr = create_buffer({
'newfile.rs',
'@@ -0,0 +1,3 @@',
'+fn main() {',
'+ println!("hello");',
'+}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('newfile.rs', hunks[1].filename)
assert.are.equal(3, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('does not match section headers as bare filenames', function()
local bufnr = create_buffer({
'Untracked files (1)',
'newfile.rs',
'@@ -0,0 +1,3 @@',
'+fn main() {',
'+ println!("hello");',
'+}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('newfile.rs', hunks[1].filename)
delete_buffer(bufnr) delete_buffer(bufnr)
end) end)
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