Compare commits

..

55 commits

Author SHA1 Message Date
55c2658419 docs: add @tris203 to acknowledgements 2026-03-07 20:51:38 -05:00
Tristan Knight
53dd5d6325
fix(highlight): handle nil Normal.bg in blending logic (#175)
Co-authored-by: Barrett Ruth <br.barrettruth@gmail.com>
2026-03-07 20:49:26 -05:00
595c35d910
doc: update integration spec 2026-03-06 14:51:10 -05:00
Barrett Ruth
a880261988
refactor(config): nest integration toggles under integrations namespace (#174)
## Problem

Integration keys (`fugitive`, `neogit`, `gitsigns`, `committia`,
`telescope`)
live at the top level of `vim.g.diffs`, cluttering the config namespace.

## Solution

Move them under `vim.g.diffs.integrations.*`. Old top-level keys still
work but
emit `vim.deprecate` targeting v0.3.2. `compute_filetypes` and
`plugin/diffs.lua`
fall back to legacy keys for pre-`init()` callers.

Also includes `83c17ac` which fixes an invalid hex hash in the combined
diff test
fixture.
2026-03-06 14:46:02 -05:00
Barrett Ruth
823743192a
fix(test): use valid hex hash in combined diff fixtures (#171)
## Problem

Combined diff test fixtures used `ghi9012` as a result hash, but `g`,
`h`, `i` are not hex digits (`%x` matches `[0-9a-fA-F]`). The `%x+`
pattern in `highlight.lua` correctly rejected this, so the manual
`@constant.diff` extmark for the result hash was never set. The existing
assertion passed anyway because the diff grammar's captures on parent
hashes (`abc1234`, `def5678`) satisfied the row-level check.

## Solution

Replace `ghi9012` with valid hex `a6b9012` in all fixtures. Tighten the
result hash assertion to verify exact column range (cols 23-30) so it
cannot be satisfied by parent hash captures.

Closes #168
2026-03-06 14:12:20 -05:00
Barrett Ruth
d584d816bf
feat: add telescope.nvim integration (#170)
Closes #169.

## Problem

Telescope never sets `filetype=diff` on preview buffers — it calls
`vim.treesitter.start(bufnr, "diff")` directly, so diffs.nvim's
`FileType` autocmd never fires.

## Solution

Add a `telescope` config toggle (same pattern as
neogit/gitsigns/committia) and a `User TelescopePreviewerLoaded` autocmd
that calls `attach()` on the preview buffer. Disabled by default; enable
with `telescope = true`.

Also adds a `diffs-telescope` vimdoc section documenting the integration
and the upstream first-line preview bug
(nvim-telescope/telescope.nvim#3626).

Includes committia.vim integration from `feat/committia`.
2026-03-06 13:46:15 -05:00
Barrett Ruth
dc6fd7a387
feat: add committia.vim integration (#166)
## Problem

committia.vim's diff pane (`ft=git`, buffer name `__committia_diff__`)
is rejected by the `ft=git` guard in the `FileType` callback, preventing
diffs.nvim from highlighting it.

## Solution

Add a `committia` config toggle following the same pattern as
`neogit`/`gitsigns`. When enabled, the `ft=git` guard also allows
committia's `__committia_diff__` buffer through.

Closes #161
2026-03-06 13:04:21 -05:00
Barrett Ruth
d06144450c
docs: revamp vimdoc structure and content (#167)
## Problem

The vimdoc had several issues: integration sections (fugitive, neogit,
gitsigns) were scattered as top-level entries with no grouping, the
intro implied diffs.nvim works automatically with fugitive/neogit out of
the box, the neogit section described a highlight override workaround
that was already removed, and `extra_filetypes` didn't mention picker
support.

## Solution

- Rewrite intro to document default behavior: `gitcommit` highlighting,
conflict detection, `&diff` winhighlight — everything else is opt-in
- Remove vim-fugitive as a dependency in setup
- Add `|diffs-integrations|` parent section grouping fugitive, neogit,
and gitsigns
- Add fugitive intro paragraph mentioning `:Gdiff`
- Trim neogit section (remove stale highlight override docs)
- Trim gitsigns section (remove implementation details)
- Add `committia` config field docs
- Expand `extra_filetypes` docs to mention telescope, snacks, and
fzf-lua
2026-03-06 11:31:40 -05:00
Barrett Ruth
8122f23541
docs: restructure vimdoc with integrations parent section (#165)
## Problem

Integration docs (fugitive, neogit, gitsigns) were scattered as
top-level sections with no grouping, making it hard to find all
supported plugin integrations in one place.

## Solution

Add `|diffs-integrations|` parent section that groups all integration
subsections under a single TOC entry. Fugitive, neogit, and gitsigns are
now subsections (using `---` separators) under the new `INTEGRATIONS`
heading. The intro paragraph documents the two attachment patterns:
automatic (config toggles like `fugitive = true`) and opt-in
(`extra_filetypes`). Conflict resolution and merge diff resolution
remain as standalone top-level sections. TOC renumbered accordingly.
2026-03-06 11:26:35 -05:00
Barrett Ruth
cb852d115b
docs: document picker integration via extra_filetypes (#164)
## Problem

`extra_filetypes = { 'diff' }` enables highlighting in telescope,
snacks, and fzf-lua git preview buffers, but this was not documented
beyond a brief mention of `.diff` files.

## Solution

Add a README FAQ entry and expand the vimdoc `extra_filetypes` field
description to mention specific pickers and which previewer styles are
supported.
2026-03-06 11:22:13 -05:00
Barrett Ruth
b2fb49d48b
fix: gate ft=git attachment on fugitive config toggle (#163)
## Problem

The `is_fugitive_buffer` guard in the `FileType` callback checked the
buffer name for `fugitive://` without checking whether the `fugitive`
integration was enabled. `ft=git` fugitive buffers got highlighted even
with `fugitive = false` (the default).

## Solution

Check `get_fugitive_config()` before `is_fugitive_buffer()`. When
`fugitive = false` (default), no `ft=git` buffer gets through the guard.
2026-03-06 11:13:53 -05:00
Barrett Ruth
993fed4a45
feat: gitsigns blame popup highlighting (#157)
## Problem

gitsigns' `:Gitsigns blame_line` popup shows flat
`GitSignsAddPreview`/`GitSignsDeletePreview` line highlights with basic
word-level inline diffs, but no treesitter syntax or diffs.nvim's
character-level intra-line highlighting.

## Solution

Add `lua/diffs/gitsigns.lua` which patches gitsigns' `Popup.create` and
`Popup.update` to intercept blame popups. Parses `Hunk N of M` sections
from the popup buffer, clears gitsigns' own `gitsigns_popup` namespace
on the diff region, and applies `highlight_hunk` with manual
`@diff.plus`/`@diff.minus` prefix extmarks. Uses a separate
`diffs-gitsigns` namespace to avoid colliding with the main decoration
provider.

Enabled via `vim.g.diffs = { gitsigns = true }`. Wired in
`plugin/diffs.lua` with a `User GitAttach` lazy-load retry for when
gitsigns loads after diffs.nvim. Config plumbing adds
`get_highlight_opts()` as a public getter, replacing the
`debug.getupvalue` hack used by the standalone `blame_hl.nvim` plugin.

Closes #155.
2026-03-06 08:42:02 -05:00
Barrett Ruth
c498fd2bac
fix(init): migrate vim.validate to positional parameter API (#154)
## Problem

Neovim 0.11+ deprecated the table-based `vim.validate({...})` form.
Every
plugin load produces 5 deprecation warnings pointing at `init.lua`
config
validation.

## Solution

Convert all `vim.validate` calls in `init()` to the new positional
`vim.validate(name, value, validator, optional_or_msg)` form. No
behavioral
change — identical validation logic, just the calling convention.
2026-03-05 23:18:31 -05:00
Barrett Ruth
58589947e8
fix(highlight): use theme-agnostic fallbacks and retry when Normal has no background (#153)
## Problem

Highlight group fallbacks in `compute_highlight_groups` were hardcoded
to
catppuccin mocha colors, producing wrong results for any other
colorscheme
when `Normal.bg` is nil. This happens on transparent terminals or when
the
colorscheme loads after the first diff buffer opens.

## Solution

Replace hardcoded fallbacks with `vim.o.background`-aware neutral
values.
When `Normal.bg` is still absent after initial computation, schedule a
single
deferred retry via `vim.schedule` that recomputes and invalidates all
attached
buffer caches. Document the load-order requirement in the setup section.
2026-03-05 19:50:22 -05:00
Barrett Ruth
e7d56e3bbe
feat(highlight): wire highlights.context into treesitter pipeline (#151)
Some checks failed
luarocks / quality (push) Has been cancelled
luarocks / publish (push) Has been cancelled
## Problem

`highlights.context.enabled` and `highlights.context.lines` were
defined, validated, and range-checked but never read during
highlighting. Hunks inside incomplete constructs (e.g., a table literal
or function body whose opening is beyond the hunk's own context lines)
parsed incorrectly because treesitter had no surrounding code.

## Solution

`compute_hunk_context` in `init.lua` reads the working tree file using
the hunk's `@@ +start,count @@` line numbers to collect up to `lines`
(default 25) surrounding code lines in each direction. Files are read
once via `io.open` and cached across hunks in the same file.
`highlight_treesitter` in `highlight.lua` accepts an optional context
parameter that prepends/appends context lines to the parse string and
offsets capture rows by the prefix count, so extmarks only land on
actual hunk lines. Wired through `highlight_hunk` for the two
code-language treesitter calls (not headers, not `highlight_text`, not
vim syntax).

Closes #148.
2026-03-05 11:14:31 -05:00
Barrett Ruth
29e624d9f0
feat: enable vim syntax fallback by default (#152)
## Problem

Languages without a treesitter parser (COBOL, Fortran, etc.) got no
syntax highlighting because \`highlights.vim.enabled\` defaulted to
\`false\`.

## Solution

Flip the default to \`true\`. The vim syntax path is already deferred
via \`vim.schedule\` so it never blocks the first paint. \`max_lines =
200\` stays unchanged — appropriate given the ~30x slower per-hunk cost
vs treesitter.
2026-03-05 11:13:28 -05:00
Barrett Ruth
e1d3b81607
feat: support email-quoted diffs (#149)
## Problem

Email-quoted diffs (`> diff --git ...`, `> @@ ...`) from git-send-email
/ email reply workflows produce 0 hunks because the parser matches
patterns against raw lines containing `> ` quote prefixes. Closes #141.

## Solution

Strip the `> ` quote prefix before pattern matching in the parser. Store
`quote_width` on each hunk. In `highlight.lua`, offset all extmark
column positions by `qw` and expand `pw > 1` guards to `qw > 0 or pw >
1` for DiffsClear suppression. Clamp body prefix DiffsClear `end_col` to
the actual buffer line byte length for bare `>` lines (1-byte buffer
lines where `end_col = pw + qw` would exceed bounds and cause
`nvim_buf_set_extmark` to silently fail inside `pcall`).

15 new specs covering parser detection, stripping, false-positive
rejection, and highlight column offsets including the bare `>` clamp
edge case.
2026-03-05 10:31:19 -05:00
Barrett Ruth
70d5bee797
fix(init): remove NeogitDiffContextHighlight override workaround (#147)
## Problem

diffs.nvim blanked `NeogitDiffContextHighlight` globally on attach and
on `ColorScheme` to work around Neogit's `ViewContext` decoration
provider overriding `DiffsAdd`/`DiffsDelete` line backgrounds with
`NeogitDiffContextHighlight` at priority 200.

## Solution

Remove the `override_neogit_context_highlights` workaround.
NeogitOrg/neogit#1907 moves the `neogit_disable_hunk_highlight` check
inside ViewContext's per-line loop, so non-cursor lines skip
`add_line_highlight` entirely.
`vim.b[bufnr].neogit_disable_hunk_highlight = true` (set on attach) is
sufficient.

Closes #135
2026-03-05 09:27:48 -05:00
Barrett Ruth
90b312e8df
fix(highlight): prevent duplicate extmarks from two-pass rendering (#145)
## Problem

Two-pass rendering (Pass 1: backgrounds + intra-line; Pass 2:
treesitter) caused Pass 2 to re-apply all extmarks that Pass 1 already
set, doubling the extmark count on affected lines.

## Solution

Add `syntax_only` mode to `highlight_hunk`. When `syntax_only = true`,
only treesitter syntax and content `DiffsClear` extmarks are applied —
backgrounds, intra-line, prefix clears, and per-char prefix highlights
are skipped. Pass 2 now uses `syntax_only = true` and no longer calls
`nvim_buf_clear_namespace`, so Pass 1's extmarks persist while Pass 2
layers syntax on top.

Closes #143
2026-03-05 09:16:04 -05:00
Barrett Ruth
7106bcc291
refactor(highlight): unified per-line extmark builder (#144)
## Problem

`highlight_hunk` applied DiffsClear extmarks across 5 scattered sites
with
ad-hoc column arithmetic. This fragmentation produced the 1-column
DiffsClear
gap on email-quoted body context lines (#142 issue 1). A redundant
`highlight_hunk_vim_syntax` function duplicated the inline vim syntax
path,
and the deferred pass in init.lua double-called it, creating duplicate
scratch
buffers and extmarks.

## Solution

Reorganize `highlight_hunk` into two clean phases:

- **Phase 1** — multi-line syntax computation (treesitter, vim syntax,
diff
grammar, header context text). Sets syntax extmarks only, no DiffsClear.
- **Phase 2** — per-line chrome (DiffsClear, backgrounds, gutter,
overlays,
  intra-line). All non-syntax extmarks consolidated in one pass.

Hoist `new_code` to function scope (needed by `highlight_text` outside
the
`use_ts` block). Hoist `at_raw_line` so Phase 1d and Phase 2b share one
`nvim_buf_get_lines` call.

Delete `highlight_hunk_vim_syntax` (redundant with inline path). Remove
the
double-call from the deferred pass in init.lua.

Extend body prefix DiffsClear `end_col` from `qw` to `pw + qw`, fixing
the
1-column gap where native treesitter background bled through on context
lines
in email-quoted diffs (#142 issue 1).

### Email-quoted diff support

The parser now strips `> ` (and `>> `, etc.) email quote prefixes before
pattern matching, enabling syntax highlighting for diffs embedded in
email
replies and `git-send-email` / sourcehut-style patch review threads.
Each hunk stores `quote_width` so the highlight pipeline can apply
`DiffsClear` at the correct column offsets to suppress native treesitter
on quoted regions.

Closes #141

### #142 status after this PR

| Sub-issue | Status |
|-----------|--------|
| 1. Col gap on context lines | Fixed |
| 2. Bare `>` context lines | Improved, edge case remains |
| 3. Diff prefix marker fg | Not addressed (follow-up) |
2026-03-05 09:01:22 -05:00
Barrett Ruth
7a3c4ea01e
docs: add table of contents to vimdoc (#146)
## Problem

The vimdoc has 16 sections but no table of contents, making it hard
to navigate with `:help diffs`.

## Solution

Add a numbered `CONTENTS` section with dot-leader formatting and
`|tag|` links to each existing section, matching the style used in
the project's other plugins.
2026-03-05 01:31:15 -05:00
Barrett Ruth
749a21ae3c
fix: clear stale gutter extmarks after fugitive section toggle (#139)
## Problem

Repeatedly toggling `=` in fugitive left green gutter
(`number_hl_group`)
extmarks on lines between sections. When fugitive collapses a diff
section,
Neovim compresses extmarks from deleted lines onto the next surviving
line
(the `M ...` file entry). Two issues prevented cleanup:

1. `carry_forward_highlighted` returned `{}` (truthy in Lua) when zero
hunks
matched, so `pending_clear` stayed `false` and the compressed extmarks
   were never cleared.
2. The `nvim_buf_clear_namespace` call in `on_buf`'s `pending_clear`
path was
removed in 2feb8a8, so even when `pending_clear` was `true` the extmarks
   survived.

## Solution

Return `nil` from `carry_forward_highlighted` when no hunks were carried
forward (`next(highlighted) == nil`), so `pending_clear` is correctly
set to
`true`. Restore `nvim_buf_clear_namespace` in `on_buf`'s `pending_clear`
block. Add `process_pending_clear` test helper and spec coverage.
2026-02-25 13:20:59 -05:00
Barrett Ruth
6040c054cb
fix: carry forward highlighted hunks on reparse to reduce flicker (#138)
## 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 the old entry had
`pending_clear=false`;
`invalidate_cache`/`ColorScheme` paths still force full re-highlight.

Closes #131
2026-02-25 12:44:57 -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 failed
luarocks / quality (push) Has been cancelled
luarocks / publish (push) Has been cancelled
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 failed
luarocks / quality (push) Has been cancelled
luarocks / publish (push) Has been cancelled
## 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 failed
luarocks / quality (push) Has been cancelled
luarocks / publish (push) Has been cancelled
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
38 changed files with 7641 additions and 1321 deletions

View file

@ -68,10 +68,26 @@ body:
load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))() load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))()
require('lazy.nvim').setup({ require('lazy.nvim').setup({
spec = { spec = {
'tpope/vim-fugitive', { 'barrettruth/midnight.nvim', lazy = false, config = function() vim.cmd.colorscheme('midnight') end },
{ 'tpope/vim-fugitive' },
{ 'NeogitOrg/neogit', dependencies = { 'nvim-lua/plenary.nvim' } },
{ 'lewis6991/gitsigns.nvim', config = true },
{ 'rhysd/committia.vim' },
{ 'nvim-telescope/telescope.nvim', dependencies = { 'nvim-lua/plenary.nvim' } },
{ {
'barrettruth/diffs.nvim', 'barrettruth/diffs.nvim',
opts = {}, init = function()
vim.g.diffs = {
debug = '/tmp/diffs.log',
integrations = {
fugitive = true,
neogit = true,
gitsigns = true,
committia = true,
telescope = true,
},
}
end,
}, },
}, },
}) })

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,8 +1,15 @@
{ {
"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",
"${3rd}/busted/library",
"${3rd}/luassert/library"
],
"workspace.checkThirdParty": false, "workspace.checkThirdParty": false,
"diagnostics.libraryFiles": "Disable",
"workspace.ignoreDir": [".direnv"],
"completion.callSnippet": "Replace" "completion.callSnippet": "Replace"
} }

1
.styluaignore Normal file
View file

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

View file

@ -2,25 +2,25 @@
**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`) - Inline merge conflict detection, highlighting, and resolution
- Vim syntax fallback for languages without a treesitter parser - gitsigns.nvim blame popup highlighting
- Hunk header context highlighting (`@@ ... @@ function foo()`) - Email quoting/patch syntax support (`> diff ...`)
- Character-level (intra-line) diff highlighting for changed characters - Vim syntax fallback
- Inline merge conflict detection, highlighting, and resolution keymaps - Configurable highlighiting blend & priorities
- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and - Context-inclusive, high-accuracy highlights
highlight overrides
## Requirements ## Requirements
@ -41,18 +41,59 @@ 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/gitsigns?**
Yes. Enable integrations in your config:
```lua
vim.g.diffs = {
fugitive = true,
neogit = true,
gitsigns = 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 +111,16 @@ 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, gitsigns blame popup
highlighting
- [@tris203](https://github.com/tris203) - support for transparent backgrounds

View file

@ -6,44 +6,72 @@ License: MIT
============================================================================== ==============================================================================
INTRODUCTION *diffs.nvim* INTRODUCTION *diffs.nvim*
diffs.nvim adds syntax highlighting to diff views. It overlays language-aware diffs.nvim adds language-aware syntax highlighting to unified diff content
highlights on top of default diff highlighting in vim-fugitive and Neovim's in Neovim buffers. It replaces flat `diffAdded`/`diffRemoved` coloring with
built-in diff mode. treesitter syntax, blended line backgrounds, and character-level intra-line
diffs.
With no configuration, diffs.nvim provides:
- `gitcommit` diff highlighting (e.g., `git commit --verbose`)
- Inline merge conflict detection, highlighting, and resolution
- Background-only diff colors for `&diff` buffers (vimdiff, diffthis)
All other integrations are opt-in. See |diffs-integrations|.
Features: ~ Features: ~
- Syntax highlighting in |:Git| summary diffs and commit detail views - Treesitter syntax highlighting in diff hunks
- Diff header highlighting (`diff --git`, `index`, `---`, `+++`) - Character-level intra-line diff highlighting
- Syntax highlighting in |:Gdiffsplit| / |:Gvdiffsplit| side-by-side diffs
- |:Gdiff| command for unified diff against any git revision
- Background-only diff colors for any `&diff` buffer (vimdiff, diffthis, etc.)
- Vim syntax fallback for languages without a treesitter parser - Vim syntax fallback for languages without a treesitter parser
- Blended diff background colors that preserve syntax visibility - Blended diff background colors that preserve syntax visibility
- Optional diff prefix (`+`/`-`/` `) concealment - Optional diff prefix (`+`/`-`/` `) concealment
- Gutter (line number) highlighting - Gutter (line number) highlighting
- Inline merge conflict marker detection, highlighting, and resolution - |:Gdiff| unified diff against any revision
- Email quoting/patch syntax support (`> diff ...`)
==============================================================================
CONTENTS *diffs-contents*
1. Introduction ............................................... |diffs.nvim|
2. Requirements ....................................... |diffs-requirements|
3. Setup ..................................................... |diffs-setup|
4. Configuration ............................................ |diffs-config|
5. Commands ............................................... |diffs-commands|
6. Mappings ............................................... |diffs-mappings|
7. Integrations ..................................... |diffs-integrations|
Fugitive .......................................... |diffs-fugitive|
Neogit .............................................. |diffs-neogit|
Gitsigns .......................................... |diffs-gitsigns|
Telescope ........................................ |diffs-telescope|
8. Conflict Resolution .................................... |diffs-conflict|
9. Merge Diff Resolution ..................................... |diffs-merge|
10. API ......................................................... |diffs-api|
11. Implementation ................................... |diffs-implementation|
12. Known Limitations ................................... |diffs-limitations|
13. Highlight Groups ..................................... |diffs-highlights|
14. Health Check ............................................. |diffs-health|
15. Acknowledgements ............................... |diffs-acknowledgements|
============================================================================== ==============================================================================
REQUIREMENTS *diffs-requirements* REQUIREMENTS *diffs-requirements*
- Neovim 0.9.0+ - Neovim 0.9.0+
- vim-fugitive (https://github.com/tpope/vim-fugitive) (optional, for unified
diff syntax highlighting in |:Git| and commit views)
- Treesitter parsers for languages you want highlighted - Treesitter parsers for languages you want highlighted
Note: The diff mode feature (background-only colors for |:diffthis|, vimdiff,
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' },
}
< <
The plugin works automatically with no configuration required. For
customization, see |diffs-config|. Do not lazy load with `event`, `lazy`, `ft`, `config`, or `keys` —
diffs.nvim lazy-loads itself.
NOTE: Load your colorscheme before diffs.nvim. With lazy.nvim, set
`priority = 1000` and `lazy = false` on your colorscheme plugin.
See |diffs-config| for customization, |diffs-integrations| for plugin
support.
============================================================================== ==============================================================================
CONFIGURATION *diffs-config* CONFIGURATION *diffs-config*
@ -52,8 +80,15 @@ 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,
integrations = {
fugitive = false,
neogit = false,
gitsigns = false,
committia = false,
telescope = false,
},
extra_filetypes = {},
highlights = { highlights = {
background = true, background = true,
gutter = true, gutter = true,
@ -67,7 +102,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
max_lines = 500, max_lines = 500,
}, },
vim = { vim = {
enabled = false, enabled = true,
max_lines = 200, max_lines = 200,
}, },
intra = { intra = {
@ -75,23 +110,26 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
algorithm = 'default', algorithm = 'default',
max_lines = 500, max_lines = 500,
}, },
overrides = {}, priorities = {
clear = 198,
syntax = 199,
line_bg = 200,
char_bg = 201,
}, },
fugitive = { overrides = {},
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 +140,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 +147,84 @@ 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.
{integrations} (table, default: all false)
Integration toggles. Each key accepts `true`,
`false`, or a table with sub-options. Passing
`true` or a table enables the integration;
`false` disables it. See |diffs-integrations|.
*diffs.IntegrationsConfig*
Fields: ~
{fugitive} (boolean|table, default: false)
Enable vim-fugitive integration. Pass `true`
for defaults, or a table with sub-options
(see |diffs.FugitiveConfig|). When active,
the `fugitive` filetype is registered and
status buffer keymaps are set. >lua
vim.g.diffs = {
integrations = { fugitive = true },
}
vim.g.diffs = {
integrations = {
fugitive = { horizontal = 'dd' },
},
}
<
{neogit} (boolean|table, default: false)
Enable Neogit integration. When active,
`NeogitStatus`, `NeogitCommitView`, and
`NeogitDiffView` filetypes are registered.
See |diffs-neogit|. >lua
integrations = { neogit = true }
<
{gitsigns} (boolean|table, default: false)
Enable gitsigns.nvim blame popup highlighting.
See |diffs-gitsigns|. >lua
integrations = { gitsigns = true }
<
{committia} (boolean|table, default: false)
Enable committia.vim integration. When active,
committia's diff pane receives treesitter
syntax and intra-line diffs. >lua
integrations = { committia = true }
<
{telescope} (boolean|table, default: false)
Enable telescope.nvim preview highlighting.
See |diffs-telescope|. >lua
integrations = { telescope = true }
<
Legacy top-level keys (`vim.g.diffs.fugitive`,
etc.) still work but emit a deprecation
warning. If both `integrations` and top-level
keys are present, `integrations` wins and
the stale keys are ignored with a warning.
{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' },
}
<
Adding `'diff'` also enables highlighting in
picker preview buffers that set `filetype=diff`:
telescope.nvim (git_commits, git_bcommits,
git_status), snacks.nvim (syntax style only),
and fzf-lua (builtin previewer only). Terminal-
based previewers are not supported.
{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.
@ -153,13 +256,17 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
See |diffs.TreesitterConfig| for fields. See |diffs.TreesitterConfig| for fields.
{vim} (table, default: see below) {vim} (table, default: see below)
Vim syntax highlighting options (experimental). Vim syntax fallback highlighting options.
See |diffs.VimConfig| for fields. See |diffs.VimConfig| for fields.
{intra} (table, default: see below) {intra} (table, default: see below)
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
@ -170,16 +277,42 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
*diffs.ContextConfig* *diffs.ContextConfig*
Context config fields: ~ Context config fields: ~
{enabled} (boolean, default: true) {enabled} (boolean, default: true)
Read lines from disk before and after each hunk Read surrounding code from the working tree
to provide surrounding syntax context. Improves file and feed it into the treesitter string
accuracy at hunk boundaries where incomplete parser. Uses the hunk's `@@ +start,count @@`
constructs (e.g., a function definition with no line numbers to read lines before and after
body) would otherwise confuse the parser. the hunk from disk. Improves syntax accuracy
when the hunk is inside an incomplete construct
(e.g., a table literal or function body whose
opening is not visible in the hunk's own
context lines).
{lines} (integer, default: 25) {lines} (integer, default: 25)
Number of context lines to read in each Max context lines to read in each direction.
direction. Lines are read with early exit — Files are read once per parse and cached across
cost scales with this value, not file size. hunks in the same file.
*diffs.PrioritiesConfig*
Priorities config fields: ~
{clear} (integer, default: 198)
Priority for `DiffsClear` extmarks that reset
underlying diff foreground colors. Must be
below {syntax}.
{syntax} (integer, default: 199)
Priority for treesitter and vim syntax extmarks.
Must be below {line_bg} so that colorscheme
backgrounds on syntax groups do not obscure
line-level diff backgrounds.
{line_bg} (integer, default: 200)
Priority for `DiffsAdd`/`DiffsDelete` line
background extmarks. Must be below {char_bg}.
{char_bg} (integer, default: 201)
Priority for `DiffsAddText`/`DiffsDeleteText`
character-level background extmarks. Highest
priority so changed characters stand out.
*diffs.TreesitterConfig* *diffs.TreesitterConfig*
Treesitter config fields: ~ Treesitter config fields: ~
@ -192,13 +325,15 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
*diffs.VimConfig* *diffs.VimConfig*
Vim config fields: ~ Vim config fields: ~
{enabled} (boolean, default: false) {enabled} (boolean, default: true)
Use vim syntax highlighting as fallback when no Use vim syntax highlighting as fallback when no
treesitter parser is available for a language. treesitter parser is available for a language.
Creates a scratch buffer, sets the filetype, and Creates a scratch buffer, sets the filetype, and
queries |synID()| per character to extract queries |synID()| per character to extract
highlight groups. Slower than treesitter but highlight groups. Deferred via |vim.schedule()|
covers languages without a TS parser installed. so it never blocks the first paint. Slower than
treesitter but covers languages without a TS
parser installed (e.g., COBOL, Fortran).
{max_lines} (integer, default: 200) {max_lines} (integer, default: 200)
Skip vim syntax highlighting for hunks larger than Skip vim syntax highlighting for hunks larger than
@ -311,10 +446,36 @@ Example configuration: >lua
vim.keymap.set('n', 'ct', '<Plug>(diffs-conflict-theirs)') vim.keymap.set('n', 'ct', '<Plug>(diffs-conflict-theirs)')
vim.keymap.set('n', 'cb', '<Plug>(diffs-conflict-both)') vim.keymap.set('n', 'cb', '<Plug>(diffs-conflict-both)')
vim.keymap.set('n', 'cn', '<Plug>(diffs-conflict-none)') vim.keymap.set('n', 'cn', '<Plug>(diffs-conflict-none)')
vim.keymap.set('n', ']x', '<Plug>(diffs-conflict-next)') vim.keymap.set('n', ']c', '<Plug>(diffs-conflict-next)')
vim.keymap.set('n', '[x', '<Plug>(diffs-conflict-prev)') 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://`
@ -322,10 +483,46 @@ Diff buffer mappings: ~
or the fugitive status keymaps. or the fugitive status keymaps.
============================================================================== ==============================================================================
FUGITIVE STATUS KEYMAPS *diffs-fugitive* INTEGRATIONS *diffs-integrations*
When inside a vim-fugitive |:Git| status buffer, diffs.nvim provides keymaps diffs.nvim integrates with several plugins. There are two attachment
to open unified diffs for files or entire sections. patterns:
Automatic: ~
Enable via config toggles. The plugin registers `FileType` autocmds for
each integration's filetypes and attaches automatically.
>lua
vim.g.diffs = {
integrations = {
fugitive = true,
neogit = true,
gitsigns = true,
},
}
<
Opt-in: ~
For filetypes not covered by a built-in integration, use `extra_filetypes`
to attach to any buffer whose content looks like a diff.
>lua
vim.g.diffs = { extra_filetypes = { 'diff' } }
<
------------------------------------------------------------------------------
FUGITIVE *diffs-fugitive*
Enable vim-fugitive (https://github.com/tpope/vim-fugitive) support: >lua
vim.g.diffs = { integrations = { fugitive = true } }
<
|:Git| status and commit views receive treesitter syntax, line backgrounds,
and intra-line diffs. |:Gdiff| opens a unified diff against any revision
(see |diffs-commands|).
Fugitive status keymaps: ~
When inside a |:Git| status buffer, diffs.nvim provides keymaps to open
unified diffs for files or entire sections.
Keymaps: ~ Keymaps: ~
*diffs-du* *diffs-dU* *diffs-du* *diffs-dU*
@ -345,6 +542,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
@ -355,12 +553,17 @@ Configuration: ~
*diffs.FugitiveConfig* *diffs.FugitiveConfig*
>lua >lua
vim.g.diffs = { vim.g.diffs = {
integrations = {
fugitive = { fugitive = {
horizontal = 'du', -- keymap for horizontal split, false to disable horizontal = 'du', -- keymap for horizontal split, false to disable
vertical = 'dU', -- keymap for vertical split, false to disable vertical = 'dU', -- keymap for vertical split, false to disable
}, },
},
} }
< <
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.
@ -370,6 +573,53 @@ Configuration: ~
Keymap for unified diff in vertical split. Keymap for unified diff in vertical split.
Set to `false` to disable. Set to `false` to disable.
------------------------------------------------------------------------------
NEOGIT *diffs-neogit*
Enable Neogit (https://github.com/NeogitOrg/neogit) support: >lua
vim.g.diffs = { integrations = { neogit = true } }
<
Expanding a diff in a Neogit buffer (e.g., TAB on a file in the status
view) applies treesitter syntax highlighting and intra-line diffs to the
hunk lines.
------------------------------------------------------------------------------
GITSIGNS *diffs-gitsigns*
Enable gitsigns.nvim (https://github.com/lewis6991/gitsigns.nvim) blame
popup highlighting: >lua
vim.g.diffs = { integrations = { gitsigns = true } }
<
`:Gitsigns blame_line full=true` popups receive treesitter syntax, line
backgrounds, and intra-line diffs.
Highlights are applied in a separate `diffs-gitsigns` namespace and do not
interfere with the main decoration provider used for diff buffers.
------------------------------------------------------------------------------
TELESCOPE *diffs-telescope*
Enable telescope.nvim (https://github.com/nvim-telescope/telescope.nvim)
preview highlighting: >lua
vim.g.diffs = { integrations = { telescope = true } }
<
Telescope does not set `filetype=diff` on preview buffers — it calls
`vim.treesitter.start(bufnr, "diff")` directly, so diffs.nvim's `FileType`
autocmd never fires. This integration listens for the
`User TelescopePreviewerLoaded` event and attaches to the preview buffer.
Pickers that show diff content (e.g. `git_bcommits`, `git_status`) will
receive treesitter syntax, line backgrounds, and intra-line diffs in the
preview pane.
Known issue: Telescope's previewer may render the first line of the preview
buffer with a black background regardless of colorscheme. This is a
Telescope artifact unrelated to diffs.nvim. Tracked upstream:
https://github.com/nvim-telescope/telescope.nvim/issues/3626
============================================================================== ==============================================================================
CONFLICT RESOLUTION *diffs-conflict* CONFLICT RESOLUTION *diffs-conflict*
@ -389,13 +639,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',
}, },
}, },
} }
@ -415,9 +667,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
@ -438,10 +718,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.
@ -458,6 +738,31 @@ 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.
============================================================================== ==============================================================================
API *diffs-api* API *diffs-api*
@ -479,8 +784,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:
@ -489,13 +794,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
@ -509,15 +815,14 @@ KNOWN LIMITATIONS *diffs-limitations*
Incomplete Syntax Context ~ Incomplete Syntax Context ~
*diffs-syntax-context* *diffs-syntax-context*
Treesitter parses each diff hunk in isolation. To provide surrounding code Treesitter parses each diff hunk in isolation. When `highlights.context` is
context, diffs.nvim reads lines from disk before and after each hunk enabled (the default), surrounding code is read from the working tree file
(see |diffs.ContextConfig|, enabled by default). This resolves most boundary and fed into the parser to improve accuracy at hunk boundaries. This helps
issues where incomplete constructs (e.g., a function definition at the edge when a hunk is inside a table, function body, or loop whose opening is
of a hunk with no body) would confuse the parser. beyond the hunk's own context lines. Requires `repo_root` and
`file_new_start` to be available on the hunk (true for standard unified
Set `highlights.context.enabled = false` to disable context padding. In rare diffs). In rare cases, hunks that start or end mid-expression may still
cases, context padding may not help if the relevant surrounding code is very produce imperfect highlights due to treesitter error recovery.
far from the hunk boundaries.
Syntax Highlighting Flash ~ Syntax Highlighting Flash ~
*diffs-flash* *diffs-flash*
@ -526,14 +831,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*
@ -575,6 +874,7 @@ character-level groups blend at 60% for more contrast. Line-number groups
combine both: background from the line-level blend, foreground from the combine both: background from the line-level blend, foreground from the
character-level blend. character-level blend.
Fugitive unified diff highlights: ~ Fugitive unified diff highlights: ~
*DiffsAdd* *DiffsAdd*
DiffsAdd Background for `+` lines. Derived by blending DiffsAdd Background for `+` lines. Derived by blending
@ -635,6 +935,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.
@ -687,7 +991,9 @@ ACKNOWLEDGEMENTS *diffs-acknowledgements*
- codediff.nvim (https://github.com/esmuellert/codediff.nvim) - codediff.nvim (https://github.com/esmuellert/codediff.nvim)
- diffview.nvim (https://github.com/sindrets/diffview.nvim) - diffview.nvim (https://github.com/sindrets/diffview.nvim)
- @phanen (https://github.com/phanen) - diff header highlighting, - @phanen (https://github.com/phanen) - diff header highlighting,
treesitter injection support treesitter injection support, blame_hl.nvim (gitsigns blame popup
highlighting inspiration)
- @tris203 (https://github.com/tris203) - support for transparent backgrounds
============================================================================== ==============================================================================
vim:tw=78:ts=8:ft=help:norl: vim:tw=78:ts=8:ft=help:norl:

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
}

53
flake.nix Normal file
View file

@ -0,0 +1,53 @@
{
description = "diffs.nvim syntax highlighting for diffs in Neovim";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
systems.url = "github:nix-systems/default";
};
outputs =
{
nixpkgs,
systems,
...
}:
let
forEachSystem =
f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system});
in
{
formatter = forEachSystem (pkgs: pkgs.nixfmt-tree);
devShells = forEachSystem (pkgs: {
default =
let
ts-plugin = pkgs.vimPlugins.nvim-treesitter.withPlugins (p: [ p.diff ]);
diff-grammar = pkgs.vimPlugins.nvim-treesitter-parsers.diff;
luaEnv = pkgs.luajit.withPackages (
ps: with ps; [
busted
nlua
]
);
busted-with-grammar = pkgs.writeShellScriptBin "busted" ''
nvim_bin=$(which nvim)
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
printf '#!/bin/sh\nexec "%s" --cmd "set rtp+=${ts-plugin}/runtime" --cmd "set rtp+=${diff-grammar}" "$@"\n' "$nvim_bin" > "$tmpdir/nvim"
chmod +x "$tmpdir/nvim"
PATH="$tmpdir:$PATH" exec ${luaEnv}/bin/busted "$@"
'';
in
pkgs.mkShell {
packages = [
busted-with-grammar
pkgs.prettier
pkgs.stylua
pkgs.selene
pkgs.lua-language-server
];
};
});
};
}

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,26 +112,53 @@ 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
local ours_label = get_virtual_text_label('ours', config)
if ours_label then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
virt_text = { { ' (current)', 'DiffsConflictMarker' } }, virt_text = { { ' (' .. ours_label .. ')', 'DiffsConflictMarker' } },
virt_text_pos = 'eol', virt_text_pos = 'eol',
}) })
end end
end
if config.show_actions then
local parts = {}
local actions = {
{ 'Current', config.keymaps.ours },
{ 'Incoming', config.keymaps.theirs },
{ 'Both', config.keymaps.both },
{ 'None', config.keymaps.none },
}
for _, action in ipairs(actions) do
if action[2] then
if #parts > 0 then
table.insert(parts, { ' \226\148\130 ', 'DiffsConflictActions' })
end
table.insert(parts, { ('%s (%s)'):format(action[1], action[2]), 'DiffsConflictActions' })
end
end
if #parts > 0 then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
virt_lines = { parts },
virt_lines_above = true,
})
end
end
for line = region.ours_start, region.ours_end - 1 do for line = region.ours_start, region.ours_end - 1 do
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
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,16 +208,19 @@ 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
local theirs_label = get_virtual_text_label('theirs', config)
if theirs_label then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, {
virt_text = { { ' (incoming)', 'DiffsConflictMarker' } }, virt_text = { { ' (' .. theirs_label .. ')', 'DiffsConflictMarker' } },
virt_text_pos = 'eol', virt_text_pos = 'eol',
}) })
end end
end end
end
end end
---@param cursor_line integer ---@param cursor_line integer
@ -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
@ -417,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

172
lua/diffs/gitsigns.lua Normal file
View file

@ -0,0 +1,172 @@
local M = {}
local api = vim.api
local fn = vim.fn
local dbg = require('diffs.log').dbg
local ns = api.nvim_create_namespace('diffs-gitsigns')
local gs_popup_ns = api.nvim_create_namespace('gitsigns_popup')
local patched = false
---@param bufnr integer
---@param src_filename string
---@param src_ft string?
---@param src_lang string?
---@return diffs.Hunk[]
function M.parse_blame_hunks(bufnr, src_filename, src_ft, src_lang)
local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false)
local hunks = {}
local hunk_lines = {}
local hunk_start = nil
for i, line in ipairs(lines) do
if line:match('^Hunk %d+ of %d+') then
if hunk_start and #hunk_lines > 0 then
table.insert(hunks, {
filename = src_filename,
ft = src_ft,
lang = src_lang,
start_line = hunk_start,
prefix_width = 1,
quote_width = 0,
lines = hunk_lines,
})
end
hunk_lines = {}
hunk_start = i
elseif hunk_start then
if line:match('^%(guessed:') then
hunk_start = i
else
local prefix = line:sub(1, 1)
if prefix == ' ' or prefix == '+' or prefix == '-' then
if #hunk_lines == 0 then
hunk_start = i - 1
end
table.insert(hunk_lines, line)
end
end
end
end
if hunk_start and #hunk_lines > 0 then
table.insert(hunks, {
filename = src_filename,
ft = src_ft,
lang = src_lang,
start_line = hunk_start,
prefix_width = 1,
quote_width = 0,
lines = hunk_lines,
})
end
return hunks
end
---@param preview_winid integer
---@param preview_bufnr integer
local function on_preview(preview_winid, preview_bufnr)
local ok, err = pcall(function()
if not api.nvim_buf_is_valid(preview_bufnr) then
return
end
if not api.nvim_win_is_valid(preview_winid) then
return
end
local win = api.nvim_get_current_win()
if win == preview_winid then
win = fn.win_getid(fn.winnr('#'))
end
if win == -1 or win == 0 or not api.nvim_win_is_valid(win) then
return
end
local srcbuf = api.nvim_win_get_buf(win)
if not api.nvim_buf_is_loaded(srcbuf) then
return
end
local ft = vim.bo[srcbuf].filetype
local name = api.nvim_buf_get_name(srcbuf)
if not name or name == '' then
name = ft and ('a.' .. ft) or 'unknown'
end
local lang = ft and require('diffs.parser').get_lang_from_ft(ft) or nil
local hunks = M.parse_blame_hunks(preview_bufnr, name, ft, lang)
if #hunks == 0 then
return
end
local diff_start = hunks[1].start_line
local last = hunks[#hunks]
local diff_end = last.start_line + #last.lines
api.nvim_buf_clear_namespace(preview_bufnr, gs_popup_ns, diff_start, diff_end)
api.nvim_buf_clear_namespace(preview_bufnr, ns, diff_start, diff_end)
local opts = require('diffs').get_highlight_opts()
local highlight = require('diffs.highlight')
for _, hunk in ipairs(hunks) do
highlight.highlight_hunk(preview_bufnr, ns, hunk, opts)
for j, line in ipairs(hunk.lines) do
local ch = line:sub(1, 1)
if ch == '+' or ch == '-' then
pcall(api.nvim_buf_set_extmark, preview_bufnr, ns, hunk.start_line + j - 1, 0, {
end_col = 1,
hl_group = ch == '+' and '@diff.plus' or '@diff.minus',
priority = opts.highlights.priorities.syntax,
})
end
end
end
dbg('gitsigns blame: highlighted %d hunks in popup buf %d', #hunks, preview_bufnr)
end)
if not ok then
dbg('gitsigns blame error: %s', err)
end
end
---@return boolean
function M.setup()
if patched then
return true
end
local pop_ok, Popup = pcall(require, 'gitsigns.popup')
if not pop_ok or not Popup then
return false
end
Popup.create = (function(orig)
return function(...)
local winid, bufnr = orig(...)
on_preview(winid, bufnr)
return winid, bufnr
end
end)(Popup.create)
Popup.update = (function(orig)
return function(winid, bufnr, ...)
orig(winid, bufnr, ...)
on_preview(winid, bufnr)
end
end)(Popup.update)
patched = true
dbg('gitsigns popup patched')
return true
end
M._test = {
parse_blame_hunks = M.parse_blame_hunks,
on_preview = on_preview,
ns = ns,
gs_popup_ns = gs_popup_ns,
}
return M

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,12 @@ 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
---@field syntax_only? boolean
---@class diffs.TSContext
---@field before string[]?
---@field after string[]?
---@param bufnr integer ---@param bufnr integer
---@param ns integer ---@param ns integer
@ -103,6 +78,9 @@ 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
---@param force_high_priority? boolean
---@param context? diffs.TSContext
---@return integer ---@return integer
local function highlight_treesitter( local function highlight_treesitter(
bufnr, bufnr,
@ -111,9 +89,36 @@ local function highlight_treesitter(
lang, lang,
line_map, line_map,
col_offset, col_offset,
covered_lines covered_lines,
priorities,
force_high_priority,
context
) )
local code = table.concat(code_lines, '\n') local prefix_count = 0
local parse_lines = code_lines
if context then
local before = context.before
local after = context.after
if (before and #before > 0) or (after and #after > 0) then
parse_lines = {}
if before then
prefix_count = #before
for _, l in ipairs(before) do
parse_lines[#parse_lines + 1] = l
end
end
for _, l in ipairs(code_lines) do
parse_lines[#parse_lines + 1] = l
end
if after then
for _, l in ipairs(after) do
parse_lines[#parse_lines + 1] = l
end
end
end
end
local code = table.concat(parse_lines, '\n')
if code == '' then if code == '' then
return 0 return 0
end end
@ -143,6 +148,8 @@ local function highlight_treesitter(
if capture ~= 'spell' and capture ~= 'nospell' then if capture ~= 'spell' and capture ~= 'nospell' then
local capture_name = '@' .. capture .. '.' .. tree_lang local capture_name = '@' .. capture .. '.' .. tree_lang
local sr, sc, er, ec = node:range() local sr, sc, er, ec = node:range()
sr = sr - prefix_count
er = er - prefix_count
local buf_sr = line_map[sr] local buf_sr = line_map[sr]
if buf_sr then if buf_sr then
@ -151,8 +158,10 @@ local function highlight_treesitter(
local buf_sc = sc + col_offset local buf_sc = sc + col_offset
local buf_ec = ec + col_offset local buf_ec = ec + col_offset
local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100) local meta_prio = tonumber(metadata.priority) or 100
or PRIORITY_SYNTAX local priority = tree_lang == 'diff'
and ((col_offset > 0 or force_high_priority) and (priorities.syntax + meta_prio - 100) or meta_prio)
or priorities.syntax
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er, end_row = buf_er,
@ -219,8 +228,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,9 +256,9 @@ 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()
---@param line integer ---@param line integer
---@param col integer ---@param col integer
@ -256,18 +274,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) + (hunk.quote_width or 0) - 1
local extmark_count = 0 local extmark_count = 0
for _, span in ipairs(spans) do for _, span in ipairs(spans) do
local adj = span.line - leading_offset local adj = span.line - leading_offset
if adj >= 1 and adj <= hunk_line_count then if adj >= 1 and adj <= hunk_line_count then
local buf_line = hunk.start_line + adj - 1 local buf_line = hunk.start_line + adj - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { 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 +303,9 @@ end
---@param hunk diffs.Hunk ---@param hunk diffs.Hunk
---@param opts diffs.HunkOpts ---@param opts diffs.HunkOpts
function M.highlight_hunk(bufnr, ns, hunk, opts) function M.highlight_hunk(bufnr, ns, hunk, opts)
local p = opts.highlights.priorities
local pw = hunk.prefix_width or 1
local qw = hunk.quote_width or 0
local use_ts = hunk.lang and opts.highlights.treesitter.enabled local use_ts = hunk.lang and opts.highlights.treesitter.enabled
local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled
@ -300,28 +322,18 @@ 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
---@type string[] ---@type string[]
local new_code = {} local new_code = {}
if use_ts then
---@type table<integer, integer> ---@type table<integer, integer>
local new_map = {} local new_map = {}
---@type string[] ---@type string[]
@ -329,20 +341,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,22 +361,38 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end end
end end
for _, pad_line in ipairs(trailing) do local ts_context = nil
table.insert(new_code, pad_line) if opts.highlights.context.enabled and (hunk.context_before or hunk.context_after) then
table.insert(old_code, pad_line) ts_context = { before = hunk.context_before, after = hunk.context_after }
end end
extmark_count = highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines) extmark_count = highlight_treesitter(
bufnr,
ns,
new_code,
hunk.lang,
new_map,
pw + qw,
covered_lines,
p,
nil,
ts_context
)
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 + qw,
covered_lines,
p,
nil,
ts_context
)
if hunk.header_context and hunk.header_context_col then if hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
end_col = hunk.header_context_col + #hunk.header_context,
hl_group = 'DiffsClear',
priority = PRIORITY_CLEAR,
})
local header_extmarks = highlight_text( local header_extmarks = highlight_text(
bufnr, bufnr,
ns, ns,
@ -375,7 +400,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 +411,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 +429,35 @@ 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,
qw,
nil,
p,
qw > 0 or pw > 1
)
end
local at_raw_line
if (qw > 0 or pw > 1) and opts.highlights.treesitter.enabled then
local at_buf_line = hunk.start_line - 1
at_raw_line = vim.api.nvim_buf_get_lines(bufnr, at_buf_line, at_buf_line + 1, false)[1]
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
not opts.syntax_only
and 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
@ -446,48 +488,150 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end end
end end
if
(qw > 0 or pw > 1)
and hunk.header_start_line
and hunk.header_lines
and #hunk.header_lines > 0
and opts.highlights.treesitter.enabled
then
for i = 0, #hunk.header_lines - 1 do
local buf_line = hunk.header_start_line - 1 + i
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
end_col = #hunk.header_lines[i + 1] + qw,
hl_group = 'DiffsClear',
priority = p.clear,
})
if pw > 1 then
local hline = hunk.header_lines[i + 1]
if hline:match('^index ') then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, qw, {
end_col = 5 + qw,
hl_group = '@keyword.diff',
priority = p.syntax,
})
local dot_pos = hline:find('%.%.', 1, false)
if dot_pos then
local rest = hline:sub(dot_pos + 2)
local hash = rest:match('^(%x+)')
if hash then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, dot_pos + 1 + qw, {
end_col = dot_pos + 1 + #hash + qw,
hl_group = '@constant.diff',
priority = p.syntax,
})
end
end
end
end
end
end
if (qw > 0 or pw > 1) and at_raw_line then
local at_buf_line = hunk.start_line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, {
end_col = #at_raw_line,
hl_group = 'DiffsClear',
priority = p.clear,
})
if opts.highlights.treesitter.enabled then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, qw, {
end_col = #at_raw_line,
hl_group = '@attribute.diff',
priority = p.syntax,
})
end
end
if use_ts and hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
end_col = hunk.header_context_col + #hunk.header_context,
hl_group = 'DiffsClear',
priority = p.clear,
})
end
local raw_body_lines
if qw > 0 then
raw_body_lines =
vim.api.nvim_buf_get_lines(bufnr, hunk.start_line, hunk.start_line + #hunk.lines, false)
end
for i, line in ipairs(hunk.lines) do 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 raw_len = raw_body_lines and #raw_body_lines[i] or nil
local prefix = line:sub(1, pw)
local has_add = prefix:find('+', 1, true) ~= nil
local has_del = prefix:find('-', 1, true) ~= nil
local is_diff_line = has_add or has_del
local line_hl = is_diff_line and (has_add and 'DiffsAdd' or 'DiffsDelete') or nil
local number_hl = is_diff_line and (has_add and 'DiffsAddNr' or 'DiffsDeleteNr') or nil
local 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 not opts.syntax_only then
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 + qw), virt_hl } },
virt_text_pos = 'overlay', virt_text_pos = 'overlay',
}) })
end end
if line_len > 1 and covered_lines[buf_line] then if qw > 0 or pw > 1 then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { local prefix_end = pw + qw
end_col = line_len, if raw_len and prefix_end > raw_len then
prefix_end = raw_len
end
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
end_col = prefix_end,
hl_group = 'DiffsClear', hl_group = 'DiffsClear',
priority = PRIORITY_CLEAR, priority = p.clear,
}) })
for ci = 0, pw - 1 do
local ch = line:sub(ci + 1, ci + 1)
if ch == '+' or ch == '-' then
local char_col = ci + qw
if raw_len and char_col >= raw_len then
break
end
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, char_col, {
end_col = char_col + 1,
hl_group = ch == '+' and '@diff.plus' or '@diff.minus',
priority = p.syntax,
})
end
end
end end
if opts.highlights.background and is_diff_line then if opts.highlights.background and is_diff_line then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
end_row = buf_line + 1, line_hl_group = line_hl,
hl_group = line_hl, number_hl_group = opts.highlights.gutter and number_hl or nil,
hl_eol = true, priority = p.line_bg,
priority = PRIORITY_LINE_BG,
})
if opts.highlights.gutter then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
number_hl_group = number_hl,
priority = PRIORITY_LINE_BG,
}) })
end end
if is_marker and line_len > pw then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, {
end_col = line_len + qw,
hl_group = 'DiffsConflictMarker',
priority = p.char_bg,
})
end 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"',
@ -498,10 +642,11 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
char_hl, char_hl,
line:sub(span.col_start + 1, span.col_end) line:sub(span.col_start + 1, span.col_end)
) )
local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { local ok, err =
end_col = span.col_end, pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + qw, {
end_col = span.col_end + qw,
hl_group = char_hl, 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)
@ -511,6 +656,15 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end end
end end
if line_len > pw and covered_lines[buf_line] then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, {
end_col = line_len + qw,
hl_group = 'DiffsClear',
priority = p.clear,
})
end
end
dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count)
end end

