Compare commits
50 commits
feat/vscod
...
doc/merge-
| Author | SHA1 | Date | |
|---|---|---|---|
| f3a72926d2 | |||
|
|
669cca53ae | ||
| a192830d8c | |||
| 35cb13419c | |||
| bae86c5fd9 | |||
| 1108c33526 | |||
| 98a1a4028b | |||
| 7ae867c413 | |||
| 74c2dd4c7a | |||
| 731222d027 | |||
|
|
e06d22936c | ||
| 6b4953bf41 | |||
| c72efec77d | |||
|
|
52013d007d | ||
| ae65c50f92 | |||
| a0870a7892 | |||
| b7477e3af2 | |||
| 8e0c41bf6b | |||
| bcc70280fb | |||
|
|
7049255931 | ||
| 5cfa91039b | |||
| d10eaed6ac | |||
|
|
d353a6a314 | ||
| 825012daeb | |||
| 38220ab368 | |||
|
|
3b2e0de2a7 | ||
| 011db2f8b3 | |||
|
|
af37d25f25 | ||
| f6c0738384 | |||
| 0cefa00d27 | |||
|
|
2b1b1c3be2 | ||
| 97a6fb2bd7 | |||
|
|
4dc650957b | ||
| 9e32384f18 | |||
| 2e1ebdee03 | |||
|
|
ba1f830629 | ||
| a046f38796 | |||
|
|
12eaac4727 | ||
| bbb87b660e | |||
| 93f6627dd2 | |||
| 2d72963f8f | |||
|
|
8d4602dbcb | ||
| f948982848 | |||
|
|
b6f1c5b749 | ||
| b79adba5f2 | |||
| 5722ccdbb2 | |||
|
|
a20623aa74 | ||
|
|
ac2eb657de | ||
| 10af59a70d | |||
| cc947167c3 |
17 changed files with 3478 additions and 215 deletions
25
README.md
25
README.md
|
|
@ -17,7 +17,10 @@ syntax highlighting.
|
||||||
- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`)
|
- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`)
|
||||||
- Vim syntax fallback for languages without a treesitter parser
|
- Vim syntax fallback for languages without a treesitter parser
|
||||||
- Hunk header context highlighting (`@@ ... @@ function foo()`)
|
- Hunk header context highlighting (`@@ ... @@ function foo()`)
|
||||||
- Configurable debouncing, max lines, and diff prefix concealment
|
- Character-level (intra-line) diff highlighting for changed characters
|
||||||
|
- Inline merge conflict detection, highlighting, and resolution keymaps
|
||||||
|
- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and
|
||||||
|
highlight overrides
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -40,12 +43,11 @@ luarocks install diffs.nvim
|
||||||
|
|
||||||
## Known Limitations
|
## Known Limitations
|
||||||
|
|
||||||
- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation
|
- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation.
|
||||||
without surrounding code context. When a hunk shows lines added to an existing
|
To improve accuracy, `diffs.nvim` reads lines from disk before and after each
|
||||||
block (e.g., adding a plugin inside `return { ... }`), the parser doesn't see
|
hunk for parsing context (`highlights.context`, enabled by default with 25
|
||||||
the `return` statement and may produce incorrect highlighting. This is
|
lines). This resolves most boundary issues. Set
|
||||||
inherent to parsing code fragments—no diff tooling solves this without
|
`highlights.context.enabled = false` to disable.
|
||||||
significant complexity.
|
|
||||||
|
|
||||||
- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event
|
- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event
|
||||||
triggered by `vim-fugitive`, at which point the buffer is preliminarily
|
triggered by `vim-fugitive`, at which point the buffer is preliminarily
|
||||||
|
|
@ -62,12 +64,17 @@ luarocks install diffs.nvim
|
||||||
compatible, but both plugins modifying line highlights may produce
|
compatible, but both plugins modifying line highlights may produce
|
||||||
unexpected results
|
unexpected results
|
||||||
- [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) -
|
- [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) -
|
||||||
conflict marker highlighting may overlap with `diffs.nvim`
|
`diffs.nvim` now includes built-in conflict resolution; disable one or the
|
||||||
|
other to avoid overlap
|
||||||
|
|
||||||
# Acknowledgements
|
# Acknowledgements
|
||||||
|
|
||||||
- [`vim-fugitive`](https://github.com/tpope/vim-fugitive)
|
- [`vim-fugitive`](https://github.com/tpope/vim-fugitive)
|
||||||
- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim)
|
- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim)
|
||||||
- [`diffview.nvim`](https://github.com/sindrets/diffview.nvim)
|
- [`diffview.nvim`](https://github.com/sindrets/diffview.nvim)
|
||||||
|
- [`difftastic`](https://github.com/Wilfred/difftastic)
|
||||||
|
- [`mini.diff`](https://github.com/echasnovski/mini.diff)
|
||||||
|
- [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim)
|
||||||
|
- [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim)
|
||||||
- [@phanen](https://github.com/phanen) - diff header highlighting, unknown
|
- [@phanen](https://github.com/phanen) - diff header highlighting, unknown
|
||||||
filetype fix, shebang/modeline detection
|
filetype fix, shebang/modeline detection, treesitter injection support
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ Features: ~
|
||||||
- Blended diff background colors that preserve syntax visibility
|
- Blended diff background colors that preserve syntax visibility
|
||||||
- Optional diff prefix (`+`/`-`/` `) concealment
|
- Optional diff prefix (`+`/`-`/` `) concealment
|
||||||
- Gutter (line number) highlighting
|
- Gutter (line number) highlighting
|
||||||
|
- Inline merge conflict marker detection, highlighting, and resolution
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
REQUIREMENTS *diffs-requirements*
|
REQUIREMENTS *diffs-requirements*
|
||||||
|
|
@ -56,24 +57,43 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
||||||
highlights = {
|
highlights = {
|
||||||
background = true,
|
background = true,
|
||||||
gutter = true,
|
gutter = true,
|
||||||
|
blend_alpha = 0.6,
|
||||||
|
context = {
|
||||||
|
enabled = true,
|
||||||
|
lines = 25,
|
||||||
|
},
|
||||||
treesitter = {
|
treesitter = {
|
||||||
enabled = true,
|
enabled = true,
|
||||||
max_lines = 500,
|
max_lines = 500,
|
||||||
},
|
},
|
||||||
vim = {
|
vim = {
|
||||||
enabled = false,
|
enabled = false,
|
||||||
max_lines = 500,
|
max_lines = 200,
|
||||||
},
|
},
|
||||||
intra = {
|
intra = {
|
||||||
enabled = true,
|
enabled = true,
|
||||||
algorithm = 'auto',
|
algorithm = 'default',
|
||||||
max_lines = 500,
|
max_lines = 500,
|
||||||
},
|
},
|
||||||
|
overrides = {},
|
||||||
},
|
},
|
||||||
fugitive = {
|
fugitive = {
|
||||||
horizontal = 'du',
|
horizontal = 'du',
|
||||||
vertical = 'dU',
|
vertical = 'dU',
|
||||||
},
|
},
|
||||||
|
conflict = {
|
||||||
|
enabled = true,
|
||||||
|
disable_diagnostics = true,
|
||||||
|
show_virtual_text = true,
|
||||||
|
keymaps = {
|
||||||
|
ours = 'doo',
|
||||||
|
theirs = 'dot',
|
||||||
|
both = 'dob',
|
||||||
|
none = 'don',
|
||||||
|
next = ']x',
|
||||||
|
prev = '[x',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
<
|
<
|
||||||
*diffs.Config*
|
*diffs.Config*
|
||||||
|
|
@ -102,6 +122,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
||||||
Fugitive status buffer keymap options.
|
Fugitive status buffer keymap options.
|
||||||
See |diffs.FugitiveConfig| for fields.
|
See |diffs.FugitiveConfig| for fields.
|
||||||
|
|
||||||
|
{conflict} (table, default: see below)
|
||||||
|
Inline merge conflict resolution options.
|
||||||
|
See |diffs.ConflictConfig| for fields.
|
||||||
|
|
||||||
*diffs.Highlights*
|
*diffs.Highlights*
|
||||||
Highlights table fields: ~
|
Highlights table fields: ~
|
||||||
{background} (boolean, default: true)
|
{background} (boolean, default: true)
|
||||||
|
|
@ -113,6 +137,17 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
||||||
Highlight line numbers with matching colors.
|
Highlight line numbers with matching colors.
|
||||||
Only visible if line numbers are enabled.
|
Only visible if line numbers are enabled.
|
||||||
|
|
||||||
|
{blend_alpha} (number, default: 0.6)
|
||||||
|
Alpha value for character-level blend intensity.
|
||||||
|
Controls how strongly changed characters stand
|
||||||
|
out from the line-level background. Must be
|
||||||
|
between 0 and 1 (inclusive). Higher values
|
||||||
|
produce more vivid character-level highlights.
|
||||||
|
|
||||||
|
{context} (table, default: see below)
|
||||||
|
Syntax parsing context options.
|
||||||
|
See |diffs.ContextConfig| for fields.
|
||||||
|
|
||||||
{treesitter} (table, default: see below)
|
{treesitter} (table, default: see below)
|
||||||
Treesitter highlighting options.
|
Treesitter highlighting options.
|
||||||
See |diffs.TreesitterConfig| for fields.
|
See |diffs.TreesitterConfig| for fields.
|
||||||
|
|
@ -125,6 +160,27 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
||||||
Character-level (intra-line) diff highlighting.
|
Character-level (intra-line) diff highlighting.
|
||||||
See |diffs.IntraConfig| for fields.
|
See |diffs.IntraConfig| for fields.
|
||||||
|
|
||||||
|
{overrides} (table, default: {})
|
||||||
|
Map of highlight group names to highlight
|
||||||
|
definitions (see |nvim_set_hl()|). Applied
|
||||||
|
after all computed groups without `default`,
|
||||||
|
so overrides always win over both computed
|
||||||
|
defaults and colorscheme definitions.
|
||||||
|
|
||||||
|
*diffs.ContextConfig*
|
||||||
|
Context config fields: ~
|
||||||
|
{enabled} (boolean, default: true)
|
||||||
|
Read lines from disk before and after each hunk
|
||||||
|
to provide surrounding syntax context. Improves
|
||||||
|
accuracy at hunk boundaries where incomplete
|
||||||
|
constructs (e.g., a function definition with no
|
||||||
|
body) would otherwise confuse the parser.
|
||||||
|
|
||||||
|
{lines} (integer, default: 25)
|
||||||
|
Number of context lines to read in each
|
||||||
|
direction. Lines are read with early exit —
|
||||||
|
cost scales with this value, not file size.
|
||||||
|
|
||||||
*diffs.TreesitterConfig*
|
*diffs.TreesitterConfig*
|
||||||
Treesitter config fields: ~
|
Treesitter config fields: ~
|
||||||
{enabled} (boolean, default: true)
|
{enabled} (boolean, default: true)
|
||||||
|
|
@ -158,12 +214,12 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
||||||
an intense background overlay while the rest of the
|
an intense background overlay while the rest of the
|
||||||
line keeps the softer line-level background.
|
line keeps the softer line-level background.
|
||||||
|
|
||||||
{algorithm} (string, default: 'auto')
|
{algorithm} (string, default: 'default')
|
||||||
Diff algorithm for character-level analysis.
|
Diff algorithm for character-level analysis.
|
||||||
`'auto'`: use libvscodediff if available, else
|
`'default'`: use |vim.diff()| with settings
|
||||||
native `vim.diff()`. `'native'`: always use
|
inherited from |'diffopt'| (`algorithm` and
|
||||||
`vim.diff()`. `'vscode'`: require libvscodediff
|
`linematch`). `'vscode'`: use libvscodediff FFI
|
||||||
(falls back to native if not available).
|
(falls back to default if not available).
|
||||||
|
|
||||||
{max_lines} (integer, default: 500)
|
{max_lines} (integer, default: 500)
|
||||||
Skip character-level highlighting for hunks larger
|
Skip character-level highlighting for hunks larger
|
||||||
|
|
@ -188,6 +244,9 @@ COMMANDS *diffs-commands*
|
||||||
code language, plus diff header highlighting for `diff --git`, `---`,
|
code language, plus diff header highlighting for `diff --git`, `---`,
|
||||||
`+++`, and `@@` lines.
|
`+++`, and `@@` lines.
|
||||||
|
|
||||||
|
If a `diffs://` window already exists in the current tabpage, the new
|
||||||
|
diff replaces its buffer instead of creating another split.
|
||||||
|
|
||||||
Parameters: ~
|
Parameters: ~
|
||||||
{revision} (string, optional) Git revision to diff against.
|
{revision} (string, optional) Git revision to diff against.
|
||||||
Defaults to HEAD.
|
Defaults to HEAD.
|
||||||
|
|
@ -205,6 +264,63 @@ COMMANDS *diffs-commands*
|
||||||
:Ghdiff [revision] *:Ghdiff*
|
:Ghdiff [revision] *:Ghdiff*
|
||||||
Like |:Gdiff| but explicitly opens in a horizontal split.
|
Like |:Gdiff| but explicitly opens in a horizontal split.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
MAPPINGS *diffs-mappings*
|
||||||
|
|
||||||
|
*<Plug>(diffs-gdiff)*
|
||||||
|
<Plug>(diffs-gdiff) Show unified diff against HEAD in a horizontal
|
||||||
|
split. Equivalent to |:Gdiff| with no arguments.
|
||||||
|
|
||||||
|
*<Plug>(diffs-gvdiff)*
|
||||||
|
<Plug>(diffs-gvdiff) Show unified diff against HEAD in a vertical
|
||||||
|
split. Equivalent to |:Gvdiff| with no arguments.
|
||||||
|
|
||||||
|
Example configuration: >lua
|
||||||
|
vim.keymap.set('n', '<leader>gd', '<Plug>(diffs-gdiff)')
|
||||||
|
vim.keymap.set('n', '<leader>gD', '<Plug>(diffs-gvdiff)')
|
||||||
|
<
|
||||||
|
|
||||||
|
*<Plug>(diffs-conflict-ours)*
|
||||||
|
<Plug>(diffs-conflict-ours)
|
||||||
|
Accept current (ours) change. Replaces the
|
||||||
|
conflict block with ours content.
|
||||||
|
|
||||||
|
*<Plug>(diffs-conflict-theirs)*
|
||||||
|
<Plug>(diffs-conflict-theirs)
|
||||||
|
Accept incoming (theirs) change. Replaces the
|
||||||
|
conflict block with theirs content.
|
||||||
|
|
||||||
|
*<Plug>(diffs-conflict-both)*
|
||||||
|
<Plug>(diffs-conflict-both)
|
||||||
|
Accept both changes (ours then theirs).
|
||||||
|
|
||||||
|
*<Plug>(diffs-conflict-none)*
|
||||||
|
<Plug>(diffs-conflict-none)
|
||||||
|
Reject both changes (delete entire block).
|
||||||
|
|
||||||
|
*<Plug>(diffs-conflict-next)*
|
||||||
|
<Plug>(diffs-conflict-next)
|
||||||
|
Jump to next conflict marker. Wraps around.
|
||||||
|
|
||||||
|
*<Plug>(diffs-conflict-prev)*
|
||||||
|
<Plug>(diffs-conflict-prev)
|
||||||
|
Jump to previous conflict marker. Wraps around.
|
||||||
|
|
||||||
|
Example configuration: >lua
|
||||||
|
vim.keymap.set('n', 'co', '<Plug>(diffs-conflict-ours)')
|
||||||
|
vim.keymap.set('n', 'ct', '<Plug>(diffs-conflict-theirs)')
|
||||||
|
vim.keymap.set('n', 'cb', '<Plug>(diffs-conflict-both)')
|
||||||
|
vim.keymap.set('n', 'cn', '<Plug>(diffs-conflict-none)')
|
||||||
|
vim.keymap.set('n', ']x', '<Plug>(diffs-conflict-next)')
|
||||||
|
vim.keymap.set('n', '[x', '<Plug>(diffs-conflict-prev)')
|
||||||
|
<
|
||||||
|
|
||||||
|
Diff buffer mappings: ~
|
||||||
|
*diffs-q*
|
||||||
|
q Close the diff window. Available in all `diffs://`
|
||||||
|
buffers created by |:Gdiff|, |:Gvdiff|, |:Ghdiff|,
|
||||||
|
or the fugitive status keymaps.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
FUGITIVE STATUS KEYMAPS *diffs-fugitive*
|
FUGITIVE STATUS KEYMAPS *diffs-fugitive*
|
||||||
|
|
||||||
|
|
@ -254,6 +370,94 @@ Configuration: ~
|
||||||
Keymap for unified diff in vertical split.
|
Keymap for unified diff in vertical split.
|
||||||
Set to `false` to disable.
|
Set to `false` to disable.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
CONFLICT RESOLUTION *diffs-conflict*
|
||||||
|
|
||||||
|
diffs.nvim detects inline merge conflict markers (`<<<<<<<`/`=======`/
|
||||||
|
`>>>>>>>`) in working files and provides highlighting and resolution keymaps.
|
||||||
|
Both standard and diff3 (`|||||||`) formats are supported.
|
||||||
|
|
||||||
|
Conflict regions are detected automatically on `BufReadPost` and re-scanned
|
||||||
|
on `TextChanged`. When all conflicts in a buffer are resolved, highlighting
|
||||||
|
is removed and diagnostics are re-enabled.
|
||||||
|
|
||||||
|
Configuration: ~
|
||||||
|
*diffs.ConflictConfig*
|
||||||
|
>lua
|
||||||
|
vim.g.diffs = {
|
||||||
|
conflict = {
|
||||||
|
enabled = true,
|
||||||
|
disable_diagnostics = true,
|
||||||
|
show_virtual_text = true,
|
||||||
|
keymaps = {
|
||||||
|
ours = 'doo',
|
||||||
|
theirs = 'dot',
|
||||||
|
both = 'dob',
|
||||||
|
none = 'don',
|
||||||
|
next = ']x',
|
||||||
|
prev = '[x',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
<
|
||||||
|
Fields: ~
|
||||||
|
{enabled} (boolean, default: true)
|
||||||
|
Enable conflict marker detection and
|
||||||
|
resolution. Set to `false` to disable
|
||||||
|
entirely.
|
||||||
|
|
||||||
|
{disable_diagnostics} (boolean, default: true)
|
||||||
|
Suppress LSP diagnostics on buffers with
|
||||||
|
conflict markers. Markers produce syntax
|
||||||
|
errors that clutter the diagnostic list.
|
||||||
|
Diagnostics are re-enabled when all conflicts
|
||||||
|
are resolved. Set `false` to leave
|
||||||
|
diagnostics alone.
|
||||||
|
|
||||||
|
{show_virtual_text} (boolean, default: true)
|
||||||
|
Show virtual text labels (" current" and
|
||||||
|
" incoming") at the end of `<<<<<<<` and
|
||||||
|
`>>>>>>>` marker lines.
|
||||||
|
|
||||||
|
{keymaps} (table, default: see above)
|
||||||
|
Buffer-local keymaps for conflict resolution
|
||||||
|
and navigation. Each value accepts a string
|
||||||
|
(custom key) or `false` (disabled).
|
||||||
|
|
||||||
|
*diffs.ConflictKeymaps*
|
||||||
|
Keymap fields: ~
|
||||||
|
{ours} (string|false, default: 'doo')
|
||||||
|
Accept current (ours) change.
|
||||||
|
|
||||||
|
{theirs} (string|false, default: 'dot')
|
||||||
|
Accept incoming (theirs) change.
|
||||||
|
|
||||||
|
{both} (string|false, default: 'dob')
|
||||||
|
Accept both changes (ours then theirs).
|
||||||
|
|
||||||
|
{none} (string|false, default: 'don')
|
||||||
|
Reject both changes (delete entire block).
|
||||||
|
|
||||||
|
{next} (string|false, default: ']x')
|
||||||
|
Jump to next conflict marker. Wraps around.
|
||||||
|
|
||||||
|
{prev} (string|false, default: '[x')
|
||||||
|
Jump to previous conflict marker. Wraps
|
||||||
|
around.
|
||||||
|
|
||||||
|
User events: ~
|
||||||
|
*DiffsConflictResolved*
|
||||||
|
DiffsConflictResolved Fired when the last conflict in a buffer is
|
||||||
|
resolved. Useful for triggering custom actions
|
||||||
|
(e.g., auto-staging the file). >lua
|
||||||
|
vim.api.nvim_create_autocmd('User', {
|
||||||
|
pattern = 'DiffsConflictResolved',
|
||||||
|
callback = function()
|
||||||
|
print('all conflicts resolved!')
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
<
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
API *diffs-api*
|
API *diffs-api*
|
||||||
|
|
||||||
|
|
@ -285,8 +489,8 @@ Summary / commit detail views: ~
|
||||||
- Code is parsed with |vim.treesitter.get_string_parser()|
|
- Code is parsed with |vim.treesitter.get_string_parser()|
|
||||||
- If no treesitter parser and `vim.enabled`: vim syntax fallback via
|
- If no treesitter parser and `vim.enabled`: vim syntax fallback via
|
||||||
scratch buffer and |synID()|
|
scratch buffer and |synID()|
|
||||||
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 198
|
- `Normal` extmarks at priority 198 clear underlying diff foreground
|
||||||
- `Normal` extmarks at priority 199 clear underlying diff foreground
|
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199
|
||||||
- Syntax highlights are applied as extmarks at priority 200
|
- Syntax highlights are applied as extmarks at priority 200
|
||||||
- Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at
|
- Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at
|
||||||
priority 201 overlay changed characters with an intense background
|
priority 201 overlay changed characters with an intense background
|
||||||
|
|
@ -305,14 +509,15 @@ KNOWN LIMITATIONS *diffs-limitations*
|
||||||
|
|
||||||
Incomplete Syntax Context ~
|
Incomplete Syntax Context ~
|
||||||
*diffs-syntax-context*
|
*diffs-syntax-context*
|
||||||
Treesitter parses each diff hunk in isolation without surrounding code
|
Treesitter parses each diff hunk in isolation. To provide surrounding code
|
||||||
context. When a hunk shows lines added to an existing block (e.g., adding a
|
context, diffs.nvim reads lines from disk before and after each hunk
|
||||||
plugin inside `return { ... }`), the parser doesn't see the `return`
|
(see |diffs.ContextConfig|, enabled by default). This resolves most boundary
|
||||||
statement and may produce incorrect or unusual highlighting.
|
issues where incomplete constructs (e.g., a function definition at the edge
|
||||||
|
of a hunk with no body) would confuse the parser.
|
||||||
|
|
||||||
This is inherent to parsing code fragments. No diff tooling solves this
|
Set `highlights.context.enabled = false` to disable context padding. In rare
|
||||||
problem without significant complexity—the parser simply doesn't have enough
|
cases, context padding may not help if the relevant surrounding code is very
|
||||||
information to understand the full syntactic structure.
|
far from the hunk boundaries.
|
||||||
|
|
||||||
Syntax Highlighting Flash ~
|
Syntax Highlighting Flash ~
|
||||||
*diffs-flash*
|
*diffs-flash*
|
||||||
|
|
@ -350,8 +555,9 @@ conflict:
|
||||||
modifying line highlights may produce unexpected results.
|
modifying line highlights may produce unexpected results.
|
||||||
|
|
||||||
- git-conflict.nvim (akinsho/git-conflict.nvim)
|
- git-conflict.nvim (akinsho/git-conflict.nvim)
|
||||||
Provides conflict marker highlighting that may overlap with
|
Provides conflict marker highlighting and resolution keymaps.
|
||||||
diffs.nvim's highlighting in conflict scenarios.
|
diffs.nvim now has built-in conflict resolution (see
|
||||||
|
|diffs-conflict|). Disable one or the other to avoid overlap.
|
||||||
|
|
||||||
If you experience visual conflicts, try disabling the conflicting plugin's
|
If you experience visual conflicts, try disabling the conflicting plugin's
|
||||||
diff-related features.
|
diff-related features.
|
||||||
|
|
@ -359,9 +565,15 @@ diff-related features.
|
||||||
==============================================================================
|
==============================================================================
|
||||||
HIGHLIGHT GROUPS *diffs-highlights*
|
HIGHLIGHT GROUPS *diffs-highlights*
|
||||||
|
|
||||||
diffs.nvim defines custom highlight groups. Fugitive unified diff groups use
|
diffs.nvim defines custom highlight groups. All groups use `default = true`,
|
||||||
`default = true`, so colorschemes can override them. Diff mode groups are
|
so colorschemes can override them by defining the group before the plugin
|
||||||
always derived from the corresponding `Diff*` groups.
|
loads.
|
||||||
|
|
||||||
|
All derived groups are computed by alpha-blending a source color into the
|
||||||
|
`Normal` background. Line-level groups blend at 40% alpha for a subtle tint;
|
||||||
|
character-level groups blend at 60% for more contrast. Line-number groups
|
||||||
|
combine both: background from the line-level blend, foreground from the
|
||||||
|
character-level blend.
|
||||||
|
|
||||||
Fugitive unified diff highlights: ~
|
Fugitive unified diff highlights: ~
|
||||||
*DiffsAdd*
|
*DiffsAdd*
|
||||||
|
|
@ -374,24 +586,54 @@ Fugitive unified diff highlights: ~
|
||||||
|
|
||||||
*DiffsAddNr*
|
*DiffsAddNr*
|
||||||
DiffsAddNr Line number for `+` lines. Foreground from
|
DiffsAddNr Line number for `+` lines. Foreground from
|
||||||
`diffAdded`, background from `DiffsAdd`.
|
`DiffsAddText`, background from `DiffsAdd`.
|
||||||
|
|
||||||
*DiffsDeleteNr*
|
*DiffsDeleteNr*
|
||||||
DiffsDeleteNr Line number for `-` lines. Foreground from
|
DiffsDeleteNr Line number for `-` lines. Foreground from
|
||||||
`diffRemoved`, background from `DiffsDelete`.
|
`DiffsDeleteText`, background from `DiffsDelete`.
|
||||||
|
|
||||||
*DiffsAddText*
|
*DiffsAddText*
|
||||||
DiffsAddText Character-level background for changed characters
|
DiffsAddText Character-level background for changed characters
|
||||||
within `+` lines. Derived by blending `diffAdded`
|
within `+` lines. Derived by blending `diffAdded`
|
||||||
foreground with `Normal` background at 40% alpha.
|
foreground with `Normal` background at 60% alpha.
|
||||||
Uses the same base color as `DiffsAddNr` foreground,
|
Only sets `bg`, so treesitter foreground colors show
|
||||||
making changed characters clearly visible. Only sets
|
through.
|
||||||
`bg`, so treesitter foreground colors show through.
|
|
||||||
|
|
||||||
*DiffsDeleteText*
|
*DiffsDeleteText*
|
||||||
DiffsDeleteText Character-level background for changed characters
|
DiffsDeleteText Character-level background for changed characters
|
||||||
within `-` lines. Derived by blending `diffRemoved`
|
within `-` lines. Derived by blending `diffRemoved`
|
||||||
foreground with `Normal` background at 40% alpha.
|
foreground with `Normal` background at 60% alpha.
|
||||||
|
|
||||||
|
Conflict highlights: ~
|
||||||
|
*DiffsConflictOurs*
|
||||||
|
DiffsConflictOurs Background for "ours" (current) content lines.
|
||||||
|
Derived by blending `DiffAdd` background with
|
||||||
|
`Normal` at 40% alpha (green tint).
|
||||||
|
|
||||||
|
*DiffsConflictTheirs*
|
||||||
|
DiffsConflictTheirs Background for "theirs" (incoming) content lines.
|
||||||
|
Derived by blending `DiffChange` background with
|
||||||
|
`Normal` at 40% alpha.
|
||||||
|
|
||||||
|
*DiffsConflictBase*
|
||||||
|
DiffsConflictBase Background for base (ancestor) content lines in
|
||||||
|
diff3 conflicts. Derived by blending `DiffText`
|
||||||
|
background with `Normal` at 30% alpha (muted).
|
||||||
|
|
||||||
|
*DiffsConflictMarker*
|
||||||
|
DiffsConflictMarker Dimmed foreground with bold for `<<<<<<<`,
|
||||||
|
`=======`, `>>>>>>>`, and `|||||||` marker lines.
|
||||||
|
|
||||||
|
*DiffsConflictOursNr*
|
||||||
|
DiffsConflictOursNr Line number for "ours" content lines. Foreground
|
||||||
|
from higher-alpha blend, background from line-level
|
||||||
|
blend.
|
||||||
|
|
||||||
|
*DiffsConflictTheirsNr*
|
||||||
|
DiffsConflictTheirsNr Line number for "theirs" content lines.
|
||||||
|
|
||||||
|
*DiffsConflictBaseNr*
|
||||||
|
DiffsConflictBaseNr Line number for base content lines (diff3).
|
||||||
|
|
||||||
Diff mode window highlights: ~
|
Diff mode window highlights: ~
|
||||||
These are used for |winhighlight| remapping in `&diff` windows.
|
These are used for |winhighlight| remapping in `&diff` windows.
|
||||||
|
|
@ -417,6 +659,16 @@ To customize these in your colorscheme: >lua
|
||||||
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = '#2e4a3a' })
|
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = '#2e4a3a' })
|
||||||
vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { link = 'DiffDelete' })
|
vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { link = 'DiffDelete' })
|
||||||
<
|
<
|
||||||
|
Or via `highlights.overrides` in config: >lua
|
||||||
|
vim.g.diffs = {
|
||||||
|
highlights = {
|
||||||
|
overrides = {
|
||||||
|
DiffsAdd = { bg = '#2e4a3a' },
|
||||||
|
DiffsDiffDelete = { link = 'DiffDelete' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
<
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
HEALTH CHECK *diffs-health*
|
HEALTH CHECK *diffs-health*
|
||||||
|
|
@ -434,7 +686,8 @@ ACKNOWLEDGEMENTS *diffs-acknowledgements*
|
||||||
- vim-fugitive (https://github.com/tpope/vim-fugitive)
|
- vim-fugitive (https://github.com/tpope/vim-fugitive)
|
||||||
- codediff.nvim (https://github.com/esmuellert/codediff.nvim)
|
- codediff.nvim (https://github.com/esmuellert/codediff.nvim)
|
||||||
- diffview.nvim (https://github.com/sindrets/diffview.nvim)
|
- diffview.nvim (https://github.com/sindrets/diffview.nvim)
|
||||||
- @phanen (https://github.com/phanen) - diff header highlighting
|
- @phanen (https://github.com/phanen) - diff header highlighting,
|
||||||
|
treesitter injection support
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
vim:tw=78:ts=8:ft=help:norl:
|
vim:tw=78:ts=8:ft=help:norl:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,27 @@ local M = {}
|
||||||
local git = require('diffs.git')
|
local git = require('diffs.git')
|
||||||
local dbg = require('diffs.log').dbg
|
local dbg = require('diffs.log').dbg
|
||||||
|
|
||||||
|
---@return integer?
|
||||||
|
function M.find_diffs_window()
|
||||||
|
local tabpage = vim.api.nvim_get_current_tabpage()
|
||||||
|
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do
|
||||||
|
if vim.api.nvim_win_is_valid(win) then
|
||||||
|
local buf = vim.api.nvim_win_get_buf(win)
|
||||||
|
local name = vim.api.nvim_buf_get_name(buf)
|
||||||
|
if name:match('^diffs://') then
|
||||||
|
return win
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
function M.setup_diff_buf(bufnr)
|
||||||
|
vim.diagnostic.enable(false, { bufnr = bufnr })
|
||||||
|
vim.keymap.set('n', 'q', '<cmd>close<CR>', { buffer = bufnr })
|
||||||
|
end
|
||||||
|
|
||||||
---@param diff_lines string[]
|
---@param diff_lines string[]
|
||||||
---@param hunk_position { hunk_header: string, offset: integer }
|
---@param hunk_position { hunk_header: string, offset: integer }
|
||||||
---@return integer?
|
---@return integer?
|
||||||
|
|
@ -86,8 +107,9 @@ function M.gdiff(revision, vertical)
|
||||||
|
|
||||||
local diff_buf = vim.api.nvim_create_buf(false, true)
|
local diff_buf = vim.api.nvim_create_buf(false, true)
|
||||||
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines)
|
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines)
|
||||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = diff_buf })
|
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf })
|
||||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf })
|
vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf })
|
||||||
|
vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf })
|
||||||
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
|
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
|
||||||
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
|
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
|
||||||
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. revision .. ':' .. rel_path)
|
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. revision .. ':' .. rel_path)
|
||||||
|
|
@ -95,9 +117,16 @@ function M.gdiff(revision, vertical)
|
||||||
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
|
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local existing_win = M.find_diffs_window()
|
||||||
|
if existing_win then
|
||||||
|
vim.api.nvim_set_current_win(existing_win)
|
||||||
|
vim.api.nvim_win_set_buf(existing_win, diff_buf)
|
||||||
|
else
|
||||||
vim.cmd(vertical and 'vsplit' or 'split')
|
vim.cmd(vertical and 'vsplit' or 'split')
|
||||||
vim.api.nvim_win_set_buf(0, diff_buf)
|
vim.api.nvim_win_set_buf(0, diff_buf)
|
||||||
|
end
|
||||||
|
|
||||||
|
M.setup_diff_buf(diff_buf)
|
||||||
dbg('opened diff buffer %d for %s against %s', diff_buf, rel_path, revision)
|
dbg('opened diff buffer %d for %s against %s', diff_buf, rel_path, revision)
|
||||||
|
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
|
|
@ -176,17 +205,27 @@ function M.gdiff_file(filepath, opts)
|
||||||
|
|
||||||
local diff_buf = vim.api.nvim_create_buf(false, true)
|
local diff_buf = vim.api.nvim_create_buf(false, true)
|
||||||
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines)
|
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines)
|
||||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = diff_buf })
|
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf })
|
||||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf })
|
vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf })
|
||||||
|
vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf })
|
||||||
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
|
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
|
||||||
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
|
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
|
||||||
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':' .. rel_path)
|
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':' .. rel_path)
|
||||||
if repo_root then
|
if repo_root then
|
||||||
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
|
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
|
||||||
end
|
end
|
||||||
|
if old_rel_path ~= rel_path then
|
||||||
|
vim.api.nvim_buf_set_var(diff_buf, 'diffs_old_filepath', old_rel_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
local existing_win = M.find_diffs_window()
|
||||||
|
if existing_win then
|
||||||
|
vim.api.nvim_set_current_win(existing_win)
|
||||||
|
vim.api.nvim_win_set_buf(existing_win, diff_buf)
|
||||||
|
else
|
||||||
vim.cmd(opts.vertical and 'vsplit' or 'split')
|
vim.cmd(opts.vertical and 'vsplit' or 'split')
|
||||||
vim.api.nvim_win_set_buf(0, diff_buf)
|
vim.api.nvim_win_set_buf(0, diff_buf)
|
||||||
|
end
|
||||||
|
|
||||||
if opts.hunk_position then
|
if opts.hunk_position then
|
||||||
local target_line = M.find_hunk_line(diff_lines, opts.hunk_position)
|
local target_line = M.find_hunk_line(diff_lines, opts.hunk_position)
|
||||||
|
|
@ -196,6 +235,7 @@ function M.gdiff_file(filepath, opts)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
M.setup_diff_buf(diff_buf)
|
||||||
dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label)
|
dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label)
|
||||||
|
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
|
|
@ -231,16 +271,24 @@ function M.gdiff_section(repo_root, opts)
|
||||||
local diff_label = opts.staged and 'staged' or 'unstaged'
|
local diff_label = opts.staged and 'staged' or 'unstaged'
|
||||||
local diff_buf = vim.api.nvim_create_buf(false, true)
|
local diff_buf = vim.api.nvim_create_buf(false, true)
|
||||||
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, result)
|
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, result)
|
||||||
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = diff_buf })
|
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf })
|
||||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf })
|
vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf })
|
||||||
|
vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf })
|
||||||
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
|
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
|
||||||
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
|
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
|
||||||
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':all')
|
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':all')
|
||||||
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
|
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
|
||||||
|
|
||||||
|
local existing_win = M.find_diffs_window()
|
||||||
|
if existing_win then
|
||||||
|
vim.api.nvim_set_current_win(existing_win)
|
||||||
|
vim.api.nvim_win_set_buf(existing_win, diff_buf)
|
||||||
|
else
|
||||||
vim.cmd(opts.vertical and 'vsplit' or 'split')
|
vim.cmd(opts.vertical and 'vsplit' or 'split')
|
||||||
vim.api.nvim_win_set_buf(0, diff_buf)
|
vim.api.nvim_win_set_buf(0, diff_buf)
|
||||||
|
end
|
||||||
|
|
||||||
|
M.setup_diff_buf(diff_buf)
|
||||||
dbg('opened section diff buffer %d (%s)', diff_buf, diff_label)
|
dbg('opened section diff buffer %d (%s)', diff_buf, diff_label)
|
||||||
|
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
|
|
@ -248,6 +296,77 @@ function M.gdiff_section(repo_root, opts)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
function M.read_buffer(bufnr)
|
||||||
|
local name = vim.api.nvim_buf_get_name(bufnr)
|
||||||
|
local url_body = name:match('^diffs://(.+)$')
|
||||||
|
if not url_body then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local label, path = url_body:match('^([^:]+):(.+)$')
|
||||||
|
if not label or not path then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, repo_root = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_repo_root')
|
||||||
|
if not ok or not repo_root then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local diff_lines
|
||||||
|
|
||||||
|
if path == 'all' then
|
||||||
|
local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' }
|
||||||
|
if label == 'staged' then
|
||||||
|
table.insert(cmd, '--cached')
|
||||||
|
end
|
||||||
|
diff_lines = vim.fn.systemlist(cmd)
|
||||||
|
if vim.v.shell_error ~= 0 then
|
||||||
|
diff_lines = {}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local abs_path = repo_root .. '/' .. path
|
||||||
|
|
||||||
|
local old_ok, old_rel_path = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_old_filepath')
|
||||||
|
local old_abs_path = old_ok and old_rel_path and (repo_root .. '/' .. old_rel_path) or abs_path
|
||||||
|
local old_name = old_ok and old_rel_path or path
|
||||||
|
|
||||||
|
local old_lines, new_lines
|
||||||
|
|
||||||
|
if label == 'untracked' then
|
||||||
|
old_lines = {}
|
||||||
|
new_lines = git.get_working_content(abs_path) or {}
|
||||||
|
elseif label == 'staged' then
|
||||||
|
old_lines = git.get_file_content('HEAD', old_abs_path) or {}
|
||||||
|
new_lines = git.get_index_content(abs_path) or {}
|
||||||
|
elseif label == 'unstaged' then
|
||||||
|
old_lines = git.get_index_content(old_abs_path)
|
||||||
|
if not old_lines then
|
||||||
|
old_lines = git.get_file_content('HEAD', old_abs_path) or {}
|
||||||
|
end
|
||||||
|
new_lines = git.get_working_content(abs_path) or {}
|
||||||
|
else
|
||||||
|
old_lines = git.get_file_content(label, abs_path) or {}
|
||||||
|
new_lines = git.get_working_content(abs_path) or {}
|
||||||
|
end
|
||||||
|
|
||||||
|
diff_lines = generate_unified_diff(old_lines, new_lines, old_name, path)
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
|
||||||
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, diff_lines)
|
||||||
|
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||||
|
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr })
|
||||||
|
vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = bufnr })
|
||||||
|
vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr })
|
||||||
|
vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr })
|
||||||
|
|
||||||
|
dbg('reloaded diff buffer %d (%s:%s)', bufnr, label, path)
|
||||||
|
|
||||||
|
require('diffs').attach(bufnr)
|
||||||
|
end
|
||||||
|
|
||||||
function M.setup()
|
function M.setup()
|
||||||
vim.api.nvim_create_user_command('Gdiff', function(opts)
|
vim.api.nvim_create_user_command('Gdiff', function(opts)
|
||||||
M.gdiff(opts.args ~= '' and opts.args or nil, false)
|
M.gdiff(opts.args ~= '' and opts.args or nil, false)
|
||||||
|
|
|
||||||
438
lua/diffs/conflict.lua
Normal file
438
lua/diffs/conflict.lua
Normal file
|
|
@ -0,0 +1,438 @@
|
||||||
|
---@class diffs.ConflictRegion
|
||||||
|
---@field marker_ours integer
|
||||||
|
---@field ours_start integer
|
||||||
|
---@field ours_end integer
|
||||||
|
---@field marker_base integer?
|
||||||
|
---@field base_start integer?
|
||||||
|
---@field base_end integer?
|
||||||
|
---@field marker_sep integer
|
||||||
|
---@field theirs_start integer
|
||||||
|
---@field theirs_end integer
|
||||||
|
---@field marker_theirs integer
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local ns = vim.api.nvim_create_namespace('diffs-conflict')
|
||||||
|
|
||||||
|
---@type table<integer, true>
|
||||||
|
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)
|
||||||
|
local regions = {}
|
||||||
|
local state = 'idle'
|
||||||
|
---@type table?
|
||||||
|
local current = nil
|
||||||
|
|
||||||
|
for i, line in ipairs(lines) do
|
||||||
|
local idx = i - 1
|
||||||
|
|
||||||
|
if state == 'idle' then
|
||||||
|
if line:match('^<<<<<<<') then
|
||||||
|
current = { marker_ours = idx, ours_start = idx + 1 }
|
||||||
|
state = 'in_ours'
|
||||||
|
end
|
||||||
|
elseif state == 'in_ours' then
|
||||||
|
if line:match('^|||||||') then
|
||||||
|
current.ours_end = idx
|
||||||
|
current.marker_base = idx
|
||||||
|
current.base_start = idx + 1
|
||||||
|
state = 'in_base'
|
||||||
|
elseif line:match('^=======') then
|
||||||
|
current.ours_end = idx
|
||||||
|
current.marker_sep = idx
|
||||||
|
current.theirs_start = idx + 1
|
||||||
|
state = 'in_theirs'
|
||||||
|
elseif line:match('^<<<<<<<') then
|
||||||
|
current = { marker_ours = idx, ours_start = idx + 1 }
|
||||||
|
elseif line:match('^>>>>>>>') then
|
||||||
|
current = nil
|
||||||
|
state = 'idle'
|
||||||
|
end
|
||||||
|
elseif state == 'in_base' then
|
||||||
|
if line:match('^=======') then
|
||||||
|
current.base_end = idx
|
||||||
|
current.marker_sep = idx
|
||||||
|
current.theirs_start = idx + 1
|
||||||
|
state = 'in_theirs'
|
||||||
|
elseif line:match('^<<<<<<<') then
|
||||||
|
current = { marker_ours = idx, ours_start = idx + 1 }
|
||||||
|
state = 'in_ours'
|
||||||
|
elseif line:match('^>>>>>>>') then
|
||||||
|
current = nil
|
||||||
|
state = 'idle'
|
||||||
|
end
|
||||||
|
elseif state == 'in_theirs' then
|
||||||
|
if line:match('^>>>>>>>') then
|
||||||
|
current.theirs_end = idx
|
||||||
|
current.marker_theirs = idx
|
||||||
|
table.insert(regions, current)
|
||||||
|
current = nil
|
||||||
|
state = 'idle'
|
||||||
|
elseif line:match('^<<<<<<<') then
|
||||||
|
current = { marker_ours = idx, ours_start = idx + 1 }
|
||||||
|
state = 'in_ours'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return regions
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@return diffs.ConflictRegion[]
|
||||||
|
local function parse_buffer(bufnr)
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
return M.parse(lines)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param regions diffs.ConflictRegion[]
|
||||||
|
---@param config diffs.ConflictConfig
|
||||||
|
local function apply_highlights(bufnr, regions, config)
|
||||||
|
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
|
||||||
|
|
||||||
|
for _, region in ipairs(regions) do
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
|
||||||
|
end_row = region.marker_ours + 1,
|
||||||
|
hl_group = 'DiffsConflictMarker',
|
||||||
|
hl_eol = true,
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
for line = region.ours_start, region.ours_end - 1 do
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
|
||||||
|
end_row = line + 1,
|
||||||
|
hl_group = 'DiffsConflictOurs',
|
||||||
|
hl_eol = true,
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
|
||||||
|
number_hl_group = 'DiffsConflictOursNr',
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
if region.marker_base then
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_base, 0, {
|
||||||
|
end_row = region.marker_base + 1,
|
||||||
|
hl_group = 'DiffsConflictMarker',
|
||||||
|
hl_eol = true,
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
|
||||||
|
for line = region.base_start, region.base_end - 1 do
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
|
||||||
|
end_row = line + 1,
|
||||||
|
hl_group = 'DiffsConflictBase',
|
||||||
|
hl_eol = true,
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
|
||||||
|
number_hl_group = 'DiffsConflictBaseNr',
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_sep, 0, {
|
||||||
|
end_row = region.marker_sep + 1,
|
||||||
|
hl_group = 'DiffsConflictMarker',
|
||||||
|
hl_eol = true,
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
|
||||||
|
for line = region.theirs_start, region.theirs_end - 1 do
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
|
||||||
|
end_row = line + 1,
|
||||||
|
hl_group = 'DiffsConflictTheirs',
|
||||||
|
hl_eol = true,
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, {
|
||||||
|
number_hl_group = 'DiffsConflictTheirsNr',
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, {
|
||||||
|
end_row = region.marker_theirs + 1,
|
||||||
|
hl_group = 'DiffsConflictMarker',
|
||||||
|
hl_eol = true,
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param cursor_line integer
|
||||||
|
---@param regions diffs.ConflictRegion[]
|
||||||
|
---@return diffs.ConflictRegion?
|
||||||
|
local function find_conflict_at_cursor(cursor_line, regions)
|
||||||
|
for _, region in ipairs(regions) do
|
||||||
|
if cursor_line >= region.marker_ours and cursor_line <= region.marker_theirs then
|
||||||
|
return region
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param region diffs.ConflictRegion
|
||||||
|
---@param replacement string[]
|
||||||
|
local function replace_region(bufnr, region, replacement)
|
||||||
|
vim.api.nvim_buf_set_lines(
|
||||||
|
bufnr,
|
||||||
|
region.marker_ours,
|
||||||
|
region.marker_theirs + 1,
|
||||||
|
false,
|
||||||
|
replacement
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param config diffs.ConflictConfig
|
||||||
|
local function refresh(bufnr, config)
|
||||||
|
local regions = parse_buffer(bufnr)
|
||||||
|
if #regions == 0 then
|
||||||
|
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
|
||||||
|
if diagnostics_suppressed[bufnr] then
|
||||||
|
pcall(vim.diagnostic.reset, nil, bufnr)
|
||||||
|
pcall(vim.diagnostic.enable, true, { bufnr = bufnr })
|
||||||
|
diagnostics_suppressed[bufnr] = nil
|
||||||
|
end
|
||||||
|
vim.api.nvim_exec_autocmds('User', { pattern = 'DiffsConflictResolved' })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
apply_highlights(bufnr, regions, config)
|
||||||
|
if config.disable_diagnostics and not diagnostics_suppressed[bufnr] then
|
||||||
|
pcall(vim.diagnostic.enable, false, { bufnr = bufnr })
|
||||||
|
diagnostics_suppressed[bufnr] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param config diffs.ConflictConfig
|
||||||
|
function M.resolve_ours(bufnr, config)
|
||||||
|
if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then
|
||||||
|
vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local regions = parse_buffer(bufnr)
|
||||||
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local region = find_conflict_at_cursor(cursor[1] - 1, regions)
|
||||||
|
if not region then
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param config diffs.ConflictConfig
|
||||||
|
function M.resolve_theirs(bufnr, config)
|
||||||
|
if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then
|
||||||
|
vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local regions = parse_buffer(bufnr)
|
||||||
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local region = find_conflict_at_cursor(cursor[1] - 1, regions)
|
||||||
|
if not region then
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param config diffs.ConflictConfig
|
||||||
|
function M.resolve_both(bufnr, config)
|
||||||
|
if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then
|
||||||
|
vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local regions = parse_buffer(bufnr)
|
||||||
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local region = find_conflict_at_cursor(cursor[1] - 1, regions)
|
||||||
|
if not region then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local ours = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false)
|
||||||
|
local theirs = vim.api.nvim_buf_get_lines(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
|
||||||
|
replace_region(bufnr, region, combined)
|
||||||
|
refresh(bufnr, config)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param config diffs.ConflictConfig
|
||||||
|
function M.resolve_none(bufnr, config)
|
||||||
|
if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then
|
||||||
|
vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local regions = parse_buffer(bufnr)
|
||||||
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local region = find_conflict_at_cursor(cursor[1] - 1, regions)
|
||||||
|
if not region then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
replace_region(bufnr, region, {})
|
||||||
|
refresh(bufnr, config)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
function M.goto_next(bufnr)
|
||||||
|
local regions = parse_buffer(bufnr)
|
||||||
|
if #regions == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local cursor_line = cursor[1] - 1
|
||||||
|
for _, region in ipairs(regions) do
|
||||||
|
if region.marker_ours > cursor_line then
|
||||||
|
vim.api.nvim_win_set_cursor(0, { region.marker_ours + 1, 0 })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vim.api.nvim_win_set_cursor(0, { regions[1].marker_ours + 1, 0 })
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
function M.goto_prev(bufnr)
|
||||||
|
local regions = parse_buffer(bufnr)
|
||||||
|
if #regions == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||||
|
local cursor_line = cursor[1] - 1
|
||||||
|
for i = #regions, 1, -1 do
|
||||||
|
if regions[i].marker_ours < cursor_line then
|
||||||
|
vim.api.nvim_win_set_cursor(0, { regions[i].marker_ours + 1, 0 })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vim.api.nvim_win_set_cursor(0, { regions[#regions].marker_ours + 1, 0 })
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param config diffs.ConflictConfig
|
||||||
|
local function setup_keymaps(bufnr, config)
|
||||||
|
local km = config.keymaps
|
||||||
|
|
||||||
|
local maps = {
|
||||||
|
{ km.ours, '<Plug>(diffs-conflict-ours)' },
|
||||||
|
{ km.theirs, '<Plug>(diffs-conflict-theirs)' },
|
||||||
|
{ km.both, '<Plug>(diffs-conflict-both)' },
|
||||||
|
{ km.none, '<Plug>(diffs-conflict-none)' },
|
||||||
|
{ km.next, '<Plug>(diffs-conflict-next)' },
|
||||||
|
{ km.prev, '<Plug>(diffs-conflict-prev)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, map in ipairs(maps) do
|
||||||
|
if map[1] then
|
||||||
|
vim.keymap.set('n', map[1], map[2], { buffer = bufnr })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
function M.detach(bufnr)
|
||||||
|
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
|
||||||
|
attached_buffers[bufnr] = nil
|
||||||
|
|
||||||
|
if diagnostics_suppressed[bufnr] then
|
||||||
|
pcall(vim.diagnostic.reset, nil, bufnr)
|
||||||
|
pcall(vim.diagnostic.enable, true, { bufnr = bufnr })
|
||||||
|
diagnostics_suppressed[bufnr] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param config diffs.ConflictConfig
|
||||||
|
function M.attach(bufnr, config)
|
||||||
|
if attached_buffers[bufnr] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local buftype = vim.api.nvim_get_option_value('buftype', { buf = bufnr })
|
||||||
|
if buftype ~= '' then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
local has_marker = false
|
||||||
|
for _, line in ipairs(lines) do
|
||||||
|
if line:match('^<<<<<<<') then
|
||||||
|
has_marker = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not has_marker then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
attached_buffers[bufnr] = true
|
||||||
|
|
||||||
|
local regions = M.parse(lines)
|
||||||
|
apply_highlights(bufnr, regions, config)
|
||||||
|
setup_keymaps(bufnr, config)
|
||||||
|
|
||||||
|
if config.disable_diagnostics then
|
||||||
|
pcall(vim.diagnostic.enable, false, { bufnr = bufnr })
|
||||||
|
diagnostics_suppressed[bufnr] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
|
||||||
|
buffer = bufnr,
|
||||||
|
callback = function()
|
||||||
|
if not attached_buffers[bufnr] then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
refresh(bufnr, config)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd('BufWipeout', {
|
||||||
|
buffer = bufnr,
|
||||||
|
callback = function()
|
||||||
|
attached_buffers[bufnr] = nil
|
||||||
|
diagnostics_suppressed[bufnr] = nil
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return integer
|
||||||
|
function M.get_namespace()
|
||||||
|
return ns
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
70
lua/diffs/debug.lua
Normal file
70
lua/diffs/debug.lua
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local ns = vim.api.nvim_create_namespace('diffs')
|
||||||
|
|
||||||
|
function M.dump()
|
||||||
|
local bufnr = vim.api.nvim_get_current_buf()
|
||||||
|
local marks = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
|
||||||
|
local by_line = {}
|
||||||
|
for _, mark in ipairs(marks) do
|
||||||
|
local id, row, col, details = mark[1], mark[2], mark[3], mark[4]
|
||||||
|
local entry = {
|
||||||
|
id = id,
|
||||||
|
row = row,
|
||||||
|
col = col,
|
||||||
|
end_row = details.end_row,
|
||||||
|
end_col = details.end_col,
|
||||||
|
hl_group = details.hl_group,
|
||||||
|
priority = details.priority,
|
||||||
|
hl_eol = details.hl_eol,
|
||||||
|
line_hl_group = details.line_hl_group,
|
||||||
|
number_hl_group = details.number_hl_group,
|
||||||
|
virt_text = details.virt_text,
|
||||||
|
}
|
||||||
|
local key = tostring(row)
|
||||||
|
if not by_line[key] then
|
||||||
|
by_line[key] = { text = lines[row + 1] or '', marks = {} }
|
||||||
|
end
|
||||||
|
table.insert(by_line[key].marks, entry)
|
||||||
|
end
|
||||||
|
|
||||||
|
local all_ns_marks = vim.api.nvim_buf_get_extmarks(bufnr, -1, 0, -1, { details = true })
|
||||||
|
local non_diffs = {}
|
||||||
|
for _, mark in ipairs(all_ns_marks) do
|
||||||
|
local details = mark[4]
|
||||||
|
if details.ns_id ~= ns then
|
||||||
|
table.insert(non_diffs, {
|
||||||
|
ns_id = details.ns_id,
|
||||||
|
row = mark[2],
|
||||||
|
col = mark[3],
|
||||||
|
end_row = details.end_row,
|
||||||
|
end_col = details.end_col,
|
||||||
|
hl_group = details.hl_group,
|
||||||
|
priority = details.priority,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = {
|
||||||
|
bufnr = bufnr,
|
||||||
|
buf_name = vim.api.nvim_buf_get_name(bufnr),
|
||||||
|
ns_id = ns,
|
||||||
|
total_diffs_marks = #marks,
|
||||||
|
total_all_marks = #all_ns_marks,
|
||||||
|
non_diffs_marks = non_diffs,
|
||||||
|
lines = by_line,
|
||||||
|
}
|
||||||
|
|
||||||
|
local state_dir = vim.fn.stdpath('state')
|
||||||
|
local path = state_dir .. '/diffs_debug.json'
|
||||||
|
local f = io.open(path, 'w')
|
||||||
|
if f then
|
||||||
|
f:write(vim.json.encode(result))
|
||||||
|
f:close()
|
||||||
|
vim.notify('[diffs.nvim] debug dump: ' .. path, vim.log.levels.INFO)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
|
@ -11,6 +11,10 @@
|
||||||
---@field del_lines {idx: integer, text: string}[]
|
---@field del_lines {idx: integer, text: string}[]
|
||||||
---@field add_lines {idx: integer, text: string}[]
|
---@field add_lines {idx: integer, text: string}[]
|
||||||
|
|
||||||
|
---@class diffs.DiffOpts
|
||||||
|
---@field algorithm? string
|
||||||
|
---@field linematch? integer
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local dbg = require('diffs.log').dbg
|
local dbg = require('diffs.log').dbg
|
||||||
|
|
@ -60,11 +64,35 @@ function M.extract_change_groups(hunk_lines)
|
||||||
return groups
|
return groups
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return diffs.DiffOpts
|
||||||
|
local function parse_diffopt()
|
||||||
|
local opts = {}
|
||||||
|
for _, item in ipairs(vim.split(vim.o.diffopt, ',')) do
|
||||||
|
local key, val = item:match('^(%w+):(.+)$')
|
||||||
|
if key == 'algorithm' then
|
||||||
|
opts.algorithm = val
|
||||||
|
elseif key == 'linematch' then
|
||||||
|
opts.linematch = tonumber(val)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return opts
|
||||||
|
end
|
||||||
|
|
||||||
---@param old_text string
|
---@param old_text string
|
||||||
---@param new_text string
|
---@param new_text string
|
||||||
|
---@param diff_opts? diffs.DiffOpts
|
||||||
---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[]
|
---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[]
|
||||||
local function byte_diff(old_text, new_text)
|
local function byte_diff(old_text, new_text, diff_opts)
|
||||||
local ok, result = pcall(vim.diff, old_text, new_text, { result_type = 'indices' })
|
local vim_opts = { result_type = 'indices' }
|
||||||
|
if diff_opts then
|
||||||
|
if diff_opts.algorithm then
|
||||||
|
vim_opts.algorithm = diff_opts.algorithm
|
||||||
|
end
|
||||||
|
if diff_opts.linematch then
|
||||||
|
vim_opts.linematch = diff_opts.linematch
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local ok, result = pcall(vim.diff, old_text, new_text, vim_opts)
|
||||||
if not ok or not result then
|
if not ok or not result then
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
|
|
@ -95,8 +123,9 @@ end
|
||||||
---@param new_line string
|
---@param new_line string
|
||||||
---@param del_idx integer
|
---@param del_idx integer
|
||||||
---@param add_idx integer
|
---@param add_idx integer
|
||||||
|
---@param diff_opts? diffs.DiffOpts
|
||||||
---@return diffs.CharSpan[], diffs.CharSpan[]
|
---@return diffs.CharSpan[], diffs.CharSpan[]
|
||||||
local function char_diff_pair(old_line, new_line, del_idx, add_idx)
|
local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts)
|
||||||
---@type diffs.CharSpan[]
|
---@type diffs.CharSpan[]
|
||||||
local del_spans = {}
|
local del_spans = {}
|
||||||
---@type diffs.CharSpan[]
|
---@type diffs.CharSpan[]
|
||||||
|
|
@ -108,7 +137,12 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx)
|
||||||
local old_text = table.concat(old_bytes, '\n') .. '\n'
|
local old_text = table.concat(old_bytes, '\n') .. '\n'
|
||||||
local new_text = table.concat(new_bytes, '\n') .. '\n'
|
local new_text = table.concat(new_bytes, '\n') .. '\n'
|
||||||
|
|
||||||
local char_hunks = byte_diff(old_text, new_text)
|
local char_opts = diff_opts
|
||||||
|
if diff_opts and diff_opts.linematch then
|
||||||
|
char_opts = { algorithm = diff_opts.algorithm }
|
||||||
|
end
|
||||||
|
|
||||||
|
local char_hunks = byte_diff(old_text, new_text, char_opts)
|
||||||
|
|
||||||
for _, ch in ipairs(char_hunks) do
|
for _, ch in ipairs(char_hunks) do
|
||||||
if ch.old_count > 0 then
|
if ch.old_count > 0 then
|
||||||
|
|
@ -132,8 +166,9 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param group diffs.ChangeGroup
|
---@param group diffs.ChangeGroup
|
||||||
|
---@param diff_opts? diffs.DiffOpts
|
||||||
---@return diffs.CharSpan[], diffs.CharSpan[]
|
---@return diffs.CharSpan[], diffs.CharSpan[]
|
||||||
local function diff_group_native(group)
|
local function diff_group_native(group, diff_opts)
|
||||||
---@type diffs.CharSpan[]
|
---@type diffs.CharSpan[]
|
||||||
local all_del = {}
|
local all_del = {}
|
||||||
---@type diffs.CharSpan[]
|
---@type diffs.CharSpan[]
|
||||||
|
|
@ -147,7 +182,8 @@ local function diff_group_native(group)
|
||||||
group.del_lines[1].text,
|
group.del_lines[1].text,
|
||||||
group.add_lines[1].text,
|
group.add_lines[1].text,
|
||||||
group.del_lines[1].idx,
|
group.del_lines[1].idx,
|
||||||
group.add_lines[1].idx
|
group.add_lines[1].idx,
|
||||||
|
diff_opts
|
||||||
)
|
)
|
||||||
vim.list_extend(all_del, ds)
|
vim.list_extend(all_del, ds)
|
||||||
vim.list_extend(all_add, as)
|
vim.list_extend(all_add, as)
|
||||||
|
|
@ -166,7 +202,7 @@ local function diff_group_native(group)
|
||||||
local old_block = table.concat(old_texts, '\n') .. '\n'
|
local old_block = table.concat(old_texts, '\n') .. '\n'
|
||||||
local new_block = table.concat(new_texts, '\n') .. '\n'
|
local new_block = table.concat(new_texts, '\n') .. '\n'
|
||||||
|
|
||||||
local line_hunks = byte_diff(old_block, new_block)
|
local line_hunks = byte_diff(old_block, new_block, diff_opts)
|
||||||
|
|
||||||
---@type table<integer, integer>
|
---@type table<integer, integer>
|
||||||
local old_to_new = {}
|
local old_to_new = {}
|
||||||
|
|
@ -184,7 +220,8 @@ local function diff_group_native(group)
|
||||||
group.del_lines[old_i].text,
|
group.del_lines[old_i].text,
|
||||||
group.add_lines[new_i].text,
|
group.add_lines[new_i].text,
|
||||||
group.del_lines[old_i].idx,
|
group.del_lines[old_i].idx,
|
||||||
group.add_lines[new_i].idx
|
group.add_lines[new_i].idx,
|
||||||
|
diff_opts
|
||||||
)
|
)
|
||||||
vim.list_extend(all_del, ds)
|
vim.list_extend(all_del, ds)
|
||||||
vim.list_extend(all_add, as)
|
vim.list_extend(all_add, as)
|
||||||
|
|
@ -202,7 +239,8 @@ local function diff_group_native(group)
|
||||||
group.del_lines[oi].text,
|
group.del_lines[oi].text,
|
||||||
group.add_lines[ni].text,
|
group.add_lines[ni].text,
|
||||||
group.del_lines[oi].idx,
|
group.del_lines[oi].idx,
|
||||||
group.add_lines[ni].idx
|
group.add_lines[ni].idx,
|
||||||
|
diff_opts
|
||||||
)
|
)
|
||||||
vim.list_extend(all_del, ds)
|
vim.list_extend(all_del, ds)
|
||||||
vim.list_extend(all_add, as)
|
vim.list_extend(all_add, as)
|
||||||
|
|
@ -295,16 +333,26 @@ function M.compute_intra_hunks(hunk_lines, algorithm)
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
algorithm = algorithm or 'auto'
|
algorithm = algorithm or 'default'
|
||||||
|
|
||||||
local lib = require('diffs.lib')
|
|
||||||
local vscode_handle = nil
|
local vscode_handle = nil
|
||||||
if algorithm ~= 'native' then
|
if algorithm == 'vscode' then
|
||||||
vscode_handle = lib.load()
|
vscode_handle = require('diffs.lib').load()
|
||||||
|
if not vscode_handle then
|
||||||
|
dbg('vscode algorithm requested but library not available, falling back to default')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if algorithm == 'vscode' and not vscode_handle then
|
---@type diffs.DiffOpts?
|
||||||
dbg('vscode algorithm requested but library not available, falling back to native')
|
local diff_opts = nil
|
||||||
|
if not vscode_handle then
|
||||||
|
diff_opts = parse_diffopt()
|
||||||
|
if diff_opts.algorithm then
|
||||||
|
dbg('diffopt algorithm: %s', diff_opts.algorithm)
|
||||||
|
end
|
||||||
|
if diff_opts.linematch then
|
||||||
|
dbg('diffopt linematch: %d', diff_opts.linematch)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@type diffs.CharSpan[]
|
---@type diffs.CharSpan[]
|
||||||
|
|
@ -312,12 +360,27 @@ function M.compute_intra_hunks(hunk_lines, algorithm)
|
||||||
---@type diffs.CharSpan[]
|
---@type diffs.CharSpan[]
|
||||||
local all_del = {}
|
local all_del = {}
|
||||||
|
|
||||||
for _, group in ipairs(groups) do
|
dbg(
|
||||||
|
'intra: %d change groups, algorithm=%s, vscode=%s',
|
||||||
|
#groups,
|
||||||
|
algorithm,
|
||||||
|
vscode_handle and 'yes' or 'no'
|
||||||
|
)
|
||||||
|
|
||||||
|
for gi, group in ipairs(groups) do
|
||||||
|
dbg('group %d: %d del lines, %d add lines', gi, #group.del_lines, #group.add_lines)
|
||||||
local ds, as
|
local ds, as
|
||||||
if vscode_handle then
|
if vscode_handle then
|
||||||
ds, as = diff_group_vscode(group, vscode_handle)
|
ds, as = diff_group_vscode(group, vscode_handle)
|
||||||
else
|
else
|
||||||
ds, as = diff_group_native(group)
|
ds, as = diff_group_native(group, diff_opts)
|
||||||
|
end
|
||||||
|
dbg('group %d result: %d del spans, %d add spans', gi, #ds, #as)
|
||||||
|
for _, s in ipairs(ds) do
|
||||||
|
dbg(' del span: line=%d col=%d..%d', s.line, s.col_start, s.col_end)
|
||||||
|
end
|
||||||
|
for _, s in ipairs(as) do
|
||||||
|
dbg(' add span: line=%d col=%d..%d', s.line, s.col_start, s.col_end)
|
||||||
end
|
end
|
||||||
vim.list_extend(all_del, ds)
|
vim.list_extend(all_del, ds)
|
||||||
vim.list_extend(all_add, as)
|
vim.list_extend(all_add, as)
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,53 @@ local M = {}
|
||||||
local dbg = require('diffs.log').dbg
|
local dbg = require('diffs.log').dbg
|
||||||
local diff = require('diffs.diff')
|
local diff = require('diffs.diff')
|
||||||
|
|
||||||
|
---@param filepath string
|
||||||
|
---@param from_line integer
|
||||||
|
---@param count integer
|
||||||
|
---@return string[]
|
||||||
|
local function read_line_range(filepath, from_line, count)
|
||||||
|
if count <= 0 then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local f = io.open(filepath, 'r')
|
||||||
|
if not f then
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local result = {}
|
||||||
|
local line_num = 0
|
||||||
|
for line in f:lines() do
|
||||||
|
line_num = line_num + 1
|
||||||
|
if line_num >= from_line then
|
||||||
|
table.insert(result, line)
|
||||||
|
if #result >= count then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
f:close()
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local PRIORITY_CLEAR = 198
|
||||||
|
local PRIORITY_SYNTAX = 199
|
||||||
|
local PRIORITY_LINE_BG = 200
|
||||||
|
local PRIORITY_CHAR_BG = 201
|
||||||
|
|
||||||
---@param bufnr integer
|
---@param bufnr integer
|
||||||
---@param ns integer
|
---@param ns integer
|
||||||
---@param hunk diffs.Hunk
|
---@param hunk diffs.Hunk
|
||||||
---@param col_offset integer
|
---@param col_offset integer
|
||||||
---@param text string
|
---@param text string
|
||||||
---@param lang string
|
---@param lang string
|
||||||
|
---@param context_lines? string[]
|
||||||
---@return integer
|
---@return integer
|
||||||
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang)
|
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines)
|
||||||
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, text, lang)
|
local parse_text = text
|
||||||
|
if context_lines and #context_lines > 0 then
|
||||||
|
parse_text = text .. '\n' .. table.concat(context_lines, '\n')
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, parse_text, lang)
|
||||||
if not ok or not parser_obj then
|
if not ok or not parser_obj then
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
|
|
@ -29,16 +67,17 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang)
|
||||||
local extmark_count = 0
|
local extmark_count = 0
|
||||||
local header_line = hunk.start_line - 1
|
local header_line = hunk.start_line - 1
|
||||||
|
|
||||||
for id, node, metadata in query:iter_captures(trees[1]:root(), text) do
|
for id, node, metadata in query:iter_captures(trees[1]:root(), parse_text) do
|
||||||
|
local sr, sc, _, ec = node:range()
|
||||||
|
if sr == 0 then
|
||||||
local capture_name = '@' .. query.captures[id] .. '.' .. lang
|
local capture_name = '@' .. query.captures[id] .. '.' .. lang
|
||||||
local sr, sc, er, ec = node:range()
|
|
||||||
|
|
||||||
local buf_sr = header_line + sr
|
local buf_sr = header_line
|
||||||
local buf_er = header_line + er
|
local buf_er = header_line
|
||||||
local buf_sc = col_offset + sc
|
local buf_sc = col_offset + sc
|
||||||
local buf_ec = col_offset + ec
|
local buf_ec = col_offset + ec
|
||||||
|
|
||||||
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200
|
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX
|
||||||
|
|
||||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
|
||||||
end_row = buf_er,
|
end_row = buf_er,
|
||||||
|
|
@ -48,6 +87,7 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang)
|
||||||
})
|
})
|
||||||
extmark_count = extmark_count + 1
|
extmark_count = extmark_count + 1
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return extmark_count
|
return extmark_count
|
||||||
end
|
end
|
||||||
|
|
@ -58,16 +98,21 @@ end
|
||||||
|
|
||||||
---@param bufnr integer
|
---@param bufnr integer
|
||||||
---@param ns integer
|
---@param ns integer
|
||||||
---@param hunk diffs.Hunk
|
|
||||||
---@param code_lines string[]
|
---@param code_lines string[]
|
||||||
---@param col_offset integer?
|
---@param lang string
|
||||||
|
---@param line_map table<integer, integer>
|
||||||
|
---@param col_offset integer
|
||||||
|
---@param covered_lines? table<integer, true>
|
||||||
---@return integer
|
---@return integer
|
||||||
local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset)
|
local function highlight_treesitter(
|
||||||
local lang = hunk.lang
|
bufnr,
|
||||||
if not lang then
|
ns,
|
||||||
return 0
|
code_lines,
|
||||||
end
|
lang,
|
||||||
|
line_map,
|
||||||
|
col_offset,
|
||||||
|
covered_lines
|
||||||
|
)
|
||||||
local code = table.concat(code_lines, '\n')
|
local code = table.concat(code_lines, '\n')
|
||||||
if code == '' then
|
if code == '' then
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -79,45 +124,35 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset)
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
|
|
||||||
local trees = parser_obj:parse()
|
local trees = parser_obj:parse(true)
|
||||||
if not trees or #trees == 0 then
|
if not trees or #trees == 0 then
|
||||||
dbg('parse returned no trees for lang: %s', lang)
|
dbg('parse returned no trees for lang: %s', lang)
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
|
|
||||||
local query = vim.treesitter.query.get(lang, 'highlights')
|
|
||||||
if not query then
|
|
||||||
dbg('no highlights query for lang: %s', lang)
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
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 = 'Normal',
|
|
||||||
priority = 199,
|
|
||||||
})
|
|
||||||
local header_extmarks =
|
|
||||||
highlight_text(bufnr, ns, hunk, hunk.header_context_col, hunk.header_context, lang)
|
|
||||||
if header_extmarks > 0 then
|
|
||||||
dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
col_offset = col_offset or 1
|
|
||||||
|
|
||||||
local extmark_count = 0
|
local extmark_count = 0
|
||||||
for id, node, metadata in query:iter_captures(trees[1]:root(), code) do
|
parser_obj:for_each_tree(function(tree, ltree)
|
||||||
local capture_name = '@' .. query.captures[id] .. '.' .. lang
|
local tree_lang = ltree:lang()
|
||||||
|
local query = vim.treesitter.query.get(tree_lang, 'highlights')
|
||||||
|
if not query then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
for id, node, metadata in query:iter_captures(tree:root(), code) do
|
||||||
|
local capture = query.captures[id]
|
||||||
|
if capture ~= 'spell' and capture ~= 'nospell' then
|
||||||
|
local capture_name = '@' .. capture .. '.' .. tree_lang
|
||||||
local sr, sc, er, ec = node:range()
|
local sr, sc, er, ec = node:range()
|
||||||
|
|
||||||
local buf_sr = hunk.start_line + sr
|
local buf_sr = line_map[sr]
|
||||||
local buf_er = hunk.start_line + er
|
if buf_sr then
|
||||||
|
local buf_er = line_map[er] or buf_sr
|
||||||
|
|
||||||
local buf_sc = sc + col_offset
|
local buf_sc = sc + col_offset
|
||||||
local buf_ec = ec + col_offset
|
local buf_ec = ec + col_offset
|
||||||
|
|
||||||
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200
|
local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100)
|
||||||
|
or PRIORITY_SYNTAX
|
||||||
|
|
||||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
|
||||||
end_row = buf_er,
|
end_row = buf_er,
|
||||||
|
|
@ -126,7 +161,13 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset)
|
||||||
priority = priority,
|
priority = priority,
|
||||||
})
|
})
|
||||||
extmark_count = extmark_count + 1
|
extmark_count = extmark_count + 1
|
||||||
|
if covered_lines then
|
||||||
|
covered_lines[buf_sr] = true
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
return extmark_count
|
return extmark_count
|
||||||
end
|
end
|
||||||
|
|
@ -176,8 +217,10 @@ end
|
||||||
---@param ns integer
|
---@param ns integer
|
||||||
---@param hunk diffs.Hunk
|
---@param hunk diffs.Hunk
|
||||||
---@param code_lines string[]
|
---@param code_lines string[]
|
||||||
|
---@param covered_lines? table<integer, true>
|
||||||
|
---@param leading_offset? integer
|
||||||
---@return integer
|
---@return integer
|
||||||
local function highlight_vim_syntax(bufnr, ns, hunk, code_lines)
|
local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset)
|
||||||
local ft = hunk.ft
|
local ft = hunk.ft
|
||||||
if not ft then
|
if not ft then
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -187,6 +230,8 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines)
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
leading_offset = leading_offset or 0
|
||||||
|
|
||||||
local scratch = vim.api.nvim_create_buf(false, true)
|
local scratch = vim.api.nvim_create_buf(false, true)
|
||||||
vim.api.nvim_buf_set_lines(scratch, 0, -1, false, code_lines)
|
vim.api.nvim_buf_set_lines(scratch, 0, -1, false, code_lines)
|
||||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scratch })
|
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scratch })
|
||||||
|
|
@ -213,15 +258,22 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines)
|
||||||
|
|
||||||
vim.api.nvim_buf_delete(scratch, { force = true })
|
vim.api.nvim_buf_delete(scratch, { force = true })
|
||||||
|
|
||||||
|
local hunk_line_count = #hunk.lines
|
||||||
local extmark_count = 0
|
local extmark_count = 0
|
||||||
for _, span in ipairs(spans) do
|
for _, span in ipairs(spans) do
|
||||||
local buf_line = hunk.start_line + span.line - 1
|
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, {
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
|
||||||
end_col = span.col_end,
|
end_col = span.col_end,
|
||||||
hl_group = span.hl_name,
|
hl_group = span.hl_name,
|
||||||
priority = 200,
|
priority = PRIORITY_SYNTAX,
|
||||||
})
|
})
|
||||||
extmark_count = extmark_count + 1
|
extmark_count = extmark_count + 1
|
||||||
|
if covered_lines then
|
||||||
|
covered_lines[buf_line] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return extmark_count
|
return extmark_count
|
||||||
|
|
@ -248,21 +300,101 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
|
||||||
use_vim = false
|
use_vim = false
|
||||||
end
|
end
|
||||||
|
|
||||||
local apply_syntax = use_ts or use_vim
|
---@type table<integer, true>
|
||||||
|
local covered_lines = {}
|
||||||
|
|
||||||
---@type string[]
|
local ctx_cfg = opts.highlights.context
|
||||||
local code_lines = {}
|
local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0
|
||||||
if apply_syntax then
|
local leading = {}
|
||||||
for _, line in ipairs(hunk.lines) do
|
local trailing = {}
|
||||||
table.insert(code_lines, line:sub(2))
|
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
|
end
|
||||||
|
local trail_from = hunk.file_new_start + (hunk.file_new_count or 0)
|
||||||
|
trailing = read_line_range(filepath, trail_from, context)
|
||||||
end
|
end
|
||||||
|
|
||||||
local extmark_count = 0
|
local extmark_count = 0
|
||||||
if use_ts then
|
if use_ts then
|
||||||
extmark_count = highlight_treesitter(bufnr, ns, hunk, code_lines)
|
---@type string[]
|
||||||
|
local new_code = {}
|
||||||
|
---@type table<integer, integer>
|
||||||
|
local new_map = {}
|
||||||
|
---@type string[]
|
||||||
|
local old_code = {}
|
||||||
|
---@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 buf_line = hunk.start_line + i - 1
|
||||||
|
|
||||||
|
if prefix == '+' then
|
||||||
|
new_map[#new_code] = buf_line
|
||||||
|
table.insert(new_code, stripped)
|
||||||
|
elseif prefix == '-' then
|
||||||
|
old_map[#old_code] = buf_line
|
||||||
|
table.insert(old_code, stripped)
|
||||||
|
else
|
||||||
|
new_map[#new_code] = buf_line
|
||||||
|
table.insert(new_code, stripped)
|
||||||
|
table.insert(old_code, stripped)
|
||||||
|
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 = extmark_count
|
||||||
|
+ highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines)
|
||||||
|
|
||||||
|
if hunk.header_context and hunk.header_context_col then
|
||||||
|
local header_line = hunk.start_line - 1
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
|
||||||
|
end_col = hunk.header_context_col + #hunk.header_context,
|
||||||
|
hl_group = 'DiffsClear',
|
||||||
|
priority = PRIORITY_CLEAR,
|
||||||
|
})
|
||||||
|
local header_extmarks = highlight_text(
|
||||||
|
bufnr,
|
||||||
|
ns,
|
||||||
|
hunk,
|
||||||
|
hunk.header_context_col,
|
||||||
|
hunk.header_context,
|
||||||
|
hunk.lang,
|
||||||
|
new_code
|
||||||
|
)
|
||||||
|
if header_extmarks > 0 then
|
||||||
|
dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks)
|
||||||
|
end
|
||||||
|
extmark_count = extmark_count + header_extmarks
|
||||||
|
end
|
||||||
elseif use_vim then
|
elseif use_vim then
|
||||||
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines)
|
---@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))
|
||||||
|
end
|
||||||
|
for _, pad_line in ipairs(trailing) do
|
||||||
|
table.insert(code_lines, pad_line)
|
||||||
|
end
|
||||||
|
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, #leading)
|
||||||
end
|
end
|
||||||
|
|
||||||
if
|
if
|
||||||
|
|
@ -271,23 +403,30 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
|
||||||
and #hunk.header_lines > 0
|
and #hunk.header_lines > 0
|
||||||
and opts.highlights.treesitter.enabled
|
and opts.highlights.treesitter.enabled
|
||||||
then
|
then
|
||||||
extmark_count = extmark_count
|
---@type table<integer, integer>
|
||||||
+ highlight_treesitter(bufnr, ns, {
|
local header_map = {}
|
||||||
filename = hunk.filename,
|
for i = 0, #hunk.header_lines - 1 do
|
||||||
start_line = hunk.header_start_line - 1,
|
header_map[i] = hunk.header_start_line - 1 + i
|
||||||
lang = 'diff',
|
end
|
||||||
lines = hunk.header_lines,
|
extmark_count = extmark_count
|
||||||
header_lines = {},
|
+ highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0)
|
||||||
}, hunk.header_lines, 0)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local syntax_applied = extmark_count > 0
|
|
||||||
|
|
||||||
---@type diffs.IntraChanges?
|
---@type diffs.IntraChanges?
|
||||||
local intra = nil
|
local intra = nil
|
||||||
local intra_cfg = opts.highlights.intra
|
local intra_cfg = opts.highlights.intra
|
||||||
if intra_cfg and intra_cfg.enabled and #hunk.lines <= intra_cfg.max_lines then
|
if intra_cfg and intra_cfg.enabled and #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)
|
intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm)
|
||||||
|
if intra then
|
||||||
|
dbg('intra result: %d add spans, %d del spans', #intra.add_spans, #intra.del_spans)
|
||||||
|
else
|
||||||
|
dbg('intra result: nil (no change groups)')
|
||||||
|
end
|
||||||
|
elseif intra_cfg and not intra_cfg.enabled then
|
||||||
|
dbg('intra disabled by config')
|
||||||
|
elseif intra_cfg and #hunk.lines > intra_cfg.max_lines then
|
||||||
|
dbg('intra skipped: %d lines > %d max', #hunk.lines, intra_cfg.max_lines)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@type table<integer, diffs.CharSpan[]>
|
---@type table<integer, diffs.CharSpan[]>
|
||||||
|
|
@ -324,33 +463,49 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
if opts.highlights.background and is_diff_line then
|
if line_len > 1 and covered_lines[buf_line] then
|
||||||
local extmark_opts = {
|
|
||||||
line_hl_group = line_hl,
|
|
||||||
priority = 198,
|
|
||||||
}
|
|
||||||
if opts.highlights.gutter then
|
|
||||||
extmark_opts.number_hl_group = number_hl
|
|
||||||
end
|
|
||||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, extmark_opts)
|
|
||||||
end
|
|
||||||
|
|
||||||
if line_len > 1 and syntax_applied then
|
|
||||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
|
||||||
end_col = line_len,
|
end_col = line_len,
|
||||||
hl_group = 'Normal',
|
hl_group = 'DiffsClear',
|
||||||
priority = 199,
|
priority = PRIORITY_CLEAR,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if opts.highlights.background and is_diff_line then
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
|
||||||
|
end_row = buf_line + 1,
|
||||||
|
hl_group = line_hl,
|
||||||
|
hl_eol = true,
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
if opts.highlights.gutter then
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
|
||||||
|
number_hl_group = number_hl,
|
||||||
|
priority = PRIORITY_LINE_BG,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if char_spans_by_line[i] then
|
if char_spans_by_line[i] then
|
||||||
local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText'
|
local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText'
|
||||||
for _, span in ipairs(char_spans_by_line[i]) do
|
for _, span in ipairs(char_spans_by_line[i]) do
|
||||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
|
dbg(
|
||||||
|
'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"',
|
||||||
|
i,
|
||||||
|
buf_line,
|
||||||
|
span.col_start,
|
||||||
|
span.col_end,
|
||||||
|
char_hl,
|
||||||
|
line:sub(span.col_start + 1, span.col_end)
|
||||||
|
)
|
||||||
|
local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
|
||||||
end_col = span.col_end,
|
end_col = span.col_end,
|
||||||
hl_group = char_hl,
|
hl_group = char_hl,
|
||||||
priority = 201,
|
priority = PRIORITY_CHAR_BG,
|
||||||
})
|
})
|
||||||
|
if not ok then
|
||||||
|
dbg('char extmark FAILED: %s', err)
|
||||||
|
end
|
||||||
extmark_count = extmark_count + 1
|
extmark_count = extmark_count + 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,16 @@
|
||||||
---@field algorithm string
|
---@field algorithm string
|
||||||
---@field max_lines integer
|
---@field max_lines integer
|
||||||
|
|
||||||
|
---@class diffs.ContextConfig
|
||||||
|
---@field enabled boolean
|
||||||
|
---@field lines integer
|
||||||
|
|
||||||
---@class diffs.Highlights
|
---@class diffs.Highlights
|
||||||
---@field background boolean
|
---@field background boolean
|
||||||
---@field gutter boolean
|
---@field gutter boolean
|
||||||
|
---@field blend_alpha? number
|
||||||
|
---@field overrides? table<string, table>
|
||||||
|
---@field context diffs.ContextConfig
|
||||||
---@field treesitter diffs.TreesitterConfig
|
---@field treesitter diffs.TreesitterConfig
|
||||||
---@field vim diffs.VimConfig
|
---@field vim diffs.VimConfig
|
||||||
---@field intra diffs.IntraConfig
|
---@field intra diffs.IntraConfig
|
||||||
|
|
@ -22,12 +29,27 @@
|
||||||
---@field horizontal string|false
|
---@field horizontal string|false
|
||||||
---@field vertical string|false
|
---@field vertical string|false
|
||||||
|
|
||||||
|
---@class diffs.ConflictKeymaps
|
||||||
|
---@field ours string|false
|
||||||
|
---@field theirs string|false
|
||||||
|
---@field both string|false
|
||||||
|
---@field none string|false
|
||||||
|
---@field next string|false
|
||||||
|
---@field prev string|false
|
||||||
|
|
||||||
|
---@class diffs.ConflictConfig
|
||||||
|
---@field enabled boolean
|
||||||
|
---@field disable_diagnostics boolean
|
||||||
|
---@field show_virtual_text boolean
|
||||||
|
---@field keymaps diffs.ConflictKeymaps
|
||||||
|
|
||||||
---@class diffs.Config
|
---@class diffs.Config
|
||||||
---@field debug boolean
|
---@field debug boolean
|
||||||
---@field debounce_ms integer
|
---@field debounce_ms integer
|
||||||
---@field hide_prefix boolean
|
---@field hide_prefix boolean
|
||||||
---@field highlights diffs.Highlights
|
---@field highlights diffs.Highlights
|
||||||
---@field fugitive diffs.FugitiveConfig
|
---@field fugitive diffs.FugitiveConfig
|
||||||
|
---@field conflict diffs.ConflictConfig
|
||||||
|
|
||||||
---@class diffs
|
---@class diffs
|
||||||
---@field attach fun(bufnr?: integer)
|
---@field attach fun(bufnr?: integer)
|
||||||
|
|
@ -80,6 +102,10 @@ local default_config = {
|
||||||
highlights = {
|
highlights = {
|
||||||
background = true,
|
background = true,
|
||||||
gutter = true,
|
gutter = true,
|
||||||
|
context = {
|
||||||
|
enabled = true,
|
||||||
|
lines = 25,
|
||||||
|
},
|
||||||
treesitter = {
|
treesitter = {
|
||||||
enabled = true,
|
enabled = true,
|
||||||
max_lines = 500,
|
max_lines = 500,
|
||||||
|
|
@ -90,7 +116,7 @@ local default_config = {
|
||||||
},
|
},
|
||||||
intra = {
|
intra = {
|
||||||
enabled = true,
|
enabled = true,
|
||||||
algorithm = 'auto',
|
algorithm = 'default',
|
||||||
max_lines = 500,
|
max_lines = 500,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -98,6 +124,19 @@ local default_config = {
|
||||||
horizontal = 'du',
|
horizontal = 'du',
|
||||||
vertical = 'dU',
|
vertical = 'dU',
|
||||||
},
|
},
|
||||||
|
conflict = {
|
||||||
|
enabled = true,
|
||||||
|
disable_diagnostics = true,
|
||||||
|
show_virtual_text = true,
|
||||||
|
keymaps = {
|
||||||
|
ours = 'doo',
|
||||||
|
theirs = 'dot',
|
||||||
|
both = 'dob',
|
||||||
|
none = 'don',
|
||||||
|
next = ']x',
|
||||||
|
prev = '[x',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
---@type diffs.Config
|
---@type diffs.Config
|
||||||
|
|
@ -183,23 +222,79 @@ local function compute_highlight_groups()
|
||||||
local blended_add = blend_color(add_bg, bg, 0.4)
|
local blended_add = blend_color(add_bg, bg, 0.4)
|
||||||
local blended_del = blend_color(del_bg, bg, 0.4)
|
local blended_del = blend_color(del_bg, bg, 0.4)
|
||||||
|
|
||||||
local blended_add_text = blend_color(add_fg, bg, 0.4)
|
local alpha = config.highlights.blend_alpha or 0.6
|
||||||
local blended_del_text = blend_color(del_fg, bg, 0.4)
|
local blended_add_text = blend_color(add_fg, bg, alpha)
|
||||||
|
local blended_del_text = blend_color(del_fg, bg, alpha)
|
||||||
|
|
||||||
|
vim.api.nvim_set_hl(0, 'DiffsClear', { default = true, fg = normal.fg or 0xc0c0c0 })
|
||||||
vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add })
|
vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add })
|
||||||
vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del })
|
vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del })
|
||||||
vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = add_fg, bg = blended_add })
|
vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add })
|
||||||
vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { default = true, fg = del_fg, bg = blended_del })
|
vim.api.nvim_set_hl(
|
||||||
|
0,
|
||||||
|
'DiffsDeleteNr',
|
||||||
|
{ default = true, fg = blended_del_text, bg = blended_del }
|
||||||
|
)
|
||||||
vim.api.nvim_set_hl(0, 'DiffsAddText', { default = true, bg = blended_add_text })
|
vim.api.nvim_set_hl(0, 'DiffsAddText', { default = true, bg = blended_add_text })
|
||||||
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { default = true, bg = blended_del_text })
|
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { default = true, bg = blended_del_text })
|
||||||
|
|
||||||
|
dbg('highlight groups: Normal.bg=#%06x DiffAdd.bg=#%06x diffAdded.fg=#%06x', bg, add_bg, add_fg)
|
||||||
|
dbg(
|
||||||
|
'DiffsAdd.bg=#%06x DiffsAddText.bg=#%06x DiffsAddNr.fg=#%06x',
|
||||||
|
blended_add,
|
||||||
|
blended_add_text,
|
||||||
|
add_fg
|
||||||
|
)
|
||||||
|
dbg('DiffsDelete.bg=#%06x DiffsDeleteText.bg=#%06x', blended_del, blended_del_text)
|
||||||
|
|
||||||
local diff_change = resolve_hl('DiffChange')
|
local diff_change = resolve_hl('DiffChange')
|
||||||
local diff_text = resolve_hl('DiffText')
|
local diff_text = resolve_hl('DiffText')
|
||||||
|
|
||||||
vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { bg = diff_add.bg })
|
vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { default = true, bg = diff_add.bg })
|
||||||
vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { fg = diff_delete.fg, bg = diff_delete.bg })
|
vim.api.nvim_set_hl(
|
||||||
vim.api.nvim_set_hl(0, 'DiffsDiffChange', { bg = diff_change.bg })
|
0,
|
||||||
vim.api.nvim_set_hl(0, 'DiffsDiffText', { bg = diff_text.bg })
|
'DiffsDiffDelete',
|
||||||
|
{ default = true, fg = diff_delete.fg, bg = diff_delete.bg }
|
||||||
|
)
|
||||||
|
vim.api.nvim_set_hl(0, 'DiffsDiffChange', { default = true, bg = diff_change.bg })
|
||||||
|
vim.api.nvim_set_hl(0, 'DiffsDiffText', { default = true, bg = diff_text.bg })
|
||||||
|
|
||||||
|
local change_bg = diff_change.bg or 0x3a3a4a
|
||||||
|
local text_bg = diff_text.bg or 0x4a4a5a
|
||||||
|
local change_fg = diff_change.fg or diff_text.fg or 0x80a0c0
|
||||||
|
|
||||||
|
local blended_ours = blend_color(add_bg, bg, 0.4)
|
||||||
|
local blended_theirs = blend_color(change_bg, bg, 0.4)
|
||||||
|
local blended_base = blend_color(text_bg, bg, 0.3)
|
||||||
|
local blended_ours_nr = blend_color(add_fg, bg, alpha)
|
||||||
|
local blended_theirs_nr = blend_color(change_fg, bg, alpha)
|
||||||
|
local blended_base_nr = blend_color(change_fg, bg, 0.4)
|
||||||
|
|
||||||
|
vim.api.nvim_set_hl(0, 'DiffsConflictOurs', { default = true, bg = blended_ours })
|
||||||
|
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,
|
||||||
|
'DiffsConflictOursNr',
|
||||||
|
{ default = true, fg = blended_ours_nr, bg = blended_ours }
|
||||||
|
)
|
||||||
|
vim.api.nvim_set_hl(
|
||||||
|
0,
|
||||||
|
'DiffsConflictTheirsNr',
|
||||||
|
{ default = true, fg = blended_theirs_nr, bg = blended_theirs }
|
||||||
|
)
|
||||||
|
vim.api.nvim_set_hl(
|
||||||
|
0,
|
||||||
|
'DiffsConflictBaseNr',
|
||||||
|
{ default = true, fg = blended_base_nr, bg = blended_base }
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.highlights.overrides then
|
||||||
|
for group, hl in pairs(config.highlights.overrides) do
|
||||||
|
vim.api.nvim_set_hl(0, group, hl)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function init()
|
local function init()
|
||||||
|
|
@ -221,11 +316,21 @@ local function init()
|
||||||
vim.validate({
|
vim.validate({
|
||||||
['highlights.background'] = { opts.highlights.background, 'boolean', true },
|
['highlights.background'] = { opts.highlights.background, 'boolean', true },
|
||||||
['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true },
|
['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true },
|
||||||
|
['highlights.blend_alpha'] = { opts.highlights.blend_alpha, 'number', true },
|
||||||
|
['highlights.overrides'] = { opts.highlights.overrides, 'table', true },
|
||||||
|
['highlights.context'] = { opts.highlights.context, 'table', true },
|
||||||
['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true },
|
['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true },
|
||||||
['highlights.vim'] = { opts.highlights.vim, 'table', true },
|
['highlights.vim'] = { opts.highlights.vim, 'table', true },
|
||||||
['highlights.intra'] = { opts.highlights.intra, 'table', true },
|
['highlights.intra'] = { opts.highlights.intra, 'table', true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if opts.highlights.context then
|
||||||
|
vim.validate({
|
||||||
|
['highlights.context.enabled'] = { opts.highlights.context.enabled, 'boolean', true },
|
||||||
|
['highlights.context.lines'] = { opts.highlights.context.lines, 'number', true },
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
if opts.highlights.treesitter then
|
if opts.highlights.treesitter then
|
||||||
vim.validate({
|
vim.validate({
|
||||||
['highlights.treesitter.enabled'] = { opts.highlights.treesitter.enabled, 'boolean', true },
|
['highlights.treesitter.enabled'] = { opts.highlights.treesitter.enabled, 'boolean', true },
|
||||||
|
|
@ -250,9 +355,9 @@ local function init()
|
||||||
['highlights.intra.algorithm'] = {
|
['highlights.intra.algorithm'] = {
|
||||||
opts.highlights.intra.algorithm,
|
opts.highlights.intra.algorithm,
|
||||||
function(v)
|
function(v)
|
||||||
return v == nil or v == 'auto' or v == 'native' or v == 'vscode'
|
return v == nil or v == 'default' or v == 'vscode'
|
||||||
end,
|
end,
|
||||||
"'auto', 'native', or 'vscode'",
|
"'default' or 'vscode'",
|
||||||
},
|
},
|
||||||
['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true },
|
['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true },
|
||||||
})
|
})
|
||||||
|
|
@ -278,9 +383,41 @@ local function init()
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if opts.conflict then
|
||||||
|
vim.validate({
|
||||||
|
['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.keymaps'] = { opts.conflict.keymaps, 'table', true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if opts.conflict.keymaps then
|
||||||
|
local keymap_validator = function(v)
|
||||||
|
return v == false or type(v) == 'string'
|
||||||
|
end
|
||||||
|
for _, key in ipairs({ 'ours', 'theirs', 'both', 'none', 'next', 'prev' }) do
|
||||||
|
vim.validate({
|
||||||
|
['conflict.keymaps.' .. key] = {
|
||||||
|
opts.conflict.keymaps[key],
|
||||||
|
keymap_validator,
|
||||||
|
'string or false',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if opts.debounce_ms and opts.debounce_ms < 0 then
|
if opts.debounce_ms and opts.debounce_ms < 0 then
|
||||||
error('diffs: debounce_ms must be >= 0')
|
error('diffs: debounce_ms must be >= 0')
|
||||||
end
|
end
|
||||||
|
if
|
||||||
|
opts.highlights
|
||||||
|
and opts.highlights.context
|
||||||
|
and opts.highlights.context.lines
|
||||||
|
and opts.highlights.context.lines < 0
|
||||||
|
then
|
||||||
|
error('diffs: highlights.context.lines must be >= 0')
|
||||||
|
end
|
||||||
if
|
if
|
||||||
opts.highlights
|
opts.highlights
|
||||||
and opts.highlights.treesitter
|
and opts.highlights.treesitter
|
||||||
|
|
@ -305,6 +442,13 @@ local function init()
|
||||||
then
|
then
|
||||||
error('diffs: highlights.intra.max_lines must be >= 1')
|
error('diffs: highlights.intra.max_lines must be >= 1')
|
||||||
end
|
end
|
||||||
|
if
|
||||||
|
opts.highlights
|
||||||
|
and opts.highlights.blend_alpha
|
||||||
|
and (opts.highlights.blend_alpha < 0 or opts.highlights.blend_alpha > 1)
|
||||||
|
then
|
||||||
|
error('diffs: highlights.blend_alpha must be >= 0 and <= 1')
|
||||||
|
end
|
||||||
|
|
||||||
config = vim.tbl_deep_extend('force', default_config, opts)
|
config = vim.tbl_deep_extend('force', default_config, opts)
|
||||||
log.set_enabled(config.debug)
|
log.set_enabled(config.debug)
|
||||||
|
|
@ -427,4 +571,10 @@ function M.get_fugitive_config()
|
||||||
return config.fugitive
|
return config.fugitive
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@return diffs.ConflictConfig
|
||||||
|
function M.get_conflict_config()
|
||||||
|
init()
|
||||||
|
return config.conflict
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@
|
||||||
---@field lines string[]
|
---@field lines string[]
|
||||||
---@field header_start_line integer?
|
---@field header_start_line integer?
|
||||||
---@field header_lines string[]?
|
---@field header_lines string[]?
|
||||||
|
---@field file_old_start integer?
|
||||||
|
---@field file_old_count integer?
|
||||||
|
---@field file_new_start integer?
|
||||||
|
---@field file_new_count integer?
|
||||||
|
---@field repo_root string?
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
|
@ -132,6 +137,14 @@ function M.parse_buffer(bufnr)
|
||||||
local header_start = nil
|
local header_start = nil
|
||||||
---@type string[]
|
---@type string[]
|
||||||
local header_lines = {}
|
local header_lines = {}
|
||||||
|
---@type integer?
|
||||||
|
local file_old_start = nil
|
||||||
|
---@type integer?
|
||||||
|
local file_old_count = nil
|
||||||
|
---@type integer?
|
||||||
|
local file_new_start = nil
|
||||||
|
---@type integer?
|
||||||
|
local file_new_count = nil
|
||||||
|
|
||||||
local function flush_hunk()
|
local function flush_hunk()
|
||||||
if hunk_start and #hunk_lines > 0 then
|
if hunk_start and #hunk_lines > 0 then
|
||||||
|
|
@ -143,6 +156,11 @@ function M.parse_buffer(bufnr)
|
||||||
header_context = hunk_header_context,
|
header_context = hunk_header_context,
|
||||||
header_context_col = hunk_header_context_col,
|
header_context_col = hunk_header_context_col,
|
||||||
lines = hunk_lines,
|
lines = hunk_lines,
|
||||||
|
file_old_start = file_old_start,
|
||||||
|
file_old_count = file_old_count,
|
||||||
|
file_new_start = file_new_start,
|
||||||
|
file_new_count = file_new_count,
|
||||||
|
repo_root = repo_root,
|
||||||
}
|
}
|
||||||
if hunk_count == 1 and header_start and #header_lines > 0 then
|
if hunk_count == 1 and header_start and #header_lines > 0 then
|
||||||
hunk.header_start_line = header_start
|
hunk.header_start_line = header_start
|
||||||
|
|
@ -154,6 +172,10 @@ function M.parse_buffer(bufnr)
|
||||||
hunk_header_context = nil
|
hunk_header_context = nil
|
||||||
hunk_header_context_col = nil
|
hunk_header_context_col = nil
|
||||||
hunk_lines = {}
|
hunk_lines = {}
|
||||||
|
file_old_start = nil
|
||||||
|
file_old_count = nil
|
||||||
|
file_new_start = nil
|
||||||
|
file_new_count = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
for i, line in ipairs(lines) do
|
for i, line in ipairs(lines) do
|
||||||
|
|
@ -174,6 +196,13 @@ function M.parse_buffer(bufnr)
|
||||||
elseif line:match('^@@.-@@') then
|
elseif line:match('^@@.-@@') then
|
||||||
flush_hunk()
|
flush_hunk()
|
||||||
hunk_start = i
|
hunk_start = i
|
||||||
|
local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
|
||||||
|
if hs then
|
||||||
|
file_old_start = tonumber(hs)
|
||||||
|
file_old_count = tonumber(hc) or 1
|
||||||
|
file_new_start = tonumber(hs2)
|
||||||
|
file_new_count = tonumber(hc2) or 1
|
||||||
|
end
|
||||||
local prefix, context = line:match('^(@@.-@@%s*)(.*)')
|
local prefix, context = line:match('^(@@.-@@%s*)(.*)')
|
||||||
if context and context ~= '' then
|
if context and context ~= '' then
|
||||||
hunk_header_context = context
|
hunk_header_context = context
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,22 @@ vim.api.nvim_create_autocmd('FileType', {
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd('BufReadCmd', {
|
||||||
|
pattern = 'diffs://*',
|
||||||
|
callback = function(args)
|
||||||
|
require('diffs.commands').read_buffer(args.buf)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd('BufReadPost', {
|
||||||
|
callback = function(args)
|
||||||
|
local conflict_config = require('diffs').get_conflict_config()
|
||||||
|
if conflict_config.enabled then
|
||||||
|
require('diffs.conflict').attach(args.buf, conflict_config)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd('OptionSet', {
|
vim.api.nvim_create_autocmd('OptionSet', {
|
||||||
pattern = 'diff',
|
pattern = 'diff',
|
||||||
callback = function()
|
callback = function()
|
||||||
|
|
@ -33,3 +49,36 @@ vim.api.nvim_create_autocmd('OptionSet', {
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
local cmds = require('diffs.commands')
|
||||||
|
vim.keymap.set('n', '<Plug>(diffs-gdiff)', function()
|
||||||
|
cmds.gdiff(nil, false)
|
||||||
|
end, { desc = 'Unified diff (horizontal)' })
|
||||||
|
vim.keymap.set('n', '<Plug>(diffs-gvdiff)', function()
|
||||||
|
cmds.gdiff(nil, true)
|
||||||
|
end, { desc = 'Unified diff (vertical)' })
|
||||||
|
|
||||||
|
local function conflict_action(fn)
|
||||||
|
local bufnr = vim.api.nvim_get_current_buf()
|
||||||
|
local config = require('diffs').get_conflict_config()
|
||||||
|
fn(bufnr, config)
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.keymap.set('n', '<Plug>(diffs-conflict-ours)', function()
|
||||||
|
conflict_action(require('diffs.conflict').resolve_ours)
|
||||||
|
end, { desc = 'Accept current (ours) change' })
|
||||||
|
vim.keymap.set('n', '<Plug>(diffs-conflict-theirs)', function()
|
||||||
|
conflict_action(require('diffs.conflict').resolve_theirs)
|
||||||
|
end, { desc = 'Accept incoming (theirs) change' })
|
||||||
|
vim.keymap.set('n', '<Plug>(diffs-conflict-both)', function()
|
||||||
|
conflict_action(require('diffs.conflict').resolve_both)
|
||||||
|
end, { desc = 'Accept both changes' })
|
||||||
|
vim.keymap.set('n', '<Plug>(diffs-conflict-none)', function()
|
||||||
|
conflict_action(require('diffs.conflict').resolve_none)
|
||||||
|
end, { desc = 'Reject both changes' })
|
||||||
|
vim.keymap.set('n', '<Plug>(diffs-conflict-next)', function()
|
||||||
|
require('diffs.conflict').goto_next(vim.api.nvim_get_current_buf())
|
||||||
|
end, { desc = 'Jump to next conflict' })
|
||||||
|
vim.keymap.set('n', '<Plug>(diffs-conflict-prev)', function()
|
||||||
|
require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf())
|
||||||
|
end, { desc = 'Jump to previous conflict' })
|
||||||
|
|
|
||||||
688
spec/conflict_spec.lua
Normal file
688
spec/conflict_spec.lua
Normal file
|
|
@ -0,0 +1,688 @@
|
||||||
|
local conflict = require('diffs.conflict')
|
||||||
|
local helpers = require('spec.helpers')
|
||||||
|
|
||||||
|
local function default_config(overrides)
|
||||||
|
local cfg = {
|
||||||
|
enabled = true,
|
||||||
|
disable_diagnostics = false,
|
||||||
|
show_virtual_text = true,
|
||||||
|
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_file_buffer(lines)
|
||||||
|
local bufnr = vim.api.nvim_create_buf(false, false)
|
||||||
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
|
||||||
|
return bufnr
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_extmarks(bufnr)
|
||||||
|
return vim.api.nvim_buf_get_extmarks(bufnr, conflict.get_namespace(), 0, -1, { details = true })
|
||||||
|
end
|
||||||
|
|
||||||
|
describe('conflict', function()
|
||||||
|
describe('parse', function()
|
||||||
|
it('parses a single conflict', function()
|
||||||
|
local lines = {
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
}
|
||||||
|
local regions = conflict.parse(lines)
|
||||||
|
assert.are.equal(1, #regions)
|
||||||
|
assert.are.equal(0, regions[1].marker_ours)
|
||||||
|
assert.are.equal(1, regions[1].ours_start)
|
||||||
|
assert.are.equal(2, regions[1].ours_end)
|
||||||
|
assert.are.equal(2, regions[1].marker_sep)
|
||||||
|
assert.are.equal(3, regions[1].theirs_start)
|
||||||
|
assert.are.equal(4, regions[1].theirs_end)
|
||||||
|
assert.are.equal(4, regions[1].marker_theirs)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses multiple conflicts', function()
|
||||||
|
local lines = {
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'a',
|
||||||
|
'=======',
|
||||||
|
'b',
|
||||||
|
'>>>>>>> feat',
|
||||||
|
'normal line',
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'c',
|
||||||
|
'=======',
|
||||||
|
'd',
|
||||||
|
'>>>>>>> feat',
|
||||||
|
}
|
||||||
|
local regions = conflict.parse(lines)
|
||||||
|
assert.are.equal(2, #regions)
|
||||||
|
assert.are.equal(0, regions[1].marker_ours)
|
||||||
|
assert.are.equal(6, regions[2].marker_ours)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses diff3 format', function()
|
||||||
|
local lines = {
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'||||||| base',
|
||||||
|
'local x = 0',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
}
|
||||||
|
local regions = conflict.parse(lines)
|
||||||
|
assert.are.equal(1, #regions)
|
||||||
|
assert.are.equal(2, regions[1].marker_base)
|
||||||
|
assert.are.equal(3, regions[1].base_start)
|
||||||
|
assert.are.equal(4, regions[1].base_end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('handles empty ours section', function()
|
||||||
|
local lines = {
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
}
|
||||||
|
local regions = conflict.parse(lines)
|
||||||
|
assert.are.equal(1, #regions)
|
||||||
|
assert.are.equal(1, regions[1].ours_start)
|
||||||
|
assert.are.equal(1, regions[1].ours_end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('handles empty theirs section', function()
|
||||||
|
local lines = {
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
}
|
||||||
|
local regions = conflict.parse(lines)
|
||||||
|
assert.are.equal(1, #regions)
|
||||||
|
assert.are.equal(3, regions[1].theirs_start)
|
||||||
|
assert.are.equal(3, regions[1].theirs_end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns empty for no markers', function()
|
||||||
|
local lines = { 'local x = 1', 'local y = 2' }
|
||||||
|
local regions = conflict.parse(lines)
|
||||||
|
assert.are.equal(0, #regions)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('discards malformed markers (no separator)', function()
|
||||||
|
local lines = {
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
}
|
||||||
|
local regions = conflict.parse(lines)
|
||||||
|
assert.are.equal(0, #regions)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('discards malformed markers (no end)', function()
|
||||||
|
local lines = {
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
}
|
||||||
|
local regions = conflict.parse(lines)
|
||||||
|
assert.are.equal(0, #regions)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('handles trailing text on marker lines', function()
|
||||||
|
local lines = {
|
||||||
|
'<<<<<<< HEAD (some text)',
|
||||||
|
'local x = 1',
|
||||||
|
'======= extra',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature-branch/some-thing',
|
||||||
|
}
|
||||||
|
local regions = conflict.parse(lines)
|
||||||
|
assert.are.equal(1, #regions)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('handles empty base in diff3', function()
|
||||||
|
local lines = {
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'||||||| base',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
}
|
||||||
|
local regions = conflict.parse(lines)
|
||||||
|
assert.are.equal(1, #regions)
|
||||||
|
assert.are.equal(3, regions[1].base_start)
|
||||||
|
assert.are.equal(3, regions[1].base_end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('highlighting', function()
|
||||||
|
after_each(function()
|
||||||
|
conflict.detach(vim.api.nvim_get_current_buf())
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('applies extmarks for conflict regions', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
|
||||||
|
conflict.attach(bufnr, default_config())
|
||||||
|
|
||||||
|
local extmarks = get_extmarks(bufnr)
|
||||||
|
assert.is_true(#extmarks > 0)
|
||||||
|
|
||||||
|
local has_ours = false
|
||||||
|
local has_theirs = false
|
||||||
|
local has_marker = false
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
local hl = mark[4] and mark[4].hl_group
|
||||||
|
if hl == 'DiffsConflictOurs' then
|
||||||
|
has_ours = true
|
||||||
|
end
|
||||||
|
if hl == 'DiffsConflictTheirs' then
|
||||||
|
has_theirs = true
|
||||||
|
end
|
||||||
|
if hl == 'DiffsConflictMarker' then
|
||||||
|
has_marker = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_true(has_ours)
|
||||||
|
assert.is_true(has_theirs)
|
||||||
|
assert.is_true(has_marker)
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('applies virtual text when enabled', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
|
||||||
|
conflict.attach(bufnr, default_config({ show_virtual_text = true }))
|
||||||
|
|
||||||
|
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(2, virt_text_count)
|
||||||
|
|
||||||
|
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',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
|
||||||
|
conflict.attach(bufnr, default_config())
|
||||||
|
|
||||||
|
local extmarks = get_extmarks(bufnr)
|
||||||
|
local has_ours_nr = false
|
||||||
|
local has_theirs_nr = false
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
local nr = mark[4] and mark[4].number_hl_group
|
||||||
|
if nr == 'DiffsConflictOursNr' then
|
||||||
|
has_ours_nr = true
|
||||||
|
end
|
||||||
|
if nr == 'DiffsConflictTheirsNr' then
|
||||||
|
has_theirs_nr = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_true(has_ours_nr)
|
||||||
|
assert.is_true(has_theirs_nr)
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('highlights base region in diff3', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'||||||| base',
|
||||||
|
'local x = 0',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
|
||||||
|
conflict.attach(bufnr, default_config())
|
||||||
|
|
||||||
|
local extmarks = get_extmarks(bufnr)
|
||||||
|
local has_base = false
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
if mark[4] and mark[4].hl_group == 'DiffsConflictBase' then
|
||||||
|
has_base = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_true(has_base)
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('clears extmarks on detach', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
|
||||||
|
conflict.attach(bufnr, default_config())
|
||||||
|
assert.is_true(#get_extmarks(bufnr) > 0)
|
||||||
|
|
||||||
|
conflict.detach(bufnr)
|
||||||
|
assert.are.equal(0, #get_extmarks(bufnr))
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('resolution', function()
|
||||||
|
local function make_conflict_buffer()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
vim.api.nvim_set_current_buf(bufnr)
|
||||||
|
return bufnr
|
||||||
|
end
|
||||||
|
|
||||||
|
it('resolve_ours keeps ours content', function()
|
||||||
|
local bufnr = make_conflict_buffer()
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
|
||||||
|
conflict.resolve_ours(bufnr, default_config())
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.equal(1, #lines)
|
||||||
|
assert.are.equal('local x = 1', lines[1])
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolve_theirs keeps theirs content', function()
|
||||||
|
local bufnr = make_conflict_buffer()
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
|
||||||
|
conflict.resolve_theirs(bufnr, default_config())
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.equal(1, #lines)
|
||||||
|
assert.are.equal('local x = 2', lines[1])
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolve_both keeps ours then theirs', function()
|
||||||
|
local bufnr = make_conflict_buffer()
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
|
||||||
|
conflict.resolve_both(bufnr, default_config())
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(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])
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolve_none removes entire block', function()
|
||||||
|
local bufnr = make_conflict_buffer()
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
|
||||||
|
conflict.resolve_none(bufnr, default_config())
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.equal(1, #lines)
|
||||||
|
assert.are.equal('', lines[1])
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('does nothing when cursor is outside conflict', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'normal line',
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
vim.api.nvim_set_current_buf(bufnr)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||||
|
|
||||||
|
conflict.resolve_ours(bufnr, default_config())
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.equal(6, #lines)
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolves one conflict among multiple', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'a',
|
||||||
|
'=======',
|
||||||
|
'b',
|
||||||
|
'>>>>>>> feat',
|
||||||
|
'middle',
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'c',
|
||||||
|
'=======',
|
||||||
|
'd',
|
||||||
|
'>>>>>>> feat',
|
||||||
|
})
|
||||||
|
vim.api.nvim_set_current_buf(bufnr)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
|
||||||
|
conflict.resolve_ours(bufnr, default_config())
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.equal('a', lines[1])
|
||||||
|
assert.are.equal('middle', lines[2])
|
||||||
|
assert.are.equal('<<<<<<< HEAD', lines[3])
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('resolve_ours with empty ours section', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
vim.api.nvim_set_current_buf(bufnr)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||||
|
|
||||||
|
conflict.resolve_ours(bufnr, default_config())
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.equal(1, #lines)
|
||||||
|
assert.are.equal('', lines[1])
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('handles diff3 resolution (ignores base)', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'||||||| base',
|
||||||
|
'local x = 0',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
vim.api.nvim_set_current_buf(bufnr)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
|
||||||
|
conflict.resolve_theirs(bufnr, default_config())
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.equal(1, #lines)
|
||||||
|
assert.are.equal('local x = 2', lines[1])
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('navigation', function()
|
||||||
|
it('goto_next jumps to next conflict', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'normal',
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'a',
|
||||||
|
'=======',
|
||||||
|
'b',
|
||||||
|
'>>>>>>> feat',
|
||||||
|
'middle',
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'c',
|
||||||
|
'=======',
|
||||||
|
'd',
|
||||||
|
'>>>>>>> feat',
|
||||||
|
})
|
||||||
|
vim.api.nvim_set_current_buf(bufnr)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||||
|
|
||||||
|
conflict.goto_next(bufnr)
|
||||||
|
assert.are.equal(2, vim.api.nvim_win_get_cursor(0)[1])
|
||||||
|
|
||||||
|
conflict.goto_next(bufnr)
|
||||||
|
assert.are.equal(8, vim.api.nvim_win_get_cursor(0)[1])
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('goto_next wraps to first conflict', 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 })
|
||||||
|
|
||||||
|
conflict.goto_next(bufnr)
|
||||||
|
assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('goto_prev jumps to previous conflict', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'a',
|
||||||
|
'=======',
|
||||||
|
'b',
|
||||||
|
'>>>>>>> feat',
|
||||||
|
'middle',
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'c',
|
||||||
|
'=======',
|
||||||
|
'd',
|
||||||
|
'>>>>>>> feat',
|
||||||
|
'end',
|
||||||
|
})
|
||||||
|
vim.api.nvim_set_current_buf(bufnr)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 12, 0 })
|
||||||
|
|
||||||
|
conflict.goto_prev(bufnr)
|
||||||
|
assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
|
||||||
|
|
||||||
|
conflict.goto_prev(bufnr)
|
||||||
|
assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('goto_prev wraps to last conflict', 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 })
|
||||||
|
|
||||||
|
conflict.goto_prev(bufnr)
|
||||||
|
assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
|
||||||
|
|
||||||
|
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)
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||||
|
|
||||||
|
conflict.goto_next(bufnr)
|
||||||
|
assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('lifecycle', function()
|
||||||
|
it('attach is idempotent', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'a',
|
||||||
|
'=======',
|
||||||
|
'b',
|
||||||
|
'>>>>>>> feat',
|
||||||
|
})
|
||||||
|
local cfg = default_config()
|
||||||
|
conflict.attach(bufnr, cfg)
|
||||||
|
local count1 = #get_extmarks(bufnr)
|
||||||
|
conflict.attach(bufnr, cfg)
|
||||||
|
local count2 = #get_extmarks(bufnr)
|
||||||
|
assert.are.equal(count1, count2)
|
||||||
|
conflict.detach(bufnr)
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('skips non-file buffers', function()
|
||||||
|
local bufnr = helpers.create_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'a',
|
||||||
|
'=======',
|
||||||
|
'b',
|
||||||
|
'>>>>>>> feat',
|
||||||
|
})
|
||||||
|
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
|
||||||
|
|
||||||
|
conflict.attach(bufnr, default_config())
|
||||||
|
assert.are.equal(0, #get_extmarks(bufnr))
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('skips buffers without conflict markers', function()
|
||||||
|
local bufnr = create_file_buffer({ 'local x = 1', 'local y = 2' })
|
||||||
|
|
||||||
|
conflict.attach(bufnr, default_config())
|
||||||
|
assert.are.equal(0, #get_extmarks(bufnr))
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('re-highlights when markers return after resolution', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
vim.api.nvim_set_current_buf(bufnr)
|
||||||
|
local cfg = default_config()
|
||||||
|
conflict.attach(bufnr, cfg)
|
||||||
|
|
||||||
|
assert.is_true(#get_extmarks(bufnr) > 0)
|
||||||
|
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
conflict.resolve_ours(bufnr, cfg)
|
||||||
|
assert.are.equal(0, #get_extmarks(bufnr))
|
||||||
|
|
||||||
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr })
|
||||||
|
|
||||||
|
assert.is_true(#get_extmarks(bufnr) > 0)
|
||||||
|
|
||||||
|
conflict.detach(bufnr)
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('detaches after last conflict resolved', function()
|
||||||
|
local bufnr = create_file_buffer({
|
||||||
|
'<<<<<<< HEAD',
|
||||||
|
'local x = 1',
|
||||||
|
'=======',
|
||||||
|
'local x = 2',
|
||||||
|
'>>>>>>> feature',
|
||||||
|
})
|
||||||
|
vim.api.nvim_set_current_buf(bufnr)
|
||||||
|
conflict.attach(bufnr, default_config())
|
||||||
|
|
||||||
|
assert.is_true(#get_extmarks(bufnr) > 0)
|
||||||
|
|
||||||
|
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||||
|
conflict.resolve_ours(bufnr, default_config())
|
||||||
|
|
||||||
|
assert.are.equal(0, #get_extmarks(bufnr))
|
||||||
|
|
||||||
|
helpers.delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
@ -73,17 +73,17 @@ describe('diff', function()
|
||||||
|
|
||||||
describe('compute_intra_hunks', function()
|
describe('compute_intra_hunks', function()
|
||||||
it('returns nil for all-addition hunks', function()
|
it('returns nil for all-addition hunks', function()
|
||||||
local result = diff.compute_intra_hunks({ '+line1', '+line2' }, 'native')
|
local result = diff.compute_intra_hunks({ '+line1', '+line2' }, 'default')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('returns nil for all-deletion hunks', function()
|
it('returns nil for all-deletion hunks', function()
|
||||||
local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'native')
|
local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'default')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('returns nil for context-only hunks', function()
|
it('returns nil for context-only hunks', function()
|
||||||
local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'native')
|
local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'default')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ describe('diff', function()
|
||||||
local result = diff.compute_intra_hunks({
|
local result = diff.compute_intra_hunks({
|
||||||
'-local x = 1',
|
'-local x = 1',
|
||||||
'+local x = 2',
|
'+local x = 2',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_not_nil(result)
|
assert.is_not_nil(result)
|
||||||
assert.is_true(#result.del_spans > 0)
|
assert.is_true(#result.del_spans > 0)
|
||||||
assert.is_true(#result.add_spans > 0)
|
assert.is_true(#result.add_spans > 0)
|
||||||
|
|
@ -101,7 +101,7 @@ describe('diff', function()
|
||||||
local result = diff.compute_intra_hunks({
|
local result = diff.compute_intra_hunks({
|
||||||
'-local x = 1',
|
'-local x = 1',
|
||||||
'+local x = 2',
|
'+local x = 2',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_not_nil(result)
|
assert.is_not_nil(result)
|
||||||
|
|
||||||
assert.are.equal(1, #result.del_spans)
|
assert.are.equal(1, #result.del_spans)
|
||||||
|
|
@ -121,7 +121,7 @@ describe('diff', function()
|
||||||
' local b = 3',
|
' local b = 3',
|
||||||
'-local c = 4',
|
'-local c = 4',
|
||||||
'+local c = 5',
|
'+local c = 5',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_not_nil(result)
|
assert.is_not_nil(result)
|
||||||
assert.is_true(#result.del_spans >= 2)
|
assert.is_true(#result.del_spans >= 2)
|
||||||
assert.is_true(#result.add_spans >= 2)
|
assert.is_true(#result.add_spans >= 2)
|
||||||
|
|
@ -132,7 +132,7 @@ describe('diff', function()
|
||||||
'-line one',
|
'-line one',
|
||||||
'-line two',
|
'-line two',
|
||||||
'+line combined',
|
'+line combined',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_not_nil(result)
|
assert.is_not_nil(result)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -140,7 +140,7 @@ describe('diff', function()
|
||||||
local result = diff.compute_intra_hunks({
|
local result = diff.compute_intra_hunks({
|
||||||
'-local x = "héllo"',
|
'-local x = "héllo"',
|
||||||
'+local x = "wörld"',
|
'+local x = "wörld"',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_not_nil(result)
|
assert.is_not_nil(result)
|
||||||
assert.is_true(#result.del_spans > 0)
|
assert.is_true(#result.del_spans > 0)
|
||||||
assert.is_true(#result.add_spans > 0)
|
assert.is_true(#result.add_spans > 0)
|
||||||
|
|
@ -150,7 +150,7 @@ describe('diff', function()
|
||||||
local result = diff.compute_intra_hunks({
|
local result = diff.compute_intra_hunks({
|
||||||
'-local x = 1',
|
'-local x = 1',
|
||||||
'+local x = 1',
|
'+local x = 1',
|
||||||
}, 'native')
|
}, 'default')
|
||||||
assert.is_nil(result)
|
assert.is_nil(result)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ local function ensure_parser(lang)
|
||||||
end
|
end
|
||||||
|
|
||||||
ensure_parser('lua')
|
ensure_parser('lua')
|
||||||
|
ensure_parser('vim')
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ describe('highlight', function()
|
||||||
|
|
||||||
before_each(function()
|
before_each(function()
|
||||||
ns = vim.api.nvim_create_namespace('diffs_test')
|
ns = vim.api.nvim_create_namespace('diffs_test')
|
||||||
|
local normal = vim.api.nvim_get_hl(0, { name = 'Normal' })
|
||||||
local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' })
|
local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' })
|
||||||
local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' })
|
local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' })
|
||||||
|
vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 })
|
||||||
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg })
|
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg })
|
||||||
vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg })
|
vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg })
|
||||||
end)
|
end)
|
||||||
|
|
@ -35,6 +37,7 @@ describe('highlight', function()
|
||||||
highlights = {
|
highlights = {
|
||||||
background = false,
|
background = false,
|
||||||
gutter = false,
|
gutter = false,
|
||||||
|
context = { enabled = false, lines = 0 },
|
||||||
treesitter = {
|
treesitter = {
|
||||||
enabled = true,
|
enabled = true,
|
||||||
max_lines = 500,
|
max_lines = 500,
|
||||||
|
|
@ -43,6 +46,11 @@ describe('highlight', function()
|
||||||
enabled = false,
|
enabled = false,
|
||||||
max_lines = 200,
|
max_lines = 200,
|
||||||
},
|
},
|
||||||
|
intra = {
|
||||||
|
enabled = false,
|
||||||
|
algorithm = 'default',
|
||||||
|
max_lines = 500,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if overrides then
|
if overrides then
|
||||||
|
|
@ -77,7 +85,7 @@ describe('highlight', function()
|
||||||
delete_buffer(bufnr)
|
delete_buffer(bufnr)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('applies Normal extmarks to clear diff colors', function()
|
it('applies DiffsClear extmarks to clear diff colors', function()
|
||||||
local bufnr = create_buffer({
|
local bufnr = create_buffer({
|
||||||
'@@ -1,1 +1,2 @@',
|
'@@ -1,1 +1,2 @@',
|
||||||
' local x = 1',
|
' local x = 1',
|
||||||
|
|
@ -94,14 +102,46 @@ describe('highlight', function()
|
||||||
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
|
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
|
||||||
|
|
||||||
local extmarks = get_extmarks(bufnr)
|
local extmarks = get_extmarks(bufnr)
|
||||||
local has_normal = false
|
local has_clear = false
|
||||||
for _, mark in ipairs(extmarks) do
|
for _, mark in ipairs(extmarks) do
|
||||||
if mark[4] and mark[4].hl_group == 'Normal' then
|
if mark[4] and mark[4].hl_group == 'DiffsClear' then
|
||||||
has_normal = true
|
has_clear = true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
assert.is_true(has_normal)
|
assert.is_true(has_clear)
|
||||||
|
delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('produces treesitter captures on all lines with split parsing', function()
|
||||||
|
local bufnr = create_buffer({
|
||||||
|
'@@ -1,3 +1,3 @@',
|
||||||
|
' local x = 1',
|
||||||
|
'-local y = 2',
|
||||||
|
'+local y = 3',
|
||||||
|
' return x',
|
||||||
|
})
|
||||||
|
|
||||||
|
local hunk = {
|
||||||
|
filename = 'test.lua',
|
||||||
|
lang = 'lua',
|
||||||
|
start_line = 1,
|
||||||
|
lines = { ' local x = 1', '-local y = 2', '+local y = 3', ' return x' },
|
||||||
|
}
|
||||||
|
|
||||||
|
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
|
||||||
|
|
||||||
|
local extmarks = get_extmarks(bufnr)
|
||||||
|
local lines_with_ts = {}
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then
|
||||||
|
lines_with_ts[mark[2]] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_true(lines_with_ts[1] ~= nil)
|
||||||
|
assert.is_true(lines_with_ts[2] ~= nil)
|
||||||
|
assert.is_true(lines_with_ts[3] ~= nil)
|
||||||
|
assert.is_true(lines_with_ts[4] ~= nil)
|
||||||
delete_buffer(bufnr)
|
delete_buffer(bufnr)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -180,6 +220,40 @@ describe('highlight', function()
|
||||||
delete_buffer(bufnr)
|
delete_buffer(bufnr)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('highlights function keyword in header context', function()
|
||||||
|
local bufnr = create_buffer({
|
||||||
|
'@@ -5,3 +5,4 @@ function M.setup()',
|
||||||
|
' local x = 1',
|
||||||
|
'+local y = 2',
|
||||||
|
' return x',
|
||||||
|
})
|
||||||
|
|
||||||
|
local hunk = {
|
||||||
|
filename = 'test.lua',
|
||||||
|
lang = 'lua',
|
||||||
|
start_line = 1,
|
||||||
|
header_context = 'function M.setup()',
|
||||||
|
header_context_col = 18,
|
||||||
|
lines = { ' local x = 1', '+local y = 2', ' return x' },
|
||||||
|
}
|
||||||
|
|
||||||
|
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
|
||||||
|
|
||||||
|
local extmarks = get_extmarks(bufnr)
|
||||||
|
local has_keyword_function = false
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
if mark[2] == 0 and mark[4] and mark[4].hl_group then
|
||||||
|
local hl = mark[4].hl_group
|
||||||
|
if hl == '@keyword.function.lua' or hl == '@keyword.lua' then
|
||||||
|
has_keyword_function = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_true(has_keyword_function)
|
||||||
|
delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
it('does not highlight header when no header_context', function()
|
it('does not highlight header when no header_context', function()
|
||||||
local bufnr = create_buffer({
|
local bufnr = create_buffer({
|
||||||
'@@ -10,3 +10,4 @@',
|
'@@ -10,3 +10,4 @@',
|
||||||
|
|
@ -322,7 +396,7 @@ describe('highlight', function()
|
||||||
local extmarks = get_extmarks(bufnr)
|
local extmarks = get_extmarks(bufnr)
|
||||||
local has_diff_add = false
|
local has_diff_add = false
|
||||||
for _, mark in ipairs(extmarks) do
|
for _, mark in ipairs(extmarks) do
|
||||||
if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
|
if mark[4] and mark[4].hl_group == 'DiffsAdd' then
|
||||||
has_diff_add = true
|
has_diff_add = true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
|
@ -355,7 +429,7 @@ describe('highlight', function()
|
||||||
local extmarks = get_extmarks(bufnr)
|
local extmarks = get_extmarks(bufnr)
|
||||||
local has_diff_delete = false
|
local has_diff_delete = false
|
||||||
for _, mark in ipairs(extmarks) do
|
for _, mark in ipairs(extmarks) do
|
||||||
if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then
|
if mark[4] and mark[4].hl_group == 'DiffsDelete' then
|
||||||
has_diff_delete = true
|
has_diff_delete = true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
|
@ -388,7 +462,7 @@ describe('highlight', function()
|
||||||
local extmarks = get_extmarks(bufnr)
|
local extmarks = get_extmarks(bufnr)
|
||||||
local has_line_hl = false
|
local has_line_hl = false
|
||||||
for _, mark in ipairs(extmarks) do
|
for _, mark in ipairs(extmarks) do
|
||||||
if mark[4] and mark[4].line_hl_group then
|
if mark[4] and (mark[4].hl_group == 'DiffsAdd' or mark[4].hl_group == 'DiffsDelete') then
|
||||||
has_line_hl = true
|
has_line_hl = true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
|
@ -520,7 +594,7 @@ describe('highlight', function()
|
||||||
local extmarks = get_extmarks(bufnr)
|
local extmarks = get_extmarks(bufnr)
|
||||||
local has_diff_add = false
|
local has_diff_add = false
|
||||||
for _, mark in ipairs(extmarks) do
|
for _, mark in ipairs(extmarks) do
|
||||||
if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
|
if mark[4] and mark[4].hl_group == 'DiffsAdd' then
|
||||||
has_diff_add = true
|
has_diff_add = true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
|
@ -571,7 +645,7 @@ describe('highlight', function()
|
||||||
local extmarks = get_extmarks(bufnr)
|
local extmarks = get_extmarks(bufnr)
|
||||||
local has_syntax_hl = false
|
local has_syntax_hl = false
|
||||||
for _, mark in ipairs(extmarks) do
|
for _, mark in ipairs(extmarks) do
|
||||||
if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'Normal' then
|
if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then
|
||||||
has_syntax_hl = true
|
has_syntax_hl = true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
|
@ -605,7 +679,7 @@ describe('highlight', function()
|
||||||
local extmarks = get_extmarks(bufnr)
|
local extmarks = get_extmarks(bufnr)
|
||||||
local has_syntax_hl = false
|
local has_syntax_hl = false
|
||||||
for _, mark in ipairs(extmarks) do
|
for _, mark in ipairs(extmarks) do
|
||||||
if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'Normal' then
|
if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then
|
||||||
has_syntax_hl = true
|
has_syntax_hl = true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
|
@ -668,7 +742,7 @@ describe('highlight', function()
|
||||||
local extmarks = get_extmarks(bufnr)
|
local extmarks = get_extmarks(bufnr)
|
||||||
local has_diff_add = false
|
local has_diff_add = false
|
||||||
for _, mark in ipairs(extmarks) do
|
for _, mark in ipairs(extmarks) do
|
||||||
if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
|
if mark[4] and mark[4].hl_group == 'DiffsAdd' then
|
||||||
has_diff_add = true
|
has_diff_add = true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
|
@ -677,7 +751,7 @@ describe('highlight', function()
|
||||||
delete_buffer(bufnr)
|
delete_buffer(bufnr)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('applies Normal blanking for vim fallback hunks', function()
|
it('applies DiffsClear blanking for vim fallback hunks', function()
|
||||||
local orig_synID = vim.fn.synID
|
local orig_synID = vim.fn.synID
|
||||||
local orig_synIDtrans = vim.fn.synIDtrans
|
local orig_synIDtrans = vim.fn.synIDtrans
|
||||||
local orig_synIDattr = vim.fn.synIDattr
|
local orig_synIDattr = vim.fn.synIDattr
|
||||||
|
|
@ -717,14 +791,565 @@ describe('highlight', function()
|
||||||
vim.fn.synIDattr = orig_synIDattr
|
vim.fn.synIDattr = orig_synIDattr
|
||||||
|
|
||||||
local extmarks = get_extmarks(bufnr)
|
local extmarks = get_extmarks(bufnr)
|
||||||
local has_normal = false
|
local has_clear = false
|
||||||
for _, mark in ipairs(extmarks) do
|
for _, mark in ipairs(extmarks) do
|
||||||
if mark[4] and mark[4].hl_group == 'Normal' then
|
if mark[4] and mark[4].hl_group == 'DiffsClear' then
|
||||||
has_normal = true
|
has_clear = true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
assert.is_true(has_normal)
|
assert.is_true(has_clear)
|
||||||
|
delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('uses hl_group not line_hl_group for line backgrounds', 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)
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
local d = mark[4]
|
||||||
|
if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
|
||||||
|
assert.is_true(d.hl_eol == true)
|
||||||
|
assert.is_nil(d.line_hl_group)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('hl_eol background extmarks are multiline so hl_eol takes effect', 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)
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
local d = mark[4]
|
||||||
|
if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
|
||||||
|
assert.is_true(d.end_row > mark[2])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('number_hl_group does not bleed to adjacent lines', function()
|
||||||
|
local bufnr = create_buffer({
|
||||||
|
'@@ -1,3 +1,3 @@',
|
||||||
|
' local a = 0',
|
||||||
|
'-local x = 1',
|
||||||
|
'+local y = 2',
|
||||||
|
' local b = 3',
|
||||||
|
})
|
||||||
|
|
||||||
|
local hunk = {
|
||||||
|
filename = 'test.lua',
|
||||||
|
lang = 'lua',
|
||||||
|
start_line = 1,
|
||||||
|
lines = { ' local a = 0', '-local x = 1', '+local y = 2', ' local b = 3' },
|
||||||
|
}
|
||||||
|
|
||||||
|
highlight.highlight_hunk(
|
||||||
|
bufnr,
|
||||||
|
ns,
|
||||||
|
hunk,
|
||||||
|
default_opts({ highlights = { background = true, gutter = true } })
|
||||||
|
)
|
||||||
|
|
||||||
|
local extmarks = get_extmarks(bufnr)
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
local d = mark[4]
|
||||||
|
if d and d.number_hl_group then
|
||||||
|
local start_row = mark[2]
|
||||||
|
local end_row = d.end_row or start_row
|
||||||
|
assert.are.equal(start_row, end_row)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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 })
|
||||||
|
|
||||||
|
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 = true, algorithm = 'default', max_lines = 500 } },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
local extmarks = get_extmarks(bufnr)
|
||||||
|
local add_text_marks = {}
|
||||||
|
local del_text_marks = {}
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
local d = mark[4]
|
||||||
|
if d and d.hl_group == 'DiffsAddText' then
|
||||||
|
table.insert(add_text_marks, mark)
|
||||||
|
end
|
||||||
|
if d and d.hl_group == 'DiffsDeleteText' then
|
||||||
|
table.insert(del_text_marks, mark)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_true(#add_text_marks > 0)
|
||||||
|
assert.is_true(#del_text_marks > 0)
|
||||||
|
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 })
|
||||||
|
|
||||||
|
local bufnr = create_buffer({
|
||||||
|
'@@ -1,0 +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 = { intra = { enabled = true, 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('enforces priority order: DiffsClear < syntax < line bg < char 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 priorities = { clear = {}, line_bg = {}, syntax = {}, char_bg = {} }
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
local d = mark[4]
|
||||||
|
if d then
|
||||||
|
if d.hl_group == 'DiffsClear' then
|
||||||
|
table.insert(priorities.clear, d.priority)
|
||||||
|
elseif d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete' then
|
||||||
|
table.insert(priorities.line_bg, d.priority)
|
||||||
|
elseif d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then
|
||||||
|
table.insert(priorities.char_bg, d.priority)
|
||||||
|
elseif d.hl_group and d.hl_group:match('^@.*%.lua$') then
|
||||||
|
table.insert(priorities.syntax, d.priority)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
assert.is_true(#priorities.clear > 0)
|
||||||
|
assert.is_true(#priorities.line_bg > 0)
|
||||||
|
assert.is_true(#priorities.syntax > 0)
|
||||||
|
assert.is_true(#priorities.char_bg > 0)
|
||||||
|
|
||||||
|
local max_clear = math.max(unpack(priorities.clear))
|
||||||
|
local min_line_bg = math.min(unpack(priorities.line_bg))
|
||||||
|
local min_syntax = math.min(unpack(priorities.syntax))
|
||||||
|
local min_char_bg = math.min(unpack(priorities.char_bg))
|
||||||
|
|
||||||
|
assert.is_true(max_clear < min_syntax)
|
||||||
|
assert.is_true(min_syntax < min_line_bg)
|
||||||
|
assert.is_true(min_line_bg < min_char_bg)
|
||||||
|
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 @@',
|
||||||
|
' 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_lua = false
|
||||||
|
local has_vim = false
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
if mark[4] and mark[4].hl_group then
|
||||||
|
if mark[4].hl_group:match('^@.*%.lua$') then
|
||||||
|
has_lua = true
|
||||||
|
end
|
||||||
|
if mark[4].hl_group:match('^@.*%.vim$') then
|
||||||
|
has_vim = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_true(has_lua)
|
||||||
|
assert.is_true(has_vim)
|
||||||
|
delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('filters @spell and @nospell captures from 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)
|
||||||
|
for _, mark in ipairs(extmarks) do
|
||||||
|
if mark[4] and mark[4].hl_group then
|
||||||
|
assert.is_falsy(mark[4].hl_group:match('@spell'))
|
||||||
|
assert.is_falsy(mark[4].hl_group:match('@nospell'))
|
||||||
|
end
|
||||||
|
end
|
||||||
delete_buffer(bufnr)
|
delete_buffer(bufnr)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
@ -758,6 +1383,7 @@ describe('highlight', function()
|
||||||
highlights = {
|
highlights = {
|
||||||
background = false,
|
background = false,
|
||||||
gutter = false,
|
gutter = false,
|
||||||
|
context = { enabled = false, lines = 0 },
|
||||||
treesitter = { enabled = true, max_lines = 500 },
|
treesitter = { enabled = true, max_lines = 500 },
|
||||||
vim = { enabled = false, max_lines = 200 },
|
vim = { enabled = false, max_lines = 200 },
|
||||||
},
|
},
|
||||||
|
|
@ -914,13 +1540,14 @@ describe('highlight', function()
|
||||||
highlights = {
|
highlights = {
|
||||||
background = false,
|
background = false,
|
||||||
gutter = false,
|
gutter = false,
|
||||||
|
context = { enabled = false, lines = 0 },
|
||||||
treesitter = { enabled = true, max_lines = 500 },
|
treesitter = { enabled = true, max_lines = 500 },
|
||||||
vim = { enabled = false, max_lines = 200 },
|
vim = { enabled = false, max_lines = 200 },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
it('uses priority 200 for code languages', function()
|
it('uses priority 199 for code languages', function()
|
||||||
local bufnr = create_buffer({
|
local bufnr = create_buffer({
|
||||||
'@@ -1,1 +1,2 @@',
|
'@@ -1,1 +1,2 @@',
|
||||||
' local x = 1',
|
' local x = 1',
|
||||||
|
|
@ -937,16 +1564,16 @@ describe('highlight', function()
|
||||||
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
|
highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
|
||||||
|
|
||||||
local extmarks = get_extmarks(bufnr)
|
local extmarks = get_extmarks(bufnr)
|
||||||
local has_priority_200 = false
|
local has_priority_199 = false
|
||||||
for _, mark in ipairs(extmarks) do
|
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] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then
|
||||||
if mark[4].priority == 200 then
|
if mark[4].priority == 199 then
|
||||||
has_priority_200 = true
|
has_priority_199 = true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
assert.is_true(has_priority_200)
|
assert.is_true(has_priority_199)
|
||||||
delete_buffer(bufnr)
|
delete_buffer(bufnr)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -984,7 +1611,7 @@ describe('highlight', function()
|
||||||
end
|
end
|
||||||
assert.is_true(#diff_extmark_priorities > 0)
|
assert.is_true(#diff_extmark_priorities > 0)
|
||||||
for _, priority in ipairs(diff_extmark_priorities) do
|
for _, priority in ipairs(diff_extmark_priorities) do
|
||||||
assert.is_true(priority < 200)
|
assert.is_true(priority < 199)
|
||||||
end
|
end
|
||||||
delete_buffer(bufnr)
|
delete_buffer(bufnr)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -421,5 +421,84 @@ describe('parser', function()
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
vim.fn.delete(repo_root, 'rf')
|
vim.fn.delete(repo_root, 'rf')
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('extracts file line numbers from @@ header', function()
|
||||||
|
local bufnr = create_buffer({
|
||||||
|
'M lua/test.lua',
|
||||||
|
'@@ -1,3 +1,4 @@',
|
||||||
|
' local M = {}',
|
||||||
|
'+local new = true',
|
||||||
|
' return M',
|
||||||
|
})
|
||||||
|
local hunks = parser.parse_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.are.equal(1, #hunks)
|
||||||
|
assert.are.equal(1, hunks[1].file_old_start)
|
||||||
|
assert.are.equal(3, hunks[1].file_old_count)
|
||||||
|
assert.are.equal(1, hunks[1].file_new_start)
|
||||||
|
assert.are.equal(4, hunks[1].file_new_count)
|
||||||
|
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',
|
||||||
|
'@@ -1 +1 @@',
|
||||||
|
' local M = {}',
|
||||||
|
})
|
||||||
|
local hunks = parser.parse_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.are.equal(1, #hunks)
|
||||||
|
assert.are.equal(1, hunks[1].file_old_start)
|
||||||
|
assert.are.equal(1, hunks[1].file_old_count)
|
||||||
|
assert.are.equal(1, hunks[1].file_new_start)
|
||||||
|
assert.are.equal(1, hunks[1].file_new_count)
|
||||||
|
delete_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('stores repo_root on hunk when available', function()
|
||||||
|
local bufnr = create_buffer({
|
||||||
|
'M lua/test.lua',
|
||||||
|
'@@ -1,3 +1,4 @@',
|
||||||
|
' local M = {}',
|
||||||
|
'+local new = true',
|
||||||
|
' return M',
|
||||||
|
})
|
||||||
|
vim.api.nvim_buf_set_var(bufnr, 'diffs_repo_root', '/tmp/test-repo')
|
||||||
|
local hunks = parser.parse_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.are.equal(1, #hunks)
|
||||||
|
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)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
400
spec/read_buffer_spec.lua
Normal file
400
spec/read_buffer_spec.lua
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
require('spec.helpers')
|
||||||
|
|
||||||
|
local commands = require('diffs.commands')
|
||||||
|
local diffs = require('diffs')
|
||||||
|
local git = require('diffs.git')
|
||||||
|
|
||||||
|
local saved_git = {}
|
||||||
|
local saved_systemlist
|
||||||
|
local test_buffers = {}
|
||||||
|
|
||||||
|
local function mock_git(overrides)
|
||||||
|
overrides = overrides or {}
|
||||||
|
saved_git.get_file_content = git.get_file_content
|
||||||
|
saved_git.get_index_content = git.get_index_content
|
||||||
|
saved_git.get_working_content = git.get_working_content
|
||||||
|
|
||||||
|
git.get_file_content = overrides.get_file_content
|
||||||
|
or function()
|
||||||
|
return { 'local M = {}', 'return M' }
|
||||||
|
end
|
||||||
|
git.get_index_content = overrides.get_index_content
|
||||||
|
or function()
|
||||||
|
return { 'local M = {}', 'return M' }
|
||||||
|
end
|
||||||
|
git.get_working_content = overrides.get_working_content
|
||||||
|
or function()
|
||||||
|
return { 'local M = {}', 'local x = 1', 'return M' }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mock_systemlist(fn)
|
||||||
|
saved_systemlist = vim.fn.systemlist
|
||||||
|
vim.fn.systemlist = function(cmd)
|
||||||
|
local result = fn(cmd)
|
||||||
|
saved_systemlist({ 'true' })
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restore_mocks()
|
||||||
|
for k, v in pairs(saved_git) do
|
||||||
|
git[k] = v
|
||||||
|
end
|
||||||
|
saved_git = {}
|
||||||
|
if saved_systemlist then
|
||||||
|
vim.fn.systemlist = saved_systemlist
|
||||||
|
saved_systemlist = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param name string
|
||||||
|
---@param vars? table<string, any>
|
||||||
|
---@return integer
|
||||||
|
local function create_diffs_buffer(name, vars)
|
||||||
|
local existing = vim.fn.bufnr(name)
|
||||||
|
if existing ~= -1 then
|
||||||
|
vim.api.nvim_buf_delete(existing, { force = true })
|
||||||
|
end
|
||||||
|
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||||
|
vim.api.nvim_buf_set_name(bufnr, name)
|
||||||
|
vars = vars or {}
|
||||||
|
for k, v in pairs(vars) do
|
||||||
|
vim.api.nvim_buf_set_var(bufnr, k, v)
|
||||||
|
end
|
||||||
|
table.insert(test_buffers, bufnr)
|
||||||
|
return bufnr
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cleanup_buffers()
|
||||||
|
for _, bufnr in ipairs(test_buffers) do
|
||||||
|
if vim.api.nvim_buf_is_valid(bufnr) then
|
||||||
|
vim.api.nvim_buf_delete(bufnr, { force = true })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
test_buffers = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe('read_buffer', function()
|
||||||
|
after_each(function()
|
||||||
|
restore_mocks()
|
||||||
|
cleanup_buffers()
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('early returns', function()
|
||||||
|
it('does nothing on non-diffs:// buffer', function()
|
||||||
|
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||||
|
table.insert(test_buffers, bufnr)
|
||||||
|
assert.has_no.errors(function()
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('does nothing on malformed url without colon separator', function()
|
||||||
|
local bufnr = create_diffs_buffer('diffs://nocolonseparator')
|
||||||
|
vim.api.nvim_buf_set_var(bufnr, 'diffs_repo_root', '/tmp')
|
||||||
|
local lines_before = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.has_no.errors(function()
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
assert.are.same(lines_before, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('does nothing when diffs_repo_root is missing', function()
|
||||||
|
local bufnr = create_diffs_buffer('diffs://staged:missing_root.lua')
|
||||||
|
assert.has_no.errors(function()
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('buffer options', function()
|
||||||
|
it('sets buftype, bufhidden, swapfile, modifiable, filetype', function()
|
||||||
|
mock_git()
|
||||||
|
local bufnr = create_diffs_buffer('diffs://staged:options_test.lua', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
})
|
||||||
|
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.are.equal('nowrite', vim.api.nvim_get_option_value('buftype', { buf = bufnr }))
|
||||||
|
assert.are.equal('delete', vim.api.nvim_get_option_value('bufhidden', { buf = bufnr }))
|
||||||
|
assert.is_false(vim.api.nvim_get_option_value('swapfile', { buf = bufnr }))
|
||||||
|
assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = bufnr }))
|
||||||
|
assert.are.equal('diff', vim.api.nvim_get_option_value('filetype', { buf = bufnr }))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('dispatch', function()
|
||||||
|
it('calls get_file_content + get_index_content for staged label', function()
|
||||||
|
local called_get_file = false
|
||||||
|
local called_get_index = false
|
||||||
|
mock_git({
|
||||||
|
get_file_content = function()
|
||||||
|
called_get_file = true
|
||||||
|
return { 'old' }
|
||||||
|
end,
|
||||||
|
get_index_content = function()
|
||||||
|
called_get_index = true
|
||||||
|
return { 'new' }
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://staged:dispatch_staged.lua', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
})
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.is_true(called_get_file)
|
||||||
|
assert.is_true(called_get_index)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('calls get_index_content + get_working_content for unstaged label', function()
|
||||||
|
local called_get_index = false
|
||||||
|
local called_get_working = false
|
||||||
|
mock_git({
|
||||||
|
get_index_content = function()
|
||||||
|
called_get_index = true
|
||||||
|
return { 'index' }
|
||||||
|
end,
|
||||||
|
get_working_content = function()
|
||||||
|
called_get_working = true
|
||||||
|
return { 'working' }
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://unstaged:dispatch_unstaged.lua', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
})
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.is_true(called_get_index)
|
||||||
|
assert.is_true(called_get_working)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('calls only get_working_content for untracked label', function()
|
||||||
|
local called_get_file = false
|
||||||
|
local called_get_working = false
|
||||||
|
mock_git({
|
||||||
|
get_file_content = function()
|
||||||
|
called_get_file = true
|
||||||
|
return {}
|
||||||
|
end,
|
||||||
|
get_working_content = function()
|
||||||
|
called_get_working = true
|
||||||
|
return { 'new file' }
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://untracked:dispatch_untracked.lua', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
})
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.is_false(called_get_file)
|
||||||
|
assert.is_true(called_get_working)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('calls get_file_content + get_working_content for revision label', function()
|
||||||
|
local captured_rev
|
||||||
|
local called_get_working = false
|
||||||
|
mock_git({
|
||||||
|
get_file_content = function(rev)
|
||||||
|
captured_rev = rev
|
||||||
|
return { 'old' }
|
||||||
|
end,
|
||||||
|
get_working_content = function()
|
||||||
|
called_get_working = true
|
||||||
|
return { 'new' }
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://HEAD~3:dispatch_rev.lua', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
})
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.are.equal('HEAD~3', captured_rev)
|
||||||
|
assert.is_true(called_get_working)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('falls back from index to HEAD for unstaged when index returns nil', function()
|
||||||
|
local call_order = {}
|
||||||
|
mock_git({
|
||||||
|
get_index_content = function()
|
||||||
|
table.insert(call_order, 'index')
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
get_file_content = function()
|
||||||
|
table.insert(call_order, 'head')
|
||||||
|
return { 'head content' }
|
||||||
|
end,
|
||||||
|
get_working_content = function()
|
||||||
|
return { 'working content' }
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://unstaged:dispatch_fallback.lua', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
})
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.are.same({ 'index', 'head' }, call_order)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('runs git diff for section diffs with path=all', function()
|
||||||
|
local captured_cmd
|
||||||
|
mock_systemlist(function(cmd)
|
||||||
|
captured_cmd = cmd
|
||||||
|
return {
|
||||||
|
'diff --git a/file.lua b/file.lua',
|
||||||
|
'--- a/file.lua',
|
||||||
|
'+++ b/file.lua',
|
||||||
|
'@@ -1 +1 @@',
|
||||||
|
'-old',
|
||||||
|
'+new',
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://unstaged:all', {
|
||||||
|
diffs_repo_root = '/home/test/repo',
|
||||||
|
})
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.is_not_nil(captured_cmd)
|
||||||
|
assert.are.equal('git', captured_cmd[1])
|
||||||
|
assert.are.equal('/home/test/repo', captured_cmd[3])
|
||||||
|
assert.are.equal('diff', captured_cmd[4])
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.equal('diff --git a/file.lua b/file.lua', lines[1])
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('passes --cached for staged section diffs', function()
|
||||||
|
local captured_cmd
|
||||||
|
mock_systemlist(function(cmd)
|
||||||
|
captured_cmd = cmd
|
||||||
|
return { 'diff --git a/f.lua b/f.lua', '@@ -1 +1 @@', '-a', '+b' }
|
||||||
|
end)
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://staged:all', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
})
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.is_truthy(vim.tbl_contains(captured_cmd, '--cached'))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('content', function()
|
||||||
|
it('generates valid unified diff header with correct paths', function()
|
||||||
|
mock_git({
|
||||||
|
get_file_content = function()
|
||||||
|
return { 'old' }
|
||||||
|
end,
|
||||||
|
get_working_content = function()
|
||||||
|
return { 'new' }
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://HEAD:lua/diffs/init.lua', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
})
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.equal('diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua', lines[1])
|
||||||
|
assert.are.equal('--- a/lua/diffs/init.lua', lines[2])
|
||||||
|
assert.are.equal('+++ b/lua/diffs/init.lua', lines[3])
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('uses old_filepath for diff header in renames', function()
|
||||||
|
mock_git({
|
||||||
|
get_file_content = function(_, path)
|
||||||
|
assert.are.equal('/tmp/old_name.lua', path)
|
||||||
|
return { 'old content' }
|
||||||
|
end,
|
||||||
|
get_index_content = function()
|
||||||
|
return { 'new content' }
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://staged:new_name.lua', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
diffs_old_filepath = 'old_name.lua',
|
||||||
|
})
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.equal('diff --git a/old_name.lua b/new_name.lua', lines[1])
|
||||||
|
assert.are.equal('--- a/old_name.lua', lines[2])
|
||||||
|
assert.are.equal('+++ b/new_name.lua', lines[3])
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('produces empty buffer when old and new are identical', function()
|
||||||
|
mock_git({
|
||||||
|
get_file_content = function()
|
||||||
|
return { 'identical' }
|
||||||
|
end,
|
||||||
|
get_working_content = function()
|
||||||
|
return { 'identical' }
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://HEAD:nodiff.lua', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
})
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.same({ '' }, lines)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('replaces existing buffer content on reload', function()
|
||||||
|
mock_git({
|
||||||
|
get_file_content = function()
|
||||||
|
return { 'old' }
|
||||||
|
end,
|
||||||
|
get_working_content = function()
|
||||||
|
return { 'new' }
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://HEAD:replace_test.lua', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
})
|
||||||
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale', 'content', 'from', 'before' })
|
||||||
|
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
assert.are.equal('diff --git a/replace_test.lua b/replace_test.lua', lines[1])
|
||||||
|
for _, line in ipairs(lines) do
|
||||||
|
assert.is_not_equal('stale', line)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('attach integration', function()
|
||||||
|
it('calls attach on the buffer', function()
|
||||||
|
mock_git()
|
||||||
|
|
||||||
|
local attach_called_with
|
||||||
|
local original_attach = diffs.attach
|
||||||
|
diffs.attach = function(bufnr)
|
||||||
|
attach_called_with = bufnr
|
||||||
|
end
|
||||||
|
|
||||||
|
local bufnr = create_diffs_buffer('diffs://staged:attach_test.lua', {
|
||||||
|
diffs_repo_root = '/tmp',
|
||||||
|
})
|
||||||
|
commands.read_buffer(bufnr)
|
||||||
|
|
||||||
|
assert.are.equal(bufnr, attach_called_with)
|
||||||
|
|
||||||
|
diffs.attach = original_attach
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
135
spec/ux_spec.lua
Normal file
135
spec/ux_spec.lua
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
local commands = require('diffs.commands')
|
||||||
|
local helpers = require('spec.helpers')
|
||||||
|
|
||||||
|
local counter = 0
|
||||||
|
|
||||||
|
local function create_diffs_buffer(name)
|
||||||
|
counter = counter + 1
|
||||||
|
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||||
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
|
||||||
|
'diff --git a/file.lua b/file.lua',
|
||||||
|
'--- a/file.lua',
|
||||||
|
'+++ b/file.lua',
|
||||||
|
'@@ -1,1 +1,2 @@',
|
||||||
|
' local x = 1',
|
||||||
|
'+local y = 2',
|
||||||
|
})
|
||||||
|
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr })
|
||||||
|
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = bufnr })
|
||||||
|
vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr })
|
||||||
|
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
|
||||||
|
vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr })
|
||||||
|
vim.api.nvim_buf_set_name(bufnr, name or ('diffs://unstaged:file_' .. counter .. '.lua'))
|
||||||
|
return bufnr
|
||||||
|
end
|
||||||
|
|
||||||
|
describe('ux', function()
|
||||||
|
describe('diagnostics', function()
|
||||||
|
it('disables diagnostics on diff buffers', function()
|
||||||
|
local bufnr = create_diffs_buffer()
|
||||||
|
commands.setup_diff_buf(bufnr)
|
||||||
|
|
||||||
|
assert.is_false(vim.diagnostic.is_enabled({ bufnr = bufnr }))
|
||||||
|
vim.api.nvim_buf_delete(bufnr, { force = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('does not affect other buffers', function()
|
||||||
|
local diff_buf = create_diffs_buffer()
|
||||||
|
local normal_buf = helpers.create_buffer({ 'hello' })
|
||||||
|
|
||||||
|
commands.setup_diff_buf(diff_buf)
|
||||||
|
|
||||||
|
assert.is_true(vim.diagnostic.is_enabled({ bufnr = normal_buf }))
|
||||||
|
vim.api.nvim_buf_delete(diff_buf, { force = true })
|
||||||
|
helpers.delete_buffer(normal_buf)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('q keymap', function()
|
||||||
|
it('sets q keymap on diff buffer', function()
|
||||||
|
local bufnr = create_diffs_buffer()
|
||||||
|
commands.setup_diff_buf(bufnr)
|
||||||
|
|
||||||
|
local keymaps = vim.api.nvim_buf_get_keymap(bufnr, 'n')
|
||||||
|
local has_q = false
|
||||||
|
for _, km in ipairs(keymaps) do
|
||||||
|
if km.lhs == 'q' then
|
||||||
|
has_q = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
assert.is_true(has_q)
|
||||||
|
vim.api.nvim_buf_delete(bufnr, { force = true })
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('q closes the window', function()
|
||||||
|
local bufnr = create_diffs_buffer()
|
||||||
|
commands.setup_diff_buf(bufnr)
|
||||||
|
|
||||||
|
vim.cmd('split')
|
||||||
|
local win = vim.api.nvim_get_current_win()
|
||||||
|
vim.api.nvim_win_set_buf(win, bufnr)
|
||||||
|
|
||||||
|
local win_count_before = #vim.api.nvim_tabpage_list_wins(0)
|
||||||
|
|
||||||
|
vim.api.nvim_buf_call(bufnr, function()
|
||||||
|
vim.cmd('normal q')
|
||||||
|
end)
|
||||||
|
|
||||||
|
local win_count_after = #vim.api.nvim_tabpage_list_wins(0)
|
||||||
|
assert.equals(win_count_before - 1, win_count_after)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('window reuse', function()
|
||||||
|
it('returns nil when no diffs window exists', function()
|
||||||
|
local win = commands.find_diffs_window()
|
||||||
|
assert.is_nil(win)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('finds existing diffs:// window', function()
|
||||||
|
local bufnr = create_diffs_buffer()
|
||||||
|
vim.cmd('split')
|
||||||
|
local expected_win = vim.api.nvim_get_current_win()
|
||||||
|
vim.api.nvim_win_set_buf(expected_win, bufnr)
|
||||||
|
|
||||||
|
local found = commands.find_diffs_window()
|
||||||
|
assert.equals(expected_win, found)
|
||||||
|
|
||||||
|
vim.api.nvim_win_close(expected_win, true)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('ignores non-diffs buffers', function()
|
||||||
|
local normal_buf = helpers.create_buffer({ 'hello' })
|
||||||
|
vim.cmd('split')
|
||||||
|
local win = vim.api.nvim_get_current_win()
|
||||||
|
vim.api.nvim_win_set_buf(win, normal_buf)
|
||||||
|
|
||||||
|
local found = commands.find_diffs_window()
|
||||||
|
assert.is_nil(found)
|
||||||
|
|
||||||
|
vim.api.nvim_win_close(win, true)
|
||||||
|
helpers.delete_buffer(normal_buf)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns first diffs window when multiple exist', function()
|
||||||
|
local buf1 = create_diffs_buffer()
|
||||||
|
local buf2 = create_diffs_buffer()
|
||||||
|
|
||||||
|
vim.cmd('split')
|
||||||
|
local win1 = vim.api.nvim_get_current_win()
|
||||||
|
vim.api.nvim_win_set_buf(win1, buf1)
|
||||||
|
|
||||||
|
vim.cmd('split')
|
||||||
|
local win2 = vim.api.nvim_get_current_win()
|
||||||
|
vim.api.nvim_win_set_buf(win2, buf2)
|
||||||
|
|
||||||
|
local found = commands.find_diffs_window()
|
||||||
|
assert.is_not_nil(found)
|
||||||
|
assert.is_true(found == win1 or found == win2)
|
||||||
|
|
||||||
|
vim.api.nvim_win_close(win1, true)
|
||||||
|
vim.api.nvim_win_close(win2, true)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue