Compare commits

...
Sign in to create a new pull request.

33 commits

Author SHA1 Message Date
2ab17a05d8 fix: carry forward highlighted hunks on reparse to reduce flicker
Problem: toggling large diffs via fugitive's `=` caused the top of the
buffer to re-render and glitch. ensure_cache always created a new cache
entry with pending_clear=true and highlighted={}, forcing on_win to
clear and re-highlight every visible hunk — including stable ones above
the toggle point that never changed.

Solution: on reparse, compare old and new hunk lists using a prefix +
suffix matching strategy. Hunks that match (same filename, line count,
and sampled content) carry forward their highlighted state so on_win
skips them. Comparison is O(1) per hunk. Only runs when old entry had
pending_clear=false; invalidate_cache/ColorScheme paths still force
full re-highlight.

Closes #131
2026-02-25 12:40:40 -05:00
Barrett Ruth
d45ffd279b
add back hard-code override (#136) 2026-02-25 11:47:25 -05:00
Barrett Ruth
d797833341
test: add decoration provider, integration, and neogit spec files (#134)
## Problem

Regressions #119 and #120 showed the test suite had no coverage of the
decoration provider cache pipeline, no end-to-end pipeline tests from
buffer content to extmarks, and no Neogit-specific integration tests.

## Solution

Adds three new spec files (28 new tests, 316 total):

- `spec/decoration_provider_spec.lua` — indirect cache pipeline tests
via `_test` table: `ensure_cache` population, content fingerprint guard,
`pending_clear` semantics, BufWipeout cleanup
- `spec/integration_spec.lua` — full pipeline: diff buffer → `attach` →
extmarks; verifies `DiffsAdd`/`DiffsDelete` on correct lines, treesitter
captures, multi-hunk coverage
- `spec/neogit_integration_spec.lua` — `neogit_disable_hunk_highlight`
behavior, NeogitStatus/NeogitDiffView attach and cache population,
parser neogit filename patterns

Depends on #133.

Closes #122
2026-02-25 11:44:56 -05:00
Barrett Ruth
2feb8a86ed
feat(neogit): use new neogit apis for highlight and repo root (#133)
## Problem

diffs.nvim was blanking 18 Neogit highlight groups globally on attach to
prevent Neogit's `line_hl_group` fg from stomping treesitter syntax. It
also fell back to `getcwd()` plus a subprocess call for repo root
detection on Neogit buffers, and had no mechanism to refresh the hunk
cache when Neogit lazy-loaded new diff sections.

## Solution

Adopts three APIs introduced in NeogitOrg/neogit#1897:

- Sets `vim.b.neogit_disable_hunk_highlight = true` on the Neogit buffer
at attach time. Neogit's `HunkLine` renderer skips all its own highlight
logic when this is set, replacing the need to blank 18 hl groups
globally and the associated ColorScheme re-application.
- Reads `vim.b.neogit_git_dir` in `get_repo_root()` as a reliable
fallback between the existing `b:git_dir` check and the `getcwd()`
subprocess path.
- Registers a buffer-local `User NeogitDiffLoaded` autocmd on attach
that calls `M.refresh()` when Neogit lazy-loads a new diff section,
keeping the hunk cache in sync.

Closes #128
2026-02-25 11:42:59 -05:00
Barrett Ruth
700a9a21ad
fix(conflict)!: change default nav keymaps from ]x/[x to ]c/[c (#132)
## Problem

The default conflict navigation keymaps `]x`/`[x` are non-standard. Vim
natively uses `]c`/`[c` for diff navigation, so the same keys are far
more
intuitive for conflict jumping.

## Solution

Change the defaults for `conflict.keymaps.next` and
`conflict.keymaps.prev`
to `]c` and `[c`. This is a breaking change for users relying on the
previous
defaults without explicit configuration.
2026-02-24 12:07:54 -05:00
bfd3a40c5f
ci: add bit luajit global 2026-02-23 18:18:30 -05:00
5946b40491
ci: migrate to nix 2026-02-23 18:14:05 -05:00
ebc65d1f8e
build(flake): add lua-language-server to devShell
Problem: lua-language-server is not available in the dev shell, making
it impossible to run local type checks.

Solution: add lua-language-server to the devShell packages.
2026-02-23 17:35:22 -05:00
dfebc68a1f
fix(doc): improve q&a format 2026-02-21 23:02:39 -05:00
Barrett Ruth
b1abfe4f4a
feat: remove config deprecation in v0.3.0 (#129) 2026-02-18 13:34:43 -05:00
Barrett Ruth
d68cddb1a4
fix(parser): exclude git diff metadata from neogit filename patterns (#127)
## Problem

Git diff metadata lines like "new file mode 100644" and "deleted file
mode 100644" matched the neogit "new file" and "deleted" filename
patterns in the parser, corrupting the current filename and breaking
syntax highlighting for subsequent hunks.

Closes #120

## Solution

Add negative guards so "new file mode" and "deleted file mode" lines
are skipped before the neogit filename capture runs. The guard must
evaluate before the capture due to Lua's and/or short-circuit semantics
— otherwise the and-operator returns true instead of the captured
string.

Added 16 parser tests covering all neogit filename patterns, all git
diff extended header lines that could collide, and integration scenarios
with mixed neogit status + diff metadata buffers.
2026-02-16 00:14:27 -05:00
Barrett Ruth
cbc93f9eaa
refactor: remove enabled field from fugitive/neogit config (#126)
## Problem

Users had to pass `enabled = true` or `enabled = false` inside
fugitive/neogit config tables, which was redundant — table presence
already implied the integration should be active.

## Solution

Remove the `enabled` field from the public API. Table presence now
implies enabled, `false` disables, `true` expands to sub-defaults.
The `enabled` field is still accepted for backward compatibility.

Added 20 `compute_filetypes` tests covering all config shapes (true,
false, table, nil, backward-compat enabled field). Updated docs
and type annotations.
2026-02-15 19:42:38 -05:00
Barrett Ruth
a00993820f
docs: add lazy.nvim installation FAQ (#125)
## Problem

Users attempt to lazy-load diffs.nvim with `event`, `ft`, `lazy`, or
`keys` options, which interferes with the plugin's own `FileType`
autocmd registered at startup.

## Solution

Add a FAQ entry with a correct lazy.nvim snippet using `init` and a
note explaining that the plugin lazy-loads itself.
2026-02-15 18:55:54 -05:00
Barrett Ruth
028ba5314e
fix(highlight): revert line backgrounds to hl_group+hl_eol (#124)
## Problem

The neogit commit (3d640c2) switched line background extmarks from
`hl_group`+`hl_eol` to `line_hl_group`. Due to [neovim#31151][1],
`line_hl_group` bg overrides `hl_group` bg regardless of extmark
priority. This made `DiffsAddText`/`DiffsDeleteText` intra-line
highlights invisible beneath line backgrounds — the extmarks were
placed correctly but Neovim rendered the line bg on top.

[1]: https://github.com/neovim/neovim/issues/31151

## Solution

Revert line backgrounds to `hl_group`+`hl_eol` where priority stacking
works correctly. Keep `number_hl_group` in a separate point extmark to
prevent gutter color bleeding to adjacent lines. The Neogit highlight
override (clearing their groups to `{}`) is independent and unaffected.
2026-02-15 18:27:39 -05:00
Barrett Ruth
cb38865b96
fix: warn users when fugitive/neogit/diff integrations are unconfigured (#123)
## Problem

Commit 0f27488 changed fugitive and neogit integrations from enabled by
default
to disabled by default. Users who never explicitly set these keys in
their config
saw no deprecation notice and silently lost integration support. The
existing
deprecation warning only fires for the old `filetypes` key, missing the
far more
common case of users who had no explicit config at all.

## Solution

Add an ephemeral migration check in `init()` that emits a `vim.notify`
warning
at WARN level when `fugitive`, `neogit`, and `diff` (via
`extra_filetypes`) are
all absent from the user's config. This covers the gap between the old
`filetypes`
deprecation and users who relied on implicit defaults. To be removed in
0.3.0.
2026-02-15 16:48:19 -05:00
Barrett Ruth
3d640c207b
feat: add neogit support (#117)
## TODO

1. docs (vimdoc + readme) - this is a non-trivial feature
2. push luarocks version

## Problem

diffs.nvim only activates on `fugitive`, `git`, and `gitcommit`
filetypes.
Neogit uses its own custom filetypes (`NeogitStatus`,
`NeogitCommitView`,
`NeogitDiffView`) and doesn't set `b:git_dir`, so the plugin never
attaches
and repo root resolution fails for filetype detection within diff hunks.

## Solution

Two changes:

1. **`lua/diffs/init.lua`** — Add the three Neogit filetypes to the
default
`filetypes` list. The `FileType` autocmd in `plugin/diffs.lua` already
handles them correctly since the `is_fugitive_buffer` guard only applies
   to the `git` filetype.

2. **`lua/diffs/parser.lua`** — Add a CWD-based fallback in
`get_repo_root()`.
After the existing `b:diffs_repo_root` and `b:git_dir` checks, fall back
to
`vim.fn.getcwd()` via `git.get_repo_root()` (already cached). Without
this,
   the parser can't resolve filetypes for files in Neogit buffers.

Neogit's expanded diffs use standard unified diff format, so the parser
handles
them without modification.

Closes #110.
2026-02-14 17:12:01 -05:00
5d3bbc3631
fix(ci): styling 2026-02-12 18:10:33 -05:00
3990014a93
feat: add support for diff and other filetypes 2026-02-12 18:04:47 -05:00
Barrett Ruth
9a0b812f69
performance improvements (#116)
closes #111
2026-02-12 16:59:13 -05:00
Barrett Ruth
330e2bc9b8
feat: highlight commit buffers (#112)
closes #109 

`:G commit` buffers are now highlighted as follows:

<img width="556" height="502" alt="image"
src="https://github.com/user-attachments/assets/4248dc42-c151-4ec8-b4b7-43b6fe919749"
/>

Co-authored-by: Barrett Ruth <br@barrettruth.com>
2026-02-11 12:14:28 -05:00
Barrett Ruth
4ce1e1786a
Update README.md 2026-02-09 20:39:38 -05:00
Barrett Ruth
eb4b7f1a0b
Update README.md 2026-02-09 19:51:29 -05:00
Barrett Ruth
18405ddbfa
Update README.md 2026-02-09 19:45:17 -05:00
Barrett Ruth
bae6707c51
Update README.md 2026-02-09 19:43:42 -05:00
Barrett Ruth
5c7e7f4bda
doc: readme video preview (#107)
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
closes #105
2026-02-09 19:40:18 -05:00
Barrett Ruth
cc5a368838
fix(highlight): support combined diff format for unmerged files (#106)
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
## Problem

Fugitive shows combined diffs (`@@@` headers, 2-character prefixes like
`++`, ` +`, `+ `) for unmerged (`UU`) files. The parser and highlight
pipeline assumed unified diff format (`@@`, 1-char prefix), causing:

- Prefix concealment only hiding 1 of 2 prefix chars
- Missing background colors on ` +` and `+ ` lines (first char is space
→ misclassified as context)
- No treesitter highlights (extra prefix char poisoned code arrays)
- `U` file header not recognized by parser (missing from filename
pattern)

## Solution

Detect prefix width from leading `@` count in hunk headers (`@@` → 1,
`@@@` → 2). Propagate `prefix_width` through the pipeline:

- **Parser**: new `prefix_width` field on `diffs.Hunk`, `U` added to
filename pattern, combined diff range extraction
- **Highlight**: prefix stripping, `col_offset`, concealment width, and
line classification all use `prefix_width`
- **Intra-line**: skipped for combined diffs (`prefix_width > 1`) since
2-char prefix semantics don't produce meaningful change groups
2026-02-09 19:30:13 -05:00
Barrett Ruth
59fcf14817
Docs/readme vscode diff (#104)
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
2026-02-09 16:34:15 -05:00
Barrett Ruth
2d7d26a1bc
docs(readme): mention vscode-diff algorithm and credit @esmuellert (#103)
## Problem

The README doesn't mention the optional vscode-diff FFI backend for
word-level intra-line accuracy, and the codediff.nvim acknowledgement
doesn't credit the author by name.

## Solution

Expand the intra-line feature bullet to mention vscode-diff with a link
to codediff.nvim. Credit @esmuellert by name in the acknowledgements
section. Also update the stale context padding reference in known
limitations to match the current behavior.
2026-02-09 15:15:42 -05:00
Barrett Ruth
35067151e4
fix: pre-release cleanup for v0.2.0 (#102)
## Problem

Three minor issues remain before the v0.2.0 release:

1. Git quotes filenames containing spaces, unicode, or special
characters
   in the fugitive status buffer. `parse_file_line` passed the quotes
   through verbatim, causing file-not-found errors on diff operations.

2. Navigation wrap-around in both conflict and merge modules was silent,
giving no indication when jumping past the last/first item back to the
   beginning/end.

3. `resolved_hunks` and `(resolved)` virtual text in the merge module
persisted across buffer re-reads, showing stale markers for hunks that
   were no longer resolved.

## Solution

1. Add an `unquote()` helper to fugitive.lua that strips surrounding
   quotes and unescapes `\\`, `\"`, `\n`, `\t`, and octal `\NNN`
   sequences. Applied to both return paths in `parse_file_line`.

2. Add `vim.notify` before the wrap-around jump in all four navigation
   functions (`goto_next`/`goto_prev` in conflict.lua and merge.lua).

3. Clear `resolved_hunks[bufnr]` and the merge namespace at the top of
   `setup_keymaps` so each buffer init starts fresh.

Closes #66
2026-02-09 15:08:36 -05:00
Barrett Ruth
b5d28e9f2b
feat(conflict): add virtual text formatting and action lines (#101)
## Problem

Conflict resolution virtual text only showed plain "current" /
"incoming"
labels with no keymap hints. Users had no way to discover available
resolution keymaps without reading docs.

## Solution

Default virtual text labels now include keymap hints: `(current — doo)`
and
`(incoming — dot)`. A new `format_virtual_text` config option lets users
customize or hide labels entirely. A new `show_actions` option (off by
default) renders a codelens-style action line above each `<<<<<<<`
marker
listing all enabled resolution keymaps. Merge diff views also gain hunk
hints on `@@` header lines showing available keymaps.

New config fields: `conflict.format_virtual_text` (function|nil),
`conflict.show_actions` (boolean). New highlight group:
`DiffsConflictActions`.
2026-02-09 13:55:13 -05:00
Barrett Ruth
f5a090baae
perf: cache repo root and harden async paths (#100)
## Problem

`get_repo_root()` shells out to `git rev-parse` on every call, causing
4-6
redundant subprocesses per `gdiff_file()` invocation. Three other minor
issues: `highlight_vim_syntax()` leaks a scratch buffer if
`nvim_buf_call`
errors, `lib.ensure()` silently drops callbacks during download so hunks
highlighted mid-download permanently miss intra-line highlights, and the
debounce timer callback can operate on a deleted buffer.

## Solution

Cache `get_repo_root()` results by parent directory — repo roots don't
change within a session. Wrap `nvim_buf_call` and `nvim_buf_delete` in
pcall so the scratch buffer is always cleaned up. Replace the early
`callback(nil)` in `lib.ensure()` with a pending callback queue that
fires
once the download completes. Guard the debounce timer callback with
`nvim_buf_is_valid`.
2026-02-09 12:39:13 -05:00
Barrett Ruth
a2053a132b
feat: unified diff conflict resolution for unmerged files (#99)
## Problem

Pressing `du` on a `UU` (unmerged) file in the fugitive status buffer
had no
effect. There was no way to see a proper ours-vs-theirs diff with syntax
highlighting and intra-line changes, or to resolve conflicts from within
a
unified diff view.

Additionally, pressing `du` on a section header containing only unmerged
files
showed "no changes in section" because `git diff` produces combined
(`diff --cc`)
output for unmerged files, which was stripped entirely.

## Solution

Fetch `:2:` (ours) and `:3:` (theirs) from the git index and generate a
standard
unified diff. The existing highlight pipeline (treesitter + intra-line)
applies
automatically. Resolution keymaps (`doo`/`dot`/`dob`/`don`) on hunks in
the diff
view write changes back to the working file's conflict markers.
Navigation
(`]x`/`[x`) jumps between unresolved conflict hunks.

For section diffs, combined diff entries are now replaced with generated
ours-vs-theirs unified diffs instead of being stripped.

Works for merge, cherry-pick, and rebase conflicts — git populates
`:2:`/`:3:`
the same way for all three.

Closes #61
2026-02-09 12:21:13 -05:00
Barrett Ruth
49fc446aae
doc: add plug mappings for merge conflict resolution (#98) 2026-02-08 16:29:39 -05:00
31 changed files with 4784 additions and 1255 deletions

View file

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

View file

@ -1,5 +1,5 @@
{ {
"runtime.version": "Lua 5.1", "runtime.version": "LuaJIT",
"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"], "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],

View file

@ -2,25 +2,22 @@
**Syntax highlighting for diffs in Neovim** **Syntax highlighting for diffs in Neovim**
Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware Enhance [vim-fugitive](https://github.com/tpope/vim-fugitive),
syntax highlighting. [Neogit](https://github.com/NeogitOrg/neogit), and Neovim's built-in diff mode
with language-aware syntax highlighting.
![diffs.nvim preview](https://github.com/user-attachments/assets/d3d64c96-b824-4fcb-af7f-4aef3f7f498a) <video src="https://github.com/user-attachments/assets/24574916-ecb2-478e-a0ea-e4cdc971e310" width="100%" controls></video>
## Features ## Features
- Treesitter syntax highlighting in `:Git` diffs and commit views - Treesitter syntax highlighting in vim-fugitive, Neogit, and `diff` filetype
- Diff header highlighting (`diff --git`, `index`, `---`, `+++`) - Character-level intra-line diff highlighting (with optional
- `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds [vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for
- `:Gdiff` unified diff against any git revision with syntax highlighting word-level accuracy)
- Fugitive status buffer keymaps (`du`/`dU`) for unified diffs - `:Gdiff` unified diff against any revision
- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`) - Background-only diff colors for `&diff` buffers
- Vim syntax fallback for languages without a treesitter parser - Inline merge conflict detection, highlighting, and resolution
- 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
@ -41,18 +38,58 @@ 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.
To improve accuracy, `diffs.nvim` reads lines from disk before and after each Context lines within the hunk provide syntactic context for the parser. In
hunk for parsing context (`highlights.context`, enabled by default with 25 rare cases, hunks that start or end mid-expression may produce imperfect
lines). This resolves most boundary issues. Set highlights due to treesitter error recovery.
`highlights.context.enabled = false` to disable.
- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event - **Syntax "flashing"**: `diffs.nvim` hooks into the `FileType fugitive` event
triggered by `vim-fugitive`, at which point the buffer is preliminarily triggered by `vim-fugitive`, at which point the buffer is preliminarily
painted. The buffer is then re-painted after `debounce_ms` milliseconds, painted. The decoration provider applies highlights on the next redraw cycle,
causing an unavoidable visual "flash" even when `debounce_ms = 0`. causing a brief visual "flash".
- **Cold Start**: Treesitter grammar loading (~10ms) and query compilation
(~4ms) are one-time costs per language per Neovim session. Each language pays
this cost on first encounter, which may cause a brief stutter when a diff
containing a new language first enters the viewport.
- **Vim syntax fallback is deferred**: The vim syntax fallback (for languages
without a treesitter parser) cannot run inside the decoration provider's
redraw cycle due to Neovim's restriction on buffer mutations. Vim syntax
highlights for these hunks appear slightly delayed.
- **Conflicting diff plugins**: `diffs.nvim` may not interact well with other - **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:
@ -70,11 +107,14 @@ luarocks install diffs.nvim
# Acknowledgements # Acknowledgements
- [`vim-fugitive`](https://github.com/tpope/vim-fugitive) - [`vim-fugitive`](https://github.com/tpope/vim-fugitive)
- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - [@esmuellert](https://github.com/esmuellert) /
[`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - vscode-diff
algorithm FFI backend for word-level intra-line accuracy
- [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) - [`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 and Neovim's highlights on top of default diff highlighting in vim-fugitive, Neogit, and
built-in diff mode. Neovim's 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*
Using lazy.nvim: >lua Install with lazy.nvim: >lua
{ {
'barrettruth/diffs.nvim', 'barrettruth/diffs.nvim',
dependencies = { 'tpope/vim-fugitive' }, dependencies = { 'tpope/vim-fugitive' },
@ -52,8 +52,10 @@ 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,
@ -75,23 +77,26 @@ 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 = ']x', next = ']c',
prev = '[x', prev = '[c',
}, },
}, },
} }
@ -102,11 +107,6 @@ 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,14 +114,45 @@ 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.
@ -160,6 +191,10 @@ 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
@ -181,6 +216,28 @@ 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)
@ -280,6 +337,67 @@ Example configuration: >lua
vim.keymap.set('n', '<leader>gD', '<Plug>(diffs-gvdiff)') vim.keymap.set('n', '<leader>gD', '<Plug>(diffs-gvdiff)')
< <
*<Plug>(diffs-conflict-ours)*
<Plug>(diffs-conflict-ours)
Accept current (ours) change. Replaces the
conflict block with ours content.
*<Plug>(diffs-conflict-theirs)*
<Plug>(diffs-conflict-theirs)
Accept incoming (theirs) change. Replaces the
conflict block with theirs content.
*<Plug>(diffs-conflict-both)*
<Plug>(diffs-conflict-both)
Accept both changes (ours then theirs).
*<Plug>(diffs-conflict-none)*
<Plug>(diffs-conflict-none)
Reject both changes (delete entire block).
*<Plug>(diffs-conflict-next)*
<Plug>(diffs-conflict-next)
Jump to next conflict marker. Wraps around.
*<Plug>(diffs-conflict-prev)*
<Plug>(diffs-conflict-prev)
Jump to previous conflict marker. Wraps around.
Example configuration: >lua
vim.keymap.set('n', 'co', '<Plug>(diffs-conflict-ours)')
vim.keymap.set('n', 'ct', '<Plug>(diffs-conflict-theirs)')
vim.keymap.set('n', 'cb', '<Plug>(diffs-conflict-both)')
vim.keymap.set('n', 'cn', '<Plug>(diffs-conflict-none)')
vim.keymap.set('n', ']c', '<Plug>(diffs-conflict-next)')
vim.keymap.set('n', '[c', '<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://`
@ -310,6 +428,7 @@ 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
@ -326,6 +445,9 @@ 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.
@ -354,13 +476,15 @@ 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 = ']x', next = ']c',
prev = '[x', prev = '[c',
}, },
}, },
} }
@ -380,9 +504,37 @@ Configuration: ~
diagnostics alone. diagnostics alone.
{show_virtual_text} (boolean, default: true) {show_virtual_text} (boolean, default: true)
Show virtual text labels (" current" and Show `(current)` and `(incoming)` labels at
" incoming") at the end of `<<<<<<<` and the end of `<<<<<<<` and `>>>>>>>` marker
`>>>>>>>` marker lines. lines. Also controls hunk hints in merge
diff views.
{format_virtual_text} (function|nil, default: nil)
Custom formatter for virtual text labels.
Receives `(side, keymap)` where `side` is
`"ours"` or `"theirs"` and `keymap` is the
configured keymap string or `false`. Return
a string (label text without parens) or
`nil` to hide the label. Example: >lua
format_virtual_text = function(side, keymap)
if keymap then
return side .. ' [' .. keymap .. ']'
end
return side
end
<
{show_actions} (boolean, default: false)
Show a codelens-style action line above each
`<<<<<<<` marker listing available resolution
keymaps. Renders as virtual lines using the
`DiffsConflictActions` highlight group.
Only keymaps that are not `false` appear.
{priority} (integer, default: 200)
Extmark priority for conflict region
backgrounds and markers. Adjust if other
plugins use the same priority range.
{keymaps} (table, default: see above) {keymaps} (table, default: see above)
Buffer-local keymaps for conflict resolution Buffer-local keymaps for conflict resolution
@ -403,10 +555,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: ']x') {next} (string|false, default: ']c')
Jump to next conflict marker. Wraps around. Jump to next conflict marker. Wraps around.
{prev} (string|false, default: '[x') {prev} (string|false, default: '[c')
Jump to previous conflict marker. Wraps Jump to previous conflict marker. Wraps
around. around.
@ -423,6 +575,52 @@ User events: ~
}) })
< <
==============================================================================
MERGE DIFF RESOLUTION *diffs-merge*
When pressing `du`/`dU` on an unmerged (`U`) file in the fugitive status
buffer, diffs.nvim opens a unified diff of ours (`git show :2:path`) vs
theirs (`git show :3:path`) with full treesitter and intra-line highlighting.
The same conflict resolution keymaps (`doo`/`dot`/`dob`/`don`/`]c`/`[c`)
are available on the diff buffer. They resolve conflicts in the working
file by matching diff hunks to conflict markers:
- `doo` replaces the conflict region with ours content
- `dot` replaces the conflict region with theirs content
- `dob` replaces with both (ours then theirs)
- `don` removes the conflict region entirely
- `]c`/`[c` navigate between unresolved conflict hunks
Resolved hunks are marked with `(resolved)` virtual text. Hunks that
correspond to auto-merged content (no conflict markers) show an
informational notification and are left unchanged.
The working file buffer is modified in place; save it when ready.
Phase 1 inline conflict highlights (see |diffs-conflict|) are refreshed
automatically after each resolution.
==============================================================================
NEOGIT *diffs-neogit*
diffs.nvim works with Neogit (https://github.com/NeogitOrg/neogit) out of
the box. Enable Neogit support in your config: >lua
vim.g.diffs = { neogit = true }
<
When a diff is expanded in a Neogit buffer (e.g., via TAB on a file in the
status view), diffs.nvim applies treesitter syntax highlighting and
intra-line diffs to the hunk lines, just as it does for fugitive.
Neogit highlight overrides: ~
On first attach to a Neogit buffer, diffs.nvim overrides Neogit's diff
highlight groups (`NeogitDiffAdd*`, `NeogitDiffDelete*`,
`NeogitDiffContext*`, `NeogitHunkHeader*`, `NeogitDiffHeader*`, etc.) by
setting them to empty (`{}`). This gives diffs.nvim sole control of diff
line visuals. The overrides are reapplied on `ColorScheme` since Neogit
re-defines its groups then. When `neogit = false`, no highlight overrides
are applied.
============================================================================== ==============================================================================
API *diffs-api* API *diffs-api*
@ -444,8 +642,8 @@ refresh({bufnr}) *diffs.refresh()*
IMPLEMENTATION *diffs-implementation* IMPLEMENTATION *diffs-implementation*
Summary / commit detail views: ~ Summary / commit detail views: ~
1. `FileType fugitive` or `FileType git` (for `fugitive://` buffers) 1. `FileType` autocmd for computed filetypes (see |diffs-config|) triggers
triggers |diffs.attach()| |diffs.attach()|. For `git` buffers, only `fugitive://` URIs are attached.
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:
@ -454,13 +652,14 @@ Summary / commit detail views: ~
- Code is parsed with |vim.treesitter.get_string_parser()| - Code is parsed with |vim.treesitter.get_string_parser()|
- If no treesitter parser and `vim.enabled`: vim syntax fallback via - If no treesitter parser and `vim.enabled`: vim syntax fallback via
scratch buffer and |synID()| scratch buffer and |synID()|
- `Normal` extmarks at priority 198 clear underlying diff foreground - `DiffsClear` extmarks at priority 198 clear underlying diff foreground
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199 - Syntax highlights are applied as extmarks at priority 199
- Syntax highlights are applied as extmarks at priority 200 - Background extmarks (`DiffsAdd`/`DiffsDelete`) 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
4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events - All priorities are configurable via |diffs.PrioritiesConfig|
4. A decoration provider re-highlights visible hunks on each redraw
Diff mode views: ~ Diff mode views: ~
1. `OptionSet diff` detects when any window enters diff mode 1. `OptionSet diff` detects when any window enters diff mode
@ -474,15 +673,10 @@ KNOWN LIMITATIONS *diffs-limitations*
Incomplete Syntax Context ~ Incomplete Syntax Context ~
*diffs-syntax-context* *diffs-syntax-context*
Treesitter parses each diff hunk in isolation. To provide surrounding code Treesitter parses each diff hunk in isolation. Context lines within the hunk
context, diffs.nvim reads lines from disk before and after each hunk (lines with a ` ` prefix) provide syntactic context for the parser. In rare
(see |diffs.ContextConfig|, enabled by default). This resolves most boundary cases, hunks that start or end mid-expression may produce imperfect highlights
issues where incomplete constructs (e.g., a function definition at the edge due to treesitter error recovery.
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*
@ -491,14 +685,8 @@ 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. Even with which fires after vim-fugitive has already painted the buffer. The
`debounce_ms = 0`, the re-painting goes through Neovim's event loop. decoration provider applies highlights on the next redraw cycle.
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*
@ -600,6 +788,10 @@ 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 Normal file
View file

@ -0,0 +1,43 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1770812194,
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

36
flake.nix Normal file
View file

@ -0,0 +1,36 @@
{
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
{
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
packages = [
(pkgs.luajit.withPackages (
ps: with ps; [
busted
nlua
]
))
pkgs.prettier
pkgs.stylua
pkgs.selene
pkgs.lua-language-server
];
};
});
};
}

View file

@ -36,6 +36,24 @@ 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
@ -69,6 +87,33 @@ 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)
@ -138,6 +183,7 @@ 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 }
@ -157,7 +203,17 @@ 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.untracked then if opts.unmerged then
old_lines = git.get_file_content(':2', filepath)
if not old_lines then
old_lines = {}
end
new_lines = git.get_file_content(':3', filepath)
if not new_lines then
new_lines = {}
end
diff_label = 'unmerged'
elseif opts.untracked then
old_lines = {} 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
@ -236,6 +292,14 @@ 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()
@ -263,6 +327,8 @@ 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
@ -325,6 +391,8 @@ 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
@ -334,7 +402,10 @@ function M.read_buffer(bufnr)
local old_lines, new_lines local old_lines, new_lines
if label == 'untracked' then if label == 'unmerged' then
old_lines = git.get_file_content(':2', abs_path) or {}
new_lines = git.get_file_content(':3', abs_path) or {}
elseif label == 'untracked' then
old_lines = {} 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,8 +20,6 @@ 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)
@ -92,6 +90,17 @@ 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
@ -103,14 +112,41 @@ 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 = PRIORITY_LINE_BG, priority = config.priority,
}) })
if config.show_virtual_text then if config.show_virtual_text then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { local ours_label = get_virtual_text_label('ours', config)
virt_text = { { ' (current)', 'DiffsConflictMarker' } }, if ours_label then
virt_text_pos = 'eol', pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
}) virt_text = { { ' (' .. ours_label .. ')', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
end
end
if config.show_actions then
local parts = {}
local actions = {
{ 'Current', config.keymaps.ours },
{ 'Incoming', config.keymaps.theirs },
{ 'Both', config.keymaps.both },
{ 'None', config.keymaps.none },
}
for _, action in ipairs(actions) do
if action[2] then
if #parts > 0 then
table.insert(parts, { ' \226\148\130 ', 'DiffsConflictActions' })
end
table.insert(parts, { ('%s (%s)'):format(action[1], action[2]), 'DiffsConflictActions' })
end
end
if #parts > 0 then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
virt_lines = { parts },
virt_lines_above = true,
})
end
end end
for line = region.ours_start, region.ours_end - 1 do for line = region.ours_start, region.ours_end - 1 do
@ -118,11 +154,11 @@ local function apply_highlights(bufnr, regions, config)
end_row = line + 1, end_row = line + 1,
hl_group = 'DiffsConflictOurs', hl_group = 'DiffsConflictOurs',
hl_eol = true, hl_eol = true,
priority = PRIORITY_LINE_BG, priority = config.priority,
}) })
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 = PRIORITY_LINE_BG, priority = config.priority,
}) })
end end
@ -131,7 +167,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 = PRIORITY_LINE_BG, priority = config.priority,
}) })
for line = region.base_start, region.base_end - 1 do for line = region.base_start, region.base_end - 1 do
@ -139,11 +175,11 @@ local function apply_highlights(bufnr, regions, config)
end_row = line + 1, end_row = line + 1,
hl_group = 'DiffsConflictBase', hl_group = 'DiffsConflictBase',
hl_eol = true, hl_eol = true,
priority = PRIORITY_LINE_BG, priority = config.priority,
}) })
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 = PRIORITY_LINE_BG, priority = config.priority,
}) })
end end
end end
@ -152,7 +188,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 = PRIORITY_LINE_BG, priority = config.priority,
}) })
for line = region.theirs_start, region.theirs_end - 1 do for line = region.theirs_start, region.theirs_end - 1 do
@ -160,11 +196,11 @@ local function apply_highlights(bufnr, regions, config)
end_row = line + 1, end_row = line + 1,
hl_group = 'DiffsConflictTheirs', hl_group = 'DiffsConflictTheirs',
hl_eol = true, hl_eol = true,
priority = PRIORITY_LINE_BG, priority = config.priority,
}) })
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 = PRIORITY_LINE_BG, priority = config.priority,
}) })
end end
@ -172,14 +208,17 @@ 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 = PRIORITY_LINE_BG, priority = config.priority,
}) })
if config.show_virtual_text then if config.show_virtual_text then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { local theirs_label = get_virtual_text_label('theirs', config)
virt_text = { { ' (incoming)', 'DiffsConflictMarker' } }, if theirs_label then
virt_text_pos = 'eol', pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, {
}) virt_text = { { ' (' .. theirs_label .. ')', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
end
end end
end end
end end
@ -199,7 +238,7 @@ end
---@param bufnr integer ---@param bufnr integer
---@param region diffs.ConflictRegion ---@param region diffs.ConflictRegion
---@param replacement string[] ---@param replacement string[]
local function replace_region(bufnr, region, replacement) function M.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,
@ -211,7 +250,7 @@ end
---@param bufnr integer ---@param bufnr integer
---@param config diffs.ConflictConfig ---@param config diffs.ConflictConfig
local function refresh(bufnr, config) function M.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)
@ -244,8 +283,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)
replace_region(bufnr, region, lines) M.replace_region(bufnr, region, lines)
refresh(bufnr, config) M.refresh(bufnr, config)
end end
---@param bufnr integer ---@param bufnr integer
@ -262,8 +301,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)
replace_region(bufnr, region, lines) M.replace_region(bufnr, region, lines)
refresh(bufnr, config) M.refresh(bufnr, config)
end end
---@param bufnr integer ---@param bufnr integer
@ -288,8 +327,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
replace_region(bufnr, region, combined) M.replace_region(bufnr, region, combined)
refresh(bufnr, config) M.refresh(bufnr, config)
end end
---@param bufnr integer ---@param bufnr integer
@ -305,8 +344,8 @@ function M.resolve_none(bufnr, config)
if not region then if not region then
return return
end end
replace_region(bufnr, region, {}) M.replace_region(bufnr, region, {})
refresh(bufnr, config) M.refresh(bufnr, config)
end end
---@param bufnr integer ---@param bufnr integer
@ -323,6 +362,7 @@ 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
@ -340,6 +380,7 @@ 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
@ -348,35 +389,19 @@ end
local function setup_keymaps(bufnr, config) local function setup_keymaps(bufnr, config)
local km = config.keymaps local km = config.keymaps
if km.ours then local maps = {
vim.keymap.set('n', km.ours, function() { km.ours, '<Plug>(diffs-conflict-ours)' },
M.resolve_ours(bufnr, config) { km.theirs, '<Plug>(diffs-conflict-theirs)' },
end, { buffer = bufnr }) { km.both, '<Plug>(diffs-conflict-both)' },
end { km.none, '<Plug>(diffs-conflict-none)' },
if km.theirs then { km.next, '<Plug>(diffs-conflict-next)' },
vim.keymap.set('n', km.theirs, function() { km.prev, '<Plug>(diffs-conflict-prev)' },
M.resolve_theirs(bufnr, config) }
end, { buffer = bufnr })
end for _, map in ipairs(maps) do
if km.both then if map[1] then
vim.keymap.set('n', km.both, function() vim.keymap.set('n', map[1], map[2], { buffer = bufnr })
M.resolve_both(bufnr, config) end
end, { buffer = bufnr })
end
if km.none then
vim.keymap.set('n', km.none, function()
M.resolve_none(bufnr, config)
end, { buffer = bufnr })
end
if km.next then
vim.keymap.set('n', km.next, function()
M.goto_next(bufnr)
end, { buffer = bufnr })
end
if km.prev then
vim.keymap.set('n', km.prev, function()
M.goto_prev(bufnr)
end, { buffer = bufnr })
end end
end end
@ -433,7 +458,7 @@ function M.attach(bufnr, config)
if not attached_buffers[bufnr] then if not attached_buffers[bufnr] then
return true return true
end end
refresh(bufnr, config) M.refresh(bufnr, config)
end, end,
}) })