File diff suppressed because it is too large Load diff

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
end
local f = io.open(version_path(), 'w') local f = io.open(version_path(), 'w')
if f then if f then
f:write(EXPECTED_VERSION) f:write(EXPECTED_VERSION)
f:close() f:close()
end end
vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO)
callback(M.load()) handle = M.load()
end
local cbs = pending_callbacks
pending_callbacks = {}
for _, cb in ipairs(cbs) do
cb(handle)
end
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)
if type(val) == 'string' then
enabled = true
log_file = val
else
enabled = val 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,19 @@
---@field file_old_count integer? ---@field file_old_count integer?
---@field file_new_start integer? ---@field file_new_start integer?
---@field file_new_count integer? ---@field file_new_count integer?
---@field prefix_width integer
---@field quote_width integer
---@field repo_root string? ---@field repo_root string?
---@field context_before string[]?
---@field context_after 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[]?
@ -56,6 +63,15 @@ local function get_ft_from_filename(filename, repo_root)
end end
local ft = vim.filetype.match({ filename = filename }) local ft = vim.filetype.match({ filename = filename })
if not ft and vim.fn.did_filetype() ~= 0 then
dbg('retrying filetype match for %s (clearing did_filetype)', filename)
local saved = rawget(vim.fn, 'did_filetype')
rawset(vim.fn, 'did_filetype', function()
return 0
end)
ft = vim.filetype.match({ filename = filename })
rawset(vim.fn, 'did_filetype', saved)
end
if ft then if ft then
dbg('filetype from filename: %s', ft) dbg('filetype from filename: %s', ft)
return ft return ft
@ -106,7 +122,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
@ -114,6 +137,18 @@ end
function M.parse_buffer(bufnr) function M.parse_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local repo_root = get_repo_root(bufnr) local repo_root = get_repo_root(bufnr)
local quote_prefix = nil
local quote_width = 0
for _, l in ipairs(lines) do
local qp = l:match('^(>+ )diff %-%-') or l:match('^(>+ )@@ %-')
if qp then
quote_prefix = qp
quote_width = #qp
break
end
end
---@type diffs.Hunk[] ---@type diffs.Hunk[]
local hunks = {} local hunks = {}
@ -133,6 +168,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 +182,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 current_quote_width = 0
local function flush_hunk() local function flush_hunk()
if hunk_start and #hunk_lines > 0 then if hunk_start and #hunk_lines > 0 then
@ -156,6 +198,8 @@ function M.parse_buffer(bufnr)
header_context = hunk_header_context, header_context = hunk_header_context,
header_context_col = hunk_header_context_col, header_context_col = hunk_header_context_col,
lines = hunk_lines, lines = hunk_lines,
prefix_width = hunk_prefix_width,
quote_width = current_quote_width,
file_old_start = file_old_start, file_old_start = file_old_start,
file_old_count = file_old_count, file_old_count = file_old_count,
file_new_start = file_new_start, file_new_start = file_new_start,
@ -176,51 +220,121 @@ 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 logical = line
if quote_prefix then
if line:sub(1, quote_width) == quote_prefix then
logical = line:sub(quote_width + 1)
elseif line:match('^>+$') then
logical = ''
end
end
local diff_git_file = logical:match('^diff %-%-git a/.+ b/(.+)$')
or logical:match('^diff %-%-combined (.+)$')
or logical:match('^diff %-%-cc (.+)$')
local neogit_file = logical:match('^modified%s+(.+)$')
or (not logical:match('^new file mode') and logical:match('^new file%s+(.+)$'))
or (not logical:match('^deleted file mode') and logical:match('^deleted%s+(.+)$'))
or logical:match('^renamed%s+(.+)$')
or logical:match('^copied%s+(.+)$')
local bare_file = not hunk_start and logical:match('^([^%s]+%.[^%s]+)$')
local filename = logical:match('^[MADRCU%?!]%s+(.+)$')
or diff_git_file
or neogit_file
or bare_file
if filename then if filename then
flush_hunk() flush_hunk()
current_filename = filename current_filename = filename
current_quote_width = (logical ~= line) and quote_width or 0
local cache_key = (repo_root or '') .. '\0' .. filename
local cached = ft_lang_cache[cache_key]
if cached then
current_ft = cached.ft
current_lang = cached.lang
else
current_ft = get_ft_from_filename(filename, repo_root) current_ft = get_ft_from_filename(filename, repo_root)
current_lang = current_ft and get_lang_from_ft(current_ft) or nil current_lang = current_ft and get_lang_from_ft(current_ft) or nil
if current_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 logical: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 = logical:match('^(@@+)')
hunk_prefix_width = #at_prefix - 1
if #at_prefix == 2 then
local hs, hc, hs2, hc2 = logical:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
if hs then if hs then
file_old_start = tonumber(hs) file_old_start = tonumber(hs)
file_old_count = tonumber(hc) or 1 file_old_count = tonumber(hc) or 1
file_new_start = tonumber(hs2) file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1 file_new_count = tonumber(hc2) or 1
old_remaining = file_old_count
new_remaining = file_new_count
end end
local prefix, context = line:match('^(@@.-@@%s*)(.*)') else
local hs, hc = logical:match('%-(%d+),?(%d*)')
if hs then
file_old_start = tonumber(hs)
file_old_count = tonumber(hc) or 1
old_remaining = file_old_count
end
local hs2, hc2 = logical:match('%+(%d+),?(%d*) @@')
if hs2 then
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
new_remaining = file_new_count
end
end
local at_end, context = logical: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 + current_quote_width
end end
if hunk_count then if hunk_count then
hunk_count = hunk_count + 1 hunk_count = hunk_count + 1
end end
elseif hunk_start then elseif hunk_start then
local prefix = line:sub(1, 1) local prefix = logical: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, logical)
if old_remaining and (prefix == ' ' or prefix == '-') then
old_remaining = old_remaining - 1
end
if new_remaining and (prefix == ' ' or prefix == '+') then
new_remaining = new_remaining - 1
end
elseif elseif
line == '' logical == ''
or line:match('^[MADRC%?!]%s+') and old_remaining
or line:match('^diff ') and old_remaining > 0
or line:match('^index ') and new_remaining
or line:match('^Binary ') and new_remaining > 0
then
table.insert(hunk_lines, string.rep(' ', hunk_prefix_width))
old_remaining = old_remaining - 1
new_remaining = new_remaining - 1
elseif
logical == ''
or logical:match('^[MADRC%?!]%s+')
or logical:match('^diff ')
or logical:match('^index ')
or logical:match('^Binary ')
then then
flush_hunk() flush_hunk()
current_filename = nil current_filename = nil
@ -230,7 +344,7 @@ function M.parse_buffer(bufnr)
end end
end end
if header_start and not hunk_start then if header_start and not hunk_start then
table.insert(header_lines, line) table.insert(header_lines, logical)
end end
end end
@ -239,4 +353,10 @@ function M.parse_buffer(bufnr)
return hunks return hunks
end end
M.get_lang_from_ft = get_lang_from_ft
M._test = {
ft_lang_cache = ft_lang_cache,
}
return M return M

