Compare commits

..

8 commits

Author SHA1 Message Date
a6dd0503b3 fix(highlight): support combined diff format for unmerged files
Problem: fugitive shows combined diffs (@@@ headers, 2-char prefixes)
for unmerged (UU) files. The parser and highlight pipeline assumed
unified diff format (@@, 1-char prefix), causing broken prefix
concealment, missing background colors on ` +`/`+ ` lines, and no
treesitter highlights due to garbage prefix chars in code arrays.

Solution: detect prefix width from the number of leading @ signs in
hunk headers. Propagate prefix_width through the parser (new field on
diffs.Hunk) and highlight pipeline (prefix stripping, col_offset,
concealment, line classification). Add U to filename pattern for
unmerged file detection. Skip intra-line diffing for combined diffs
since the 2-char prefix semantics don't produce meaningful change
groups.
2026-02-09 17:03:17 -05:00
Barrett Ruth
59fcf14817
Docs/readme vscode diff (#104)
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
2026-02-09 16:34:15 -05:00
Barrett Ruth
2d7d26a1bc
docs(readme): mention vscode-diff algorithm and credit @esmuellert (#103)
## Problem

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

## Solution

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

Three minor issues remain before the v0.2.0 release:

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

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

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

## Solution

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

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

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

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

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

## Solution

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

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

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

## Solution

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

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

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

## Solution

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

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

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

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

View file

@ -9,18 +9,14 @@ syntax highlighting.
## Features
- Treesitter syntax highlighting in `:Git` diffs and commit views
- Diff header highlighting (`diff --git`, `index`, `---`, `+++`)
- `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds
- `:Gdiff` unified diff against any git revision with syntax highlighting
- Fugitive status buffer keymaps (`du`/`dU`) for unified diffs
- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`)
- Vim syntax fallback for languages without a treesitter parser
- Hunk header context highlighting (`@@ ... @@ function foo()`)
- Character-level (intra-line) diff highlighting for changed characters
- Inline merge conflict detection, highlighting, and resolution keymaps
- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and
highlight overrides
- Treesitter syntax highlighting in fugitive diffs and commit views
- Character-level intra-line diff highlighting (with optional
[vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for
word-level accuracy)
- `:Gdiff` unified diff against any revision
- Background-only diff colors for `&diff` buffers
- Inline merge conflict detection, highlighting, and resolution
- Vim syntax fallback, configurable blend/debounce/priorities
## Requirements
@ -44,10 +40,9 @@ luarocks install diffs.nvim
## Known Limitations
- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation.
To improve accuracy, `diffs.nvim` reads lines from disk before and after each
hunk for parsing context (`highlights.context`, enabled by default with 25
lines). This resolves most boundary issues. Set
`highlights.context.enabled = false` to disable.
Context lines within the hunk (` ` prefix) provide syntactic context for the
parser. In rare cases, hunks that start or end mid-expression may produce
imperfect highlights due to treesitter error recovery.
- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event
triggered by `vim-fugitive`, at which point the buffer is preliminarily
@ -70,7 +65,9 @@ luarocks install diffs.nvim
# Acknowledgements
- [`vim-fugitive`](https://github.com/tpope/vim-fugitive)
- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim)
- [@esmuellert](https://github.com/esmuellert) /
[`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - vscode-diff
algorithm FFI backend for word-level intra-line accuracy
- [`diffview.nvim`](https://github.com/sindrets/diffview.nvim)
- [`difftastic`](https://github.com/Wilfred/difftastic)
- [`mini.diff`](https://github.com/echasnovski/mini.diff)

View file

@ -75,6 +75,12 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
algorithm = 'default',
max_lines = 500,
},
priorities = {
clear = 198,
syntax = 199,
line_bg = 200,
char_bg = 201,
},
overrides = {},
},
fugitive = {
@ -85,6 +91,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
show_actions = false,
keymaps = {
ours = 'doo',
theirs = 'dot',
@ -160,6 +167,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
Character-level (intra-line) diff highlighting.
See |diffs.IntraConfig| for fields.
{priorities} (table, default: see below)
Extmark priority values.
See |diffs.PrioritiesConfig| for fields.
{overrides} (table, default: {})
Map of highlight group names to highlight
definitions (see |nvim_set_hl()|). Applied
@ -181,6 +192,28 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
direction. Lines are read with early exit —
cost scales with this value, not file size.
*diffs.PrioritiesConfig*
Priorities config fields: ~
{clear} (integer, default: 198)
Priority for `DiffsClear` extmarks that reset
underlying diff foreground colors. Must be
below {syntax}.
{syntax} (integer, default: 199)
Priority for treesitter and vim syntax extmarks.
Must be below {line_bg} so that colorscheme
backgrounds on syntax groups do not obscure
line-level diff backgrounds.
{line_bg} (integer, default: 200)
Priority for `DiffsAdd`/`DiffsDelete` line
background extmarks. Must be below {char_bg}.
{char_bg} (integer, default: 201)
Priority for `DiffsAddText`/`DiffsDeleteText`
character-level background extmarks. Highest
priority so changed characters stand out.
*diffs.TreesitterConfig*
Treesitter config fields: ~
{enabled} (boolean, default: true)
@ -315,6 +348,32 @@ Example configuration: >lua
vim.keymap.set('n', '[x', '<Plug>(diffs-conflict-prev)')
<
*<Plug>(diffs-merge-ours)*
<Plug>(diffs-merge-ours)
Accept ours in a merge diff view. Resolves the
conflict in the working file with ours content.
*<Plug>(diffs-merge-theirs)*
<Plug>(diffs-merge-theirs)
Accept theirs in a merge diff view.
*<Plug>(diffs-merge-both)*
<Plug>(diffs-merge-both)
Accept both (ours then theirs) in a merge diff view.
*<Plug>(diffs-merge-none)*
<Plug>(diffs-merge-none)
Reject both in a merge diff view.
*<Plug>(diffs-merge-next)*
<Plug>(diffs-merge-next)
Jump to next unresolved conflict hunk in merge diff.
*<Plug>(diffs-merge-prev)*
<Plug>(diffs-merge-prev)
Jump to previous unresolved conflict hunk in merge
diff.
Diff buffer mappings: ~
*diffs-q*
q Close the diff window. Available in all `diffs://`
@ -345,6 +404,7 @@ Behavior by file status: ~
A Staged (empty) index file as all-added
D Staged HEAD (empty) file as all-removed
R Staged HEAD:oldname index:newname content diff
U Unstaged :2: (ours) :3: (theirs) merge diff
? Untracked (empty) working tree file as all-added
On section headers, the keymap runs `git diff` (or `git diff --cached` for
@ -389,6 +449,8 @@ Configuration: ~
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
show_actions = false,
priority = 200,
keymaps = {
ours = 'doo',
theirs = 'dot',
@ -415,9 +477,37 @@ Configuration: ~
diagnostics alone.
{show_virtual_text} (boolean, default: true)
Show virtual text labels (" current" and
" incoming") at the end of `<<<<<<<` and
`>>>>>>>` marker lines.
Show `(current)` and `(incoming)` labels at
the end of `<<<<<<<` and `>>>>>>>` marker
lines. Also controls hunk hints in merge
diff views.
{format_virtual_text} (function|nil, default: nil)
Custom formatter for virtual text labels.
Receives `(side, keymap)` where `side` is
`"ours"` or `"theirs"` and `keymap` is the
configured keymap string or `false`. Return
a string (label text without parens) or
`nil` to hide the label. Example: >lua
format_virtual_text = function(side, keymap)
if keymap then
return side .. ' [' .. keymap .. ']'
end
return side
end
<
{show_actions} (boolean, default: false)
Show a codelens-style action line above each
`<<<<<<<` marker listing available resolution
keymaps. Renders as virtual lines using the
`DiffsConflictActions` highlight group.
Only keymaps that are not `false` appear.
{priority} (integer, default: 200)
Extmark priority for conflict region
backgrounds and markers. Adjust if other
plugins use the same priority range.
{keymaps} (table, default: see above)
Buffer-local keymaps for conflict resolution
@ -458,6 +548,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`/`]x`/`[x`)
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
- `]x`/`[x` 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*
@ -489,12 +604,13 @@ Summary / commit detail views: ~
- Code is parsed with |vim.treesitter.get_string_parser()|
- If no treesitter parser and `vim.enabled`: vim syntax fallback via
scratch buffer and |synID()|
- `Normal` extmarks at priority 198 clear underlying diff foreground
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199
- Syntax highlights are applied as extmarks at priority 200
- `DiffsClear` extmarks at priority 198 clear underlying diff foreground
- Syntax highlights are applied as extmarks at priority 199
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 200
- Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at
priority 201 overlay changed characters with an intense background
- Conceal extmarks hide diff prefixes when `hide_prefix` is enabled
- All priorities are configurable via |diffs.PrioritiesConfig|
4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events
Diff mode views: ~
@ -509,15 +625,10 @@ KNOWN LIMITATIONS *diffs-limitations*
Incomplete Syntax Context ~
*diffs-syntax-context*
Treesitter parses each diff hunk in isolation. To provide surrounding code
context, diffs.nvim reads lines from disk before and after each hunk
(see |diffs.ContextConfig|, enabled by default). This resolves most boundary
issues where incomplete constructs (e.g., a function definition at the edge
of a hunk with no body) would confuse the parser.
Set `highlights.context.enabled = false` to disable context padding. In rare
cases, context padding may not help if the relevant surrounding code is very
far from the hunk boundaries.
Treesitter parses each diff hunk in isolation. Context lines within the hunk
(lines with a ` ` prefix) provide syntactic context for the parser. In rare
cases, hunks that start or end mid-expression may produce imperfect highlights
due to treesitter error recovery.
Syntax Highlighting Flash ~
*diffs-flash*
@ -635,6 +746,10 @@ Conflict highlights: ~
*DiffsConflictBaseNr*
DiffsConflictBaseNr Line number for base content lines (diff3).
*DiffsConflictActions*
DiffsConflictActions Dimmed foreground (no bold) for the codelens-style
action line shown when `show_actions` is true.
Diff mode window highlights: ~
These are used for |winhighlight| remapping in `&diff` windows.

View file

@ -36,6 +36,24 @@ function M.find_hunk_line(diff_lines, hunk_position)
return nil
end
---@param lines string[]
---@return string[]
function M.filter_combined_diffs(lines)
local result = {}
local skip = false
for _, line in ipairs(lines) do
if line:match('^diff %-%-cc ') then
skip = true
elseif line:match('^diff %-%-git ') then
skip = false
end
if not skip then
table.insert(result, line)
end
end
return result
end
---@param old_lines string[]
---@param new_lines string[]
---@param old_name string
@ -69,6 +87,33 @@ local function generate_unified_diff(old_lines, new_lines, old_name, new_name)
return result
end
---@param raw_lines string[]
---@param repo_root string
---@return string[]
local function replace_combined_diffs(raw_lines, repo_root)
local unmerged_files = {}
for _, line in ipairs(raw_lines) do
local cc_file = line:match('^diff %-%-cc (.+)$')
if cc_file then
table.insert(unmerged_files, cc_file)
end
end
local result = M.filter_combined_diffs(raw_lines)
for _, filename in ipairs(unmerged_files) do
local filepath = repo_root .. '/' .. filename
local old_lines = git.get_file_content(':2', filepath) or {}
local new_lines = git.get_file_content(':3', filepath) or {}
local diff_lines = generate_unified_diff(old_lines, new_lines, filename, filename)
for _, dl in ipairs(diff_lines) do
table.insert(result, dl)
end
end
return result
end
---@param revision? string
---@param vertical? boolean
function M.gdiff(revision, vertical)
@ -138,6 +183,7 @@ end
---@field vertical? boolean
---@field staged? boolean
---@field untracked? boolean
---@field unmerged? boolean
---@field old_filepath? string
---@field hunk_position? { hunk_header: string, offset: integer }
@ -157,7 +203,17 @@ function M.gdiff_file(filepath, opts)
local old_lines, new_lines, err
local diff_label
if opts.untracked then
if opts.unmerged then
old_lines = git.get_file_content(':2', filepath)
if not old_lines then
old_lines = {}
end
new_lines = git.get_file_content(':3', filepath)
if not new_lines then
new_lines = {}
end
diff_label = 'unmerged'
elseif opts.untracked then
old_lines = {}
new_lines, err = git.get_working_content(filepath)
if not new_lines then
@ -236,6 +292,14 @@ function M.gdiff_file(filepath, opts)
end
M.setup_diff_buf(diff_buf)
if diff_label == 'unmerged' then
vim.api.nvim_buf_set_var(diff_buf, 'diffs_unmerged', true)
vim.api.nvim_buf_set_var(diff_buf, 'diffs_working_path', filepath)
local conflict_config = require('diffs').get_conflict_config()
require('diffs.merge').setup_keymaps(diff_buf, conflict_config)
end
dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label)
vim.schedule(function()
@ -263,6 +327,8 @@ function M.gdiff_section(repo_root, opts)
return
end
result = replace_combined_diffs(result, repo_root)
if #result == 0 then
vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO)
return
@ -325,6 +391,8 @@ function M.read_buffer(bufnr)
if vim.v.shell_error ~= 0 then
diff_lines = {}
end
diff_lines = replace_combined_diffs(diff_lines, repo_root)
else
local abs_path = repo_root .. '/' .. path
@ -334,7 +402,10 @@ function M.read_buffer(bufnr)
local old_lines, new_lines
if label == 'untracked' then
if label == 'unmerged' then
old_lines = git.get_file_content(':2', abs_path) or {}
new_lines = git.get_file_content(':3', abs_path) or {}
elseif label == 'untracked' then
old_lines = {}
new_lines = git.get_working_content(abs_path) or {}
elseif label == 'staged' then

View file

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

View file

@ -26,20 +26,65 @@ function M.get_section_at_line(bufnr, lnum)
return nil
end
---@param s string
---@return string
local function unquote(s)
if s:sub(1, 1) ~= '"' then
return s
end
local inner = s:sub(2, -2)
local result = {}
local i = 1
while i <= #inner do
if inner:sub(i, i) == '\\' and i < #inner then
local next_char = inner:sub(i + 1, i + 1)
if next_char == 'n' then
table.insert(result, '\n')
i = i + 2
elseif next_char == 't' then
table.insert(result, '\t')
i = i + 2
elseif next_char == '"' then
table.insert(result, '"')
i = i + 2
elseif next_char == '\\' then
table.insert(result, '\\')
i = i + 2
elseif next_char:match('%d') then
local oct = inner:match('^(%d%d%d)', i + 1)
if oct then
table.insert(result, string.char(tonumber(oct, 8)))
i = i + 4
else
table.insert(result, next_char)
i = i + 2
end
else
table.insert(result, next_char)
i = i + 2
end
else
table.insert(result, inner:sub(i, i))
i = i + 1
end
end
return table.concat(result)
end
---@param line string
---@return string?, string?
---@return string?, string?, string?
local function parse_file_line(line)
local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$')
if old and new then
return vim.trim(new), vim.trim(old)
return unquote(vim.trim(new)), unquote(vim.trim(old)), 'R'
end
local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$')
if filename then
return vim.trim(filename), nil
local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$')
if status and filename then
return unquote(vim.trim(filename)), nil, status
end
return nil, nil
return nil, nil, nil
end
---@param line string
@ -57,34 +102,34 @@ end
---@param bufnr integer
---@param lnum integer
---@return string?, diffs.FugitiveSection, boolean, string?
---@return string?, diffs.FugitiveSection, boolean, string?, string?
function M.get_file_at_line(bufnr, lnum)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local current_line = lines[lnum]
if not current_line then
return nil, nil, false, nil
return nil, nil, false, nil, nil
end
local section_header = parse_section_header(current_line)
if section_header then
return nil, section_header, true, nil
return nil, section_header, true, nil, nil
end
local filename, old_filename = parse_file_line(current_line)
local filename, old_filename, status = parse_file_line(current_line)
if filename then
local section = M.get_section_at_line(bufnr, lnum)
return filename, section, false, old_filename
return filename, section, false, old_filename, status
end
local prefix = current_line:sub(1, 1)
if prefix == '+' or prefix == '-' or prefix == ' ' then
for i = lnum - 1, 1, -1 do
local prev_line = lines[i]
filename, old_filename = parse_file_line(prev_line)
filename, old_filename, status = parse_file_line(prev_line)
if filename then
local section = M.get_section_at_line(bufnr, i)
return filename, section, false, old_filename
return filename, section, false, old_filename, status
end
if prev_line:match('^%w+ %(') or prev_line == '' then
break
@ -92,7 +137,7 @@ function M.get_file_at_line(bufnr, lnum)
end
end
return nil, nil, false, nil
return nil, nil, false, nil, nil
end
---@class diffs.HunkPosition
@ -150,7 +195,7 @@ function M.diff_file_under_cursor(vertical)
local bufnr = vim.api.nvim_get_current_buf()
local lnum = vim.api.nvim_win_get_cursor(0)[1]
local filename, section, is_header, old_filename = M.get_file_at_line(bufnr, lnum)
local filename, section, is_header, old_filename, status = M.get_file_at_line(bufnr, lnum)
local repo_root = get_repo_root_from_fugitive(bufnr)
if not repo_root then
@ -192,6 +237,7 @@ function M.diff_file_under_cursor(vertical)
vertical = vertical,
staged = section == 'staged',
untracked = section == 'untracked',
unmerged = status == 'U',
old_filepath = old_filepath,
hunk_position = hunk_position,
})

View file

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

View file

@ -3,38 +3,6 @@ local M = {}
local dbg = require('diffs.log').dbg
local diff = require('diffs.diff')
---@param filepath string
---@param from_line integer
---@param count integer
---@return string[]
local function read_line_range(filepath, from_line, count)
if count <= 0 then
return {}
end
local f = io.open(filepath, 'r')
if not f then
return {}
end
local result = {}
local line_num = 0
for line in f:lines() do
line_num = line_num + 1
if line_num >= from_line then
table.insert(result, line)
if #result >= count then
break
end
end
end
f:close()
return result
end
local PRIORITY_CLEAR = 198
local PRIORITY_SYNTAX = 199
local PRIORITY_LINE_BG = 200
local PRIORITY_CHAR_BG = 201
---@param bufnr integer
---@param ns integer
---@param hunk diffs.Hunk
@ -42,8 +10,9 @@ local PRIORITY_CHAR_BG = 201
---@param text string
---@param lang string
---@param context_lines? string[]
---@param priorities diffs.PrioritiesConfig
---@return integer
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines)
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines, priorities)
local parse_text = text
if context_lines and #context_lines > 0 then
parse_text = text .. '\n' .. table.concat(context_lines, '\n')
@ -77,7 +46,7 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_l
local buf_sc = col_offset + sc
local buf_ec = col_offset + ec
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or priorities.syntax
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
@ -103,6 +72,7 @@ end
---@param line_map table<integer, integer>
---@param col_offset integer
---@param covered_lines? table<integer, true>
---@param priorities diffs.PrioritiesConfig
---@return integer
local function highlight_treesitter(
bufnr,
@ -111,7 +81,8 @@ local function highlight_treesitter(
lang,
line_map,
col_offset,
covered_lines
covered_lines,
priorities
)
local code = table.concat(code_lines, '\n')
if code == '' then
@ -152,7 +123,7 @@ local function highlight_treesitter(
local buf_ec = ec + col_offset
local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100)
or PRIORITY_SYNTAX
or priorities.syntax
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
end_row = buf_er,
@ -219,8 +190,17 @@ end
---@param code_lines string[]
---@param covered_lines? table<integer, true>
---@param leading_offset? integer
---@param priorities diffs.PrioritiesConfig
---@return integer
local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset)
local function highlight_vim_syntax(
bufnr,
ns,
hunk,
code_lines,
covered_lines,
leading_offset,
priorities
)
local ft = hunk.ft
if not ft then
return 0
@ -238,7 +218,7 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines,
local spans = {}
vim.api.nvim_buf_call(scratch, function()
pcall(vim.api.nvim_buf_call, scratch, function()
vim.cmd('setlocal syntax=' .. ft)
vim.cmd('redraw')
@ -256,18 +236,19 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines,
spans = M.coalesce_syntax_spans(query_fn, code_lines)
end)
vim.api.nvim_buf_delete(scratch, { force = true })
pcall(vim.api.nvim_buf_delete, scratch, { force = true })
local hunk_line_count = #hunk.lines
local col_off = (hunk.prefix_width or 1) - 1
local extmark_count = 0
for _, span in ipairs(spans) do
local adj = span.line - leading_offset
if adj >= 1 and adj <= hunk_line_count then
local buf_line = hunk.start_line + adj - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end,
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + col_off, {
end_col = span.col_end + col_off,
hl_group = span.hl_name,
priority = PRIORITY_SYNTAX,
priority = priorities.syntax,
})
extmark_count = extmark_count + 1
if covered_lines then
@ -284,6 +265,8 @@ end
---@param hunk diffs.Hunk
---@param opts diffs.HunkOpts
function M.highlight_hunk(bufnr, ns, hunk, opts)
local p = opts.highlights.priorities
local pw = hunk.prefix_width or 1
local use_ts = hunk.lang and opts.highlights.treesitter.enabled
local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled
@ -303,21 +286,6 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
---@type table<integer, true>
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
if use_ts then
---@type string[]
@ -329,20 +297,17 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
---@type table<integer, integer>
local old_map = {}
for _, pad_line in ipairs(leading) do
table.insert(new_code, pad_line)
table.insert(old_code, pad_line)
end
for i, line in ipairs(hunk.lines) do
local prefix = line:sub(1, 1)
local stripped = line:sub(2)
local prefix = line:sub(1, pw)
local stripped = line:sub(pw + 1)
local buf_line = hunk.start_line + i - 1
local has_add = prefix:find('+', 1, true) ~= nil
local has_del = prefix:find('-', 1, true) ~= nil
if prefix == '+' then
if has_add and not has_del then
new_map[#new_code] = buf_line
table.insert(new_code, stripped)
elseif prefix == '-' then
elseif has_del and not has_add then
old_map[#old_code] = buf_line
table.insert(old_code, stripped)
else
@ -352,21 +317,17 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
end
for _, pad_line in ipairs(trailing) do
table.insert(new_code, pad_line)
table.insert(old_code, pad_line)
end
extmark_count = highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines)
extmark_count =
highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, pw, covered_lines, p)
extmark_count = extmark_count
+ highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines)
+ highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, pw, covered_lines, p)
if hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
end_col = hunk.header_context_col + #hunk.header_context,
hl_group = 'DiffsClear',
priority = PRIORITY_CLEAR,
priority = p.clear,
})
local header_extmarks = highlight_text(
bufnr,
@ -375,7 +336,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
hunk.header_context_col,
hunk.header_context,
hunk.lang,
new_code
new_code,
p
)
if header_extmarks > 0 then
dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks)
@ -385,16 +347,10 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
elseif use_vim then
---@type string[]
local code_lines = {}
for _, pad_line in ipairs(leading) do
table.insert(code_lines, pad_line)
end
for _, line in ipairs(hunk.lines) do
table.insert(code_lines, line:sub(2))
table.insert(code_lines, line:sub(pw + 1))
end
for _, pad_line in ipairs(trailing) do
table.insert(code_lines, pad_line)
end
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, #leading)
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p)
end
if
@ -409,13 +365,13 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
header_map[i] = hunk.header_start_line - 1 + i
end
extmark_count = extmark_count
+ highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0)
+ highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0, nil, p)
end
---@type diffs.IntraChanges?
local intra = nil
local intra_cfg = opts.highlights.intra
if intra_cfg and intra_cfg.enabled and #hunk.lines <= intra_cfg.max_lines then
if intra_cfg and intra_cfg.enabled and pw == 1 and #hunk.lines <= intra_cfg.max_lines then
dbg('computing intra for hunk %s:%d (%d lines)', hunk.filename, hunk.start_line, #hunk.lines)
intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm)
if intra then
@ -449,25 +405,26 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
for i, line in ipairs(hunk.lines) do
local buf_line = hunk.start_line + i - 1
local line_len = #line
local prefix = line:sub(1, 1)
local is_diff_line = prefix == '+' or prefix == '-'
local line_hl = is_diff_line and (prefix == '+' and 'DiffsAdd' or 'DiffsDelete') or nil
local number_hl = is_diff_line and (prefix == '+' and 'DiffsAddNr' or 'DiffsDeleteNr') or nil
local 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
if opts.hide_prefix then
local virt_hl = (opts.highlights.background and line_hl) or nil
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
virt_text = { { ' ', virt_hl } },
virt_text = { { string.rep(' ', pw), virt_hl } },
virt_text_pos = 'overlay',
})
end
if line_len > 1 and covered_lines[buf_line] then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
if line_len > pw and covered_lines[buf_line] then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw, {
end_col = line_len,
hl_group = 'DiffsClear',
priority = PRIORITY_CLEAR,
priority = p.clear,
})
end
@ -476,18 +433,18 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end_row = buf_line + 1,
hl_group = line_hl,
hl_eol = true,
priority = PRIORITY_LINE_BG,
priority = p.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,
priority = p.line_bg,
})
end
end
if char_spans_by_line[i] then
local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText'
local char_hl = has_add and 'DiffsAddText' or 'DiffsDeleteText'
for _, span in ipairs(char_spans_by_line[i]) do
dbg(
'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"',
@ -501,7 +458,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
end_col = span.col_end,
hl_group = char_hl,
priority = PRIORITY_CHAR_BG,
priority = p.char_bg,
})
if not ok then
dbg('char extmark FAILED: %s', err)

View file

@ -15,6 +15,12 @@
---@field enabled boolean
---@field lines integer
---@class diffs.PrioritiesConfig
---@field clear integer
---@field syntax integer
---@field line_bg integer
---@field char_bg integer
---@class diffs.Highlights
---@field background boolean
---@field gutter boolean
@ -24,6 +30,7 @@
---@field treesitter diffs.TreesitterConfig
---@field vim diffs.VimConfig
---@field intra diffs.IntraConfig
---@field priorities diffs.PrioritiesConfig
---@class diffs.FugitiveConfig
---@field horizontal string|false
@ -41,6 +48,9 @@
---@field enabled boolean
---@field disable_diagnostics boolean
---@field show_virtual_text boolean
---@field format_virtual_text? fun(side: string, keymap: string|false): string?
---@field show_actions boolean
---@field priority integer
---@field keymaps diffs.ConflictKeymaps
---@class diffs.Config
@ -119,6 +129,12 @@ local default_config = {
algorithm = 'default',
max_lines = 500,
},
priorities = {
clear = 198,
syntax = 199,
line_bg = 200,
char_bg = 201,
},
},
fugitive = {
horizontal = 'du',
@ -128,6 +144,8 @@ local default_config = {
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
show_actions = false,
priority = 200,
keymaps = {
ours = 'doo',
theirs = 'dot',
@ -200,7 +218,9 @@ local function create_debounced_highlight(bufnr)
timer = nil
t:close()
end
highlight_buffer(bufnr)
if vim.api.nvim_buf_is_valid(bufnr) then
highlight_buffer(bufnr)
end
end)
)
end
@ -274,6 +294,7 @@ local function compute_highlight_groups()
vim.api.nvim_set_hl(0, 'DiffsConflictTheirs', { default = true, bg = blended_theirs })
vim.api.nvim_set_hl(0, 'DiffsConflictBase', { default = true, bg = blended_base })
vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { default = true, fg = 0x808080, bold = true })
vim.api.nvim_set_hl(0, 'DiffsConflictActions', { default = true, fg = 0x808080 })
vim.api.nvim_set_hl(
0,
'DiffsConflictOursNr',
@ -322,6 +343,7 @@ local function init()
['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true },
['highlights.vim'] = { opts.highlights.vim, 'table', true },
['highlights.intra'] = { opts.highlights.intra, 'table', true },
['highlights.priorities'] = { opts.highlights.priorities, 'table', true },
})
if opts.highlights.context then
@ -362,6 +384,15 @@ local function init()
['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true },
})
end
if opts.highlights.priorities then
vim.validate({
['highlights.priorities.clear'] = { opts.highlights.priorities.clear, 'number', true },
['highlights.priorities.syntax'] = { opts.highlights.priorities.syntax, 'number', true },
['highlights.priorities.line_bg'] = { opts.highlights.priorities.line_bg, 'number', true },
['highlights.priorities.char_bg'] = { opts.highlights.priorities.char_bg, 'number', true },
})
end
end
if opts.fugitive then
@ -388,6 +419,9 @@ local function init()
['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true },
['conflict.disable_diagnostics'] = { opts.conflict.disable_diagnostics, 'boolean', true },
['conflict.show_virtual_text'] = { opts.conflict.show_virtual_text, 'boolean', true },
['conflict.format_virtual_text'] = { opts.conflict.format_virtual_text, 'function', true },
['conflict.show_actions'] = { opts.conflict.show_actions, 'boolean', true },
['conflict.priority'] = { opts.conflict.priority, 'number', true },
['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true },
})
@ -449,6 +483,17 @@ local function init()
then
error('diffs: highlights.blend_alpha must be >= 0 and <= 1')
end
if opts.highlights and opts.highlights.priorities then
for _, key in ipairs({ 'clear', 'syntax', 'line_bg', 'char_bg' }) do
local v = opts.highlights.priorities[key]
if v and v < 0 then
error('diffs: highlights.priorities.' .. key .. ' must be >= 0')
end
end
end
if opts.conflict and opts.conflict.priority and opts.conflict.priority < 0 then
error('diffs: conflict.priority must be >= 0')
end
config = vim.tbl_deep_extend('force', default_config, opts)
log.set_enabled(config.debug)

View file

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

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,6 +12,7 @@
---@field file_old_count integer?
---@field file_new_start integer?
---@field file_new_count integer?
---@field prefix_width integer
---@field repo_root string?
local M = {}
@ -133,6 +134,8 @@ function M.parse_buffer(bufnr)
local hunk_lines = {}
---@type integer?
local hunk_count = nil
---@type integer
local hunk_prefix_width = 1
---@type integer?
local header_start = nil
---@type string[]
@ -156,6 +159,7 @@ function M.parse_buffer(bufnr)
header_context = hunk_header_context,
header_context_col = hunk_header_context_col,
lines = hunk_lines,
prefix_width = hunk_prefix_width,
file_old_start = file_old_start,
file_old_count = file_old_count,
file_new_start = file_new_start,
@ -179,7 +183,7 @@ function M.parse_buffer(bufnr)
end
for i, line in ipairs(lines) do
local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$')
local filename = line:match('^[MADRCU%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$')
if filename then
flush_hunk()
current_filename = filename
@ -191,22 +195,33 @@ function M.parse_buffer(bufnr)
dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft)
end
hunk_count = 0
hunk_prefix_width = 1
header_start = i
header_lines = {}
elseif line:match('^@@.-@@') then
elseif line:match('^@@+') then
flush_hunk()
hunk_start = i
local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
if hs then
file_old_start = tonumber(hs)
file_old_count = tonumber(hc) or 1
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
local at_prefix = line:match('^(@@+)')
hunk_prefix_width = #at_prefix - 1
if #at_prefix == 2 then
local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
if hs then
file_old_start = tonumber(hs)
file_old_count = tonumber(hc) or 1
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
end
else
local hs2, hc2 = line:match('%+(%d+),?(%d*) @@')
if hs2 then
file_new_start = tonumber(hs2)
file_new_count = tonumber(hc2) or 1
end
end
local prefix, context = line:match('^(@@.-@@%s*)(.*)')
local at_end, context = line:match('^(@@+.-@@+%s*)(.*)')
if context and context ~= '' then
hunk_header_context = context
hunk_header_context_col = #prefix
hunk_header_context_col = #at_end
end
if hunk_count then
hunk_count = hunk_count + 1

View file

@ -82,3 +82,28 @@ end, { desc = 'Jump to next conflict' })
vim.keymap.set('n', '<Plug>(diffs-conflict-prev)', function()
require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to previous conflict' })
local function merge_action(fn)
local bufnr = vim.api.nvim_get_current_buf()
local config = require('diffs').get_conflict_config()
fn(bufnr, config)
end
vim.keymap.set('n', '<Plug>(diffs-merge-ours)', function()
merge_action(require('diffs.merge').resolve_ours)
end, { desc = 'Accept ours in merge diff' })
vim.keymap.set('n', '<Plug>(diffs-merge-theirs)', function()
merge_action(require('diffs.merge').resolve_theirs)
end, { desc = 'Accept theirs in merge diff' })
vim.keymap.set('n', '<Plug>(diffs-merge-both)', function()
merge_action(require('diffs.merge').resolve_both)
end, { desc = 'Accept both in merge diff' })
vim.keymap.set('n', '<Plug>(diffs-merge-none)', function()
merge_action(require('diffs.merge').resolve_none)
end, { desc = 'Reject both in merge diff' })
vim.keymap.set('n', '<Plug>(diffs-merge-next)', function()
require('diffs.merge').goto_next(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to next conflict hunk' })
vim.keymap.set('n', '<Plug>(diffs-merge-prev)', function()
require('diffs.merge').goto_prev(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to previous conflict hunk' })

View file

@ -40,6 +40,78 @@ describe('commands', function()
end)
end)
describe('filter_combined_diffs', function()
it('strips diff --cc entries entirely', function()
local lines = {
'diff --cc main.lua',
'index d13ab94,b113aee..0000000',
'--- a/main.lua',
'+++ b/main.lua',
'@@@ -1,7 -1,7 +1,11 @@@',
' local M = {}',
'++<<<<<<< HEAD',
' + return 1',
'++=======',
'+ return 2',
'++>>>>>>> theirs',
' end',
}
local result = commands.filter_combined_diffs(lines)
assert.are.equal(0, #result)
end)
it('preserves diff --git entries', function()
local lines = {
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,3 +1,3 @@',
' local M = {}',
'-local x = 1',
'+local x = 2',
' return M',
}
local result = commands.filter_combined_diffs(lines)
assert.are.equal(8, #result)
assert.are.same(lines, result)
end)
it('strips combined but keeps unified in mixed output', function()
local lines = {
'diff --cc conflict.lua',
'index aaa,bbb..000',
'@@@ -1,1 -1,1 +1,5 @@@',
'++<<<<<<< HEAD',
'diff --git a/clean.lua b/clean.lua',
'--- a/clean.lua',
'+++ b/clean.lua',
'@@ -1,1 +1,1 @@',
'-old',
'+new',
}
local result = commands.filter_combined_diffs(lines)
assert.are.equal(6, #result)
assert.are.equal('diff --git a/clean.lua b/clean.lua', result[1])
assert.are.equal('+new', result[6])
end)
it('returns empty for empty input', function()
local result = commands.filter_combined_diffs({})
assert.are.equal(0, #result)
end)
it('returns empty when all entries are combined', function()
local lines = {
'diff --cc a.lua',
'some content',
'diff --cc b.lua',
'more content',
}
local result = commands.filter_combined_diffs(lines)
assert.are.equal(0, #result)
end)
end)
describe('find_hunk_line', function()
it('finds matching @@ header and returns target line', function()
local diff_lines = {

View file

@ -6,6 +6,7 @@ local function default_config(overrides)
enabled = true,
disable_diagnostics = false,
show_virtual_text = true,
show_actions = false,
keymaps = {
ours = 'doo',
theirs = 'dot',
@ -234,29 +235,6 @@ describe('conflict', function()
helpers.delete_buffer(bufnr)
end)
it('does not apply virtual text when disabled', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(bufnr, default_config({ show_virtual_text = false }))
local extmarks = get_extmarks(bufnr)
local virt_text_count = 0
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
virt_text_count = virt_text_count + 1
end
end
assert.are.equal(0, virt_text_count)
helpers.delete_buffer(bufnr)
end)
it('applies number_hl_group to content lines', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
@ -531,6 +509,33 @@ describe('conflict', function()
helpers.delete_buffer(bufnr)
end)
it('goto_next notifies on wrap-around', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'a',
'=======',
'b',
'>>>>>>> feat',
})
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 5, 0 })
local notified = false
local orig_notify = vim.notify
vim.notify = function(msg)
if msg:match('wrapped to first conflict') then
notified = true
end
end
conflict.goto_next(bufnr)
vim.notify = orig_notify
assert.is_true(notified)
helpers.delete_buffer(bufnr)
end)
it('goto_prev jumps to previous conflict', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
@ -575,6 +580,33 @@ describe('conflict', function()
helpers.delete_buffer(bufnr)
end)
it('goto_prev notifies on wrap-around', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'a',
'=======',
'b',
'>>>>>>> feat',
})
vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
local notified = false
local orig_notify = vim.notify
vim.notify = function(msg)
if msg:match('wrapped to last conflict') then
notified = true
end
end
conflict.goto_prev(bufnr)
vim.notify = orig_notify
assert.is_true(notified)
helpers.delete_buffer(bufnr)
end)
it('goto_next does nothing with no conflicts', function()
local bufnr = create_file_buffer({ 'normal line' })
vim.api.nvim_set_current_buf(bufnr)
@ -685,4 +717,158 @@ describe('conflict', function()
helpers.delete_buffer(bufnr)
end)
end)
describe('virtual text formatting', function()
after_each(function()
conflict.detach(vim.api.nvim_get_current_buf())
end)
it('default labels show current and incoming without keymaps', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(bufnr, default_config())
local extmarks = get_extmarks(bufnr)
local labels = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
table.insert(labels, mark[4].virt_text[1][1])
end
end
assert.are.equal(2, #labels)
assert.are.equal(' (current)', labels[1])
assert.are.equal(' (incoming)', labels[2])
helpers.delete_buffer(bufnr)
end)
it('uses custom format_virtual_text function', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(
bufnr,
default_config({
format_virtual_text = function(side)
return side == 'ours' and 'OURS' or 'THEIRS'
end,
})
)
local extmarks = get_extmarks(bufnr)
local labels = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
table.insert(labels, mark[4].virt_text[1][1])
end
end
assert.are.equal(2, #labels)
assert.are.equal(' (OURS)', labels[1])
assert.are.equal(' (THEIRS)', labels[2])
helpers.delete_buffer(bufnr)
end)
it('hides label when format_virtual_text returns nil', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(
bufnr,
default_config({
format_virtual_text = function()
return nil
end,
})
)
local extmarks = get_extmarks(bufnr)
local virt_text_count = 0
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
virt_text_count = virt_text_count + 1
end
end
assert.are.equal(0, virt_text_count)
helpers.delete_buffer(bufnr)
end)
end)
describe('action lines', function()
after_each(function()
conflict.detach(vim.api.nvim_get_current_buf())
end)
it('adds virt_lines when show_actions is true', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(bufnr, default_config({ show_actions = true }))
local extmarks = get_extmarks(bufnr)
local virt_lines_count = 0
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_lines then
virt_lines_count = virt_lines_count + 1
end
end
assert.are.equal(1, virt_lines_count)
helpers.delete_buffer(bufnr)
end)
it('omits disabled keymaps from action line', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(
bufnr,
default_config({ show_actions = true, keymaps = { both = false, none = false } })
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_lines then
local line = mark[4].virt_lines[1]
local text = ''
for _, chunk in ipairs(line) do
text = text .. chunk[1]
end
assert.is_truthy(text:find('Current'))
assert.is_truthy(text:find('Incoming'))
assert.is_falsy(text:find('Both'))
assert.is_falsy(text:find('None'))
end
end
helpers.delete_buffer(bufnr)
end)
end)
end)

View file

@ -87,28 +87,6 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('parses added file', function()
local buf = create_status_buffer({
'Staged (1)',
'A newfile.lua',
})
local filename, section = fugitive.get_file_at_line(buf, 2)
assert.equals('newfile.lua', filename)
assert.equals('staged', section)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('parses deleted file', function()
local buf = create_status_buffer({
'Staged (1)',
'D oldfile.lua',
})
local filename, section = fugitive.get_file_at_line(buf, 2)
assert.equals('oldfile.lua', filename)
assert.equals('staged', section)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('parses renamed file and returns both names', function()
local buf = create_status_buffer({
'Staged (1)',
@ -157,28 +135,6 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles renamed file in subdirectory', function()
local buf = create_status_buffer({
'Staged (1)',
'R src/old.lua -> src/new.lua',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('src/new.lua', filename)
assert.equals('src/old.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles renamed file moved to different directory', function()
local buf = create_status_buffer({
'Staged (1)',
'R old/file.lua -> new/file.lua',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('new/file.lua', filename)
assert.equals('old/file.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('KNOWN LIMITATION: filename containing arrow parsed incorrectly', function()
local buf = create_status_buffer({
'Staged (1)',
@ -190,77 +146,54 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles double extensions', function()
it('unquotes git-quoted filenames with spaces', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M "path with spaces/file.lua"',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('path with spaces/file.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('unquotes escaped quotes in filenames', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M "file\\"name.lua"',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('file"name.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('unquotes octal escapes in filenames', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M "\\303\\251le.lua"',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('\195\169le.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('passes through unquoted filenames unchanged', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M normal.lua',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('normal.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('unquotes renamed files with quotes', function()
local buf = create_status_buffer({
'Staged (1)',
'M test.spec.lua',
'R100 "old name.lua" -> "new name.lua"',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('test.spec.lua', filename)
assert.is_nil(old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles hyphenated filenames', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M my-component-test.lua',
})
local filename, section = fugitive.get_file_at_line(buf, 2)
assert.equals('my-component-test.lua', filename)
assert.equals('unstaged', section)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles underscores and numbers', function()
local buf = create_status_buffer({
'Staged (1)',
'A test_file_123.lua',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('test_file_123.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles dotfiles', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M .gitignore',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('.gitignore', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles renamed with complex names', function()
local buf = create_status_buffer({
'Staged (1)',
'R src/old-file.spec.lua -> src/new-file.spec.lua',
})
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
assert.equals('src/new-file.spec.lua', filename)
assert.equals('src/old-file.spec.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('handles deeply nested paths', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M lua/diffs/ui/components/diff-view.lua',
})
local filename = fugitive.get_file_at_line(buf, 2)
assert.equals('lua/diffs/ui/components/diff-view.lua', filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('parses untracked file', function()
local buf = create_status_buffer({
'Untracked (1)',
'? untracked.lua',
})
local filename, section = fugitive.get_file_at_line(buf, 2)
assert.equals('untracked.lua', filename)
assert.equals('untracked', section)
assert.equals('new name.lua', filename)
assert.equals('old name.lua', old_filename)
vim.api.nvim_buf_delete(buf, { force = true })
end)
@ -321,30 +254,6 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('detects section header for Unstaged', function()
local buf = create_status_buffer({
'Unstaged (3)',
'M file1.lua',
})
local filename, section, is_header = fugitive.get_file_at_line(buf, 1)
assert.is_nil(filename)
assert.equals('unstaged', section)
assert.is_true(is_header)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('detects section header for Untracked', function()
local buf = create_status_buffer({
'Untracked (1)',
'? newfile.lua',
})
local filename, section, is_header = fugitive.get_file_at_line(buf, 1)
assert.is_nil(filename)
assert.equals('untracked', section)
assert.is_true(is_header)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns is_header=false for file lines', function()
local buf = create_status_buffer({
'Staged (1)',
@ -406,22 +315,6 @@ describe('fugitive', function()
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns hunk header and offset for - line', function()
local buf = create_status_buffer({
'Unstaged (1)',
'M file.lua',
'@@ -1,3 +1,3 @@',
' local M = {}',
'-local old = false',
' return M',
})
local pos = fugitive.get_hunk_position(buf, 5)
assert.is_not_nil(pos)
assert.equals('@@ -1,3 +1,3 @@', pos.hunk_header)
assert.equals(2, pos.offset)
vim.api.nvim_buf_delete(buf, { force = true })
end)
it('returns hunk header and offset for context line', function()
local buf = create_status_buffer({
'Unstaged (1)',

View file

@ -51,6 +51,12 @@ describe('highlight', function()
algorithm = 'default',
max_lines = 500,
},
priorities = {
clear = 198,
syntax = 199,
line_bg = 200,
char_bg = 201,
},
},
}
if overrides then
@ -64,27 +70,6 @@ describe('highlight', function()
return opts
end
it('applies extmarks for lua code', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
assert.is_true(#extmarks > 0)
delete_buffer(bufnr)
end)
it('applies DiffsClear extmarks to clear diff colors', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@ -190,36 +175,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('highlights header context when enabled', function()
local bufnr = create_buffer({
'@@ -10,3 +10,4 @@ function hello()',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
header_context = 'function hello()',
header_context_col = 18,
lines = { ' local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local has_header_extmark = false
for _, mark in ipairs(extmarks) do
if mark[2] == 0 then
has_header_extmark = true
break
end
end
assert.is_true(has_header_extmark)
delete_buffer(bufnr)
end)
it('highlights function keyword in header context', function()
local bufnr = create_buffer({
'@@ -5,3 +5,4 @@ function M.setup()',
@ -280,44 +235,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('handles empty hunk lines', function()
local bufnr = create_buffer({
'@@ -1,0 +1,0 @@',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = {},
}
assert.has_no.errors(function()
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
end)
delete_buffer(bufnr)
end)
it('handles code that is just whitespace', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' ',
'+ ',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' ', '+ ' },
}
assert.has_no.errors(function()
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
end)
delete_buffer(bufnr)
end)
it('applies overlay extmarks when hide_prefix enabled', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@ -345,33 +262,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('does not apply overlay extmarks when hide_prefix disabled', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = false }))
local extmarks = get_extmarks(bufnr)
local overlay_count = 0
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text_pos == 'overlay' then
overlay_count = overlay_count + 1
end
end
assert.are.equal(0, overlay_count)
delete_buffer(bufnr)
end)
it('applies DiffAdd background to + lines when background enabled', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@ -438,39 +328,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('does not apply background when background disabled', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { background = false } })
)
local extmarks = get_extmarks(bufnr)
local has_line_hl = false
for _, mark in ipairs(extmarks) do
if mark[4] and (mark[4].hl_group == 'DiffsAdd' or mark[4].hl_group == 'DiffsDelete') then
has_line_hl = true
break
end
end
assert.is_false(has_line_hl)
delete_buffer(bufnr)
end)
it('applies number_hl_group when gutter enabled', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@ -504,72 +361,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('does not apply number_hl_group when gutter disabled', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { background = true, gutter = false } })
)
local extmarks = get_extmarks(bufnr)
local has_number_hl = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].number_hl_group then
has_number_hl = true
break
end
end
assert.is_false(has_number_hl)
delete_buffer(bufnr)
end)
it('skips treesitter highlights when treesitter disabled', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { treesitter = { enabled = false }, background = true } })
)
local extmarks = get_extmarks(bufnr)
local has_ts_highlight = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then
has_ts_highlight = true
break
end
end
assert.is_false(has_ts_highlight)
delete_buffer(bufnr)
end)
it('still applies background when treesitter disabled', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@ -654,40 +445,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('skips vim fallback when vim.enabled is false', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
ft = 'abap',
lang = nil,
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { vim = { enabled = false } } })
)
local extmarks = get_extmarks(bufnr)
local has_syntax_hl = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then
has_syntax_hl = true
break
end
end
assert.is_false(has_syntax_hl)
delete_buffer(bufnr)
end)
it('respects vim.max_lines', function()
local lines = { '@@ -1,100 +1,101 @@' }
local hunk_lines = {}
@ -900,92 +657,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('line bg priority > DiffsClear priority', function()
local bufnr = create_buffer({
'@@ -1,2 +1,1 @@',
'-local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { background = true } })
)
local extmarks = get_extmarks(bufnr)
local clear_priority = nil
local line_bg_priority = nil
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.hl_group == 'DiffsClear' then
clear_priority = d.priority
end
if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
line_bg_priority = d.priority
end
end
assert.is_not_nil(clear_priority)
assert.is_not_nil(line_bg_priority)
assert.is_true(line_bg_priority > clear_priority)
delete_buffer(bufnr)
end)
it('char-level extmarks have higher priority than line bg', function()
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
local bufnr = create_buffer({
'@@ -1,2 +1,2 @@',
'-local x = 1',
'+local x = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local x = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({
highlights = {
background = true,
intra = { enabled = true, algorithm = 'default', max_lines = 500 },
},
})
)
local extmarks = get_extmarks(bufnr)
local line_bg_priority = nil
local char_bg_priority = nil
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
line_bg_priority = d.priority
end
if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then
char_bg_priority = d.priority
end
end
assert.is_not_nil(line_bg_priority)
assert.is_not_nil(char_bg_priority)
assert.is_true(char_bg_priority > line_bg_priority)
delete_buffer(bufnr)
end)
it('creates char-level extmarks for changed characters', function()
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
@ -1029,38 +700,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('does not create char-level extmarks when intra disabled', function()
local bufnr = create_buffer({
'@@ -1,2 +1,2 @@',
'-local x = 1',
'+local x = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { '-local x = 1', '+local x = 2' },
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({
highlights = { intra = { enabled = false, algorithm = 'default', max_lines = 500 } },
})
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local d = mark[4]
assert.is_not_equal('DiffsAddText', d and d.hl_group)
assert.is_not_equal('DiffsDeleteText', d and d.hl_group)
end
delete_buffer(bufnr)
end)
it('does not create char-level extmarks for pure additions', function()
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
@ -1157,142 +796,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('context padding produces no extmarks on padding lines', function()
local repo_root = '/tmp/diffs-test-context'
vim.fn.mkdir(repo_root, 'p')
local f = io.open(repo_root .. '/test.lua', 'w')
f:write('local M = {}\n')
f:write('function M.hello()\n')
f:write(' return "hi"\n')
f:write('end\n')
f:write('return M\n')
f:close()
local bufnr = create_buffer({
'@@ -3,1 +3,2 @@',
' return "hi"',
'+"bye"',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' return "hi"', '+"bye"' },
file_old_start = 3,
file_old_count = 1,
file_new_start = 3,
file_new_count = 2,
repo_root = repo_root,
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { context = { enabled = true, lines = 25 } } })
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local row = mark[2]
assert.is_true(row >= 1 and row <= 2)
end
delete_buffer(bufnr)
os.remove(repo_root .. '/test.lua')
vim.fn.delete(repo_root, 'rf')
end)
it('context disabled matches behavior without padding', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
file_new_start = 1,
file_new_count = 2,
repo_root = '/nonexistent',
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { context = { enabled = false, lines = 0 } } })
)
local extmarks = get_extmarks(bufnr)
assert.is_true(#extmarks > 0)
delete_buffer(bufnr)
end)
it('gracefully handles missing file for context padding', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
file_new_start = 1,
file_new_count = 2,
repo_root = '/nonexistent/path',
}
assert.has_no.errors(function()
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { context = { enabled = true, lines = 25 } } })
)
end)
local extmarks = get_extmarks(bufnr)
assert.is_true(#extmarks > 0)
delete_buffer(bufnr)
end)
it('highlights treesitter injections', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+vim.cmd([[ echo 1 ]])',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+vim.cmd([[ echo 1 ]])' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local has_vim_capture = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.vim$') then
has_vim_capture = true
break
end
end
assert.is_true(has_vim_capture)
delete_buffer(bufnr)
end)
it('includes captures from both base and injected languages', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@ -1327,6 +830,114 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('applies DiffsAdd background to combined diff lines with + in prefix', function()
local bufnr = create_buffer({
'@@@ -1,3 -1,5 +1,9 @@@',
' local M = {}',
'++<<<<<<< HEAD',
' + return 1',
'++=======',
'+ return 2',
'++>>>>>>> feature',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
prefix_width = 2,
lines = {
' local M = {}',
'++<<<<<<< HEAD',
' + return 1',
'++=======',
'+ return 2',
'++>>>>>>> feature',
},
}
highlight.highlight_hunk(
bufnr,
ns,
hunk,
default_opts({ highlights = { background = true } })
)
local extmarks = get_extmarks(bufnr)
local add_lines = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group == 'DiffsAdd' then
add_lines[mark[2]] = true
end
end
assert.is_nil(add_lines[0])
assert.is_nil(add_lines[1])
assert.is_true(add_lines[2] ~= nil)
assert.is_true(add_lines[3] ~= nil)
assert.is_true(add_lines[4] ~= nil)
assert.is_true(add_lines[5] ~= nil)
assert.is_true(add_lines[6] ~= nil)
delete_buffer(bufnr)
end)
it('conceals 2-char prefix for combined diffs', function()
local bufnr = create_buffer({
'@@@ -1,2 -1,2 +1,3 @@@',
' local M = {}',
'++<<<<<<< HEAD',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
prefix_width = 2,
lines = { ' local M = {}', '++<<<<<<< HEAD' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = true }))
local extmarks = get_extmarks(bufnr)
local overlay_count = 0
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text_pos == 'overlay' then
overlay_count = overlay_count + 1
assert.are.equal(' ', mark[4].virt_text[1][1])
end
end
assert.are.equal(2, overlay_count)
delete_buffer(bufnr)
end)
it('produces treesitter captures on combined diff content lines', function()
local bufnr = create_buffer({
'@@@ -1,2 -1,2 +1,3 @@@',
' local M = {}',
' +local x = 1',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
prefix_width = 2,
lines = { ' local M = {}', ' +local x = 1' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
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('filters @spell and @nospell captures from injections', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
@ -1386,6 +997,7 @@ describe('highlight', function()
context = { enabled = false, lines = 0 },
treesitter = { enabled = true, max_lines = 500 },
vim = { enabled = false, max_lines = 200 },
priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 },
},
}
end
@ -1468,47 +1080,6 @@ describe('highlight', function()
assert.are.equal(0, header_extmarks)
delete_buffer(bufnr)
end)
it('does not apply header highlights when treesitter disabled', function()
local bufnr = create_buffer({
'diff --git a/parser.lua b/parser.lua',
'index 3e8afa0..018159c 100644',
'--- a/parser.lua',
'+++ b/parser.lua',
'@@ -1,2 +1,3 @@',
' local M = {}',
'+local x = 1',
})
local hunk = {
filename = 'parser.lua',
lang = 'lua',
start_line = 5,
lines = { ' local M = {}', '+local x = 1' },
header_start_line = 1,
header_lines = {
'diff --git a/parser.lua b/parser.lua',
'index 3e8afa0..018159c 100644',
'--- a/parser.lua',
'+++ b/parser.lua',
},
}
local opts = default_opts()
opts.highlights.treesitter.enabled = false
highlight.highlight_hunk(bufnr, ns, hunk, opts)
local extmarks = get_extmarks(bufnr)
local header_extmarks = 0
for _, mark in ipairs(extmarks) do
if mark[2] < 4 and mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then
header_extmarks = header_extmarks + 1
end
end
assert.are.equal(0, header_extmarks)
delete_buffer(bufnr)
end)
end)
describe('extmark priority', function()
@ -1543,40 +1114,11 @@ describe('highlight', function()
context = { enabled = false, lines = 0 },
treesitter = { enabled = true, max_lines = 500 },
vim = { enabled = false, max_lines = 200 },
priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 },
},
}
end
it('uses priority 199 for code languages', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
' local x = 1',
'+local y = 2',
})
local hunk = {
filename = 'test.lua',
lang = 'lua',
start_line = 1,
lines = { ' local x = 1', '+local y = 2' },
}
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
local extmarks = get_extmarks(bufnr)
local has_priority_199 = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then
if mark[4].priority == 199 then
has_priority_199 = true
break
end
end
end
assert.is_true(has_priority_199)
delete_buffer(bufnr)
end)
it('uses treesitter priority for diff language', function()
local bufnr = create_buffer({
'diff --git a/test.lua b/test.lua',

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 = ']x',
prev = '[x',
},
}
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

@ -391,37 +391,6 @@ describe('parser', function()
vim.fn.delete(repo_root, 'rf')
end)
it('detects python from shebang without open buffer', function()
local repo_root = '/tmp/diffs-test-shebang-py'
vim.fn.mkdir(repo_root, 'p')
local file_path = repo_root .. '/deploy'
local f = io.open(file_path, 'w')
f:write('#!/usr/bin/env python3\n')
f:write('import sys\n')
f:write('print("hi")\n')
f:close()
local diff_buf = create_buffer({
'M deploy',
'@@ -1,2 +1,3 @@',
' #!/usr/bin/env python3',
'+import sys',
' print("hi")',
})
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
local hunks = parser.parse_buffer(diff_buf)
assert.are.equal(1, #hunks)
assert.are.equal('deploy', hunks[1].filename)
assert.are.equal('python', hunks[1].ft)
delete_buffer(diff_buf)
os.remove(file_path)
vim.fn.delete(repo_root, 'rf')
end)
it('extracts file line numbers from @@ header', function()
local bufnr = create_buffer({
'M lua/test.lua',
@ -440,22 +409,6 @@ describe('parser', function()
delete_buffer(bufnr)
end)
it('extracts large line numbers from @@ header', function()
local bufnr = create_buffer({
'M lua/test.lua',
'@@ -100,20 +200,30 @@',
' local M = {}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal(100, hunks[1].file_old_start)
assert.are.equal(20, hunks[1].file_old_count)
assert.are.equal(200, hunks[1].file_new_start)
assert.are.equal(30, hunks[1].file_new_count)
delete_buffer(bufnr)
end)
it('defaults count to 1 when omitted in @@ header', function()
local bufnr = create_buffer({
'M lua/test.lua',
@ -472,6 +425,81 @@ describe('parser', function()
delete_buffer(bufnr)
end)
it('recognizes U prefix for unmerged files', function()
local bufnr = create_buffer({
'U merge_me.lua',
'@@@ -1,3 -1,5 +1,9 @@@',
' local M = {}',
'++<<<<<<< HEAD',
' + return 1',
'++=======',
'+ return 2',
'++>>>>>>> feature',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('merge_me.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
delete_buffer(bufnr)
end)
it('sets prefix_width 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('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)
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('stores repo_root on hunk when available', function()
local bufnr = create_buffer({
'M lua/test.lua',
@ -487,18 +515,5 @@ describe('parser', function()
assert.are.equal('/tmp/test-repo', hunks[1].repo_root)
delete_buffer(bufnr)
end)
it('repo_root is nil when not available', function()
local bufnr = create_buffer({
'M lua/test.lua',
'@@ -1,3 +1,4 @@',
' local M = {}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.is_nil(hunks[1].repo_root)
delete_buffer(bufnr)
end)
end)
end)