View file

@ -26,20 +26,65 @@ 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? ---@return string?, 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 vim.trim(new), vim.trim(old) return unquote(vim.trim(new)), unquote(vim.trim(old)), 'R'
end end
local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$') local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$')
if filename then if status and filename then
return vim.trim(filename), nil return unquote(vim.trim(filename)), nil, status
end end
return nil, nil return nil, nil, nil
end end
---@param line string ---@param line string
@ -57,34 +102,34 @@ end
---@param bufnr integer ---@param bufnr integer
---@param lnum integer ---@param lnum integer
---@return string?, diffs.FugitiveSection, boolean, string? ---@return string?, diffs.FugitiveSection, boolean, string?, string?
function M.get_file_at_line(bufnr, lnum) 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 return nil, nil, false, nil, 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 return nil, section_header, true, nil, nil
end end
local filename, old_filename = parse_file_line(current_line) local filename, old_filename, status = 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 return filename, section, false, old_filename, status
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 = parse_file_line(prev_line) filename, old_filename, status = 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 return filename, section, false, old_filename, status
end end
if prev_line:match('^%w+ %(') or prev_line == '' then if prev_line:match('^%w+ %(') or prev_line == '' then
break break
@ -92,7 +137,7 @@ function M.get_file_at_line(bufnr, lnum)
end end
end end
return nil, nil, false, nil return nil, nil, false, nil, nil
end end
---@class diffs.HunkPosition ---@class diffs.HunkPosition
@ -150,7 +195,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 = M.get_file_at_line(bufnr, lnum) local filename, section, is_header, old_filename, status = M.get_file_at_line(bufnr, lnum)
local repo_root = get_repo_root_from_fugitive(bufnr) local repo_root = get_repo_root_from_fugitive(bufnr)
if not repo_root then if not repo_root then
@ -192,6 +237,7 @@ 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,13 +1,19 @@
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,38 +3,6 @@ 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
@ -42,8 +10,9 @@ local PRIORITY_CHAR_BG = 201
---@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) local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines, priorities)
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')
@ -77,7 +46,7 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_l
local buf_sc = col_offset + sc local buf_sc = col_offset + sc
local buf_ec = col_offset + ec local buf_ec = col_offset + ec
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or priorities.syntax
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er, end_row = buf_er,
@ -95,6 +64,7 @@ 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
@ -103,6 +73,7 @@ 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,
@ -111,7 +82,8 @@ 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
@ -152,7 +124,7 @@ local function highlight_treesitter(
local buf_ec = ec + col_offset local buf_ec = ec + col_offset
local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100) local priority = tree_lang == 'diff' and (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,
@ -219,8 +191,17 @@ 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(bufnr, ns, hunk, code_lines, covered_lines, leading_offset) local function highlight_vim_syntax(
bufnr,
ns,
hunk,
code_lines,
covered_lines,
leading_offset,
priorities
)
local ft = hunk.ft local ft = hunk.ft
if not ft then if not ft then
return 0 return 0
@ -238,7 +219,7 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines,
local spans = {} local spans = {}
vim.api.nvim_buf_call(scratch, function() pcall(vim.api.nvim_buf_call, scratch, function()
vim.cmd('setlocal syntax=' .. ft) vim.cmd('setlocal syntax=' .. ft)
vim.cmd('redraw') vim.cmd('redraw')
@ -256,18 +237,19 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines,
spans = M.coalesce_syntax_spans(query_fn, code_lines) spans = M.coalesce_syntax_spans(query_fn, code_lines)
end) end)
vim.api.nvim_buf_delete(scratch, { force = true }) pcall(vim.api.nvim_buf_delete, scratch, { force = true })
local hunk_line_count = #hunk.lines local hunk_line_count = #hunk.lines
local col_off = (hunk.prefix_width or 1) - 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, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + col_off, {
end_col = span.col_end, end_col = span.col_end + col_off,
hl_group = span.hl_name, hl_group = span.hl_name,
priority = PRIORITY_SYNTAX, priority = priorities.syntax,
}) })
extmark_count = extmark_count + 1 extmark_count = extmark_count + 1
if covered_lines then if covered_lines then
@ -284,6 +266,8 @@ 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 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
@ -300,24 +284,13 @@ 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 ctx_cfg = opts.highlights.context
local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0
local leading = {}
local trailing = {}
if (use_ts or use_vim) and context > 0 and hunk.file_new_start and hunk.repo_root then
local filepath = vim.fs.joinpath(hunk.repo_root, hunk.filename)
local lead_from = math.max(1, hunk.file_new_start - context)
local lead_count = hunk.file_new_start - lead_from
if lead_count > 0 then
leading = read_line_range(filepath, lead_from, lead_count)
end
local trail_from = hunk.file_new_start + (hunk.file_new_count or 0)
trailing = read_line_range(filepath, trail_from, context)
end
local extmark_count = 0 local extmark_count = 0
if use_ts then if use_ts then
---@type string[] ---@type string[]
@ -329,20 +302,17 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
---@type table<integer, integer> ---@type table<integer, integer>
local old_map = {} local old_map = {}
for _, pad_line in ipairs(leading) do
table.insert(new_code, pad_line)
table.insert(old_code, pad_line)
end
for i, line in ipairs(hunk.lines) do for i, line in ipairs(hunk.lines) do
local prefix = line:sub(1, 1) local prefix = line:sub(1, pw)
local stripped = line:sub(2) local stripped = line:sub(pw + 1)
local buf_line = hunk.start_line + i - 1 local buf_line = hunk.start_line + i - 1
local has_add = prefix:find('+', 1, true) ~= nil
local has_del = prefix:find('-', 1, true) ~= nil
if prefix == '+' then if has_add and not has_del then
new_map[#new_code] = buf_line new_map[#new_code] = buf_line
table.insert(new_code, stripped) table.insert(new_code, stripped)
elseif prefix == '-' then elseif has_del and not has_add 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
@ -352,21 +322,17 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end end
end end
for _, pad_line in ipairs(trailing) do extmark_count =
table.insert(new_code, pad_line) highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, pw, covered_lines, p)
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, 1, covered_lines) + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, pw, covered_lines, p)
if hunk.header_context and hunk.header_context_col then if hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1 local header_line = hunk.start_line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
end_col = hunk.header_context_col + #hunk.header_context, end_col = hunk.header_context_col + #hunk.header_context,
hl_group = 'DiffsClear', hl_group = 'DiffsClear',
priority = PRIORITY_CLEAR, priority = p.clear,
}) })
local header_extmarks = highlight_text( local header_extmarks = highlight_text(
bufnr, bufnr,
@ -375,7 +341,8 @@ 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)
@ -385,16 +352,10 @@ 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 _, pad_line in ipairs(leading) do
table.insert(code_lines, pad_line)
end
for _, line in ipairs(hunk.lines) do for _, line in ipairs(hunk.lines) do
table.insert(code_lines, line:sub(2)) table.insert(code_lines, line:sub(pw + 1))
end end
for _, pad_line in ipairs(trailing) do extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p)
table.insert(code_lines, pad_line)
end
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, #leading)
end end
if if
@ -409,13 +370,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, 0) + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0, nil, p)
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 #hunk.lines <= intra_cfg.max_lines then if intra_cfg and intra_cfg.enabled and pw == 1 and #hunk.lines <= intra_cfg.max_lines then
dbg('computing intra for hunk %s:%d (%d lines)', hunk.filename, hunk.start_line, #hunk.lines) 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
@ -449,25 +410,35 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
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, 1) local prefix = line:sub(1, pw)
local has_add = prefix:find('+', 1, true) ~= nil
local has_del = prefix:find('-', 1, true) ~= nil
local is_diff_line = has_add or has_del
local line_hl = is_diff_line and (has_add and 'DiffsAdd' or 'DiffsDelete') or nil
local number_hl = is_diff_line and (has_add and 'DiffsAddNr' or 'DiffsDeleteNr') or nil
local is_diff_line = prefix == '+' or prefix == '-' local is_marker = false
local line_hl = is_diff_line and (prefix == '+' and 'DiffsAdd' or 'DiffsDelete') or nil if pw > 1 and line_hl and not prefix:find('[^+]') then
local number_hl = is_diff_line and (prefix == '+' and 'DiffsAddNr' or 'DiffsDeleteNr') or nil local content = line:sub(pw + 1)
is_marker = content:match('^<<<<<<<')
or content:match('^=======')
or content:match('^>>>>>>>')
or content:match('^|||||||')
end
if opts.hide_prefix then 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 = { { ' ', virt_hl } }, virt_text = { { string.rep(' ', pw), virt_hl } },
virt_text_pos = 'overlay', virt_text_pos = 'overlay',
}) })
end end
if line_len > 1 and covered_lines[buf_line] then if line_len > pw and covered_lines[buf_line] then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, {
end_col = line_len, end_col = line_len,
hl_group = 'DiffsClear', hl_group = 'DiffsClear',
priority = PRIORITY_CLEAR, priority = p.clear,
}) })
end end
@ -476,18 +447,26 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end_row = buf_line + 1, end_row = buf_line + 1,
hl_group = line_hl, hl_group = line_hl,
hl_eol = true, hl_eol = true,
priority = PRIORITY_LINE_BG, priority = p.line_bg,
}) })
if opts.highlights.gutter then if opts.highlights.gutter 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, {
number_hl_group = number_hl, number_hl_group = number_hl,
priority = PRIORITY_LINE_BG, priority = p.line_bg,
}) })
end end
end end
if is_marker and line_len > pw then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, {
end_col = line_len,
hl_group = 'DiffsConflictMarker',
priority = p.char_bg,
})
end
if char_spans_by_line[i] then if char_spans_by_line[i] then
local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText' local char_hl = has_add and 'DiffsAddText' or 'DiffsDeleteText'
for _, span in ipairs(char_spans_by_line[i]) do 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"',
@ -501,7 +480,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end, end_col = span.col_end,
hl_group = char_hl, hl_group = char_hl,
priority = PRIORITY_CHAR_BG, priority = p.char_bg,
}) })
if not ok then if not ok then
dbg('char extmark FAILED: %s', err) dbg('char extmark FAILED: %s', err)
@ -514,4 +493,40 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count)
end end
---@param bufnr integer
---@param ns integer
---@param hunk diffs.Hunk
---@param opts diffs.HunkOpts
function M.highlight_hunk_vim_syntax(bufnr, ns, hunk, opts)
local p = opts.highlights.priorities
local pw = hunk.prefix_width or 1
if not hunk.ft or #hunk.lines == 0 then
return
end
if #hunk.lines > opts.highlights.vim.max_lines then
return
end
local code_lines = {}
for _, line in ipairs(hunk.lines) do
table.insert(code_lines, line:sub(pw + 1))
end
local covered_lines = {}
highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p)
for buf_line in pairs(covered_lines) do
local line = hunk.lines[buf_line - hunk.start_line + 1]
if line and #line > pw then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, {
end_col = #line,
hl_group = 'DiffsClear',
priority = p.clear,
})
end
end
end
return M return M