View file

@ -5,13 +5,51 @@ vim.g.loaded_diffs = 1
require('diffs.commands').setup() require('diffs.commands').setup()
local function get_raw_integration(key)
local user = vim.g.diffs or {}
local intg = user.integrations or {}
local v = intg[key]
if v ~= nil then
return v
end
return user[key]
end
local gs_cfg = get_raw_integration('gitsigns')
if gs_cfg == true or type(gs_cfg) == 'table' then
if not require('diffs.gitsigns').setup() then
vim.api.nvim_create_autocmd('User', {
pattern = 'GitAttach',
once = true,
callback = function()
require('diffs.gitsigns').setup()
end,
})
end
end
local tel_cfg = get_raw_integration('telescope')
if tel_cfg == true or type(tel_cfg) == 'table' then
vim.api.nvim_create_autocmd('User', {
pattern = 'TelescopePreviewerLoaded',
callback = function()
require('diffs').attach(vim.api.nvim_get_current_buf())
end,
})
end
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' then
local is_fugitive = diffs.get_fugitive_config() and diffs.is_fugitive_buffer(args.buf)
local is_committia = diffs.get_committia_config()
and vim.api.nvim_buf_get_name(args.buf):match('__committia_diff__$')
if not is_fugitive and not is_committia then
return return
end end
end
diffs.attach(args.buf) diffs.attach(args.buf)
if args.match == 'fugitive' then if args.match == 'fugitive' then
@ -82,3 +120,28 @@ end, { desc = 'Jump to next conflict' })
vim.keymap.set('n', '<Plug>(diffs-conflict-prev)', function() vim.keymap.set('n', '<Plug>(diffs-conflict-prev)', function()
require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf()) require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to previous conflict' }) end, { desc = 'Jump to previous conflict' })
local function merge_action(fn)
local bufnr = vim.api.nvim_get_current_buf()
local config = require('diffs').get_conflict_config()
fn(bufnr, config)
end
vim.keymap.set('n', '<Plug>(diffs-merge-ours)', function()
merge_action(require('diffs.merge').resolve_ours)
end, { desc = 'Accept ours in merge diff' })
vim.keymap.set('n', '<Plug>(diffs-merge-theirs)', function()
merge_action(require('diffs.merge').resolve_theirs)
end, { desc = 'Accept theirs in merge diff' })
vim.keymap.set('n', '<Plug>(diffs-merge-both)', function()
merge_action(require('diffs.merge').resolve_both)
end, { desc = 'Accept both in merge diff' })
vim.keymap.set('n', '<Plug>(diffs-merge-none)', function()
merge_action(require('diffs.merge').resolve_none)
end, { desc = 'Reject both in merge diff' })
vim.keymap.set('n', '<Plug>(diffs-merge-next)', function()
require('diffs.merge').goto_next(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to next conflict hunk' })
vim.keymap.set('n', '<Plug>(diffs-merge-prev)', function()
require('diffs.merge').goto_prev(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to previous conflict hunk' })

10
scripts/ci.sh Executable file
View file

@ -0,0 +1,10 @@
#!/bin/sh
set -eu
nix develop --command stylua --check .
git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet
nix develop --command prettier --check .
nix fmt
git diff --exit-code -- '*.nix'
nix develop --command lua-language-server --check . --checklevel=Warning
nix develop --command busted

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)

382
spec/context_spec.lua Normal file
View file

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

View file

@ -0,0 +1,329 @@
require('spec.helpers')
local diffs = require('diffs')
describe('decoration_provider', function()
local function create_buffer(lines)
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
return bufnr
end
local function delete_buffer(bufnr)
if vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end
describe('ensure_cache', function()
it('populates hunk cache for a buffer with diff content', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,2 +1,3 @@',
' local x = 1',
'-local y = 2',
'+local y = 3',
' local z = 4',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.is_not_nil(entry)
assert.is_table(entry.hunks)
assert.is_true(#entry.hunks > 0)
delete_buffer(bufnr)
end)
it('cache tick matches buffer changedtick after attach', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
local tick = vim.api.nvim_buf_get_changedtick(bufnr)
assert.are.equal(tick, entry.tick)
delete_buffer(bufnr)
end)
it('re-parses and advances tick when buffer content changes', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
diffs.attach(bufnr)
local tick_before = diffs._test.hunk_cache[bufnr].tick
vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' })
diffs._test.ensure_cache(bufnr)
local tick_after = diffs._test.hunk_cache[bufnr].tick
assert.is_true(tick_after > tick_before)
delete_buffer(bufnr)
end)
it('skips reparse when fingerprint unchanged but sets pending_clear', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
local original_hunks = entry.hunks
entry.pending_clear = false
local lc = vim.api.nvim_buf_line_count(bufnr)
local bc = vim.api.nvim_buf_get_offset(bufnr, lc)
entry.line_count = lc
entry.byte_count = bc
entry.tick = -1
diffs._test.ensure_cache(bufnr)
local updated = diffs._test.hunk_cache[bufnr]
local current_tick = vim.api.nvim_buf_get_changedtick(bufnr)
assert.are.equal(original_hunks, updated.hunks)
assert.are.equal(current_tick, updated.tick)
assert.is_true(updated.pending_clear)
delete_buffer(bufnr)
end)
it('does nothing for invalid buffer', function()
local bufnr = create_buffer({})
diffs.attach(bufnr)
vim.api.nvim_buf_delete(bufnr, { force = true })
assert.has_no.errors(function()
diffs._test.ensure_cache(bufnr)
end)
end)
end)
describe('pending_clear', function()
it('is true after invalidate_cache', function()
local bufnr = create_buffer({})
diffs.attach(bufnr)
diffs._test.invalidate_cache(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.is_true(entry.pending_clear)
delete_buffer(bufnr)
end)
it('is true immediately after fresh ensure_cache', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.is_true(entry.pending_clear)
delete_buffer(bufnr)
end)
it('clears namespace extmarks when on_buf processes pending_clear', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
diffs.attach(bufnr)
local ns_id = vim.api.nvim_create_namespace('diffs')
vim.api.nvim_buf_set_extmark(bufnr, ns_id, 0, 0, { line_hl_group = 'DiffAdd' })
assert.are.equal(1, #vim.api.nvim_buf_get_extmarks(bufnr, ns_id, 0, -1, {}))
diffs._test.invalidate_cache(bufnr)
diffs._test.ensure_cache(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.is_true(entry.pending_clear)
diffs._test.process_pending_clear(bufnr)
entry = diffs._test.hunk_cache[bufnr]
assert.is_false(entry.pending_clear)
assert.are.same({}, vim.api.nvim_buf_get_extmarks(bufnr, ns_id, 0, -1, {}))
delete_buffer(bufnr)
end)
end)
describe('BufWipeout cleanup', function()
it('removes hunk_cache entry after buffer wipeout', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
diffs.attach(bufnr)
assert.is_not_nil(diffs._test.hunk_cache[bufnr])
vim.api.nvim_buf_delete(bufnr, { force = true })
assert.is_nil(diffs._test.hunk_cache[bufnr])
end)
end)
describe('hunk stability', function()
it('carries forward highlighted for stable hunks on section expansion', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,2 +1,2 @@',
' local x = 1',
'-local y = 2',
'+local y = 3',
'@@ -10,2 +10,3 @@',
' function M.foo()',
'+ return true',
' end',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.are.equal(2, #entry.hunks)
entry.pending_clear = false
entry.highlighted = { [1] = true, [2] = true }
vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, {
'@@ -5,1 +5,2 @@',
' local z = 4',
'+local w = 5',
})
diffs._test.ensure_cache(bufnr)
local updated = diffs._test.hunk_cache[bufnr]
assert.are.equal(3, #updated.hunks)
assert.is_true(updated.highlighted[1] == true)
assert.is_nil(updated.highlighted[2])
assert.is_true(updated.highlighted[3] == true)
assert.is_false(updated.pending_clear)
delete_buffer(bufnr)
end)
it('carries forward highlighted for stable hunks on section collapse', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,2 +1,2 @@',
' local x = 1',
'-local y = 2',
'+local y = 3',
'@@ -5,1 +5,2 @@',
' local z = 4',
'+local w = 5',
'@@ -10,2 +10,3 @@',
' function M.foo()',
'+ return true',
' end',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.are.equal(3, #entry.hunks)
entry.pending_clear = false
entry.highlighted = { [1] = true, [2] = true, [3] = true }
vim.api.nvim_buf_set_lines(bufnr, 5, 8, false, {})
diffs._test.ensure_cache(bufnr)
local updated = diffs._test.hunk_cache[bufnr]
assert.are.equal(2, #updated.hunks)
assert.is_true(updated.highlighted[1] == true)
assert.is_true(updated.highlighted[2] == true)
assert.is_false(updated.pending_clear)
delete_buffer(bufnr)
end)
it('bypasses carry-forward when pending_clear was true', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,2 +1,2 @@',
' local x = 1',
'-local y = 2',
'+local y = 3',
'@@ -10,2 +10,3 @@',
' function M.foo()',
'+ return true',
' end',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
entry.highlighted = { [1] = true, [2] = true }
entry.pending_clear = true
vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, {
'@@ -5,1 +5,2 @@',
' local z = 4',
'+local w = 5',
})
diffs._test.ensure_cache(bufnr)
local updated = diffs._test.hunk_cache[bufnr]
assert.are.same({}, updated.highlighted)
assert.is_true(updated.pending_clear)
delete_buffer(bufnr)
end)
it('does not carry forward when all hunks changed', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,2 +1,2 @@',
' local x = 1',
'-local y = 2',
'+local y = 3',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
entry.pending_clear = false
entry.highlighted = { [1] = true }
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
'M other.lua',
'@@ -1,1 +1,2 @@',
' local a = 1',
'+local b = 2',
})
diffs._test.ensure_cache(bufnr)
local updated = diffs._test.hunk_cache[bufnr]
assert.is_nil(updated.highlighted[1])
assert.is_true(updated.pending_clear)
delete_buffer(bufnr)
end)
end)
describe('multiple hunks in cache', function()
it('stores all parsed hunks for a multi-hunk buffer', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,2 +1,2 @@',
' local x = 1',
'-local y = 2',
'+local y = 3',
'@@ -10,2 +10,3 @@',
' function M.foo()',
'+ return true',
' end',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.is_not_nil(entry)
assert.are.equal(2, #entry.hunks)
delete_buffer(bufnr)
end)
it('stores empty hunks table for buffer with no diff content', function()
local bufnr = create_buffer({
'Head: main',
'Help: g?',
'',
'Nothing to see here',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.is_not_nil(entry)
assert.are.same({}, entry.hunks)
delete_buffer(bufnr)
end)
end)
end)

477
spec/email_quote_spec.lua Normal file
View file

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

View file

@ -87,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)',

236
spec/gitsigns_spec.lua Normal file
View file

@ -0,0 +1,236 @@
require('spec.helpers')
local gs = require('diffs.gitsigns')
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 bufnr and vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end
describe('gitsigns', function()
describe('parse_blame_hunks', function()
it('parses a single hunk', function()
local bufnr = create_buffer({
'commit abc1234',
'Author: Test User',
'',
'Hunk 1 of 1',
' local x = 1',
'-local y = 2',
'+local y = 3',
' local z = 4',
})
local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua')
assert.are.equal(1, #hunks)
assert.are.equal('test.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
assert.are.equal('lua', hunks[1].lang)
assert.are.equal(1, hunks[1].prefix_width)
assert.are.equal(0, hunks[1].quote_width)
assert.are.equal(4, #hunks[1].lines)
assert.are.equal(4, hunks[1].start_line)
assert.are.equal(' local x = 1', hunks[1].lines[1])
assert.are.equal('-local y = 2', hunks[1].lines[2])
assert.are.equal('+local y = 3', hunks[1].lines[3])
delete_buffer(bufnr)
end)
it('parses multiple hunks', function()
local bufnr = create_buffer({
'commit abc1234',
'',
'Hunk 1 of 2',
'-local a = 1',
'+local a = 2',
'Hunk 2 of 2',
' local b = 3',
'+local c = 4',
})
local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua')
assert.are.equal(2, #hunks)
assert.are.equal(2, #hunks[1].lines)
assert.are.equal(2, #hunks[2].lines)
delete_buffer(bufnr)
end)
it('skips guessed-offset lines', function()
local bufnr = create_buffer({
'commit abc1234',
'',
'Hunk 1 of 1',
'(guessed: hunk offset may be wrong)',
' local x = 1',
'+local y = 2',
})
local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua')
assert.are.equal(1, #hunks)
assert.are.equal(2, #hunks[1].lines)
assert.are.equal(' local x = 1', hunks[1].lines[1])
delete_buffer(bufnr)
end)
it('returns empty table when no hunks present', function()
local bufnr = create_buffer({
'commit abc1234',
'Author: Test User',
'Date: 2024-01-01',
})
local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua')
assert.are.equal(0, #hunks)
delete_buffer(bufnr)
end)
it('handles hunk with no diff lines after header', function()
local bufnr = create_buffer({
'Hunk 1 of 1',
'some non-diff text',
})
local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua')
assert.are.equal(0, #hunks)
delete_buffer(bufnr)
end)
end)
describe('on_preview', function()
before_each(function()
setup_highlight_groups()
end)
it('applies extmarks to popup buffer with diff content', function()
local bufnr = create_buffer({
'commit abc1234',
'',
'Hunk 1 of 1',
' local x = 1',
'-local y = 2',
'+local y = 3',
})
local winid = vim.api.nvim_open_win(bufnr, false, {
relative = 'editor',
width = 40,
height = 10,
row = 0,
col = 0,
})
gs._test.on_preview(winid, bufnr)
local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, gs._test.ns, 0, -1, { details = true })
assert.is_true(#extmarks > 0)
vim.api.nvim_win_close(winid, true)
delete_buffer(bufnr)
end)
it('clears gitsigns_popup namespace on diff region', function()
local bufnr = create_buffer({
'commit abc1234',
'',
'Hunk 1 of 1',
' local x = 1',
'+local y = 2',
})
vim.api.nvim_buf_set_extmark(bufnr, gs._test.gs_popup_ns, 3, 0, {
end_col = 12,
hl_group = 'GitSignsAddPreview',
})
vim.api.nvim_buf_set_extmark(bufnr, gs._test.gs_popup_ns, 4, 0, {
end_col = 12,
hl_group = 'GitSignsAddPreview',
})
local winid = vim.api.nvim_open_win(bufnr, false, {
relative = 'editor',
width = 40,
height = 10,
row = 0,
col = 0,
})
gs._test.on_preview(winid, bufnr)
local gs_extmarks =
vim.api.nvim_buf_get_extmarks(bufnr, gs._test.gs_popup_ns, 0, -1, { details = true })
assert.are.equal(0, #gs_extmarks)
vim.api.nvim_win_close(winid, true)
delete_buffer(bufnr)
end)
it('does not error on invalid buffer', function()
assert.has_no.errors(function()
gs._test.on_preview(0, 99999)
end)
end)
end)
describe('setup', function()
it('returns false when gitsigns.popup is not available', function()
local saved = package.loaded['gitsigns.popup']
package.loaded['gitsigns.popup'] = nil
package.preload['gitsigns.popup'] = nil
local fresh = loadfile('lua/diffs/gitsigns.lua')()
local result = fresh.setup()
assert.is_false(result)
package.loaded['gitsigns.popup'] = saved
end)
it('patches gitsigns.popup when available', function()
local create_called = false
local update_called = false
local mock_popup = {
create = function()
create_called = true
local bufnr = create_buffer({ 'test' })
local winid = vim.api.nvim_open_win(bufnr, false, {
relative = 'editor',
width = 10,
height = 1,
row = 0,
col = 0,
})
return winid, bufnr
end,
update = function()
update_called = true
end,
}
local saved = package.loaded['gitsigns.popup']
package.loaded['gitsigns.popup'] = mock_popup
local fresh = loadfile('lua/diffs/gitsigns.lua')()
local result = fresh.setup()
assert.is_true(result)
mock_popup.create()
assert.is_true(create_called)
mock_popup.update(0, 0)
assert.is_true(update_called)
package.loaded['gitsigns.popup'] = saved
end)
end)
end)

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,265 @@ 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 integrations.fugitive = true', function()
local fts = compute({ integrations = { fugitive = true } })
assert.is_true(vim.tbl_contains(fts, 'fugitive'))
end)
it('includes fugitive when integrations.fugitive is a table', function()
local fts = compute({ integrations = { fugitive = { horizontal = 'dd' } } })
assert.is_true(vim.tbl_contains(fts, 'fugitive'))
end)
it('excludes fugitive when integrations.fugitive = false', function()
local fts = compute({ integrations = { fugitive = false } })
assert.is_false(vim.tbl_contains(fts, 'fugitive'))
end)
it('excludes fugitive when integrations.fugitive is nil', function()
local fts = compute({ integrations = {} })
assert.is_false(vim.tbl_contains(fts, 'fugitive'))
end)
it('includes neogit filetypes when integrations.neogit = true', function()
local fts = compute({ integrations = { 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 integrations.neogit is a table', function()
local fts = compute({ integrations = { neogit = {} } })
assert.is_true(vim.tbl_contains(fts, 'NeogitStatus'))
end)
it('excludes neogit when integrations.neogit = false', function()
local fts = compute({ integrations = { neogit = false } })
assert.is_false(vim.tbl_contains(fts, 'NeogitStatus'))
end)
it('excludes neogit when integrations.neogit is nil', function()
local fts = compute({ integrations = {} })
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 integrations and extra_filetypes', function()
local fts = compute({
integrations = { 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)
it('falls back to legacy top-level fugitive key', function()
local fts = compute({ fugitive = true })
assert.is_true(vim.tbl_contains(fts, 'fugitive'))
end)
it('falls back to legacy top-level neogit key', function()
local fts = compute({ neogit = true })
assert.is_true(vim.tbl_contains(fts, 'NeogitStatus'))
end)
it('prefers integrations key over legacy top-level key', function()
local fts = compute({ integrations = { fugitive = false }, fugitive = true })
assert.is_false(vim.tbl_contains(fts, 'fugitive'))
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')
@ -269,4 +527,77 @@ describe('diffs', function()
end) end)
end) end)
end) end)
describe('compute_highlight_groups', function()
local saved_get_hl, saved_set_hl, saved_schedule
local set_calls, schedule_cbs
before_each(function()
saved_get_hl = vim.api.nvim_get_hl
saved_set_hl = vim.api.nvim_set_hl
saved_schedule = vim.schedule
set_calls = {}
schedule_cbs = {}
vim.api.nvim_set_hl = function(_, group, opts)
set_calls[group] = opts
end
vim.schedule = function(cb)
table.insert(schedule_cbs, cb)
end
diffs._test.set_hl_retry_pending(false)
end)
after_each(function()
vim.api.nvim_get_hl = saved_get_hl
vim.api.nvim_set_hl = saved_set_hl
vim.schedule = saved_schedule
diffs._test.set_hl_retry_pending(false)
end)
it('sets DiffsClear.bg to a number when Normal.bg is nil', function()
vim.api.nvim_get_hl = function(ns, opts)
if opts.name == 'Normal' then
return { fg = 0xc0c0c0 }
end
return saved_get_hl(ns, opts)
end
diffs._test.compute_highlight_groups()
assert.is_number(set_calls.DiffsClear.bg)
assert.is_table(set_calls.DiffsAdd)
assert.is_table(set_calls.DiffsDelete)
end)
it('retries once then stops when Normal.bg stays nil', function()
vim.api.nvim_get_hl = function(ns, opts)
if opts.name == 'Normal' then
return { fg = 0xc0c0c0 }
end
return saved_get_hl(ns, opts)
end
diffs._test.compute_highlight_groups()
assert.are.equal(1, #schedule_cbs)
schedule_cbs[1]()
assert.are.equal(1, #schedule_cbs)
assert.is_true(diffs._test.get_hl_retry_pending())
end)
it('picks up bg on retry when colorscheme loads late', function()
local call_count = 0
vim.api.nvim_get_hl = function(ns, opts)
if opts.name == 'Normal' then
call_count = call_count + 1
if call_count <= 1 then
return { fg = 0xc0c0c0 }
end
return { fg = 0xc0c0c0, bg = 0x1e1e2e }
end
return saved_get_hl(ns, opts)
end
diffs._test.compute_highlight_groups()
assert.are.equal(1, #schedule_cbs)
schedule_cbs[1]()
assert.is_number(set_calls.DiffsClear.bg)
assert.are.equal(1, #schedule_cbs)
end)
end)
end) end)

434
spec/integration_spec.lua Normal file
View file

@ -0,0 +1,434 @@
require('spec.helpers')
local diffs = require('diffs')
local highlight = require('diffs.highlight')
local function setup_highlight_groups()
local normal = vim.api.nvim_get_hl(0, { name = 'Normal' })
local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' })
local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' })
vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 })
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg or 0x2e4a3a })
vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg or 0x4a2e3a })
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
end
local function create_buffer(lines)
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
return bufnr
end
local function delete_buffer(bufnr)
if vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end
local function get_diffs_ns()
return vim.api.nvim_get_namespaces()['diffs']
end
local function get_extmarks(bufnr, ns)
return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })
end
local function highlight_opts_with_background()
return {
hide_prefix = false,
highlights = {
background = true,
gutter = false,
context = { enabled = false, lines = 0 },
treesitter = { enabled = true, max_lines = 500 },
vim = { enabled = false, max_lines = 200 },
intra = { enabled = false, algorithm = 'default', max_lines = 500 },
priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 },
},
}
end
describe('integration', function()
before_each(function()
setup_highlight_groups()
end)
describe('attach and parse', function()
it('attach populates hunk cache for unified diff buffer', function()
local bufnr = create_buffer({
'diff --git a/foo.lua b/foo.lua',
'index abc..def 100644',
'--- a/foo.lua',
'+++ b/foo.lua',
'@@ -1,3 +1,3 @@',
' local x = 1',
'-local y = 2',
'+local y = 3',
' local z = 4',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.is_not_nil(entry)
assert.are.equal(1, #entry.hunks)
assert.are.equal('foo.lua', entry.hunks[1].filename)
delete_buffer(bufnr)
end)
it('attach parses multiple hunks across multiple files', function()
local bufnr = create_buffer({
'M foo.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
'M bar.lua',
'@@ -1,1 +1,2 @@',
' local a = 1',
'+local b = 2',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.is_not_nil(entry)
assert.are.equal(2, #entry.hunks)
delete_buffer(bufnr)
end)
it('re-attach on same buffer is idempotent', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
diffs.attach(bufnr)
local entry_before = diffs._test.hunk_cache[bufnr]
local tick_before = entry_before.tick
diffs.attach(bufnr)
local entry_after = diffs._test.hunk_cache[bufnr]
assert.are.equal(tick_before, entry_after.tick)
delete_buffer(bufnr)
end)
it('refresh after content change invalidates cache', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
diffs.attach(bufnr)
local tick_before = diffs._test.hunk_cache[bufnr].tick
vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' })
diffs.refresh(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.are.equal(-1, entry.tick)
assert.is_true(entry.pending_clear)
assert.is_true(tick_before >= 0)
delete_buffer(bufnr)
end)
end)
describe('ft_retry_pending', function()
before_each(function()
rawset(vim.fn, 'did_filetype', function()
return 1
end)
require('diffs.parser')._test.ft_lang_cache = {}
end)
after_each(function()
rawset(vim.fn, 'did_filetype', nil)
end)
it('sets ft_retry_pending when nil-ft hunks detected under did_filetype', function()
local bufnr = create_buffer({
'diff --git a/app.conf b/app.conf',
'@@ -1,2 +1,2 @@',
' server {',
'- listen 80;',
'+ listen 8080;',
})
diffs.attach(bufnr)
local entry = diffs._test.hunk_cache[bufnr]
assert.is_not_nil(entry)
assert.is_nil(entry.hunks[1].ft)
assert.is_true(diffs._test.ft_retry_pending[bufnr] == true)
delete_buffer(bufnr)
end)
it('clears ft_retry_pending after scheduled callback fires', function()
local bufnr = create_buffer({
'diff --git a/app.conf b/app.conf',
'@@ -1,2 +1,2 @@',
' server {',
'- listen 80;',
'+ listen 8080;',
})
diffs.attach(bufnr)
assert.is_true(diffs._test.ft_retry_pending[bufnr] == true)
local done = false
vim.schedule(function()
done = true
end)
vim.wait(1000, function()
return done
end)
assert.is_nil(diffs._test.ft_retry_pending[bufnr])
delete_buffer(bufnr)
end)
it('invalidates cache after scheduled callback fires', function()
local bufnr = create_buffer({
'diff --git a/app.conf b/app.conf',
'@@ -1,2 +1,2 @@',
' server {',
'- listen 80;',
'+ listen 8080;',
})
diffs.attach(bufnr)
local tick_after_attach = diffs._test.hunk_cache[bufnr].tick
assert.is_true(tick_after_attach >= 0)
local done = false
vim.schedule(function()
done = true
end)
vim.wait(1000, function()
return done
end)
local entry = diffs._test.hunk_cache[bufnr]
assert.are.equal(-1, entry.tick)
assert.is_true(entry.pending_clear)
delete_buffer(bufnr)
end)
it('does not set ft_retry_pending when did_filetype() is zero', function()
rawset(vim.fn, 'did_filetype', nil)
local bufnr = create_buffer({
'diff --git a/test.sh b/test.sh',
'@@ -1,2 +1,3 @@',
' #!/usr/bin/env bash',
'-old line',
'+new line',
})
diffs.attach(bufnr)
assert.is_falsy(diffs._test.ft_retry_pending[bufnr])
delete_buffer(bufnr)
end)
it('does not set ft_retry_pending for files with resolvable ft', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
diffs.attach(bufnr)
assert.is_falsy(diffs._test.ft_retry_pending[bufnr])
delete_buffer(bufnr)
end)
end)
describe('extmarks from highlight pipeline', function()
it('DiffsAdd background applied to + lines', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local ns = vim.api.nvim_create_namespace('diffs_integration_test_add')
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background())
local extmarks = get_extmarks(bufnr, ns)
local has_diff_add = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
has_diff_add = true
break
end
end
assert.is_true(has_diff_add)
delete_buffer(bufnr)
end)
it('DiffsDelete background applied to - lines', function()
local bufnr = create_buffer({
'@@ -1,2 +1,1 @@',
' local x = 1',
'-local y = 2',
})
local ns = vim.api.nvim_create_namespace('diffs_integration_test_del')
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '-local y = 2' },
}
highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background())
local extmarks = get_extmarks(bufnr, ns)
local has_diff_delete = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then
has_diff_delete = true
break
end
end
assert.is_true(has_diff_delete)
delete_buffer(bufnr)
end)
it('mixed hunk produces both DiffsAdd and DiffsDelete backgrounds', function()
local bufnr = create_buffer({
'@@ -1,2 +1,2 @@',
'-local x = 1',
'+local x = 2',
})
local ns = vim.api.nvim_create_namespace('diffs_integration_test_mixed')
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local x = 2' },
}
highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background())
local extmarks = get_extmarks(bufnr, ns)
local has_add = false
local has_delete = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
has_add = true
end
if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then
has_delete = true
end
end
assert.is_true(has_add)
assert.is_true(has_delete)
delete_buffer(bufnr)
end)
it('no background extmarks for context lines', function()
local bufnr = create_buffer({
'@@ -1,3 +1,3 @@',
' local x = 1',
'-local y = 2',
'+local y = 3',
' local z = 4',
})
local ns = vim.api.nvim_create_namespace('diffs_integration_test_ctx')
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '-local y = 2', '+local y = 3', ' local z = 4' },
}
highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background())
local extmarks = get_extmarks(bufnr, ns)
local line_bgs = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then
line_bgs[mark[2]] = d.line_hl_group
end
end
assert.is_nil(line_bgs[1])
assert.is_nil(line_bgs[4])
assert.are.equal('DiffsDelete', line_bgs[2])
assert.are.equal('DiffsAdd', line_bgs[3])
delete_buffer(bufnr)
end)
it('treesitter extmarks applied for lua hunks', function()
local bufnr = create_buffer({
'@@ -1,2 +1,3 @@',
' local x = 1',
'+local y = 2',
' return x',
})
local ns = vim.api.nvim_create_namespace('diffs_integration_test_ts')
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2', ' return x' },
}
highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background())
local extmarks = get_extmarks(bufnr, ns)
local has_ts = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then
has_ts = true
break
end
end
assert.is_true(has_ts)
delete_buffer(bufnr)
end)
it('diffs namespace exists after attach', function()
local bufnr = create_buffer({
'M test.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
diffs.attach(bufnr)
local ns = get_diffs_ns()
assert.is_not_nil(ns)
assert.is_number(ns)
delete_buffer(bufnr)
end)
end)
describe('multiple hunks highlighting', function()
it('both hunks in multi-hunk buffer get background extmarks', function()
local bufnr = create_buffer({
'@@ -1,2 +1,2 @@',
'-local x = 1',
'+local x = 10',
'@@ -10,2 +10,2 @@',
'-local y = 2',
'+local y = 20',
})
local ns = vim.api.nvim_create_namespace('diffs_integration_test_multi')
local hunk1 = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local x = 10' },
}
local hunk2 = {
filename = 'test.lua',
lang = 'lua',
start_line = 4,
lines = { '-local y = 2', '+local y = 20' },
}
highlight.highlight_hunk(bufnr, ns, hunk1, highlight_opts_with_background())
highlight.highlight_hunk(bufnr, ns, hunk2, highlight_opts_with_background())
local extmarks = get_extmarks(bufnr, ns)
local add_lines = {}
local del_lines = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.line_hl_group == 'DiffsAdd' then
add_lines[mark[2]] = true
end
if d and d.line_hl_group == 'DiffsDelete' then
del_lines[mark[2]] = true
end
end
assert.is_true(del_lines[1] ~= nil)
assert.is_true(add_lines[2] ~= nil)
assert.is_true(del_lines[4] ~= nil)
assert.is_true(add_lines[5] ~= nil)
delete_buffer(bufnr)
end)
end)
end)

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

View file

@ -163,10 +163,10 @@ describe('parser', function()
end end
end) end)
it('stops hunk at blank line', function() it('stops hunk at blank line when remaining counts exhausted', function()
local bufnr = create_buffer({ local bufnr = create_buffer({
'M test.lua', 'M test.lua',
'@@ -1,2 +1,3 @@', '@@ -1,1 +1,2 @@',
' local x = 1', ' local x = 1',
'+local y = 2', '+local y = 2',
'', '',
@ -391,35 +391,27 @@ 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() it('detects filetype for .sh files when did_filetype() is non-zero', function()
local repo_root = '/tmp/diffs-test-shebang-py' rawset(vim.fn, 'did_filetype', function()
vim.fn.mkdir(repo_root, 'p') return 1
end)
local file_path = repo_root .. '/deploy' parser._test.ft_lang_cache = {}
local f = io.open(file_path, 'w') local bufnr = create_buffer({
f:write('#!/usr/bin/env python3\n') 'diff --git a/test.sh b/test.sh',
f:write('import sys\n') '@@ -1,3 +1,4 @@',
f:write('print("hi")\n') ' #!/usr/bin/env bash',
f:close() ' set -euo pipefail',
'-echo "running tests..."',
local diff_buf = create_buffer({ '+echo "running tests with coverage..."',
'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(bufnr)
local hunks = parser.parse_buffer(diff_buf)
assert.are.equal(1, #hunks) assert.are.equal(1, #hunks)
assert.are.equal('deploy', hunks[1].filename) assert.are.equal('test.sh', hunks[1].filename)
assert.are.equal('python', hunks[1].ft) assert.are.equal('sh', hunks[1].ft)
delete_buffer(bufnr)
delete_buffer(diff_buf) rawset(vim.fn, 'did_filetype', nil)
os.remove(file_path)
vim.fn.delete(repo_root, 'rf')
end) end)
it('extracts file line numbers from @@ header', function() it('extracts file line numbers from @@ header', function()
@ -440,22 +432,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 +448,154 @@ 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.are.equal(1, hunks[1].file_old_start)
assert.are.equal(3, hunks[1].file_old_count)
delete_buffer(bufnr)
end)
it('extracts header context from combined diff header', function()
local bufnr = create_buffer({
'U test.lua',
'@@@ -1,3 -1,5 +1,9 @@@ function M.greet()',
' local M = {}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('function M.greet()', hunks[1].header_context)
delete_buffer(bufnr)
end)
it('resets prefix_width when switching from combined to unified diff', function()
local bufnr = create_buffer({
'U merge.lua',
'@@@ -1,1 -1,1 +1,3 @@@',
' local M = {}',
'++<<<<<<< HEAD',
'M other.lua',
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(2, #hunks)
assert.are.equal(2, hunks[1].prefix_width)
assert.are.equal(1, hunks[2].prefix_width)
delete_buffer(bufnr)
end)
it('parses diff from gitcommit verbose buffer', function()
local bufnr = create_buffer({
'',
'# Please enter the commit message for your changes.',
'#',
'# On branch main',
'# Changes to be committed:',
'#\tmodified: test.lua',
'#',
'# ------------------------ >8 ------------------------',
'# Do not modify or remove the line above.',
'diff --git a/test.lua b/test.lua',
'index abc1234..def5678 100644',
'--- a/test.lua',
'+++ b/test.lua',
'@@ -1,3 +1,3 @@',
' local function hello()',
'- print("hello world")',
'+ print("hello universe")',
' return true',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('test.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
assert.are.equal(4, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('stores repo_root on hunk when available', function() 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 +612,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