View file

@ -15,6 +15,12 @@
---@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
@ -24,11 +30,14 @@
---@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
@ -41,14 +50,18 @@
---@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 ---@field debug boolean|string
---@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 ---@field fugitive diffs.FugitiveConfig|false
---@field neogit diffs.NeogitConfig|false
---@field conflict diffs.ConflictConfig ---@field conflict diffs.ConflictConfig
---@class diffs ---@class diffs
@ -97,8 +110,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,
@ -119,22 +132,28 @@ 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 = { fugitive = false,
horizontal = 'du', neogit = false,
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 = ']x', next = ']c',
prev = '[x', prev = '[c',
}, },
}, },
} }
@ -144,66 +163,229 @@ 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> ---@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 highlight_buffer(bufnr) local function invalidate_cache(bufnr)
if not vim.api.nvim_buf_is_valid(bufnr) then local entry = hunk_cache[bufnr]
return if entry then
end entry.tick = -1
entry.pending_clear = true
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
local hunks = parser.parse_buffer(bufnr)
dbg('found %d hunks in buffer %d', #hunks, bufnr)
for _, hunk in ipairs(hunks) do
highlight.highlight_hunk(bufnr, ns, hunk, {
hide_prefix = config.hide_prefix,
highlights = config.highlights,
})
end end
end end
---@param bufnr integer ---@param a diffs.Hunk
---@return fun() ---@param b diffs.Hunk
local function create_debounced_highlight(bufnr) ---@return boolean
local timer = nil ---@type table? local function hunks_eq(a, b)
return function() local n = #a.lines
if timer then if n ~= #b.lines or a.filename ~= b.filename then
timer:stop() ---@diagnostic disable-line: undefined-field return false
timer:close() ---@diagnostic disable-line: undefined-field end
timer = nil 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
local t = vim.uv.new_timer() end
if not t then return true
highlight_buffer(bufnr) 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
)
return highlighted
end
---@param bufnr integer
local function ensure_cache(bufnr)
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
local tick = vim.api.nvim_buf_get_changedtick(bufnr)
local entry = hunk_cache[bufnr]
if entry and entry.tick == tick then
return
end
if entry and not entry.pending_clear then
local lc = vim.api.nvim_buf_line_count(bufnr)
local bc = vim.api.nvim_buf_get_offset(bufnr, lc)
if lc == entry.line_count and bc == entry.byte_count then
entry.tick = tick
entry.pending_clear = true
dbg('content unchanged in buffer %d (tick %d), skipping reparse', bufnr, tick)
return return
end end
timer = t
t:start(
config.debounce_ms,
0,
vim.schedule_wrap(function()
if timer == t then
timer = nil
t:close()
end
highlight_buffer(bufnr)
end)
)
end end
local hunks = parser.parse_buffer(bufnr)
local lc = vim.api.nvim_buf_line_count(bufnr)
local bc = vim.api.nvim_buf_get_offset(bufnr, lc)
dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick)
local carried = entry and not entry.pending_clear and carry_forward_highlighted(entry, hunks)
hunk_cache[bufnr] = {
hunks = hunks,
tick = tick,
highlighted = carried or {},
pending_clear = not carried,
line_count = lc,
byte_count = bc,
}
local has_nil_ft = false
for _, hunk in ipairs(hunks) do
if not has_nil_ft and not hunk.ft and hunk.filename then
has_nil_ft = true
end
end
if has_nil_ft and vim.fn.did_filetype() ~= 0 then
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)
end
end)
end
end
---@param hunks diffs.Hunk[]
---@param toprow integer
---@param botrow integer
---@return integer first
---@return integer last
local function find_visible_hunks(hunks, toprow, botrow)
local n = #hunks
if n == 0 then
return 0, 0
end
local lo, hi = 1, n + 1
while lo < hi do
local mid = math.floor((lo + hi) / 2)
local h = hunks[mid]
local bottom = h.start_line - 1 + #h.lines - 1
if bottom < toprow then
lo = mid + 1
else
hi = mid
end
end
if lo > n then
return 0, 0
end
local first = lo
local h = hunks[first]
local top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1)
if top >= botrow then
return 0, 0
end
local last = first
for i = first + 1, n do
h = hunks[i]
top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1)
if top >= botrow then
break
end
last = i
end
return first, last
end end
local function compute_highlight_groups() local function compute_highlight_groups()
@ -274,6 +456,7 @@ 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',
@ -297,6 +480,14 @@ 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
@ -305,10 +496,41 @@ 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 = { opts.debug, 'boolean', true }, debug = {
debounce_ms = { opts.debounce_ms, 'number', true }, opts.debug,
function(v)
return v == nil or type(v) == 'boolean' or type(v) == 'string'
end,
'boolean or string (file path)',
},
hide_prefix = { opts.hide_prefix, 'boolean', true }, 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 },
}) })
@ -322,6 +544,7 @@ 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
@ -362,21 +585,32 @@ 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 opts.fugitive then if type(opts.fugitive) == 'table' then
---@type diffs.FugitiveConfig
local fug = opts.fugitive
vim.validate({ vim.validate({
['fugitive.horizontal'] = { ['fugitive.horizontal'] = {
opts.fugitive.horizontal, fug.horizontal,
function(v) function(v)
return v == false or type(v) == 'string' return v == nil or v == false or type(v) == 'string'
end, end,
'string or false', 'string or false',
}, },
['fugitive.vertical'] = { ['fugitive.vertical'] = {
opts.fugitive.vertical, fug.vertical,
function(v) function(v)
return v == false or type(v) == 'string' return v == nil or v == false or type(v) == 'string'
end, end,
'string or false', 'string or false',
}, },
@ -388,6 +622,9 @@ 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 },
}) })
@ -407,9 +644,6 @@ 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
@ -449,17 +683,136 @@ 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
highlight_buffer(bufnr) invalidate_cache(bufnr)
end
end,
})
vim.api.nvim_set_decoration_provider(ns, {
on_buf = function(_, bufnr)
if not attached_buffers[bufnr] then
return false
end
local t0 = config.debug and vim.uv.hrtime() or nil
ensure_cache(bufnr)
local entry = hunk_cache[bufnr]
if entry and entry.pending_clear then
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 = clear_start + #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
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
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 = start_row + #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)
if not hunk.lang and hunk.ft then
highlight.highlight_hunk_vim_syntax(bufnr, ns, hunk, full_opts)
end
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,
}) })
@ -484,37 +837,34 @@ 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)
local debounced = create_debounced_highlight(bufnr) ensure_cache(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
if neogit_augroup then
pcall(vim.api.nvim_del_augroup_by_id, neogit_augroup)
end
end, end,
}) })
end end
@ -522,7 +872,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()
highlight_buffer(bufnr) invalidate_cache(bufnr)
end end
local DIFF_WINHIGHLIGHT = table.concat({ local DIFF_WINHIGHLIGHT = table.concat({
@ -565,7 +915,7 @@ function M.detach_diff()
end end
end end
---@return diffs.FugitiveConfig ---@return diffs.FugitiveConfig|false
function M.get_fugitive_config() function M.get_fugitive_config()
init() init()
return config.fugitive return config.fugitive
@ -577,4 +927,12 @@ function M.get_conflict_config()
return config.conflict return config.conflict
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,
}
return M return M

View file

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

View file

@ -1,10 +1,17 @@
local M = {} local M = {}
local enabled = false local enabled = false
local log_file = nil
---@param val boolean ---@param val boolean|string
function M.set_enabled(val) function M.set_enabled(val)
enabled = val if type(val) == 'string' then
enabled = true
log_file = val
else
enabled = val
log_file = nil
end
end end
---@param msg string ---@param msg string
@ -13,7 +20,16 @@ function M.dbg(msg, ...)
if not enabled then if not enabled then
return return
end end
vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG) local formatted = '[diffs.nvim]: ' .. string.format(msg, ...)
if log_file then
local f = io.open(log_file, 'a')
if f then
f:write(string.format('%.6fs', vim.uv.hrtime() / 1e9) .. ' ' .. formatted .. '\n')
f:close()
end
else
vim.notify(formatted, vim.log.levels.DEBUG)
end
end end
return M return M

418
lua/diffs/merge.lua Normal file
View file

@ -0,0 +1,418 @@
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,12 +12,16 @@
---@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 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[]?
@ -106,7 +110,14 @@ local function get_repo_root(bufnr)
return vim.fn.fnamemodify(git_dir, ':h') return vim.fn.fnamemodify(git_dir, ':h')
end end
return nil local ok3, neogit_git_dir = pcall(vim.api.nvim_buf_get_var, bufnr, 'neogit_git_dir')
if ok3 and neogit_git_dir then
return vim.fn.fnamemodify(neogit_git_dir, ':h')
end
local cwd = vim.fn.getcwd()
local git = require('diffs.git')
return git.get_repo_root(cwd .. '/.')
end end
---@param bufnr integer ---@param bufnr integer
@ -133,6 +144,8 @@ 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[]
@ -145,6 +158,11 @@ 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 function flush_hunk() local function flush_hunk()
if hunk_start and #hunk_lines > 0 then if hunk_start and #hunk_lines > 0 then
@ -156,6 +174,7 @@ 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,
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,
@ -176,37 +195,70 @@ 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 filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$') local diff_git_file = line:match('^diff %-%-git a/.+ b/(.+)$')
local neogit_file = line:match('^modified%s+(.+)$')
or (not line:match('^new file mode') and line:match('^new file%s+(.+)$'))
or (not line:match('^deleted file mode') and line:match('^deleted%s+(.+)$'))
or line:match('^renamed%s+(.+)$')
or line:match('^copied%s+(.+)$')
local bare_file = not hunk_start and line:match('^([^%s]+%.[^%s]+)$')
local filename = line:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file or neogit_file or bare_file
if filename then if filename then
is_unified_diff = diff_git_file ~= nil
flush_hunk() flush_hunk()
current_filename = filename current_filename = filename
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 line:match('^@@.-@@') then elseif line:match('^@@+') then
flush_hunk() flush_hunk()
hunk_start = i hunk_start = i
local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') local at_prefix = line:match('^(@@+)')
if hs then hunk_prefix_width = #at_prefix - 1
file_old_start = tonumber(hs) if #at_prefix == 2 then
file_old_count = tonumber(hc) or 1 local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
file_new_start = tonumber(hs2) if hs then
file_new_count = tonumber(hc2) or 1 file_old_start = tonumber(hs)
file_old_count = tonumber(hc) or 1
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
old_remaining = file_old_count
new_remaining = file_new_count
end
else
local hs2, hc2 = line:match('%+(%d+),?(%d*) @@')
if hs2 then
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
end
end end
local prefix, context = line:match('^(@@.-@@%s*)(.*)') local at_end, context = line:match('^(@@+.-@@+%s*)(.*)')
if context and context ~= '' then if context and context ~= '' then
hunk_header_context = context hunk_header_context = context
hunk_header_context_col = #prefix hunk_header_context_col = #at_end
end end
if hunk_count then if hunk_count then
hunk_count = hunk_count + 1 hunk_count = hunk_count + 1
@ -215,6 +267,23 @@ function M.parse_buffer(bufnr)
local prefix = line:sub(1, 1) local prefix = line:sub(1, 1)
if prefix == ' ' or prefix == '+' or prefix == '-' then if prefix == ' ' or prefix == '+' or prefix == '-' then
table.insert(hunk_lines, line) table.insert(hunk_lines, line)
if old_remaining and (prefix == ' ' or prefix == '-') then
old_remaining = old_remaining - 1
end
if new_remaining and (prefix == ' ' or prefix == '+') then
new_remaining = new_remaining - 1
end
elseif
line == ''
and is_unified_diff
and old_remaining
and old_remaining > 0
and new_remaining
and new_remaining > 0
then
table.insert(hunk_lines, ' ')
old_remaining = old_remaining - 1
new_remaining = new_remaining - 1
elseif elseif
line == '' line == ''
or line:match('^[MADRC%?!]%s+') or line:match('^[MADRC%?!]%s+')
@ -239,4 +308,8 @@ 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 = { 'fugitive', 'git' }, pattern = require('diffs').compute_filetypes(vim.g.diffs or {}),
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
@ -57,3 +57,53 @@ end, { desc = 'Unified diff (horizontal)' })
vim.keymap.set('n', '<Plug>(diffs-gvdiff)', function() vim.keymap.set('n', '<Plug>(diffs-gvdiff)', function()
cmds.gdiff(nil, true) cmds.gdiff(nil, true)
end, { desc = 'Unified diff (vertical)' }) end, { desc = 'Unified diff (vertical)' })
local function conflict_action(fn)
local bufnr = vim.api.nvim_get_current_buf()
local config = require('diffs').get_conflict_config()
fn(bufnr, config)
end
vim.keymap.set('n', '<Plug>(diffs-conflict-ours)', function()
conflict_action(require('diffs.conflict').resolve_ours)
end, { desc = 'Accept current (ours) change' })
vim.keymap.set('n', '<Plug>(diffs-conflict-theirs)', function()
conflict_action(require('diffs.conflict').resolve_theirs)
end, { desc = 'Accept incoming (theirs) change' })
vim.keymap.set('n', '<Plug>(diffs-conflict-both)', function()
conflict_action(require('diffs.conflict').resolve_both)
end, { desc = 'Accept both changes' })
vim.keymap.set('n', '<Plug>(diffs-conflict-none)', function()
conflict_action(require('diffs.conflict').resolve_none)
end, { desc = 'Reject both changes' })
vim.keymap.set('n', '<Plug>(diffs-conflict-next)', function()
require('diffs.conflict').goto_next(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to next conflict' })
vim.keymap.set('n', '<Plug>(diffs-conflict-prev)', function()
require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to previous conflict' })
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 +1,4 @@
std = 'vim' std = 'vim'
[lints]
bad_string_escape = 'allow'

View file

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

@ -0,0 +1,304 @@
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)
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_false(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

@ -87,28 +87,6 @@ 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)',
@ -157,28 +135,6 @@ 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)',
@ -190,77 +146,54 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true }) vim.api.nvim_buf_delete(buf, { force = true })
end) end)
it('handles double extensions', function() it('unquotes git-quoted filenames with spaces', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M "path with spaces/file.lua"',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('path with spaces/file.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('unquotes escaped quotes in filenames', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M "file\\"name.lua"',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('file"name.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('unquotes octal escapes in filenames', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M "\\303\\251le.lua"',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('\195\169le.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('passes through unquoted filenames unchanged', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M normal.lua',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('normal.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('unquotes renamed files with quotes', function()
local buf = create_status_buffer({ local buf = create_status_buffer({
'Staged (1)', 'Staged (1)',
'M test.spec.lua', 'R100 "old name.lua" -> "new name.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('test.spec.lua', filename) assert.equals('new name.lua', filename)
assert.is_nil(old_filename) assert.equals('old name.lua', 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)
@ -321,30 +254,6 @@ 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)',
@ -406,22 +315,6 @@ 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,7 +24,6 @@ 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,
@ -46,7 +45,7 @@ describe('diffs', function()
it('accepts partial config', function() it('accepts partial config', function()
vim.g.diffs = { vim.g.diffs = {
debounce_ms = 25, hide_prefix = true,
} }
assert.has_no.errors(function() assert.has_no.errors(function()
diffs.attach() diffs.attach()
@ -152,6 +151,247 @@ 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')

330
spec/integration_spec.lua Normal file
View file

@ -0,0 +1,330 @@
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('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].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].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].hl_group == 'DiffsAdd' then
has_add = true
end
if mark[4] and mark[4].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.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') and d.hl_eol then
line_bgs[mark[2]] = d.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.hl_group == 'DiffsAdd' and d.hl_eol then
add_lines[mark[2]] = true
end
if d and d.hl_group == 'DiffsDelete' and d.hl_eol 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)

815
spec/merge_spec.lua Normal file
View file

@ -0,0 +1,815 @@
local helpers = require('spec.helpers')
local merge = require('diffs.merge')
local function default_config(overrides)
local cfg = {
enabled = true,
disable_diagnostics = false,
show_virtual_text = true,
show_actions = false,
keymaps = {
ours = 'doo',
theirs = 'dot',
both = 'dob',
none = 'don',
next = ']c',
prev = '[c',
},
}
if overrides then
cfg = vim.tbl_deep_extend('force', cfg, overrides)
end
return cfg
end
local function create_diff_buffer(lines, working_path)
local bufnr = helpers.create_buffer(lines)
if working_path then
vim.api.nvim_buf_set_var(bufnr, 'diffs_working_path', working_path)
end
return bufnr
end
local function create_working_buffer(lines, name)
local bufnr = vim.api.nvim_create_buf(true, false)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
if name then
vim.api.nvim_buf_set_name(bufnr, name)
end
return bufnr
end
describe('merge', function()
describe('parse_hunks', function()
it('parses a single hunk', function()
local bufnr = helpers.create_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,3 +1,3 @@',
' local M = {}',
'-local x = 1',
'+local x = 2',
' return M',
})
local hunks = merge.parse_hunks(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(3, hunks[1].start_line)
assert.are.equal(7, hunks[1].end_line)
assert.are.same({ 'local x = 1' }, hunks[1].del_lines)
assert.are.same({ 'local x = 2' }, hunks[1].add_lines)
helpers.delete_buffer(bufnr)
end)
it('parses multiple hunks', function()
local bufnr = helpers.create_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,3 +1,3 @@',
' local M = {}',
'-local x = 1',
'+local x = 2',
' return M',
'@@ -10,3 +10,3 @@',
' function M.foo()',
'- return 1',
'+ return 2',
' end',
})
local hunks = merge.parse_hunks(bufnr)
assert.are.equal(2, #hunks)
assert.are.equal(3, hunks[1].start_line)
assert.are.equal(8, hunks[2].start_line)
helpers.delete_buffer(bufnr)
end)
it('parses add-only hunk', function()
local bufnr = helpers.create_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,2 +1,3 @@',
' local M = {}',
'+local new = true',
' return M',
})
local hunks = merge.parse_hunks(bufnr)
assert.are.equal(1, #hunks)
assert.are.same({}, hunks[1].del_lines)
assert.are.same({ 'local new = true' }, hunks[1].add_lines)
helpers.delete_buffer(bufnr)
end)
it('parses delete-only hunk', function()
local bufnr = helpers.create_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,3 +1,2 @@',
' local M = {}',
'-local old = false',
' return M',
})
local hunks = merge.parse_hunks(bufnr)
assert.are.equal(1, #hunks)
assert.are.same({ 'local old = false' }, hunks[1].del_lines)
assert.are.same({}, hunks[1].add_lines)
helpers.delete_buffer(bufnr)
end)
it('returns empty for buffer with no hunks', function()
local bufnr = helpers.create_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
})
local hunks = merge.parse_hunks(bufnr)
assert.are.equal(0, #hunks)
helpers.delete_buffer(bufnr)
end)
end)
describe('match_hunk_to_conflict', function()
it('matches hunk to conflict region', function()
local working_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
}, '/tmp/diffs_test_match.lua')
local hunk = {
index = 1,
start_line = 3,
end_line = 7,
del_lines = { 'local x = 1' },
add_lines = { 'local x = 2' },
}
local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
assert.is_not_nil(region)
assert.are.equal(0, region.marker_ours)
helpers.delete_buffer(working_bufnr)
end)
it('returns nil for auto-merged content', function()
local working_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
}, '/tmp/diffs_test_auto.lua')
local hunk = {
index = 1,
start_line = 3,
end_line = 7,
del_lines = { 'local y = 3' },
add_lines = { 'local y = 4' },
}
local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
assert.is_nil(region)
helpers.delete_buffer(working_bufnr)
end)
it('matches with empty ours section', function()
local working_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'=======',
'local x = 2',
'>>>>>>> feature',
}, '/tmp/diffs_test_empty_ours.lua')
local hunk = {
index = 1,
start_line = 3,
end_line = 5,
del_lines = {},
add_lines = { 'local x = 2' },
}
local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
assert.is_not_nil(region)
helpers.delete_buffer(working_bufnr)
end)
it('matches correct region among multiple conflicts', function()
local working_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local a = 1',
'=======',
'local a = 2',
'>>>>>>> feature',
'middle',
'<<<<<<< HEAD',
'local b = 3',
'=======',
'local b = 4',
'>>>>>>> feature',
}, '/tmp/diffs_test_multi.lua')
local hunk = {
index = 2,
start_line = 8,
end_line = 12,
del_lines = { 'local b = 3' },
add_lines = { 'local b = 4' },
}
local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
assert.is_not_nil(region)
assert.are.equal(6, region.marker_ours)
helpers.delete_buffer(working_bufnr)
end)
it('matches with diff3 format', function()
local working_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local x = 1',
'||||||| base',
'local x = 0',
'=======',
'local x = 2',
'>>>>>>> feature',
}, '/tmp/diffs_test_diff3.lua')
local hunk = {
index = 1,
start_line = 3,
end_line = 7,
del_lines = { 'local x = 1' },
add_lines = { 'local x = 2' },
}
local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
assert.is_not_nil(region)
assert.are.equal(2, region.marker_base)
helpers.delete_buffer(working_bufnr)
end)
end)
describe('resolution', function()
local diff_bufnr, working_bufnr
local function setup_buffers()
local working_path = '/tmp/diffs_test_resolve.lua'
working_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
}, working_path)
diff_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local x = 1',
'+local x = 2',
}, working_path)
vim.api.nvim_set_current_buf(diff_bufnr)
end
local function cleanup()
helpers.delete_buffer(diff_bufnr)
helpers.delete_buffer(working_bufnr)
end
it('resolve_ours keeps ours content in working file', function()
setup_buffers()
vim.api.nvim_win_set_cursor(0, { 5, 0 })
merge.resolve_ours(diff_bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
assert.are.equal(1, #lines)
assert.are.equal('local x = 1', lines[1])
cleanup()
end)
it('resolve_theirs keeps theirs content in working file', function()
setup_buffers()
vim.api.nvim_win_set_cursor(0, { 5, 0 })
merge.resolve_theirs(diff_bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
assert.are.equal(1, #lines)
assert.are.equal('local x = 2', lines[1])
cleanup()
end)
it('resolve_both keeps ours then theirs in working file', function()
setup_buffers()
vim.api.nvim_win_set_cursor(0, { 5, 0 })
merge.resolve_both(diff_bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
assert.are.equal(2, #lines)
assert.are.equal('local x = 1', lines[1])
assert.are.equal('local x = 2', lines[2])
cleanup()
end)
it('resolve_none removes entire block from working file', function()
setup_buffers()
vim.api.nvim_win_set_cursor(0, { 5, 0 })
merge.resolve_none(diff_bufnr, default_config())
local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
assert.are.equal(1, #lines)
assert.are.equal('', lines[1])
cleanup()
end)
it('tracks resolved hunks', function()
setup_buffers()
vim.api.nvim_win_set_cursor(0, { 5, 0 })
assert.is_false(merge.is_resolved(diff_bufnr, 1))
merge.resolve_ours(diff_bufnr, default_config())
assert.is_true(merge.is_resolved(diff_bufnr, 1))
cleanup()
end)
it('adds virtual text for resolved hunks', function()
setup_buffers()
vim.api.nvim_win_set_cursor(0, { 5, 0 })
merge.resolve_ours(diff_bufnr, default_config())
local extmarks =
vim.api.nvim_buf_get_extmarks(diff_bufnr, merge.get_namespace(), 0, -1, { details = true })
local has_resolved_text = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
for _, chunk in ipairs(mark[4].virt_text) do
if chunk[1]:match('resolved') then
has_resolved_text = true
end
end
end
end
assert.is_true(has_resolved_text)
cleanup()
end)
it('notifies when hunk is already resolved', function()
setup_buffers()
vim.api.nvim_win_set_cursor(0, { 5, 0 })
merge.resolve_ours(diff_bufnr, default_config())
local notified = false
local orig_notify = vim.notify
vim.notify = function(msg)
if msg:match('already resolved') then
notified = true
end
end
merge.resolve_ours(diff_bufnr, default_config())
vim.notify = orig_notify
assert.is_true(notified)
cleanup()
end)
it('notifies when hunk does not match a conflict', function()
local working_path = '/tmp/diffs_test_no_conflict.lua'
local w_bufnr = create_working_buffer({
'local y = 1',
}, working_path)
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local x = 1',
'+local x = 2',
}, working_path)
vim.api.nvim_set_current_buf(d_bufnr)
vim.api.nvim_win_set_cursor(0, { 5, 0 })
local notified = false
local orig_notify = vim.notify
vim.notify = function(msg)
if msg:match('does not correspond') then
notified = true
end
end
merge.resolve_ours(d_bufnr, default_config())
vim.notify = orig_notify
assert.is_true(notified)
helpers.delete_buffer(d_bufnr)
helpers.delete_buffer(w_bufnr)
end)
end)
describe('navigation', function()
it('goto_next jumps to next conflict hunk', function()
local working_path = '/tmp/diffs_test_nav.lua'
local w_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local a = 1',
'=======',
'local a = 2',
'>>>>>>> feature',
'middle',
'<<<<<<< HEAD',
'local b = 3',
'=======',
'local b = 4',
'>>>>>>> feature',
}, working_path)
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local a = 1',
'+local a = 2',
'@@ -5,1 +5,1 @@',
'-local b = 3',
'+local b = 4',
}, working_path)
vim.api.nvim_set_current_buf(d_bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
merge.goto_next(d_bufnr)
assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
merge.goto_next(d_bufnr)
assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
helpers.delete_buffer(d_bufnr)
helpers.delete_buffer(w_bufnr)
end)
it('goto_next wraps around', function()
local working_path = '/tmp/diffs_test_wrap.lua'
local w_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
}, working_path)
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local x = 1',
'+local x = 2',
}, working_path)
vim.api.nvim_set_current_buf(d_bufnr)
vim.api.nvim_win_set_cursor(0, { 6, 0 })
merge.goto_next(d_bufnr)
assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
helpers.delete_buffer(d_bufnr)
helpers.delete_buffer(w_bufnr)
end)
it('goto_next notifies on wrap-around', function()
local working_path = '/tmp/diffs_test_wrap_notify.lua'
local w_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
}, working_path)
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local x = 1',
'+local x = 2',
}, working_path)
vim.api.nvim_set_current_buf(d_bufnr)
vim.api.nvim_win_set_cursor(0, { 6, 0 })
local notified = false
local orig_notify = vim.notify
vim.notify = function(msg)
if msg:match('wrapped to first hunk') then
notified = true
end
end
merge.goto_next(d_bufnr)
vim.notify = orig_notify
assert.is_true(notified)
helpers.delete_buffer(d_bufnr)
helpers.delete_buffer(w_bufnr)
end)
it('goto_prev jumps to previous conflict hunk', function()
local working_path = '/tmp/diffs_test_prev.lua'
local w_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local a = 1',
'=======',
'local a = 2',
'>>>>>>> feature',
'middle',
'<<<<<<< HEAD',
'local b = 3',
'=======',
'local b = 4',
'>>>>>>> feature',
}, working_path)
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local a = 1',
'+local a = 2',
'@@ -5,1 +5,1 @@',
'-local b = 3',
'+local b = 4',
}, working_path)
vim.api.nvim_set_current_buf(d_bufnr)
vim.api.nvim_win_set_cursor(0, { 9, 0 })
merge.goto_prev(d_bufnr)
assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
merge.goto_prev(d_bufnr)
assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
helpers.delete_buffer(d_bufnr)
helpers.delete_buffer(w_bufnr)
end)
it('goto_prev wraps around', function()
local working_path = '/tmp/diffs_test_prev_wrap.lua'
local w_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
}, working_path)
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local x = 1',
'+local x = 2',
}, working_path)
vim.api.nvim_set_current_buf(d_bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
merge.goto_prev(d_bufnr)
assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
helpers.delete_buffer(d_bufnr)
helpers.delete_buffer(w_bufnr)
end)
it('goto_prev notifies on wrap-around', function()
local working_path = '/tmp/diffs_test_prev_wrap_notify.lua'
local w_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
}, working_path)
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local x = 1',
'+local x = 2',
}, working_path)
vim.api.nvim_set_current_buf(d_bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
local notified = false
local orig_notify = vim.notify
vim.notify = function(msg)
if msg:match('wrapped to last hunk') then
notified = true
end
end
merge.goto_prev(d_bufnr)
vim.notify = orig_notify
assert.is_true(notified)
helpers.delete_buffer(d_bufnr)
helpers.delete_buffer(w_bufnr)
end)
it('skips resolved hunks', function()
local working_path = '/tmp/diffs_test_skip_resolved.lua'
local w_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local a = 1',
'=======',
'local a = 2',
'>>>>>>> feature',
'middle',
'<<<<<<< HEAD',
'local b = 3',
'=======',
'local b = 4',
'>>>>>>> feature',
}, working_path)
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local a = 1',
'+local a = 2',
'@@ -5,1 +5,1 @@',
'-local b = 3',
'+local b = 4',
}, working_path)
vim.api.nvim_set_current_buf(d_bufnr)
vim.api.nvim_win_set_cursor(0, { 5, 0 })
merge.resolve_ours(d_bufnr, default_config())
vim.api.nvim_win_set_cursor(0, { 1, 0 })
merge.goto_next(d_bufnr)
assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
helpers.delete_buffer(d_bufnr)
helpers.delete_buffer(w_bufnr)
end)
end)
describe('hunk hints', function()
it('adds keymap hints on hunk header lines', function()
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local x = 1',
'+local x = 2',
})
merge.setup_keymaps(d_bufnr, default_config())
local extmarks =
vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true })
local hint_marks = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
local text = ''
for _, chunk in ipairs(mark[4].virt_text) do
text = text .. chunk[1]
end
table.insert(hint_marks, { line = mark[2], text = text })
end
end
assert.are.equal(1, #hint_marks)
assert.are.equal(3, hint_marks[1].line)
assert.is_truthy(hint_marks[1].text:find('doo'))
assert.is_truthy(hint_marks[1].text:find('dot'))
helpers.delete_buffer(d_bufnr)
end)
end)
describe('setup_keymaps', function()
it('clears resolved state on re-init', function()
local working_path = '/tmp/diffs_test_reinit.lua'
local w_bufnr = create_working_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
}, working_path)
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local x = 1',
'+local x = 2',
}, working_path)
vim.api.nvim_set_current_buf(d_bufnr)
vim.api.nvim_win_set_cursor(0, { 5, 0 })
local cfg = default_config()
merge.resolve_ours(d_bufnr, cfg)
assert.is_true(merge.is_resolved(d_bufnr, 1))
local extmarks =
vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true })
assert.is_true(#extmarks > 0)
merge.setup_keymaps(d_bufnr, cfg)
assert.is_false(merge.is_resolved(d_bufnr, 1))
extmarks =
vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true })
local resolved_count = 0
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
for _, chunk in ipairs(mark[4].virt_text) do
if chunk[1]:match('resolved') then
resolved_count = resolved_count + 1
end
end
end
end
assert.are.equal(0, resolved_count)
helpers.delete_buffer(d_bufnr)
helpers.delete_buffer(w_bufnr)
end)
end)
describe('fugitive integration', function()
it('parse_file_line returns status for unmerged files', function()
local fugitive = require('diffs.fugitive')
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
'Unstaged (1)',
'U conflict.lua',
})
local filename, section, is_header, old_filename, status = fugitive.get_file_at_line(buf, 2)
assert.are.equal('conflict.lua', filename)
assert.are.equal('unstaged', section)
assert.is_false(is_header)
assert.is_nil(old_filename)
assert.are.equal('U', status)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('walkback from hunk line propagates status', function()
local fugitive = require('diffs.fugitive')
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
'Unstaged (1)',
'U conflict.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
'+local new = true',
})
local _, _, _, _, status = fugitive.get_file_at_line(buf, 5)
assert.are.equal('U', status)
vim.api.nvim_buf_delete(buf, { force = true })
end)
end)
end)

View file

@ -0,0 +1,126 @@
require('spec.helpers')
vim.g.diffs = { neogit = true }
local diffs = require('diffs')
local parser = require('diffs.parser')
local function create_buffer(lines)
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
return bufnr
end
local function delete_buffer(bufnr)
if vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end
describe('neogit_integration', function()
describe('neogit_disable_hunk_highlight', function()
it('sets neogit_disable_hunk_highlight on NeogitStatus buffer after attach', function()
local bufnr = create_buffer({
'modified test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
vim.api.nvim_set_option_value('filetype', 'NeogitStatus', { buf = bufnr })
diffs.attach(bufnr)
assert.is_true(vim.b[bufnr].neogit_disable_hunk_highlight)
delete_buffer(bufnr)
end)
it('does not set neogit_disable_hunk_highlight on non-Neogit buffer', function()
local bufnr = create_buffer({})
vim.api.nvim_set_option_value('filetype', 'git', { buf = bufnr })
diffs.attach(bufnr)
assert.is_not_true(vim.b[bufnr].neogit_disable_hunk_highlight)
delete_buffer(bufnr)
end)
end)
describe('NeogitStatus buffer attach', function()
it('populates hunk_cache for NeogitStatus buffer with diff content', function()
local bufnr = create_buffer({
'modified hello.lua',
'@@ -1,2 +1,3 @@',
' local M = {}',
'+local x = 1',
' return M',
})
vim.api.nvim_set_option_value('filetype', 'NeogitStatus', { buf = bufnr })
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.is_not_nil(entry)
assert.is_table(entry.hunks)
assert.are.equal(1, #entry.hunks)
assert.are.equal('hello.lua', entry.hunks[1].filename)
delete_buffer(bufnr)
end)
it('populates hunk_cache for NeogitDiffView buffer', function()
local bufnr = create_buffer({
'new file newmod.lua',
'@@ -0,0 +1,2 @@',
'+local M = {}',
'+return M',
})
vim.api.nvim_set_option_value('filetype', 'NeogitDiffView', { buf = bufnr })
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.is_not_nil(entry)
assert.is_table(entry.hunks)
assert.are.equal(1, #entry.hunks)
delete_buffer(bufnr)
end)
end)
describe('parser neogit patterns', function()
it('detects renamed prefix via parser', function()
local bufnr = create_buffer({
'renamed old.lua',
'@@ -1,2 +1,3 @@',
' local M = {}',
'+local x = 1',
' return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('old.lua', hunks[1].filename)
delete_buffer(bufnr)
end)
it('detects copied prefix via parser', function()
local bufnr = create_buffer({
'copied orig.lua',
'@@ -1,2 +1,3 @@',
' local M = {}',
'+local x = 1',
' return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('orig.lua', hunks[1].filename)
delete_buffer(bufnr)
end)
it('detects deleted prefix via parser', function()
local bufnr = create_buffer({
'deleted gone.lua',
'@@ -1,2 +0,0 @@',
'-local M = {}',
'-return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('gone.lua', hunks[1].filename)
delete_buffer(bufnr)
end)
end)
end)

View file

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

View file

@ -1,33 +0,0 @@
[selene]
base = "lua51"
name = "vim"
[vim]
any = true
[jit]
any = true
[bit]
any = true
[assert]
any = true
[describe]
any = true
[it]
any = true
[before_each]
any = true
[after_each]
any = true
[spy]
any = true
[stub]
any = true

26
vim.yaml Normal file
View file

@ -0,0 +1,26 @@
---
base: lua51
name: vim
lua_versions:
- luajit
globals:
vim:
any: true
jit:
any: true
assert:
any: true
describe:
any: true
it:
any: true
before_each:
any: true
after_each:
any: true
spy:
any: true
stub:
any: true
bit:
any: true