Compare commits
No commits in common. "doc/merge-conflicts" and "feat/test" have entirely different histories.
doc/merge-
...
feat/test
30 changed files with 469 additions and 6506 deletions
1
.github/workflows/quality.yaml
vendored
1
.github/workflows/quality.yaml
vendored
|
|
@ -1,7 +1,6 @@
|
|||
name: quality
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
|
|
|
|||
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
|
|
@ -20,9 +20,3 @@ jobs:
|
|||
- uses: nvim-neorocks/nvim-busted-action@v1
|
||||
with:
|
||||
nvim_version: ${{ matrix.nvim }}
|
||||
before: |
|
||||
git clone --depth 1 https://github.com/the-mikedavis/tree-sitter-diff /tmp/ts-diff
|
||||
cd /tmp/ts-diff && cc -shared -fPIC -o diff.so -I./src src/parser.c
|
||||
mkdir -p ~/.local/share/nvim/site/parser ~/.local/share/nvim/site/queries/diff
|
||||
cp diff.so ~/.local/share/nvim/site/parser/
|
||||
cp queries/*.scm ~/.local/share/nvim/site/queries/diff/
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"runtime.version": "Lua 5.1",
|
||||
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
|
||||
"diagnostics.globals": ["vim", "jit"],
|
||||
"diagnostics.globals": ["vim"],
|
||||
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
|
||||
"workspace.checkThirdParty": false,
|
||||
"completion.callSnippet": "Replace"
|
||||
|
|
|
|||
86
README.md
86
README.md
|
|
@ -2,37 +2,36 @@
|
|||
|
||||
**Syntax highlighting for diffs in Neovim**
|
||||
|
||||
Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware
|
||||
syntax highlighting.
|
||||
Enhance vim-fugitive and Neovim's built-in diff mode with language-aware syntax
|
||||
highlighting.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Treesitter syntax highlighting in `:Git` diffs and commit views
|
||||
- Diff header highlighting (`diff --git`, `index`, `---`, `+++`)
|
||||
- `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds
|
||||
- `:Gdiff` unified diff against any git revision with syntax highlighting
|
||||
- Fugitive status buffer keymaps (`du`/`dU`) for unified diffs
|
||||
- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`)
|
||||
- Vim syntax fallback for languages without a treesitter parser
|
||||
- Hunk header context highlighting (`@@ ... @@ function foo()`)
|
||||
- Character-level (intra-line) diff highlighting for changed characters
|
||||
- Inline merge conflict detection, highlighting, and resolution keymaps
|
||||
- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and
|
||||
highlight overrides
|
||||
- Configurable debouncing, max lines, and diff prefix concealment
|
||||
|
||||
## Requirements
|
||||
|
||||
- Neovim 0.9.0+
|
||||
- [vim-fugitive](https://github.com/tpope/vim-fugitive) (optional, for unified
|
||||
diff syntax highlighting)
|
||||
|
||||
## Installation
|
||||
|
||||
Install with your package manager of choice or via
|
||||
[luarocks](https://luarocks.org/modules/barrettruth/diffs.nvim):
|
||||
Using [lazy.nvim](https://github.com/folke/lazy.nvim):
|
||||
|
||||
```
|
||||
luarocks install diffs.nvim
|
||||
```lua
|
||||
{
|
||||
'barrettruth/diffs.nvim',
|
||||
dependencies = { 'tpope/vim-fugitive' },
|
||||
opts = {},
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
|
@ -41,40 +40,35 @@ luarocks install diffs.nvim
|
|||
:help diffs.nvim
|
||||
```
|
||||
|
||||
## Highlight Groups
|
||||
|
||||
diffs.nvim defines the following highlight groups. All use `default = true`, so
|
||||
colorschemes can override them.
|
||||
|
||||
| Group | Purpose |
|
||||
| ----------------- | -------------------------------------------------- |
|
||||
| `DiffsAdd` | Background for `+` lines in fugitive unified diffs |
|
||||
| `DiffsDelete` | Background for `-` lines in fugitive unified diffs |
|
||||
| `DiffsAddNr` | Line number highlight for `+` lines |
|
||||
| `DiffsDeleteNr` | Line number highlight for `-` lines |
|
||||
| `DiffsDiffAdd` | Background-only `DiffAdd` for `&diff` windows |
|
||||
| `DiffsDiffDelete` | Background-only `DiffDelete` for `&diff` windows |
|
||||
| `DiffsDiffChange` | Background-only `DiffChange` for `&diff` windows |
|
||||
| `DiffsDiffText` | Background-only `DiffText` for `&diff` windows |
|
||||
|
||||
By default, these are computed from your colorscheme's `DiffAdd`, `DiffDelete`,
|
||||
`DiffChange`, `DiffText`, and `Normal` groups. To customize, define them in your
|
||||
colorscheme before diffs.nvim loads, or link them to existing groups.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation.
|
||||
To improve accuracy, `diffs.nvim` reads lines from disk before and after each
|
||||
hunk for parsing context (`highlights.context`, enabled by default with 25
|
||||
lines). This resolves most boundary issues. Set
|
||||
`highlights.context.enabled = false` to disable.
|
||||
- Syntax "flashing": diffs.nvim hooks into the `FileType fugitive` event
|
||||
triggered by vim-fugitive, at which point the buffer is preliminarily painted.
|
||||
The buffer is then re-painted after `debounce_ms` milliseconds, causing an
|
||||
unavoidable visual "flash" even when `debounce_ms = 0`.
|
||||
|
||||
- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event
|
||||
triggered by `vim-fugitive`, at which point the buffer is preliminarily
|
||||
painted. The buffer is then re-painted after `debounce_ms` milliseconds,
|
||||
causing an unavoidable visual "flash" even when `debounce_ms = 0`.
|
||||
## Acknowledgements
|
||||
|
||||
- **Conflicting diff plugins**: `diffs.nvim` may not interact well with other
|
||||
plugins that modify diff highlighting. Known plugins that may conflict:
|
||||
- [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) - provides its
|
||||
own diff highlighting and conflict resolution UI
|
||||
- [`mini.diff`](https://github.com/echasnovski/mini.diff) - visualizes buffer
|
||||
differences with its own highlighting system
|
||||
- [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim) - generally
|
||||
compatible, but both plugins modifying line highlights may produce
|
||||
unexpected results
|
||||
- [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) -
|
||||
`diffs.nvim` now includes built-in conflict resolution; disable one or the
|
||||
other to avoid overlap
|
||||
|
||||
# Acknowledgements
|
||||
|
||||
- [`vim-fugitive`](https://github.com/tpope/vim-fugitive)
|
||||
- [`codediff.nvim`](https://github.com/esmuellert/codediff.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
|
||||
filetype fix, shebang/modeline detection, treesitter injection support
|
||||
- [vim-fugitive](https://github.com/tpope/vim-fugitive)
|
||||
- [codediff.nvim](https://github.com/esmuellert/codediff.nvim)
|
||||
- [diffview.nvim](https://github.com/sindrets/diffview.nvim)
|
||||
|
|
|
|||
|
|
@ -12,15 +12,12 @@ built-in diff mode.
|
|||
|
||||
Features: ~
|
||||
- Syntax highlighting in |:Git| summary diffs and commit detail views
|
||||
- Diff header highlighting (`diff --git`, `index`, `---`, `+++`)
|
||||
- Syntax highlighting in |:Gdiffsplit| / |:Gvdiffsplit| side-by-side diffs
|
||||
- |:Gdiff| command for unified diff against any git revision
|
||||
- Background-only diff colors for any `&diff` buffer (vimdiff, diffthis, etc.)
|
||||
- Vim syntax fallback for languages without a treesitter parser
|
||||
- Blended diff background colors that preserve syntax visibility
|
||||
- Optional diff prefix (`+`/`-`/` `) concealment
|
||||
- Gutter (line number) highlighting
|
||||
- Inline merge conflict marker detection, highlighting, and resolution
|
||||
|
||||
==============================================================================
|
||||
REQUIREMENTS *diffs-requirements*
|
||||
|
|
@ -40,6 +37,7 @@ Using lazy.nvim: >lua
|
|||
{
|
||||
'barrettruth/diffs.nvim',
|
||||
dependencies = { 'tpope/vim-fugitive' },
|
||||
opts = {},
|
||||
}
|
||||
<
|
||||
The plugin works automatically with no configuration required. For
|
||||
|
|
@ -48,56 +46,11 @@ customization, see |diffs-config|.
|
|||
==============================================================================
|
||||
CONFIGURATION *diffs-config*
|
||||
|
||||
Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
||||
>lua
|
||||
vim.g.diffs = {
|
||||
debug = false,
|
||||
debounce_ms = 0,
|
||||
hide_prefix = false,
|
||||
highlights = {
|
||||
background = true,
|
||||
gutter = true,
|
||||
blend_alpha = 0.6,
|
||||
context = {
|
||||
enabled = true,
|
||||
lines = 25,
|
||||
},
|
||||
treesitter = {
|
||||
enabled = true,
|
||||
max_lines = 500,
|
||||
},
|
||||
vim = {
|
||||
enabled = false,
|
||||
max_lines = 200,
|
||||
},
|
||||
intra = {
|
||||
enabled = true,
|
||||
algorithm = 'default',
|
||||
max_lines = 500,
|
||||
},
|
||||
overrides = {},
|
||||
},
|
||||
fugitive = {
|
||||
horizontal = '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*
|
||||
Fields: ~
|
||||
{enabled} (boolean, default: true)
|
||||
Enable or disable highlighting globally.
|
||||
|
||||
{debug} (boolean, default: false)
|
||||
Enable debug logging to |:messages| with
|
||||
`[diffs]` prefix.
|
||||
|
|
@ -114,40 +67,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
|||
is also enabled, the overlay inherits the line's
|
||||
background color.
|
||||
|
||||
{highlights} (table, default: see below)
|
||||
Controls which highlight features are enabled.
|
||||
See |diffs.Highlights| for fields.
|
||||
|
||||
{fugitive} (table, default: see below)
|
||||
Fugitive status buffer keymap options.
|
||||
See |diffs.FugitiveConfig| for fields.
|
||||
|
||||
{conflict} (table, default: see below)
|
||||
Inline merge conflict resolution options.
|
||||
See |diffs.ConflictConfig| for fields.
|
||||
|
||||
*diffs.Highlights*
|
||||
Highlights table fields: ~
|
||||
{background} (boolean, default: true)
|
||||
Apply background highlighting to `+`/`-` lines
|
||||
using `DiffsAdd`/`DiffsDelete` groups (derived
|
||||
from `DiffAdd`/`DiffDelete` backgrounds).
|
||||
|
||||
{gutter} (boolean, default: true)
|
||||
Highlight line numbers with matching colors.
|
||||
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 highlighting options.
|
||||
See |diffs.TreesitterConfig| for fields.
|
||||
|
|
@ -156,30 +75,9 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
|||
Vim syntax highlighting options (experimental).
|
||||
See |diffs.VimConfig| for fields.
|
||||
|
||||
{intra} (table, default: see below)
|
||||
Character-level (intra-line) diff highlighting.
|
||||
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.
|
||||
{highlights} (table, default: see below)
|
||||
Controls which highlight features are enabled.
|
||||
See |diffs.Highlights| for fields.
|
||||
|
||||
*diffs.TreesitterConfig*
|
||||
Treesitter config fields: ~
|
||||
|
|
@ -205,25 +103,16 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
|||
this many lines. Lower than the treesitter default
|
||||
due to the per-character cost of |synID()|.
|
||||
|
||||
*diffs.IntraConfig*
|
||||
Intra config fields: ~
|
||||
{enabled} (boolean, default: true)
|
||||
Enable character-level diff highlighting within
|
||||
changed lines. When a line changes from `local x = 1`
|
||||
to `local x = 2`, only the `1`/`2` characters get
|
||||
an intense background overlay while the rest of the
|
||||
line keeps the softer line-level background.
|
||||
*diffs.Highlights*
|
||||
Highlights table fields: ~
|
||||
{background} (boolean, default: true)
|
||||
Apply background highlighting to `+`/`-` lines
|
||||
using `DiffsAdd`/`DiffsDelete` groups (derived
|
||||
from `DiffAdd`/`DiffDelete` backgrounds).
|
||||
|
||||
{algorithm} (string, default: 'default')
|
||||
Diff algorithm for character-level analysis.
|
||||
`'default'`: use |vim.diff()| with settings
|
||||
inherited from |'diffopt'| (`algorithm` and
|
||||
`linematch`). `'vscode'`: use libvscodediff FFI
|
||||
(falls back to default if not available).
|
||||
|
||||
{max_lines} (integer, default: 500)
|
||||
Skip character-level highlighting for hunks larger
|
||||
than this many lines.
|
||||
{gutter} (boolean, default: true)
|
||||
Highlight line numbers with matching colors.
|
||||
Only visible if line numbers are enabled.
|
||||
|
||||
Note: Header context (e.g., `@@ -10,3 +10,4 @@ func()`) is always
|
||||
highlighted with treesitter when a parser is available.
|
||||
|
|
@ -234,232 +123,13 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
|
|||
|vim.filetype.add()| and |vim.treesitter.language.register()|.
|
||||
|
||||
==============================================================================
|
||||
COMMANDS *diffs-commands*
|
||||
API *diffs-api*
|
||||
|
||||
:Gdiff [revision] *:Gdiff*
|
||||
Open a unified diff of the current file against a git revision. Displays
|
||||
in a horizontal split below the current window.
|
||||
|
||||
The diff buffer shows `+`/`-` lines with full syntax highlighting for the
|
||||
code language, plus diff header highlighting for `diff --git`, `---`,
|
||||
`+++`, and `@@` lines.
|
||||
|
||||
If a `diffs://` window already exists in the current tabpage, the new
|
||||
diff replaces its buffer instead of creating another split.
|
||||
setup({opts}) *diffs.setup()*
|
||||
Configure the plugin with `opts`.
|
||||
|
||||
Parameters: ~
|
||||
{revision} (string, optional) Git revision to diff against.
|
||||
Defaults to HEAD.
|
||||
|
||||
Examples: >vim
|
||||
:Gdiff " diff against HEAD
|
||||
:Gdiff main " diff against main branch
|
||||
:Gdiff HEAD~3 " diff against 3 commits ago
|
||||
:Gdiff abc123 " diff against specific commit
|
||||
<
|
||||
|
||||
:Gvdiff [revision] *:Gvdiff*
|
||||
Like |:Gdiff| but opens in a vertical split.
|
||||
|
||||
:Ghdiff [revision] *:Ghdiff*
|
||||
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*
|
||||
|
||||
When inside a vim-fugitive |:Git| status buffer, diffs.nvim provides keymaps
|
||||
to open unified diffs for files or entire sections.
|
||||
|
||||
Keymaps: ~
|
||||
*diffs-du* *diffs-dU*
|
||||
du Open unified diff in a horizontal split.
|
||||
dU Open unified diff in a vertical split.
|
||||
|
||||
These keymaps work on:
|
||||
- File lines (e.g., `M src/foo.lua`) - opens diff for that file
|
||||
- Section headers (e.g., `Staged (3)`) - opens diff for all files in section
|
||||
- Hunk/context lines below a file - opens diff for the parent file
|
||||
|
||||
Behavior by file status: ~
|
||||
|
||||
Status Section Base Current Result ~
|
||||
M Unstaged index working tree unstaged changes
|
||||
M Staged HEAD index staged changes
|
||||
A Staged (empty) index file as all-added
|
||||
D Staged HEAD (empty) file as all-removed
|
||||
R Staged HEAD:oldname index:newname content diff
|
||||
? Untracked (empty) working tree file as all-added
|
||||
|
||||
On section headers, the keymap runs `git diff` (or `git diff --cached` for
|
||||
staged) and displays all changes in that section as a single unified diff.
|
||||
Untracked section headers show a warning since there is no meaningful diff.
|
||||
|
||||
Configuration: ~
|
||||
*diffs.FugitiveConfig*
|
||||
>lua
|
||||
vim.g.diffs = {
|
||||
fugitive = {
|
||||
horizontal = 'du', -- keymap for horizontal split, false to disable
|
||||
vertical = 'dU', -- keymap for vertical split, false to disable
|
||||
},
|
||||
}
|
||||
<
|
||||
Fields: ~
|
||||
{horizontal} (string|false, default: 'du')
|
||||
Keymap for unified diff in horizontal split.
|
||||
Set to `false` to disable.
|
||||
|
||||
{vertical} (string|false, default: 'dU')
|
||||
Keymap for unified diff in vertical split.
|
||||
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*
|
||||
{opts} (|diffs.Config|, optional) Configuration table.
|
||||
|
||||
attach({bufnr}) *diffs.attach()*
|
||||
Manually attach highlighting to a buffer. Called automatically for
|
||||
|
|
@ -489,11 +159,9 @@ Summary / commit detail views: ~
|
|||
- Code is parsed with |vim.treesitter.get_string_parser()|
|
||||
- If no treesitter parser and `vim.enabled`: vim syntax fallback via
|
||||
scratch buffer and |synID()|
|
||||
- `Normal` extmarks at priority 198 clear underlying diff foreground
|
||||
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199
|
||||
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 198
|
||||
- `Normal` extmarks at priority 199 clear underlying diff foreground
|
||||
- Syntax highlights are applied as extmarks at priority 200
|
||||
- Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at
|
||||
priority 201 overlay changed characters with an intense background
|
||||
- Conceal extmarks hide diff prefixes when `hide_prefix` is enabled
|
||||
4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events
|
||||
|
||||
|
|
@ -507,18 +175,6 @@ Diff mode views: ~
|
|||
==============================================================================
|
||||
KNOWN LIMITATIONS *diffs-limitations*
|
||||
|
||||
Incomplete Syntax Context ~
|
||||
*diffs-syntax-context*
|
||||
Treesitter parses each diff hunk in isolation. To provide surrounding code
|
||||
context, diffs.nvim reads lines from disk before and after each hunk
|
||||
(see |diffs.ContextConfig|, enabled by default). This resolves most boundary
|
||||
issues where incomplete constructs (e.g., a function definition at the edge
|
||||
of a hunk with no body) would confuse the parser.
|
||||
|
||||
Set `highlights.context.enabled = false` to disable context padding. In rare
|
||||
cases, context padding may not help if the relevant surrounding code is very
|
||||
far from the hunk boundaries.
|
||||
|
||||
Syntax Highlighting Flash ~
|
||||
*diffs-flash*
|
||||
When opening a fugitive buffer, there is an unavoidable visual "flash" where
|
||||
|
|
@ -530,50 +186,16 @@ which fires after vim-fugitive has already painted the buffer. Even with
|
|||
`debounce_ms = 0`, the re-painting goes through Neovim's event loop.
|
||||
|
||||
To minimize the flash, use a low debounce value: >lua
|
||||
vim.g.diffs = {
|
||||
require('diffs').setup({
|
||||
debounce_ms = 0,
|
||||
}
|
||||
})
|
||||
<
|
||||
|
||||
Conflicting Diff Plugins ~
|
||||
*diffs-plugin-conflicts*
|
||||
diffs.nvim may not interact well with other plugins that modify diff
|
||||
highlighting or the sign column in diff views. Known plugins that may
|
||||
conflict:
|
||||
|
||||
- diffview.nvim (sindrets/diffview.nvim)
|
||||
Provides its own diff highlighting and conflict resolution UI.
|
||||
When using diffview.nvim for viewing diffs, you may want to disable
|
||||
diffs.nvim's diff mode attachment or use one plugin exclusively.
|
||||
|
||||
- mini.diff (echasnovski/mini.diff)
|
||||
Visualizes buffer differences with its own highlighting system.
|
||||
May override or conflict with diffs.nvim's background highlighting.
|
||||
|
||||
- gitsigns.nvim (lewis6991/gitsigns.nvim)
|
||||
Generally compatible for sign column decorations, but both plugins
|
||||
modifying line highlights may produce unexpected results.
|
||||
|
||||
- git-conflict.nvim (akinsho/git-conflict.nvim)
|
||||
Provides conflict marker highlighting and resolution keymaps.
|
||||
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
|
||||
diff-related features.
|
||||
|
||||
==============================================================================
|
||||
HIGHLIGHT GROUPS *diffs-highlights*
|
||||
|
||||
diffs.nvim defines custom highlight groups. All groups use `default = true`,
|
||||
so colorschemes can override them by defining the group before the plugin
|
||||
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.
|
||||
diffs.nvim defines custom highlight groups. All are set with `default = true`,
|
||||
so colorschemes can override them by defining them first.
|
||||
|
||||
Fugitive unified diff highlights: ~
|
||||
*DiffsAdd*
|
||||
|
|
@ -586,89 +208,32 @@ Fugitive unified diff highlights: ~
|
|||
|
||||
*DiffsAddNr*
|
||||
DiffsAddNr Line number for `+` lines. Foreground from
|
||||
`DiffsAddText`, background from `DiffsAdd`.
|
||||
`diffAdded`, background from `DiffsAdd`.
|
||||
|
||||
*DiffsDeleteNr*
|
||||
DiffsDeleteNr Line number for `-` lines. Foreground from
|
||||
`DiffsDeleteText`, background from `DiffsDelete`.
|
||||
|
||||
*DiffsAddText*
|
||||
DiffsAddText Character-level background for changed characters
|
||||
within `+` lines. Derived by blending `diffAdded`
|
||||
foreground with `Normal` background at 60% alpha.
|
||||
Only sets `bg`, so treesitter foreground colors show
|
||||
through.
|
||||
|
||||
*DiffsDeleteText*
|
||||
DiffsDeleteText Character-level background for changed characters
|
||||
within `-` lines. Derived by blending `diffRemoved`
|
||||
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).
|
||||
`diffRemoved`, background from `DiffsDelete`.
|
||||
|
||||
Diff mode window highlights: ~
|
||||
These are used for |winhighlight| remapping in `&diff` windows.
|
||||
These are background-only variants used for |winhighlight| remapping in
|
||||
`&diff` windows, allowing treesitter syntax to show through.
|
||||
|
||||
*DiffsDiffAdd*
|
||||
DiffsDiffAdd Background-only. Derived from `DiffAdd` bg.
|
||||
Treesitter provides foreground syntax highlighting.
|
||||
DiffsDiffAdd Background-only version of `DiffAdd`.
|
||||
|
||||
*DiffsDiffDelete*
|
||||
DiffsDiffDelete Foreground and background from `DiffDelete`.
|
||||
Used for filler lines (`/////`) which have no real
|
||||
code content to highlight.
|
||||
DiffsDiffDelete Background-only version of `DiffDelete`.
|
||||
|
||||
*DiffsDiffChange*
|
||||
DiffsDiffChange Background-only. Derived from `DiffChange` bg.
|
||||
Treesitter provides foreground syntax highlighting.
|
||||
DiffsDiffChange Background-only version of `DiffChange`.
|
||||
|
||||
*DiffsDiffText*
|
||||
DiffsDiffText Background-only. Derived from `DiffText` bg.
|
||||
Treesitter provides foreground syntax highlighting.
|
||||
DiffsDiffText Background-only version of `DiffText`.
|
||||
|
||||
To customize these in your colorscheme: >lua
|
||||
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = '#2e4a3a' })
|
||||
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*
|
||||
|
|
@ -678,16 +243,6 @@ Run |:checkhealth| diffs to verify your setup.
|
|||
Checks performed:
|
||||
- Neovim version >= 0.9.0
|
||||
- vim-fugitive is installed (optional)
|
||||
- libvscode_diff shared library is available (optional)
|
||||
|
||||
==============================================================================
|
||||
ACKNOWLEDGEMENTS *diffs-acknowledgements*
|
||||
|
||||
- vim-fugitive (https://github.com/tpope/vim-fugitive)
|
||||
- codediff.nvim (https://github.com/esmuellert/codediff.nvim)
|
||||
- diffview.nvim (https://github.com/sindrets/diffview.nvim)
|
||||
- @phanen (https://github.com/phanen) - diff header highlighting,
|
||||
treesitter injection support
|
||||
|
||||
==============================================================================
|
||||
vim:tw=78:ts=8:ft=help:norl:
|
||||
|
|
|
|||
|
|
@ -1,393 +0,0 @@
|
|||
local M = {}
|
||||
|
||||
local git = require('diffs.git')
|
||||
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 hunk_position { hunk_header: string, offset: integer }
|
||||
---@return integer?
|
||||
function M.find_hunk_line(diff_lines, hunk_position)
|
||||
for i, line in ipairs(diff_lines) do
|
||||
if line == hunk_position.hunk_header then
|
||||
return i + hunk_position.offset
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param old_lines string[]
|
||||
---@param new_lines string[]
|
||||
---@param old_name string
|
||||
---@param new_name string
|
||||
---@return string[]
|
||||
local function generate_unified_diff(old_lines, new_lines, old_name, new_name)
|
||||
local old_content = table.concat(old_lines, '\n')
|
||||
local new_content = table.concat(new_lines, '\n')
|
||||
|
||||
local diff_fn = vim.text and vim.text.diff or vim.diff
|
||||
local diff_output = diff_fn(old_content, new_content, {
|
||||
result_type = 'unified',
|
||||
ctxlen = 3,
|
||||
})
|
||||
|
||||
if not diff_output or diff_output == '' then
|
||||
return {}
|
||||
end
|
||||
|
||||
local diff_lines = vim.split(diff_output, '\n', { plain = true })
|
||||
|
||||
local result = {
|
||||
'diff --git a/' .. old_name .. ' b/' .. new_name,
|
||||
'--- a/' .. old_name,
|
||||
'+++ b/' .. new_name,
|
||||
}
|
||||
for _, line in ipairs(diff_lines) do
|
||||
table.insert(result, line)
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
---@param revision? string
|
||||
---@param vertical? boolean
|
||||
function M.gdiff(revision, vertical)
|
||||
revision = revision or 'HEAD'
|
||||
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local filepath = vim.api.nvim_buf_get_name(bufnr)
|
||||
|
||||
if filepath == '' then
|
||||
vim.notify('[diffs.nvim]: cannot diff unnamed buffer', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local rel_path = git.get_relative_path(filepath)
|
||||
if not rel_path then
|
||||
vim.notify('[diffs.nvim]: not in a git repository', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local old_lines, err = git.get_file_content(revision, filepath)
|
||||
if not old_lines then
|
||||
vim.notify('[diffs.nvim]: ' .. (err or 'unknown error'), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local new_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
|
||||
local diff_lines = generate_unified_diff(old_lines, new_lines, rel_path, rel_path)
|
||||
|
||||
if #diff_lines == 0 then
|
||||
vim.notify('[diffs.nvim]: no diff against ' .. revision, vim.log.levels.INFO)
|
||||
return
|
||||
end
|
||||
|
||||
local repo_root = git.get_repo_root(filepath)
|
||||
|
||||
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_set_option_value('buftype', 'nowrite', { 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('filetype', 'diff', { buf = diff_buf })
|
||||
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. revision .. ':' .. rel_path)
|
||||
if repo_root then
|
||||
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
|
||||
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.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)
|
||||
|
||||
vim.schedule(function()
|
||||
require('diffs').attach(diff_buf)
|
||||
end)
|
||||
end
|
||||
|
||||
---@class diffs.GdiffFileOpts
|
||||
---@field vertical? boolean
|
||||
---@field staged? boolean
|
||||
---@field untracked? boolean
|
||||
---@field old_filepath? string
|
||||
---@field hunk_position? { hunk_header: string, offset: integer }
|
||||
|
||||
---@param filepath string
|
||||
---@param opts? diffs.GdiffFileOpts
|
||||
function M.gdiff_file(filepath, opts)
|
||||
opts = opts or {}
|
||||
|
||||
local rel_path = git.get_relative_path(filepath)
|
||||
if not rel_path then
|
||||
vim.notify('[diffs.nvim]: not in a git repository', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local old_rel_path = opts.old_filepath and git.get_relative_path(opts.old_filepath) or rel_path
|
||||
|
||||
local old_lines, new_lines, err
|
||||
local diff_label
|
||||
|
||||
if opts.untracked then
|
||||
old_lines = {}
|
||||
new_lines, err = git.get_working_content(filepath)
|
||||
if not new_lines then
|
||||
vim.notify('[diffs.nvim]: ' .. (err or 'cannot read file'), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
diff_label = 'untracked'
|
||||
elseif opts.staged then
|
||||
old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath)
|
||||
if not old_lines then
|
||||
old_lines = {}
|
||||
end
|
||||
new_lines, err = git.get_index_content(filepath)
|
||||
if not new_lines then
|
||||
new_lines = {}
|
||||
end
|
||||
diff_label = 'staged'
|
||||
else
|
||||
old_lines, err = git.get_index_content(opts.old_filepath or filepath)
|
||||
if not old_lines then
|
||||
old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath)
|
||||
if not old_lines then
|
||||
old_lines = {}
|
||||
diff_label = 'untracked'
|
||||
else
|
||||
diff_label = 'unstaged'
|
||||
end
|
||||
else
|
||||
diff_label = 'unstaged'
|
||||
end
|
||||
new_lines, err = git.get_working_content(filepath)
|
||||
if not new_lines then
|
||||
new_lines = {}
|
||||
end
|
||||
end
|
||||
|
||||
local diff_lines = generate_unified_diff(old_lines, new_lines, old_rel_path, rel_path)
|
||||
|
||||
if #diff_lines == 0 then
|
||||
vim.notify('[diffs.nvim]: no changes', vim.log.levels.INFO)
|
||||
return
|
||||
end
|
||||
|
||||
local repo_root = git.get_repo_root(filepath)
|
||||
|
||||
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_set_option_value('buftype', 'nowrite', { 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('filetype', 'diff', { buf = diff_buf })
|
||||
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':' .. rel_path)
|
||||
if repo_root then
|
||||
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
|
||||
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.api.nvim_win_set_buf(0, diff_buf)
|
||||
end
|
||||
|
||||
if opts.hunk_position then
|
||||
local target_line = M.find_hunk_line(diff_lines, opts.hunk_position)
|
||||
if target_line then
|
||||
vim.api.nvim_win_set_cursor(0, { target_line, 0 })
|
||||
dbg('jumped to line %d for hunk', target_line)
|
||||
end
|
||||
end
|
||||
|
||||
M.setup_diff_buf(diff_buf)
|
||||
dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label)
|
||||
|
||||
vim.schedule(function()
|
||||
require('diffs').attach(diff_buf)
|
||||
end)
|
||||
end
|
||||
|
||||
---@class diffs.GdiffSectionOpts
|
||||
---@field vertical? boolean
|
||||
---@field staged? boolean
|
||||
|
||||
---@param repo_root string
|
||||
---@param opts? diffs.GdiffSectionOpts
|
||||
function M.gdiff_section(repo_root, opts)
|
||||
opts = opts or {}
|
||||
|
||||
local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' }
|
||||
if opts.staged then
|
||||
table.insert(cmd, '--cached')
|
||||
end
|
||||
|
||||
local result = vim.fn.systemlist(cmd)
|
||||
if vim.v.shell_error ~= 0 then
|
||||
vim.notify('[diffs.nvim]: git diff failed', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
if #result == 0 then
|
||||
vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO)
|
||||
return
|
||||
end
|
||||
|
||||
local diff_label = opts.staged and 'staged' or 'unstaged'
|
||||
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_set_option_value('buftype', 'nowrite', { 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('filetype', 'diff', { buf = diff_buf })
|
||||
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)
|
||||
|
||||
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.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)
|
||||
|
||||
vim.schedule(function()
|
||||
require('diffs').attach(diff_buf)
|
||||
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()
|
||||
vim.api.nvim_create_user_command('Gdiff', function(opts)
|
||||
M.gdiff(opts.args ~= '' and opts.args or nil, false)
|
||||
end, {
|
||||
nargs = '?',
|
||||
desc = 'Show unified diff against git revision (default: HEAD)',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('Gvdiff', function(opts)
|
||||
M.gdiff(opts.args ~= '' and opts.args or nil, true)
|
||||
end, {
|
||||
nargs = '?',
|
||||
desc = 'Show unified diff against git revision in vertical split',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('Ghdiff', function(opts)
|
||||
M.gdiff(opts.args ~= '' and opts.args or nil, false)
|
||||
end, {
|
||||
nargs = '?',
|
||||
desc = 'Show unified diff against git revision in horizontal split',
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
---@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
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,401 +0,0 @@
|
|||
---@class diffs.CharSpan
|
||||
---@field line integer
|
||||
---@field col_start integer
|
||||
---@field col_end integer
|
||||
|
||||
---@class diffs.IntraChanges
|
||||
---@field add_spans diffs.CharSpan[]
|
||||
---@field del_spans diffs.CharSpan[]
|
||||
|
||||
---@class diffs.ChangeGroup
|
||||
---@field del_lines {idx: integer, text: string}[]
|
||||
---@field add_lines {idx: integer, text: string}[]
|
||||
|
||||
---@class diffs.DiffOpts
|
||||
---@field algorithm? string
|
||||
---@field linematch? integer
|
||||
|
||||
local M = {}
|
||||
|
||||
local dbg = require('diffs.log').dbg
|
||||
|
||||
---@param hunk_lines string[]
|
||||
---@return diffs.ChangeGroup[]
|
||||
function M.extract_change_groups(hunk_lines)
|
||||
---@type diffs.ChangeGroup[]
|
||||
local groups = {}
|
||||
---@type {idx: integer, text: string}[]
|
||||
local del_buf = {}
|
||||
---@type {idx: integer, text: string}[]
|
||||
local add_buf = {}
|
||||
|
||||
---@type boolean
|
||||
local in_del = false
|
||||
|
||||
for i, line in ipairs(hunk_lines) do
|
||||
local prefix = line:sub(1, 1)
|
||||
if prefix == '-' then
|
||||
if not in_del and #add_buf > 0 then
|
||||
if #del_buf > 0 then
|
||||
table.insert(groups, { del_lines = del_buf, add_lines = add_buf })
|
||||
end
|
||||
del_buf = {}
|
||||
add_buf = {}
|
||||
end
|
||||
in_del = true
|
||||
table.insert(del_buf, { idx = i, text = line:sub(2) })
|
||||
elseif prefix == '+' then
|
||||
in_del = false
|
||||
table.insert(add_buf, { idx = i, text = line:sub(2) })
|
||||
else
|
||||
if #del_buf > 0 and #add_buf > 0 then
|
||||
table.insert(groups, { del_lines = del_buf, add_lines = add_buf })
|
||||
end
|
||||
del_buf = {}
|
||||
add_buf = {}
|
||||
in_del = false
|
||||
end
|
||||
end
|
||||
|
||||
if #del_buf > 0 and #add_buf > 0 then
|
||||
table.insert(groups, { del_lines = del_buf, add_lines = add_buf })
|
||||
end
|
||||
|
||||
return groups
|
||||
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 new_text string
|
||||
---@param diff_opts? diffs.DiffOpts
|
||||
---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[]
|
||||
local function byte_diff(old_text, new_text, diff_opts)
|
||||
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
|
||||
return {}
|
||||
end
|
||||
---@type {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[]
|
||||
local hunks = {}
|
||||
for _, h in ipairs(result) do
|
||||
table.insert(hunks, {
|
||||
old_start = h[1],
|
||||
old_count = h[2],
|
||||
new_start = h[3],
|
||||
new_count = h[4],
|
||||
})
|
||||
end
|
||||
return hunks
|
||||
end
|
||||
|
||||
---@param s string
|
||||
---@return string[]
|
||||
local function split_bytes(s)
|
||||
local bytes = {}
|
||||
for i = 1, #s do
|
||||
table.insert(bytes, s:sub(i, i))
|
||||
end
|
||||
return bytes
|
||||
end
|
||||
|
||||
---@param old_line string
|
||||
---@param new_line string
|
||||
---@param del_idx integer
|
||||
---@param add_idx integer
|
||||
---@param diff_opts? diffs.DiffOpts
|
||||
---@return diffs.CharSpan[], diffs.CharSpan[]
|
||||
local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts)
|
||||
---@type diffs.CharSpan[]
|
||||
local del_spans = {}
|
||||
---@type diffs.CharSpan[]
|
||||
local add_spans = {}
|
||||
|
||||
local old_bytes = split_bytes(old_line)
|
||||
local new_bytes = split_bytes(new_line)
|
||||
|
||||
local old_text = table.concat(old_bytes, '\n') .. '\n'
|
||||
local new_text = table.concat(new_bytes, '\n') .. '\n'
|
||||
|
||||
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
|
||||
if ch.old_count > 0 then
|
||||
table.insert(del_spans, {
|
||||
line = del_idx,
|
||||
col_start = ch.old_start,
|
||||
col_end = ch.old_start + ch.old_count,
|
||||
})
|
||||
end
|
||||
|
||||
if ch.new_count > 0 then
|
||||
table.insert(add_spans, {
|
||||
line = add_idx,
|
||||
col_start = ch.new_start,
|
||||
col_end = ch.new_start + ch.new_count,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return del_spans, add_spans
|
||||
end
|
||||
|
||||
---@param group diffs.ChangeGroup
|
||||
---@param diff_opts? diffs.DiffOpts
|
||||
---@return diffs.CharSpan[], diffs.CharSpan[]
|
||||
local function diff_group_native(group, diff_opts)
|
||||
---@type diffs.CharSpan[]
|
||||
local all_del = {}
|
||||
---@type diffs.CharSpan[]
|
||||
local all_add = {}
|
||||
|
||||
local del_count = #group.del_lines
|
||||
local add_count = #group.add_lines
|
||||
|
||||
if del_count == 1 and add_count == 1 then
|
||||
local ds, as = char_diff_pair(
|
||||
group.del_lines[1].text,
|
||||
group.add_lines[1].text,
|
||||
group.del_lines[1].idx,
|
||||
group.add_lines[1].idx,
|
||||
diff_opts
|
||||
)
|
||||
vim.list_extend(all_del, ds)
|
||||
vim.list_extend(all_add, as)
|
||||
return all_del, all_add
|
||||
end
|
||||
|
||||
local old_texts = {}
|
||||
for _, l in ipairs(group.del_lines) do
|
||||
table.insert(old_texts, l.text)
|
||||
end
|
||||
local new_texts = {}
|
||||
for _, l in ipairs(group.add_lines) do
|
||||
table.insert(new_texts, l.text)
|
||||
end
|
||||
|
||||
local old_block = table.concat(old_texts, '\n') .. '\n'
|
||||
local new_block = table.concat(new_texts, '\n') .. '\n'
|
||||
|
||||
local line_hunks = byte_diff(old_block, new_block, diff_opts)
|
||||
|
||||
---@type table<integer, integer>
|
||||
local old_to_new = {}
|
||||
for _, lh in ipairs(line_hunks) do
|
||||
if lh.old_count == lh.new_count then
|
||||
for k = 0, lh.old_count - 1 do
|
||||
old_to_new[lh.old_start + k] = lh.new_start + k
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for old_i, new_i in pairs(old_to_new) do
|
||||
if group.del_lines[old_i] and group.add_lines[new_i] then
|
||||
local ds, as = char_diff_pair(
|
||||
group.del_lines[old_i].text,
|
||||
group.add_lines[new_i].text,
|
||||
group.del_lines[old_i].idx,
|
||||
group.add_lines[new_i].idx,
|
||||
diff_opts
|
||||
)
|
||||
vim.list_extend(all_del, ds)
|
||||
vim.list_extend(all_add, as)
|
||||
end
|
||||
end
|
||||
|
||||
for _, lh in ipairs(line_hunks) do
|
||||
if lh.old_count ~= lh.new_count then
|
||||
local pairs_count = math.min(lh.old_count, lh.new_count)
|
||||
for k = 0, pairs_count - 1 do
|
||||
local oi = lh.old_start + k
|
||||
local ni = lh.new_start + k
|
||||
if group.del_lines[oi] and group.add_lines[ni] then
|
||||
local ds, as = char_diff_pair(
|
||||
group.del_lines[oi].text,
|
||||
group.add_lines[ni].text,
|
||||
group.del_lines[oi].idx,
|
||||
group.add_lines[ni].idx,
|
||||
diff_opts
|
||||
)
|
||||
vim.list_extend(all_del, ds)
|
||||
vim.list_extend(all_add, as)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return all_del, all_add
|
||||
end
|
||||
|
||||
---@param group diffs.ChangeGroup
|
||||
---@param handle table
|
||||
---@return diffs.CharSpan[], diffs.CharSpan[]
|
||||
local function diff_group_vscode(group, handle)
|
||||
---@type diffs.CharSpan[]
|
||||
local all_del = {}
|
||||
---@type diffs.CharSpan[]
|
||||
local all_add = {}
|
||||
|
||||
local ffi = require('ffi')
|
||||
|
||||
local old_texts = {}
|
||||
for _, l in ipairs(group.del_lines) do
|
||||
table.insert(old_texts, l.text)
|
||||
end
|
||||
local new_texts = {}
|
||||
for _, l in ipairs(group.add_lines) do
|
||||
table.insert(new_texts, l.text)
|
||||
end
|
||||
|
||||
local orig_arr = ffi.new('const char*[?]', #old_texts)
|
||||
for i, t in ipairs(old_texts) do
|
||||
orig_arr[i - 1] = t
|
||||
end
|
||||
|
||||
local mod_arr = ffi.new('const char*[?]', #new_texts)
|
||||
for i, t in ipairs(new_texts) do
|
||||
mod_arr[i - 1] = t
|
||||
end
|
||||
|
||||
local opts = ffi.new('DiffsDiffOptions', {
|
||||
ignore_trim_whitespace = false,
|
||||
max_computation_time_ms = 1000,
|
||||
compute_moves = false,
|
||||
extend_to_subwords = false,
|
||||
})
|
||||
|
||||
local result = handle.compute_diff(orig_arr, #old_texts, mod_arr, #new_texts, opts)
|
||||
if result == nil then
|
||||
return all_del, all_add
|
||||
end
|
||||
|
||||
for ci = 0, result.changes.count - 1 do
|
||||
local mapping = result.changes.mappings[ci]
|
||||
for ii = 0, mapping.inner_change_count - 1 do
|
||||
local inner = mapping.inner_changes[ii]
|
||||
|
||||
local orig_line = inner.original.start_line
|
||||
if group.del_lines[orig_line] then
|
||||
table.insert(all_del, {
|
||||
line = group.del_lines[orig_line].idx,
|
||||
col_start = inner.original.start_col,
|
||||
col_end = inner.original.end_col,
|
||||
})
|
||||
end
|
||||
|
||||
local mod_line = inner.modified.start_line
|
||||
if group.add_lines[mod_line] then
|
||||
table.insert(all_add, {
|
||||
line = group.add_lines[mod_line].idx,
|
||||
col_start = inner.modified.start_col,
|
||||
col_end = inner.modified.end_col,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
handle.free_lines_diff(result)
|
||||
|
||||
return all_del, all_add
|
||||
end
|
||||
|
||||
---@param hunk_lines string[]
|
||||
---@param algorithm? string
|
||||
---@return diffs.IntraChanges?
|
||||
function M.compute_intra_hunks(hunk_lines, algorithm)
|
||||
local groups = M.extract_change_groups(hunk_lines)
|
||||
if #groups == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
algorithm = algorithm or 'default'
|
||||
|
||||
local vscode_handle = nil
|
||||
if algorithm == 'vscode' then
|
||||
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
|
||||
|
||||
---@type diffs.DiffOpts?
|
||||
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
|
||||
|
||||
---@type diffs.CharSpan[]
|
||||
local all_add = {}
|
||||
---@type diffs.CharSpan[]
|
||||
local all_del = {}
|
||||
|
||||
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
|
||||
if vscode_handle then
|
||||
ds, as = diff_group_vscode(group, vscode_handle)
|
||||
else
|
||||
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
|
||||
vim.list_extend(all_del, ds)
|
||||
vim.list_extend(all_add, as)
|
||||
end
|
||||
|
||||
if #all_add == 0 and #all_del == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
return { add_spans = all_add, del_spans = all_del }
|
||||
end
|
||||
|
||||
---@return boolean
|
||||
function M.has_vscode()
|
||||
return require('diffs.lib').has_lib()
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
local M = {}
|
||||
|
||||
local commands = require('diffs.commands')
|
||||
local git = require('diffs.git')
|
||||
local dbg = require('diffs.log').dbg
|
||||
|
||||
---@alias diffs.FugitiveSection 'staged' | 'unstaged' | 'untracked' | nil
|
||||
|
||||
---@param bufnr integer
|
||||
---@param lnum integer
|
||||
---@return diffs.FugitiveSection
|
||||
function M.get_section_at_line(bufnr, lnum)
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false)
|
||||
|
||||
for i = #lines, 1, -1 do
|
||||
local line = lines[i]
|
||||
if line:match('^Staged ') then
|
||||
return 'staged'
|
||||
elseif line:match('^Unstaged ') then
|
||||
return 'unstaged'
|
||||
elseif line:match('^Untracked ') then
|
||||
return 'untracked'
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param line string
|
||||
---@return string?, string?
|
||||
local function parse_file_line(line)
|
||||
local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$')
|
||||
if old and new then
|
||||
return vim.trim(new), vim.trim(old)
|
||||
end
|
||||
|
||||
local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$')
|
||||
if filename then
|
||||
return vim.trim(filename), nil
|
||||
end
|
||||
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
---@param line string
|
||||
---@return diffs.FugitiveSection?
|
||||
local function parse_section_header(line)
|
||||
if line:match('^Staged %(%d') then
|
||||
return 'staged'
|
||||
elseif line:match('^Unstaged %(%d') then
|
||||
return 'unstaged'
|
||||
elseif line:match('^Untracked %(%d') then
|
||||
return 'untracked'
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@param lnum integer
|
||||
---@return string?, diffs.FugitiveSection, boolean, string?
|
||||
function M.get_file_at_line(bufnr, lnum)
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local current_line = lines[lnum]
|
||||
|
||||
if not current_line then
|
||||
return nil, nil, false, nil
|
||||
end
|
||||
|
||||
local section_header = parse_section_header(current_line)
|
||||
if section_header then
|
||||
return nil, section_header, true, nil
|
||||
end
|
||||
|
||||
local filename, old_filename = parse_file_line(current_line)
|
||||
if filename then
|
||||
local section = M.get_section_at_line(bufnr, lnum)
|
||||
return filename, section, false, old_filename
|
||||
end
|
||||
|
||||
local prefix = current_line:sub(1, 1)
|
||||
if prefix == '+' or prefix == '-' or prefix == ' ' then
|
||||
for i = lnum - 1, 1, -1 do
|
||||
local prev_line = lines[i]
|
||||
filename, old_filename = parse_file_line(prev_line)
|
||||
if filename then
|
||||
local section = M.get_section_at_line(bufnr, i)
|
||||
return filename, section, false, old_filename
|
||||
end
|
||||
if prev_line:match('^%w+ %(') or prev_line == '' then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nil, nil, false, nil
|
||||
end
|
||||
|
||||
---@class diffs.HunkPosition
|
||||
---@field hunk_header string
|
||||
---@field offset integer
|
||||
|
||||
---@param bufnr integer
|
||||
---@param lnum integer
|
||||
---@return diffs.HunkPosition?
|
||||
function M.get_hunk_position(bufnr, lnum)
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false)
|
||||
local current = lines[lnum]
|
||||
|
||||
if not current then
|
||||
return nil
|
||||
end
|
||||
|
||||
local prefix = current:sub(1, 1)
|
||||
if prefix ~= '+' and prefix ~= '-' and prefix ~= ' ' then
|
||||
return nil
|
||||
end
|
||||
|
||||
for i = lnum - 1, 1, -1 do
|
||||
local line = lines[i]
|
||||
if line:match('^@@.-@@') then
|
||||
return {
|
||||
hunk_header = line,
|
||||
offset = lnum - i,
|
||||
}
|
||||
end
|
||||
if line:match('^[MADRCU?!]%s') or line:match('^%w+ %(') then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return string?
|
||||
local function get_repo_root_from_fugitive(bufnr)
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local fugitive_path = bufname:match('^fugitive://(.+)///')
|
||||
if fugitive_path then
|
||||
return fugitive_path
|
||||
end
|
||||
|
||||
local cwd = vim.fn.getcwd()
|
||||
local root = git.get_repo_root(cwd .. '/.')
|
||||
return root
|
||||
end
|
||||
|
||||
---@param vertical boolean
|
||||
function M.diff_file_under_cursor(vertical)
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||
|
||||
local filename, section, is_header, old_filename = M.get_file_at_line(bufnr, lnum)
|
||||
|
||||
local repo_root = get_repo_root_from_fugitive(bufnr)
|
||||
if not repo_root then
|
||||
vim.notify('[diffs.nvim]: could not determine repository root', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
if is_header then
|
||||
dbg('diff_section: %s', section or 'unknown')
|
||||
if section == 'untracked' then
|
||||
vim.notify('[diffs.nvim]: cannot diff untracked section', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
commands.gdiff_section(repo_root, {
|
||||
vertical = vertical,
|
||||
staged = section == 'staged',
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
if not filename then
|
||||
vim.notify('[diffs.nvim]: no file under cursor', vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
local filepath = repo_root .. '/' .. filename
|
||||
local old_filepath = old_filename and (repo_root .. '/' .. old_filename) or nil
|
||||
local hunk_position = M.get_hunk_position(bufnr, lnum)
|
||||
|
||||
dbg(
|
||||
'diff_file_under_cursor: %s (section: %s, old: %s, hunk_offset: %s)',
|
||||
filename,
|
||||
section or 'unknown',
|
||||
old_filename or 'none',
|
||||
hunk_position and tostring(hunk_position.offset) or 'none'
|
||||
)
|
||||
|
||||
commands.gdiff_file(filepath, {
|
||||
vertical = vertical,
|
||||
staged = section == 'staged',
|
||||
untracked = section == 'untracked',
|
||||
old_filepath = old_filepath,
|
||||
hunk_position = hunk_position,
|
||||
})
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@param config { horizontal: string|false, vertical: string|false }
|
||||
function M.setup_keymaps(bufnr, config)
|
||||
if config.horizontal and config.horizontal ~= '' then
|
||||
vim.keymap.set('n', config.horizontal, function()
|
||||
M.diff_file_under_cursor(false)
|
||||
end, { buffer = bufnr, desc = 'Unified diff (horizontal)' })
|
||||
dbg('set keymap %s for buffer %d', config.horizontal, bufnr)
|
||||
end
|
||||
|
||||
if config.vertical and config.vertical ~= '' then
|
||||
vim.keymap.set('n', config.vertical, function()
|
||||
M.diff_file_under_cursor(true)
|
||||
end, { buffer = bufnr, desc = 'Unified diff (vertical)' })
|
||||
dbg('set keymap %s for buffer %d', config.vertical, bufnr)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
local M = {}
|
||||
|
||||
---@param filepath string
|
||||
---@return string?
|
||||
function M.get_repo_root(filepath)
|
||||
local dir = vim.fn.fnamemodify(filepath, ':h')
|
||||
local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' })
|
||||
if vim.v.shell_error ~= 0 then
|
||||
return nil
|
||||
end
|
||||
return result[1]
|
||||
end
|
||||
|
||||
---@param revision string
|
||||
---@param filepath string
|
||||
---@return string[]?, string?
|
||||
function M.get_file_content(revision, filepath)
|
||||
local repo_root = M.get_repo_root(filepath)
|
||||
if not repo_root then
|
||||
return nil, 'not in a git repository'
|
||||
end
|
||||
|
||||
local rel_path = vim.fn.fnamemodify(filepath, ':.')
|
||||
if vim.startswith(filepath, repo_root) then
|
||||
rel_path = filepath:sub(#repo_root + 2)
|
||||
end
|
||||
|
||||
local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'show', revision .. ':' .. rel_path })
|
||||
if vim.v.shell_error ~= 0 then
|
||||
return nil, 'failed to get file at revision: ' .. revision
|
||||
end
|
||||
return result, nil
|
||||
end
|
||||
|
||||
---@param filepath string
|
||||
---@return string?
|
||||
function M.get_relative_path(filepath)
|
||||
local repo_root = M.get_repo_root(filepath)
|
||||
if not repo_root then
|
||||
return nil
|
||||
end
|
||||
if vim.startswith(filepath, repo_root) then
|
||||
return filepath:sub(#repo_root + 2)
|
||||
end
|
||||
return vim.fn.fnamemodify(filepath, ':.')
|
||||
end
|
||||
|
||||
---@param filepath string
|
||||
---@return string[]?, string?
|
||||
function M.get_index_content(filepath)
|
||||
local repo_root = M.get_repo_root(filepath)
|
||||
if not repo_root then
|
||||
return nil, 'not in a git repository'
|
||||
end
|
||||
|
||||
local rel_path = M.get_relative_path(filepath)
|
||||
if not rel_path then
|
||||
return nil, 'could not determine relative path'
|
||||
end
|
||||
|
||||
local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'show', ':0:' .. rel_path })
|
||||
if vim.v.shell_error ~= 0 then
|
||||
return nil, 'file not in index'
|
||||
end
|
||||
return result, nil
|
||||
end
|
||||
|
||||
---@param filepath string
|
||||
---@return string[]?, string?
|
||||
function M.get_working_content(filepath)
|
||||
if vim.fn.filereadable(filepath) ~= 1 then
|
||||
return nil, 'file not readable'
|
||||
end
|
||||
local lines = vim.fn.readfile(filepath)
|
||||
return lines, nil
|
||||
end
|
||||
|
||||
---@param filepath string
|
||||
---@return boolean
|
||||
function M.file_exists_in_index(filepath)
|
||||
local repo_root = M.get_repo_root(filepath)
|
||||
if not repo_root then
|
||||
return false
|
||||
end
|
||||
|
||||
local rel_path = M.get_relative_path(filepath)
|
||||
if not rel_path then
|
||||
return false
|
||||
end
|
||||
|
||||
vim.fn.system({ 'git', '-C', repo_root, 'ls-files', '--stage', '--', rel_path })
|
||||
return vim.v.shell_error == 0
|
||||
end
|
||||
|
||||
---@param revision string
|
||||
---@param filepath string
|
||||
---@return boolean
|
||||
function M.file_exists_at_revision(revision, filepath)
|
||||
local repo_root = M.get_repo_root(filepath)
|
||||
if not repo_root then
|
||||
return false
|
||||
end
|
||||
|
||||
local rel_path = M.get_relative_path(filepath)
|
||||
if not rel_path then
|
||||
return false
|
||||
end
|
||||
|
||||
vim.fn.system({ 'git', '-C', repo_root, 'cat-file', '-e', revision .. ':' .. rel_path })
|
||||
return vim.v.shell_error == 0
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -15,13 +15,6 @@ function M.check()
|
|||
else
|
||||
vim.health.warn('vim-fugitive not detected (required for unified diff highlighting)')
|
||||
end
|
||||
|
||||
local lib = require('diffs.lib')
|
||||
if lib.has_lib() then
|
||||
vim.health.ok('libvscode_diff found at ' .. lib.lib_path())
|
||||
else
|
||||
vim.health.info('libvscode_diff not found (optional, using native vim.diff fallback)')
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
|
|||
|
|
@ -1,39 +1,6 @@
|
|||
local M = {}
|
||||
|
||||
local dbg = require('diffs.log').dbg
|
||||
local diff = require('diffs.diff')
|
||||
|
||||
---@param filepath string
|
||||
---@param from_line integer
|
||||
---@param count integer
|
||||
---@return string[]
|
||||
local function read_line_range(filepath, from_line, count)
|
||||
if count <= 0 then
|
||||
return {}
|
||||
end
|
||||
local f = io.open(filepath, 'r')
|
||||
if not f then
|
||||
return {}
|
||||
end
|
||||
local result = {}
|
||||
local line_num = 0
|
||||
for line in f:lines() do
|
||||
line_num = line_num + 1
|
||||
if line_num >= from_line then
|
||||
table.insert(result, line)
|
||||
if #result >= count then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
f:close()
|
||||
return result
|
||||
end
|
||||
|
||||
local PRIORITY_CLEAR = 198
|
||||
local PRIORITY_SYNTAX = 199
|
||||
local PRIORITY_LINE_BG = 200
|
||||
local PRIORITY_CHAR_BG = 201
|
||||
|
||||
---@param bufnr integer
|
||||
---@param ns integer
|
||||
|
|
@ -41,15 +8,9 @@ local PRIORITY_CHAR_BG = 201
|
|||
---@param col_offset integer
|
||||
---@param text string
|
||||
---@param lang string
|
||||
---@param context_lines? string[]
|
||||
---@return integer
|
||||
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines)
|
||||
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)
|
||||
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang)
|
||||
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, text, lang)
|
||||
if not ok or not parser_obj then
|
||||
return 0
|
||||
end
|
||||
|
|
@ -67,26 +28,22 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_l
|
|||
local extmark_count = 0
|
||||
local header_line = hunk.start_line - 1
|
||||
|
||||
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
|
||||
for id, node, _ in query:iter_captures(trees[1]:root(), text) do
|
||||
local capture_name = '@' .. query.captures[id]
|
||||
local sr, sc, er, ec = node:range()
|
||||
|
||||
local buf_sr = header_line
|
||||
local buf_er = header_line
|
||||
local buf_sc = col_offset + sc
|
||||
local buf_ec = col_offset + ec
|
||||
local buf_sr = header_line + sr
|
||||
local buf_er = header_line + er
|
||||
local buf_sc = col_offset + sc
|
||||
local buf_ec = col_offset + ec
|
||||
|
||||
local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX
|
||||
|
||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
|
||||
end_row = buf_er,
|
||||
end_col = buf_ec,
|
||||
hl_group = capture_name,
|
||||
priority = priority,
|
||||
})
|
||||
extmark_count = extmark_count + 1
|
||||
end
|
||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
|
||||
end_row = buf_er,
|
||||
end_col = buf_ec,
|
||||
hl_group = capture_name,
|
||||
priority = 200,
|
||||
})
|
||||
extmark_count = extmark_count + 1
|
||||
end
|
||||
|
||||
return extmark_count
|
||||
|
|
@ -94,25 +51,21 @@ end
|
|||
|
||||
---@class diffs.HunkOpts
|
||||
---@field hide_prefix boolean
|
||||
---@field treesitter diffs.TreesitterConfig
|
||||
---@field vim diffs.VimConfig
|
||||
---@field highlights diffs.Highlights
|
||||
|
||||
---@param bufnr integer
|
||||
---@param ns integer
|
||||
---@param hunk diffs.Hunk
|
||||
---@param code_lines string[]
|
||||
---@param lang string
|
||||
---@param line_map table<integer, integer>
|
||||
---@param col_offset integer
|
||||
---@param covered_lines? table<integer, true>
|
||||
---@return integer
|
||||
local function highlight_treesitter(
|
||||
bufnr,
|
||||
ns,
|
||||
code_lines,
|
||||
lang,
|
||||
line_map,
|
||||
col_offset,
|
||||
covered_lines
|
||||
)
|
||||
local function highlight_treesitter(bufnr, ns, hunk, code_lines)
|
||||
local lang = hunk.lang
|
||||
if not lang then
|
||||
return 0
|
||||
end
|
||||
|
||||
local code = table.concat(code_lines, '\n')
|
||||
if code == '' then
|
||||
return 0
|
||||
|
|
@ -124,50 +77,50 @@ local function highlight_treesitter(
|
|||
return 0
|
||||
end
|
||||
|
||||
local trees = parser_obj:parse(true)
|
||||
local trees = parser_obj:parse()
|
||||
if not trees or #trees == 0 then
|
||||
dbg('parse returned no trees for lang: %s', lang)
|
||||
return 0
|
||||
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
|
||||
|
||||
local extmark_count = 0
|
||||
parser_obj:for_each_tree(function(tree, ltree)
|
||||
local tree_lang = ltree:lang()
|
||||
local query = vim.treesitter.query.get(tree_lang, 'highlights')
|
||||
if not query then
|
||||
return
|
||||
end
|
||||
for id, node, _ in query:iter_captures(trees[1]:root(), code) do
|
||||
local capture_name = '@' .. query.captures[id]
|
||||
local sr, sc, er, ec = node:range()
|
||||
|
||||
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 buf_sr = hunk.start_line + sr
|
||||
local buf_er = hunk.start_line + er
|
||||
local buf_sc = sc + 1
|
||||
local buf_ec = ec + 1
|
||||
|
||||
local buf_sr = line_map[sr]
|
||||
if buf_sr then
|
||||
local buf_er = line_map[er] or buf_sr
|
||||
|
||||
local buf_sc = sc + col_offset
|
||||
local buf_ec = ec + col_offset
|
||||
|
||||
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, {
|
||||
end_row = buf_er,
|
||||
end_col = buf_ec,
|
||||
hl_group = capture_name,
|
||||
priority = priority,
|
||||
})
|
||||
extmark_count = extmark_count + 1
|
||||
if covered_lines then
|
||||
covered_lines[buf_sr] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, {
|
||||
end_row = buf_er,
|
||||
end_col = buf_ec,
|
||||
hl_group = capture_name,
|
||||
priority = 200,
|
||||
})
|
||||
extmark_count = extmark_count + 1
|
||||
end
|
||||
|
||||
return extmark_count
|
||||
end
|
||||
|
|
@ -217,10 +170,8 @@ end
|
|||
---@param ns integer
|
||||
---@param hunk diffs.Hunk
|
||||
---@param code_lines string[]
|
||||
---@param covered_lines? table<integer, true>
|
||||
---@param leading_offset? integer
|
||||
---@return integer
|
||||
local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset)
|
||||
local function highlight_vim_syntax(bufnr, ns, hunk, code_lines)
|
||||
local ft = hunk.ft
|
||||
if not ft then
|
||||
return 0
|
||||
|
|
@ -230,8 +181,6 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines,
|
|||
return 0
|
||||
end
|
||||
|
||||
leading_offset = leading_offset or 0
|
||||
|
||||
local scratch = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_lines(scratch, 0, -1, false, code_lines)
|
||||
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scratch })
|
||||
|
|
@ -258,22 +207,15 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines,
|
|||
|
||||
vim.api.nvim_buf_delete(scratch, { force = true })
|
||||
|
||||
local hunk_line_count = #hunk.lines
|
||||
local extmark_count = 0
|
||||
for _, span in ipairs(spans) do
|
||||
local adj = span.line - leading_offset
|
||||
if adj >= 1 and adj <= hunk_line_count then
|
||||
local buf_line = hunk.start_line + adj - 1
|
||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
|
||||
end_col = span.col_end,
|
||||
hl_group = span.hl_name,
|
||||
priority = PRIORITY_SYNTAX,
|
||||
})
|
||||
extmark_count = extmark_count + 1
|
||||
if covered_lines then
|
||||
covered_lines[buf_line] = true
|
||||
end
|
||||
end
|
||||
local buf_line = hunk.start_line + span.line - 1
|
||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, {
|
||||
end_col = span.col_end,
|
||||
hl_group = span.hl_name,
|
||||
priority = 200,
|
||||
})
|
||||
extmark_count = extmark_count + 1
|
||||
end
|
||||
|
||||
return extmark_count
|
||||
|
|
@ -284,10 +226,10 @@ end
|
|||
---@param hunk diffs.Hunk
|
||||
---@param opts diffs.HunkOpts
|
||||
function M.highlight_hunk(bufnr, ns, hunk, opts)
|
||||
local use_ts = hunk.lang and opts.highlights.treesitter.enabled
|
||||
local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled
|
||||
local use_ts = hunk.lang and opts.treesitter.enabled
|
||||
local use_vim = not use_ts and hunk.ft and opts.vim.enabled
|
||||
|
||||
local max_lines = use_ts and opts.highlights.treesitter.max_lines or opts.highlights.vim.max_lines
|
||||
local max_lines = use_ts and opts.treesitter.max_lines or opts.vim.max_lines
|
||||
if (use_ts or use_vim) and #hunk.lines > max_lines then
|
||||
dbg(
|
||||
'skipping hunk %s:%d (%d lines > %d max)',
|
||||
|
|
@ -300,151 +242,24 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
|
|||
use_vim = false
|
||||
end
|
||||
|
||||
---@type table<integer, true>
|
||||
local covered_lines = {}
|
||||
local apply_syntax = use_ts or use_vim
|
||||
|
||||
local ctx_cfg = opts.highlights.context
|
||||
local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0
|
||||
local leading = {}
|
||||
local trailing = {}
|
||||
if (use_ts or use_vim) and context > 0 and hunk.file_new_start and hunk.repo_root then
|
||||
local filepath = vim.fs.joinpath(hunk.repo_root, hunk.filename)
|
||||
local lead_from = math.max(1, hunk.file_new_start - context)
|
||||
local lead_count = hunk.file_new_start - lead_from
|
||||
if lead_count > 0 then
|
||||
leading = read_line_range(filepath, lead_from, lead_count)
|
||||
---@type string[]
|
||||
local code_lines = {}
|
||||
if apply_syntax then
|
||||
for _, line in ipairs(hunk.lines) do
|
||||
table.insert(code_lines, line:sub(2))
|
||||
end
|
||||
local trail_from = hunk.file_new_start + (hunk.file_new_count or 0)
|
||||
trailing = read_line_range(filepath, trail_from, context)
|
||||
end
|
||||
|
||||
local extmark_count = 0
|
||||
if use_ts then
|
||||
---@type string[]
|
||||
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
|
||||
extmark_count = highlight_treesitter(bufnr, ns, hunk, code_lines)
|
||||
elseif use_vim then
|
||||
---@type string[]
|
||||
local code_lines = {}
|
||||
for _, pad_line in ipairs(leading) do
|
||||
table.insert(code_lines, pad_line)
|
||||
end
|
||||
for _, line in ipairs(hunk.lines) do
|
||||
table.insert(code_lines, line:sub(2))
|
||||
end
|
||||
for _, pad_line in ipairs(trailing) do
|
||||
table.insert(code_lines, pad_line)
|
||||
end
|
||||
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, #leading)
|
||||
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines)
|
||||
end
|
||||
|
||||
if
|
||||
hunk.header_start_line
|
||||
and hunk.header_lines
|
||||
and #hunk.header_lines > 0
|
||||
and opts.highlights.treesitter.enabled
|
||||
then
|
||||
---@type table<integer, integer>
|
||||
local header_map = {}
|
||||
for i = 0, #hunk.header_lines - 1 do
|
||||
header_map[i] = hunk.header_start_line - 1 + i
|
||||
end
|
||||
extmark_count = extmark_count
|
||||
+ highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0)
|
||||
end
|
||||
|
||||
---@type diffs.IntraChanges?
|
||||
local intra = nil
|
||||
local intra_cfg = opts.highlights.intra
|
||||
if intra_cfg and intra_cfg.enabled and #hunk.lines <= intra_cfg.max_lines then
|
||||
dbg('computing intra for hunk %s:%d (%d lines)', hunk.filename, hunk.start_line, #hunk.lines)
|
||||
intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm)
|
||||
if intra then
|
||||
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
|
||||
|
||||
---@type table<integer, diffs.CharSpan[]>
|
||||
local char_spans_by_line = {}
|
||||
if intra then
|
||||
for _, span in ipairs(intra.add_spans) do
|
||||
if not char_spans_by_line[span.line] then
|
||||
char_spans_by_line[span.line] = {}
|
||||
end
|
||||
table.insert(char_spans_by_line[span.line], span)
|
||||
end
|
||||
for _, span in ipairs(intra.del_spans) do
|
||||
if not char_spans_by_line[span.line] then
|
||||
char_spans_by_line[span.line] = {}
|
||||
end
|
||||
table.insert(char_spans_by_line[span.line], span)
|
||||
end
|
||||
end
|
||||
local syntax_applied = extmark_count > 0
|
||||
|
||||
for i, line in ipairs(hunk.lines) do
|
||||
local buf_line = hunk.start_line + i - 1
|
||||
|
|
@ -463,52 +278,24 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
|
|||
})
|
||||
end
|
||||
|
||||
if line_len > 1 and covered_lines[buf_line] then
|
||||
if opts.highlights.background and is_diff_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, {
|
||||
end_col = line_len,
|
||||
hl_group = 'DiffsClear',
|
||||
priority = PRIORITY_CLEAR,
|
||||
hl_group = 'Normal',
|
||||
priority = 199,
|
||||
})
|
||||
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
|
||||
local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText'
|
||||
for _, span in ipairs(char_spans_by_line[i]) do
|
||||
dbg(
|
||||
'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"',
|
||||
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,
|
||||
hl_group = char_hl,
|
||||
priority = PRIORITY_CHAR_BG,
|
||||
})
|
||||
if not ok then
|
||||
dbg('char extmark FAILED: %s', err)
|
||||
end
|
||||
extmark_count = extmark_count + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
---@class diffs.Highlights
|
||||
---@field background boolean
|
||||
---@field gutter boolean
|
||||
|
||||
---@class diffs.TreesitterConfig
|
||||
---@field enabled boolean
|
||||
---@field max_lines integer
|
||||
|
|
@ -6,54 +10,19 @@
|
|||
---@field enabled boolean
|
||||
---@field max_lines integer
|
||||
|
||||
---@class diffs.IntraConfig
|
||||
---@field enabled boolean
|
||||
---@field algorithm string
|
||||
---@field max_lines integer
|
||||
|
||||
---@class diffs.ContextConfig
|
||||
---@field enabled boolean
|
||||
---@field lines integer
|
||||
|
||||
---@class diffs.Highlights
|
||||
---@field background boolean
|
||||
---@field gutter boolean
|
||||
---@field blend_alpha? number
|
||||
---@field overrides? table<string, table>
|
||||
---@field context diffs.ContextConfig
|
||||
---@field treesitter diffs.TreesitterConfig
|
||||
---@field vim diffs.VimConfig
|
||||
---@field intra diffs.IntraConfig
|
||||
|
||||
---@class diffs.FugitiveConfig
|
||||
---@field horizontal 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
|
||||
---@field enabled boolean
|
||||
---@field debug boolean
|
||||
---@field debounce_ms integer
|
||||
---@field hide_prefix boolean
|
||||
---@field treesitter diffs.TreesitterConfig
|
||||
---@field vim diffs.VimConfig
|
||||
---@field highlights diffs.Highlights
|
||||
---@field fugitive diffs.FugitiveConfig
|
||||
---@field conflict diffs.ConflictConfig
|
||||
|
||||
---@class diffs
|
||||
---@field attach fun(bufnr?: integer)
|
||||
---@field refresh fun(bufnr?: integer)
|
||||
---@field setup fun(opts?: diffs.Config)
|
||||
local M = {}
|
||||
|
||||
local highlight = require('diffs.highlight')
|
||||
|
|
@ -96,54 +65,27 @@ end
|
|||
|
||||
---@type diffs.Config
|
||||
local default_config = {
|
||||
enabled = true,
|
||||
debug = false,
|
||||
debounce_ms = 0,
|
||||
hide_prefix = false,
|
||||
treesitter = {
|
||||
enabled = true,
|
||||
max_lines = 500,
|
||||
},
|
||||
vim = {
|
||||
enabled = false,
|
||||
max_lines = 200,
|
||||
},
|
||||
highlights = {
|
||||
background = true,
|
||||
gutter = true,
|
||||
context = {
|
||||
enabled = true,
|
||||
lines = 25,
|
||||
},
|
||||
treesitter = {
|
||||
enabled = true,
|
||||
max_lines = 500,
|
||||
},
|
||||
vim = {
|
||||
enabled = false,
|
||||
max_lines = 200,
|
||||
},
|
||||
intra = {
|
||||
enabled = true,
|
||||
algorithm = 'default',
|
||||
max_lines = 500,
|
||||
},
|
||||
},
|
||||
fugitive = {
|
||||
horizontal = '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
|
||||
local config = vim.deepcopy(default_config)
|
||||
|
||||
local initialized = false
|
||||
|
||||
---@type table<integer, boolean>
|
||||
local attached_buffers = {}
|
||||
|
||||
|
|
@ -160,6 +102,10 @@ local dbg = log.dbg
|
|||
|
||||
---@param bufnr integer
|
||||
local function highlight_buffer(bufnr)
|
||||
if not config.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
return
|
||||
end
|
||||
|
|
@ -171,6 +117,8 @@ local function highlight_buffer(bufnr)
|
|||
for _, hunk in ipairs(hunks) do
|
||||
highlight.highlight_hunk(bufnr, ns, hunk, {
|
||||
hide_prefix = config.hide_prefix,
|
||||
treesitter = config.treesitter,
|
||||
vim = config.vim,
|
||||
highlights = config.highlights,
|
||||
})
|
||||
end
|
||||
|
|
@ -206,277 +154,8 @@ local function create_debounced_highlight(bufnr)
|
|||
end
|
||||
end
|
||||
|
||||
local function compute_highlight_groups()
|
||||
local normal = vim.api.nvim_get_hl(0, { name = 'Normal' })
|
||||
local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' })
|
||||
local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' })
|
||||
local diff_added = resolve_hl('diffAdded')
|
||||
local diff_removed = resolve_hl('diffRemoved')
|
||||
|
||||
local bg = normal.bg or 0x1e1e2e
|
||||
local add_bg = diff_add.bg or 0x2e4a3a
|
||||
local del_bg = diff_delete.bg or 0x4a2e3a
|
||||
local add_fg = diff_added.fg or diff_add.fg or 0x80c080
|
||||
local del_fg = diff_removed.fg or diff_delete.fg or 0xc08080
|
||||
|
||||
local blended_add = blend_color(add_bg, bg, 0.4)
|
||||
local blended_del = blend_color(del_bg, bg, 0.4)
|
||||
|
||||
local alpha = config.highlights.blend_alpha or 0.6
|
||||
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, 'DiffsDelete', { default = true, bg = blended_del })
|
||||
vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add })
|
||||
vim.api.nvim_set_hl(
|
||||
0,
|
||||
'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, '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_text = resolve_hl('DiffText')
|
||||
|
||||
vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { default = true, bg = diff_add.bg })
|
||||
vim.api.nvim_set_hl(
|
||||
0,
|
||||
'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
|
||||
|
||||
local function init()
|
||||
if initialized then
|
||||
return
|
||||
end
|
||||
initialized = true
|
||||
|
||||
local opts = vim.g.diffs or {}
|
||||
|
||||
vim.validate({
|
||||
debug = { opts.debug, 'boolean', true },
|
||||
debounce_ms = { opts.debounce_ms, 'number', true },
|
||||
hide_prefix = { opts.hide_prefix, 'boolean', true },
|
||||
highlights = { opts.highlights, 'table', true },
|
||||
})
|
||||
|
||||
if opts.highlights then
|
||||
vim.validate({
|
||||
['highlights.background'] = { opts.highlights.background, '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.vim'] = { opts.highlights.vim, '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
|
||||
vim.validate({
|
||||
['highlights.treesitter.enabled'] = { opts.highlights.treesitter.enabled, 'boolean', true },
|
||||
['highlights.treesitter.max_lines'] = {
|
||||
opts.highlights.treesitter.max_lines,
|
||||
'number',
|
||||
true,
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
if opts.highlights.vim then
|
||||
vim.validate({
|
||||
['highlights.vim.enabled'] = { opts.highlights.vim.enabled, 'boolean', true },
|
||||
['highlights.vim.max_lines'] = { opts.highlights.vim.max_lines, 'number', true },
|
||||
})
|
||||
end
|
||||
|
||||
if opts.highlights.intra then
|
||||
vim.validate({
|
||||
['highlights.intra.enabled'] = { opts.highlights.intra.enabled, 'boolean', true },
|
||||
['highlights.intra.algorithm'] = {
|
||||
opts.highlights.intra.algorithm,
|
||||
function(v)
|
||||
return v == nil or v == 'default' or v == 'vscode'
|
||||
end,
|
||||
"'default' or 'vscode'",
|
||||
},
|
||||
['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true },
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
if opts.fugitive then
|
||||
vim.validate({
|
||||
['fugitive.horizontal'] = {
|
||||
opts.fugitive.horizontal,
|
||||
function(v)
|
||||
return v == false or type(v) == 'string'
|
||||
end,
|
||||
'string or false',
|
||||
},
|
||||
['fugitive.vertical'] = {
|
||||
opts.fugitive.vertical,
|
||||
function(v)
|
||||
return v == false or type(v) == 'string'
|
||||
end,
|
||||
'string or false',
|
||||
},
|
||||
})
|
||||
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
|
||||
error('diffs: debounce_ms must be >= 0')
|
||||
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
|
||||
opts.highlights
|
||||
and opts.highlights.treesitter
|
||||
and opts.highlights.treesitter.max_lines
|
||||
and opts.highlights.treesitter.max_lines < 1
|
||||
then
|
||||
error('diffs: highlights.treesitter.max_lines must be >= 1')
|
||||
end
|
||||
if
|
||||
opts.highlights
|
||||
and opts.highlights.vim
|
||||
and opts.highlights.vim.max_lines
|
||||
and opts.highlights.vim.max_lines < 1
|
||||
then
|
||||
error('diffs: highlights.vim.max_lines must be >= 1')
|
||||
end
|
||||
if
|
||||
opts.highlights
|
||||
and opts.highlights.intra
|
||||
and opts.highlights.intra.max_lines
|
||||
and opts.highlights.intra.max_lines < 1
|
||||
then
|
||||
error('diffs: highlights.intra.max_lines must be >= 1')
|
||||
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)
|
||||
log.set_enabled(config.debug)
|
||||
|
||||
compute_highlight_groups()
|
||||
|
||||
vim.api.nvim_create_autocmd('ColorScheme', {
|
||||
callback = function()
|
||||
compute_highlight_groups()
|
||||
for bufnr, _ in pairs(attached_buffers) do
|
||||
highlight_buffer(bufnr)
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd('WinClosed', {
|
||||
callback = function(args)
|
||||
local win = tonumber(args.match)
|
||||
if win and diff_windows[win] then
|
||||
diff_windows[win] = nil
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
---@param bufnr? integer
|
||||
function M.attach(bufnr)
|
||||
init()
|
||||
bufnr = bufnr or vim.api.nvim_get_current_buf()
|
||||
|
||||
if attached_buffers[bufnr] then
|
||||
|
|
@ -525,6 +204,36 @@ function M.refresh(bufnr)
|
|||
highlight_buffer(bufnr)
|
||||
end
|
||||
|
||||
local function compute_highlight_groups()
|
||||
local normal = vim.api.nvim_get_hl(0, { name = 'Normal' })
|
||||
local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' })
|
||||
local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' })
|
||||
local diff_added = resolve_hl('diffAdded')
|
||||
local diff_removed = resolve_hl('diffRemoved')
|
||||
|
||||
local bg = normal.bg or 0x1e1e2e
|
||||
local add_bg = diff_add.bg or 0x2e4a3a
|
||||
local del_bg = diff_delete.bg or 0x4a2e3a
|
||||
local add_fg = diff_added.fg or diff_add.fg or 0x80c080
|
||||
local del_fg = diff_removed.fg or diff_delete.fg or 0xc08080
|
||||
|
||||
local blended_add = blend_color(add_bg, bg, 0.4)
|
||||
local blended_del = blend_color(del_bg, bg, 0.4)
|
||||
|
||||
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, 'DiffsAddNr', { default = true, fg = add_fg, bg = blended_add })
|
||||
vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { default = true, fg = del_fg, bg = blended_del })
|
||||
|
||||
local diff_change = resolve_hl('DiffChange')
|
||||
local diff_text = resolve_hl('DiffText')
|
||||
|
||||
vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { bg = diff_add.bg })
|
||||
vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { fg = diff_delete.fg, bg = diff_delete.bg })
|
||||
vim.api.nvim_set_hl(0, 'DiffsDiffChange', { bg = diff_change.bg })
|
||||
vim.api.nvim_set_hl(0, 'DiffsDiffText', { bg = diff_text.bg })
|
||||
end
|
||||
|
||||
local DIFF_WINHIGHLIGHT = table.concat({
|
||||
'DiffAdd:DiffsDiffAdd',
|
||||
'DiffDelete:DiffsDiffDelete',
|
||||
|
|
@ -533,7 +242,10 @@ local DIFF_WINHIGHLIGHT = table.concat({
|
|||
}, ',')
|
||||
|
||||
function M.attach_diff()
|
||||
init()
|
||||
if not config.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local tabpage = vim.api.nvim_get_current_tabpage()
|
||||
local wins = vim.api.nvim_tabpage_list_wins(tabpage)
|
||||
|
||||
|
|
@ -565,16 +277,73 @@ function M.detach_diff()
|
|||
end
|
||||
end
|
||||
|
||||
---@return diffs.FugitiveConfig
|
||||
function M.get_fugitive_config()
|
||||
init()
|
||||
return config.fugitive
|
||||
end
|
||||
---@param opts? diffs.Config
|
||||
function M.setup(opts)
|
||||
opts = opts or {}
|
||||
|
||||
---@return diffs.ConflictConfig
|
||||
function M.get_conflict_config()
|
||||
init()
|
||||
return config.conflict
|
||||
vim.validate({
|
||||
enabled = { opts.enabled, 'boolean', true },
|
||||
debug = { opts.debug, 'boolean', true },
|
||||
debounce_ms = { opts.debounce_ms, 'number', true },
|
||||
hide_prefix = { opts.hide_prefix, 'boolean', true },
|
||||
treesitter = { opts.treesitter, 'table', true },
|
||||
vim = { opts.vim, 'table', true },
|
||||
highlights = { opts.highlights, 'table', true },
|
||||
})
|
||||
|
||||
if opts.treesitter then
|
||||
vim.validate({
|
||||
['treesitter.enabled'] = { opts.treesitter.enabled, 'boolean', true },
|
||||
['treesitter.max_lines'] = { opts.treesitter.max_lines, 'number', true },
|
||||
})
|
||||
end
|
||||
|
||||
if opts.vim then
|
||||
vim.validate({
|
||||
['vim.enabled'] = { opts.vim.enabled, 'boolean', true },
|
||||
['vim.max_lines'] = { opts.vim.max_lines, 'number', true },
|
||||
})
|
||||
end
|
||||
|
||||
if opts.highlights then
|
||||
vim.validate({
|
||||
['highlights.background'] = { opts.highlights.background, 'boolean', true },
|
||||
['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true },
|
||||
})
|
||||
end
|
||||
|
||||
if opts.debounce_ms and opts.debounce_ms < 0 then
|
||||
error('diffs: debounce_ms must be >= 0')
|
||||
end
|
||||
if opts.treesitter and opts.treesitter.max_lines and opts.treesitter.max_lines < 1 then
|
||||
error('diffs: treesitter.max_lines must be >= 1')
|
||||
end
|
||||
if opts.vim and opts.vim.max_lines and opts.vim.max_lines < 1 then
|
||||
error('diffs: vim.max_lines must be >= 1')
|
||||
end
|
||||
|
||||
config = vim.tbl_deep_extend('force', default_config, opts)
|
||||
log.set_enabled(config.debug)
|
||||
|
||||
compute_highlight_groups()
|
||||
|
||||
vim.api.nvim_create_autocmd('ColorScheme', {
|
||||
callback = function()
|
||||
compute_highlight_groups()
|
||||
for bufnr, _ in pairs(attached_buffers) do
|
||||
highlight_buffer(bufnr)
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd('WinClosed', {
|
||||
callback = function(args)
|
||||
local win = tonumber(args.match)
|
||||
if win and diff_windows[win] then
|
||||
diff_windows[win] = nil
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
|
|||
|
|
@ -1,214 +0,0 @@
|
|||
local M = {}
|
||||
|
||||
local dbg = require('diffs.log').dbg
|
||||
|
||||
---@type table?
|
||||
local cached_handle = nil
|
||||
|
||||
---@type boolean
|
||||
local download_in_progress = false
|
||||
|
||||
---@return string
|
||||
local function get_os()
|
||||
local os_name = jit.os:lower()
|
||||
if os_name == 'osx' then
|
||||
return 'macos'
|
||||
end
|
||||
return os_name
|
||||
end
|
||||
|
||||
---@return string
|
||||
local function get_arch()
|
||||
return jit.arch:lower()
|
||||
end
|
||||
|
||||
---@return string
|
||||
local function get_ext()
|
||||
local os_name = jit.os:lower()
|
||||
if os_name == 'windows' then
|
||||
return 'dll'
|
||||
elseif os_name == 'osx' then
|
||||
return 'dylib'
|
||||
end
|
||||
return 'so'
|
||||
end
|
||||
|
||||
---@return string
|
||||
local function lib_dir()
|
||||
return vim.fn.stdpath('data') .. '/diffs/lib'
|
||||
end
|
||||
|
||||
---@return string
|
||||
local function lib_path()
|
||||
return lib_dir() .. '/libvscode_diff.' .. get_ext()
|
||||
end
|
||||
|
||||
---@return string
|
||||
local function version_path()
|
||||
return lib_dir() .. '/version'
|
||||
end
|
||||
|
||||
local EXPECTED_VERSION = '2.18.0'
|
||||
|
||||
---@return boolean
|
||||
function M.has_lib()
|
||||
if cached_handle then
|
||||
return true
|
||||
end
|
||||
return vim.fn.filereadable(lib_path()) == 1
|
||||
end
|
||||
|
||||
---@return string
|
||||
function M.lib_path()
|
||||
return lib_path()
|
||||
end
|
||||
|
||||
---@return table?
|
||||
function M.load()
|
||||
if cached_handle then
|
||||
return cached_handle
|
||||
end
|
||||
|
||||
local path = lib_path()
|
||||
if vim.fn.filereadable(path) ~= 1 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local ffi = require('ffi')
|
||||
|
||||
ffi.cdef([[
|
||||
typedef struct {
|
||||
int start_line;
|
||||
int end_line;
|
||||
} DiffsLineRange;
|
||||
|
||||
typedef struct {
|
||||
int start_line;
|
||||
int start_col;
|
||||
int end_line;
|
||||
int end_col;
|
||||
} DiffsCharRange;
|
||||
|
||||
typedef struct {
|
||||
DiffsCharRange original;
|
||||
DiffsCharRange modified;
|
||||
} DiffsRangeMapping;
|
||||
|
||||
typedef struct {
|
||||
DiffsLineRange original;
|
||||
DiffsLineRange modified;
|
||||
DiffsRangeMapping* inner_changes;
|
||||
int inner_change_count;
|
||||
} DiffsDetailedMapping;
|
||||
|
||||
typedef struct {
|
||||
DiffsDetailedMapping* mappings;
|
||||
int count;
|
||||
int capacity;
|
||||
} DiffsDetailedMappingArray;
|
||||
|
||||
typedef struct {
|
||||
DiffsLineRange original;
|
||||
DiffsLineRange modified;
|
||||
} DiffsMovedText;
|
||||
|
||||
typedef struct {
|
||||
DiffsMovedText* moves;
|
||||
int count;
|
||||
int capacity;
|
||||
} DiffsMovedTextArray;
|
||||
|
||||
typedef struct {
|
||||
DiffsDetailedMappingArray changes;
|
||||
DiffsMovedTextArray moves;
|
||||
bool hit_timeout;
|
||||
} DiffsLinesDiff;
|
||||
|
||||
typedef struct {
|
||||
bool ignore_trim_whitespace;
|
||||
int max_computation_time_ms;
|
||||
bool compute_moves;
|
||||
bool extend_to_subwords;
|
||||
} DiffsDiffOptions;
|
||||
|
||||
DiffsLinesDiff* compute_diff(
|
||||
const char** original_lines,
|
||||
int original_count,
|
||||
const char** modified_lines,
|
||||
int modified_count,
|
||||
const DiffsDiffOptions* options
|
||||
);
|
||||
|
||||
void free_lines_diff(DiffsLinesDiff* diff);
|
||||
]])
|
||||
|
||||
local ok, handle = pcall(ffi.load, path)
|
||||
if not ok then
|
||||
dbg('failed to load libvscode_diff: %s', handle)
|
||||
return nil
|
||||
end
|
||||
|
||||
cached_handle = handle
|
||||
return handle
|
||||
end
|
||||
|
||||
---@param callback fun(handle: table?)
|
||||
function M.ensure(callback)
|
||||
if cached_handle then
|
||||
callback(cached_handle)
|
||||
return
|
||||
end
|
||||
|
||||
if M.has_lib() then
|
||||
callback(M.load())
|
||||
return
|
||||
end
|
||||
|
||||
if download_in_progress then
|
||||
dbg('download already in progress')
|
||||
callback(nil)
|
||||
return
|
||||
end
|
||||
|
||||
download_in_progress = true
|
||||
|
||||
local dir = lib_dir()
|
||||
vim.fn.mkdir(dir, 'p')
|
||||
|
||||
local os_name = get_os()
|
||||
local arch = get_arch()
|
||||
local ext = get_ext()
|
||||
local filename = ('libvscode_diff_%s_%s_%s.%s'):format(os_name, arch, EXPECTED_VERSION, ext)
|
||||
local url = ('https://github.com/esmuellert/vscode-diff.nvim/releases/download/v%s/%s'):format(
|
||||
EXPECTED_VERSION,
|
||||
filename
|
||||
)
|
||||
|
||||
local dest = lib_path()
|
||||
vim.notify('[diffs] downloading libvscode_diff...', vim.log.levels.INFO)
|
||||
|
||||
local cmd = { 'curl', '-fSL', '-o', dest, url }
|
||||
|
||||
vim.system(cmd, {}, function(result)
|
||||
download_in_progress = false
|
||||
vim.schedule(function()
|
||||
if result.code ~= 0 then
|
||||
vim.notify('[diffs] failed to download libvscode_diff', vim.log.levels.WARN)
|
||||
dbg('curl failed: %s', result.stderr or '')
|
||||
callback(nil)
|
||||
return
|
||||
end
|
||||
|
||||
local f = io.open(version_path(), 'w')
|
||||
if f then
|
||||
f:write(EXPECTED_VERSION)
|
||||
f:close()
|
||||
end
|
||||
|
||||
vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO)
|
||||
callback(M.load())
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -13,7 +13,7 @@ function M.dbg(msg, ...)
|
|||
if not enabled then
|
||||
return
|
||||
end
|
||||
vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG)
|
||||
vim.notify('[diffs] ' .. string.format(msg, ...), vim.log.levels.DEBUG)
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
|
|||
|
|
@ -6,75 +6,19 @@
|
|||
---@field header_context string?
|
||||
---@field header_context_col integer?
|
||||
---@field lines string[]
|
||||
---@field header_start_line integer?
|
||||
---@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 dbg = require('diffs.log').dbg
|
||||
|
||||
---@param filepath string
|
||||
---@param n integer
|
||||
---@return string[]?
|
||||
local function read_first_lines(filepath, n)
|
||||
local f = io.open(filepath, 'r')
|
||||
if not f then
|
||||
return nil
|
||||
end
|
||||
local lines = {}
|
||||
for _ = 1, n do
|
||||
local line = f:read('*l')
|
||||
if not line then
|
||||
break
|
||||
end
|
||||
table.insert(lines, line)
|
||||
end
|
||||
f:close()
|
||||
return #lines > 0 and lines or nil
|
||||
end
|
||||
|
||||
---@param filename string
|
||||
---@param repo_root string?
|
||||
---@return string?
|
||||
local function get_ft_from_filename(filename, repo_root)
|
||||
if repo_root then
|
||||
local full_path = vim.fs.joinpath(repo_root, filename)
|
||||
|
||||
local buf = vim.fn.bufnr(full_path)
|
||||
if buf ~= -1 then
|
||||
local ft = vim.api.nvim_get_option_value('filetype', { buf = buf })
|
||||
if ft and ft ~= '' then
|
||||
dbg('filetype from existing buffer %d: %s', buf, ft)
|
||||
return ft
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function get_ft_from_filename(filename)
|
||||
local ft = vim.filetype.match({ filename = filename })
|
||||
if ft then
|
||||
dbg('filetype from filename: %s', ft)
|
||||
return ft
|
||||
if not ft then
|
||||
dbg('no filetype for: %s', filename)
|
||||
end
|
||||
|
||||
if repo_root then
|
||||
local full_path = vim.fs.joinpath(repo_root, filename)
|
||||
local contents = read_first_lines(full_path, 10)
|
||||
if contents then
|
||||
ft = vim.filetype.match({ filename = filename, contents = contents })
|
||||
if ft then
|
||||
dbg('filetype from file content: %s', ft)
|
||||
return ft
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
dbg('no filetype for: %s', filename)
|
||||
return nil
|
||||
return ft
|
||||
end
|
||||
|
||||
---@param ft string
|
||||
|
|
@ -93,27 +37,10 @@ local function get_lang_from_ft(ft)
|
|||
return nil
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return string?
|
||||
local function get_repo_root(bufnr)
|
||||
local ok, repo_root = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_repo_root')
|
||||
if ok and repo_root then
|
||||
return repo_root
|
||||
end
|
||||
|
||||
local ok2, git_dir = pcall(vim.api.nvim_buf_get_var, bufnr, 'git_dir')
|
||||
if ok2 and git_dir then
|
||||
return vim.fn.fnamemodify(git_dir, ':h')
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return diffs.Hunk[]
|
||||
function M.parse_buffer(bufnr)
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local repo_root = get_repo_root(bufnr)
|
||||
---@type diffs.Hunk[]
|
||||
local hunks = {}
|
||||
|
||||
|
|
@ -131,24 +58,10 @@ function M.parse_buffer(bufnr)
|
|||
local hunk_header_context_col = nil
|
||||
---@type string[]
|
||||
local hunk_lines = {}
|
||||
---@type integer?
|
||||
local hunk_count = nil
|
||||
---@type integer?
|
||||
local header_start = nil
|
||||
---@type string[]
|
||||
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()
|
||||
if hunk_start and #hunk_lines > 0 then
|
||||
local hunk = {
|
||||
if hunk_start and #hunk_lines > 0 and (current_lang or current_ft) then
|
||||
table.insert(hunks, {
|
||||
filename = current_filename,
|
||||
ft = current_ft,
|
||||
lang = current_lang,
|
||||
|
|
@ -156,26 +69,12 @@ function M.parse_buffer(bufnr)
|
|||
header_context = hunk_header_context,
|
||||
header_context_col = hunk_header_context_col,
|
||||
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
|
||||
hunk.header_start_line = header_start
|
||||
hunk.header_lines = header_lines
|
||||
end
|
||||
table.insert(hunks, hunk)
|
||||
})
|
||||
end
|
||||
hunk_start = nil
|
||||
hunk_header_context = nil
|
||||
hunk_header_context_col = nil
|
||||
hunk_lines = {}
|
||||
file_old_start = nil
|
||||
file_old_count = nil
|
||||
file_new_start = nil
|
||||
file_new_count = nil
|
||||
end
|
||||
|
||||
for i, line in ipairs(lines) do
|
||||
|
|
@ -183,34 +82,21 @@ function M.parse_buffer(bufnr)
|
|||
if filename then
|
||||
flush_hunk()
|
||||
current_filename = filename
|
||||
current_ft = get_ft_from_filename(filename, repo_root)
|
||||
current_ft = get_ft_from_filename(filename)
|
||||
current_lang = current_ft and get_lang_from_ft(current_ft) or nil
|
||||
if current_lang then
|
||||
dbg('file: %s -> lang: %s', filename, current_lang)
|
||||
elseif current_ft then
|
||||
dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft)
|
||||
end
|
||||
hunk_count = 0
|
||||
header_start = i
|
||||
header_lines = {}
|
||||
elseif line:match('^@@.-@@') then
|
||||
flush_hunk()
|
||||
hunk_start = i
|
||||
local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
|
||||
if hs then
|
||||
file_old_start = tonumber(hs)
|
||||
file_old_count = tonumber(hc) or 1
|
||||
file_new_start = tonumber(hs2)
|
||||
file_new_count = tonumber(hc2) or 1
|
||||
end
|
||||
local prefix, context = line:match('^(@@.-@@%s*)(.*)')
|
||||
if context and context ~= '' then
|
||||
hunk_header_context = context
|
||||
hunk_header_context_col = #prefix
|
||||
end
|
||||
if hunk_count then
|
||||
hunk_count = hunk_count + 1
|
||||
end
|
||||
elseif hunk_start then
|
||||
local prefix = line:sub(1, 1)
|
||||
if prefix == ' ' or prefix == '+' or prefix == '-' then
|
||||
|
|
@ -226,12 +112,8 @@ function M.parse_buffer(bufnr)
|
|||
current_filename = nil
|
||||
current_ft = nil
|
||||
current_lang = nil
|
||||
header_start = nil
|
||||
end
|
||||
end
|
||||
if header_start and not hunk_start then
|
||||
table.insert(header_lines, line)
|
||||
end
|
||||
end
|
||||
|
||||
flush_hunk()
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ if vim.g.loaded_diffs then
|
|||
end
|
||||
vim.g.loaded_diffs = 1
|
||||
|
||||
require('diffs.commands').setup()
|
||||
|
||||
vim.api.nvim_create_autocmd('FileType', {
|
||||
pattern = { 'fugitive', 'git' },
|
||||
callback = function(args)
|
||||
|
|
@ -13,29 +11,6 @@ vim.api.nvim_create_autocmd('FileType', {
|
|||
return
|
||||
end
|
||||
diffs.attach(args.buf)
|
||||
|
||||
if args.match == 'fugitive' then
|
||||
local fugitive_config = diffs.get_fugitive_config()
|
||||
if fugitive_config.horizontal or fugitive_config.vertical then
|
||||
require('diffs.fugitive').setup_keymaps(args.buf, fugitive_config)
|
||||
end
|
||||
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,
|
||||
})
|
||||
|
||||
|
|
@ -49,36 +24,3 @@ vim.api.nvim_create_autocmd('OptionSet', {
|
|||
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' })
|
||||
|
|
|
|||
65
scripts/ci.sh
Executable file
65
scripts/ci.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BOLD='\033[1m'
|
||||
RESET='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
run_job() {
|
||||
local name=$1
|
||||
shift
|
||||
local log="$tmpdir/$name.log"
|
||||
if "$@" >"$log" 2>&1; then
|
||||
echo -e "${GREEN}✓${RESET} $name"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗${RESET} $name"
|
||||
cat "$log"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo -e "${BOLD}Running CI jobs in parallel...${RESET}"
|
||||
echo
|
||||
|
||||
pids=()
|
||||
jobs_names=()
|
||||
|
||||
run_job "stylua" stylua --check . &
|
||||
pids+=($!); jobs_names+=("stylua")
|
||||
|
||||
run_job "selene" selene --display-style quiet . &
|
||||
pids+=($!); jobs_names+=("selene")
|
||||
|
||||
run_job "prettier" prettier --check . &
|
||||
pids+=($!); jobs_names+=("prettier")
|
||||
|
||||
run_job "busted" env \
|
||||
LUA_PATH="/usr/share/lua/5.1/?.lua;/usr/share/lua/5.1/?/init.lua;/usr/lib/lua/5.1/?.lua;/usr/lib/lua/5.1/?/init.lua;;" \
|
||||
LUA_CPATH="/usr/lib/lua/5.1/?.so;;" \
|
||||
nvim -l /usr/lib/luarocks/rocks-5.1/busted/2.3.0-1/bin/busted --verbose spec/ &
|
||||
pids+=($!); jobs_names+=("busted")
|
||||
|
||||
failed=0
|
||||
for i in "${!pids[@]}"; do
|
||||
if ! wait "${pids[$i]}"; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
if [ "$failed" -eq 0 ]; then
|
||||
echo -e "${GREEN}${BOLD}All jobs passed.${RESET}"
|
||||
else
|
||||
echo -e "${RED}${BOLD}Some jobs failed.${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
require('spec.helpers')
|
||||
|
||||
local commands = require('diffs.commands')
|
||||
|
||||
describe('commands', function()
|
||||
describe('setup', function()
|
||||
it('registers Gdiff, Gvdiff, and Ghdiff commands', function()
|
||||
commands.setup()
|
||||
local cmds = vim.api.nvim_get_commands({})
|
||||
assert.is_not_nil(cmds.Gdiff)
|
||||
assert.is_not_nil(cmds.Gvdiff)
|
||||
assert.is_not_nil(cmds.Ghdiff)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('unified diff generation', function()
|
||||
local old_lines = { 'local M = {}', 'return M' }
|
||||
local new_lines = { 'local M = {}', 'local x = 1', 'return M' }
|
||||
local diff_fn = vim.text and vim.text.diff or vim.diff
|
||||
|
||||
it('generates valid unified diff', function()
|
||||
local old_content = table.concat(old_lines, '\n')
|
||||
local new_content = table.concat(new_lines, '\n')
|
||||
local diff_output = diff_fn(old_content, new_content, {
|
||||
result_type = 'unified',
|
||||
ctxlen = 3,
|
||||
})
|
||||
assert.is_not_nil(diff_output)
|
||||
assert.is_true(diff_output:find('@@ ') ~= nil)
|
||||
assert.is_true(diff_output:find('+local x = 1') ~= nil)
|
||||
end)
|
||||
|
||||
it('returns empty for identical content', function()
|
||||
local content = table.concat(old_lines, '\n')
|
||||
local diff_output = diff_fn(content, content, {
|
||||
result_type = 'unified',
|
||||
ctxlen = 3,
|
||||
})
|
||||
assert.are.equal('', diff_output)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('find_hunk_line', function()
|
||||
it('finds matching @@ header and returns target line', function()
|
||||
local diff_lines = {
|
||||
'diff --git a/file.lua b/file.lua',
|
||||
'--- a/file.lua',
|
||||
'+++ b/file.lua',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' local M = {}',
|
||||
'+local new = true',
|
||||
' return M',
|
||||
}
|
||||
local hunk_position = {
|
||||
hunk_header = '@@ -1,3 +1,4 @@',
|
||||
offset = 2,
|
||||
}
|
||||
local target_line = commands.find_hunk_line(diff_lines, hunk_position)
|
||||
assert.equals(6, target_line)
|
||||
end)
|
||||
|
||||
it('returns nil when hunk header not found', function()
|
||||
local diff_lines = {
|
||||
'diff --git a/file.lua b/file.lua',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' local M = {}',
|
||||
}
|
||||
local hunk_position = {
|
||||
hunk_header = '@@ -99,3 +99,4 @@',
|
||||
offset = 1,
|
||||
}
|
||||
local target_line = commands.find_hunk_line(diff_lines, hunk_position)
|
||||
assert.is_nil(target_line)
|
||||
end)
|
||||
|
||||
it('handles multiple hunks and finds correct one', function()
|
||||
local diff_lines = {
|
||||
'diff --git a/file.lua b/file.lua',
|
||||
'--- a/file.lua',
|
||||
'+++ b/file.lua',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' local M = {}',
|
||||
'+local x = 1',
|
||||
' ',
|
||||
'@@ -10,3 +11,4 @@',
|
||||
' function M.foo()',
|
||||
'+ print("hello")',
|
||||
' end',
|
||||
}
|
||||
local hunk_position = {
|
||||
hunk_header = '@@ -10,3 +11,4 @@',
|
||||
offset = 2,
|
||||
}
|
||||
local target_line = commands.find_hunk_line(diff_lines, hunk_position)
|
||||
assert.equals(10, target_line)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
|
@ -1,688 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
require('spec.helpers')
|
||||
local diff = require('diffs.diff')
|
||||
|
||||
describe('diff', function()
|
||||
describe('extract_change_groups', function()
|
||||
it('returns empty for all context lines', function()
|
||||
local groups = diff.extract_change_groups({ ' line1', ' line2', ' line3' })
|
||||
assert.are.equal(0, #groups)
|
||||
end)
|
||||
|
||||
it('returns empty for pure additions', function()
|
||||
local groups = diff.extract_change_groups({ '+line1', '+line2' })
|
||||
assert.are.equal(0, #groups)
|
||||
end)
|
||||
|
||||
it('returns empty for pure deletions', function()
|
||||
local groups = diff.extract_change_groups({ '-line1', '-line2' })
|
||||
assert.are.equal(0, #groups)
|
||||
end)
|
||||
|
||||
it('extracts single change group', function()
|
||||
local groups = diff.extract_change_groups({
|
||||
' context',
|
||||
'-old line',
|
||||
'+new line',
|
||||
' context',
|
||||
})
|
||||
assert.are.equal(1, #groups)
|
||||
assert.are.equal(1, #groups[1].del_lines)
|
||||
assert.are.equal(1, #groups[1].add_lines)
|
||||
assert.are.equal('old line', groups[1].del_lines[1].text)
|
||||
assert.are.equal('new line', groups[1].add_lines[1].text)
|
||||
end)
|
||||
|
||||
it('extracts multiple change groups separated by context', function()
|
||||
local groups = diff.extract_change_groups({
|
||||
'-old1',
|
||||
'+new1',
|
||||
' context',
|
||||
'-old2',
|
||||
'+new2',
|
||||
})
|
||||
assert.are.equal(2, #groups)
|
||||
assert.are.equal('old1', groups[1].del_lines[1].text)
|
||||
assert.are.equal('new1', groups[1].add_lines[1].text)
|
||||
assert.are.equal('old2', groups[2].del_lines[1].text)
|
||||
assert.are.equal('new2', groups[2].add_lines[1].text)
|
||||
end)
|
||||
|
||||
it('tracks correct line indices', function()
|
||||
local groups = diff.extract_change_groups({
|
||||
' context',
|
||||
'-deleted',
|
||||
'+added',
|
||||
})
|
||||
assert.are.equal(2, groups[1].del_lines[1].idx)
|
||||
assert.are.equal(3, groups[1].add_lines[1].idx)
|
||||
end)
|
||||
|
||||
it('handles multiple del lines followed by multiple add lines', function()
|
||||
local groups = diff.extract_change_groups({
|
||||
'-del1',
|
||||
'-del2',
|
||||
'+add1',
|
||||
'+add2',
|
||||
'+add3',
|
||||
})
|
||||
assert.are.equal(1, #groups)
|
||||
assert.are.equal(2, #groups[1].del_lines)
|
||||
assert.are.equal(3, #groups[1].add_lines)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('compute_intra_hunks', function()
|
||||
it('returns nil for all-addition hunks', function()
|
||||
local result = diff.compute_intra_hunks({ '+line1', '+line2' }, 'default')
|
||||
assert.is_nil(result)
|
||||
end)
|
||||
|
||||
it('returns nil for all-deletion hunks', function()
|
||||
local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'default')
|
||||
assert.is_nil(result)
|
||||
end)
|
||||
|
||||
it('returns nil for context-only hunks', function()
|
||||
local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'default')
|
||||
assert.is_nil(result)
|
||||
end)
|
||||
|
||||
it('returns spans for single word change', function()
|
||||
local result = diff.compute_intra_hunks({
|
||||
'-local x = 1',
|
||||
'+local x = 2',
|
||||
}, 'default')
|
||||
assert.is_not_nil(result)
|
||||
assert.is_true(#result.del_spans > 0)
|
||||
assert.is_true(#result.add_spans > 0)
|
||||
end)
|
||||
|
||||
it('identifies correct byte offsets for word change', function()
|
||||
local result = diff.compute_intra_hunks({
|
||||
'-local x = 1',
|
||||
'+local x = 2',
|
||||
}, 'default')
|
||||
assert.is_not_nil(result)
|
||||
|
||||
assert.are.equal(1, #result.del_spans)
|
||||
assert.are.equal(1, #result.add_spans)
|
||||
local del_span = result.del_spans[1]
|
||||
local add_span = result.add_spans[1]
|
||||
local del_text = ('local x = 1'):sub(del_span.col_start, del_span.col_end - 1)
|
||||
local add_text = ('local x = 2'):sub(add_span.col_start, add_span.col_end - 1)
|
||||
assert.are.equal('1', del_text)
|
||||
assert.are.equal('2', add_text)
|
||||
end)
|
||||
|
||||
it('handles multiple change groups separated by context', function()
|
||||
local result = diff.compute_intra_hunks({
|
||||
'-local a = 1',
|
||||
'+local a = 2',
|
||||
' local b = 3',
|
||||
'-local c = 4',
|
||||
'+local c = 5',
|
||||
}, 'default')
|
||||
assert.is_not_nil(result)
|
||||
assert.is_true(#result.del_spans >= 2)
|
||||
assert.is_true(#result.add_spans >= 2)
|
||||
end)
|
||||
|
||||
it('handles uneven line counts (2 old, 1 new)', function()
|
||||
local result = diff.compute_intra_hunks({
|
||||
'-line one',
|
||||
'-line two',
|
||||
'+line combined',
|
||||
}, 'default')
|
||||
assert.is_not_nil(result)
|
||||
end)
|
||||
|
||||
it('handles multi-byte UTF-8 content', function()
|
||||
local result = diff.compute_intra_hunks({
|
||||
'-local x = "héllo"',
|
||||
'+local x = "wörld"',
|
||||
}, 'default')
|
||||
assert.is_not_nil(result)
|
||||
assert.is_true(#result.del_spans > 0)
|
||||
assert.is_true(#result.add_spans > 0)
|
||||
end)
|
||||
|
||||
it('returns nil when del and add are identical', function()
|
||||
local result = diff.compute_intra_hunks({
|
||||
'-local x = 1',
|
||||
'+local x = 1',
|
||||
}, 'default')
|
||||
assert.is_nil(result)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('has_vscode', function()
|
||||
it('returns false in test environment', function()
|
||||
assert.is_false(diff.has_vscode())
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
|
@ -1,480 +0,0 @@
|
|||
require('spec.helpers')
|
||||
|
||||
local fugitive = require('diffs.fugitive')
|
||||
|
||||
describe('fugitive', function()
|
||||
describe('get_section_at_line', function()
|
||||
local function create_status_buffer(lines)
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||
return buf
|
||||
end
|
||||
|
||||
it('returns staged for lines in Staged section', function()
|
||||
local buf = create_status_buffer({
|
||||
'Head: main',
|
||||
'',
|
||||
'Staged (2)',
|
||||
'M file1.lua',
|
||||
'A file2.lua',
|
||||
'',
|
||||
'Unstaged (1)',
|
||||
'M file3.lua',
|
||||
})
|
||||
assert.equals('staged', fugitive.get_section_at_line(buf, 4))
|
||||
assert.equals('staged', fugitive.get_section_at_line(buf, 5))
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns unstaged for lines in Unstaged section', function()
|
||||
local buf = create_status_buffer({
|
||||
'Head: main',
|
||||
'',
|
||||
'Staged (1)',
|
||||
'M file1.lua',
|
||||
'',
|
||||
'Unstaged (2)',
|
||||
'M file2.lua',
|
||||
'M file3.lua',
|
||||
})
|
||||
assert.equals('unstaged', fugitive.get_section_at_line(buf, 7))
|
||||
assert.equals('unstaged', fugitive.get_section_at_line(buf, 8))
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns untracked for lines in Untracked section', function()
|
||||
local buf = create_status_buffer({
|
||||
'Head: main',
|
||||
'',
|
||||
'Untracked (2)',
|
||||
'? newfile.lua',
|
||||
'? another.lua',
|
||||
})
|
||||
assert.equals('untracked', fugitive.get_section_at_line(buf, 4))
|
||||
assert.equals('untracked', fugitive.get_section_at_line(buf, 5))
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns nil for lines before any section', function()
|
||||
local buf = create_status_buffer({
|
||||
'Head: main',
|
||||
'Push: origin/main',
|
||||
'',
|
||||
'Staged (1)',
|
||||
'M file1.lua',
|
||||
})
|
||||
assert.is_nil(fugitive.get_section_at_line(buf, 1))
|
||||
assert.is_nil(fugitive.get_section_at_line(buf, 2))
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('get_file_at_line', function()
|
||||
local function create_status_buffer(lines)
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||
return buf
|
||||
end
|
||||
|
||||
it('parses simple modified file', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M src/foo.lua',
|
||||
})
|
||||
local filename, section = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('src/foo.lua', filename)
|
||||
assert.equals('unstaged', section)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('parses added file', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'A newfile.lua',
|
||||
})
|
||||
local filename, section = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('newfile.lua', filename)
|
||||
assert.equals('staged', section)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('parses deleted file', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'D oldfile.lua',
|
||||
})
|
||||
local filename, section = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('oldfile.lua', filename)
|
||||
assert.equals('staged', section)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('parses renamed file and returns both names', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'R oldname.lua -> newname.lua',
|
||||
})
|
||||
local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('newname.lua', filename)
|
||||
assert.equals('staged', section)
|
||||
assert.is_false(is_header)
|
||||
assert.equals('oldname.lua', old_filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('parses renamed file with similarity index', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'R100 old.lua -> new.lua',
|
||||
})
|
||||
local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('new.lua', filename)
|
||||
assert.equals('staged', section)
|
||||
assert.equals('old.lua', old_filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns nil old_filename for non-renames', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'M modified.lua',
|
||||
})
|
||||
local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('modified.lua', filename)
|
||||
assert.equals('staged', section)
|
||||
assert.is_nil(old_filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('handles renamed file with spaces in name', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'R old file.lua -> new file.lua',
|
||||
})
|
||||
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('new file.lua', filename)
|
||||
assert.equals('old file.lua', old_filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('handles renamed file in subdirectory', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'R src/old.lua -> src/new.lua',
|
||||
})
|
||||
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('src/new.lua', filename)
|
||||
assert.equals('src/old.lua', old_filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('handles renamed file moved to different directory', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'R old/file.lua -> new/file.lua',
|
||||
})
|
||||
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('new/file.lua', filename)
|
||||
assert.equals('old/file.lua', old_filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('KNOWN LIMITATION: filename containing arrow parsed incorrectly', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'R a -> b.lua -> c.lua',
|
||||
})
|
||||
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('b.lua -> c.lua', filename)
|
||||
assert.equals('a', old_filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('handles double extensions', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'M test.spec.lua',
|
||||
})
|
||||
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('test.spec.lua', filename)
|
||||
assert.is_nil(old_filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('handles hyphenated filenames', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M my-component-test.lua',
|
||||
})
|
||||
local filename, section = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('my-component-test.lua', filename)
|
||||
assert.equals('unstaged', section)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('handles underscores and numbers', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'A test_file_123.lua',
|
||||
})
|
||||
local filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('test_file_123.lua', filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('handles dotfiles', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M .gitignore',
|
||||
})
|
||||
local filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('.gitignore', filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('handles renamed with complex names', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'R src/old-file.spec.lua -> src/new-file.spec.lua',
|
||||
})
|
||||
local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('src/new-file.spec.lua', filename)
|
||||
assert.equals('src/old-file.spec.lua', old_filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('handles deeply nested paths', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M lua/diffs/ui/components/diff-view.lua',
|
||||
})
|
||||
local filename = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('lua/diffs/ui/components/diff-view.lua', filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('parses untracked file', function()
|
||||
local buf = create_status_buffer({
|
||||
'Untracked (1)',
|
||||
'? untracked.lua',
|
||||
})
|
||||
local filename, section = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('untracked.lua', filename)
|
||||
assert.equals('untracked', section)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns nil for section header', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M file.lua',
|
||||
})
|
||||
local filename = fugitive.get_file_at_line(buf, 1)
|
||||
assert.is_nil(filename)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('walks back from hunk line to find file', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M file.lua',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' local M = {}',
|
||||
'+local new = true',
|
||||
' return M',
|
||||
})
|
||||
local filename, section = fugitive.get_file_at_line(buf, 5)
|
||||
assert.equals('file.lua', filename)
|
||||
assert.equals('unstaged', section)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('handles file with both staged and unstaged indicator', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'M both.lua',
|
||||
'',
|
||||
'Unstaged (1)',
|
||||
'M both.lua',
|
||||
})
|
||||
local filename1, section1 = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('both.lua', filename1)
|
||||
assert.equals('staged', section1)
|
||||
|
||||
local filename2, section2 = fugitive.get_file_at_line(buf, 5)
|
||||
assert.equals('both.lua', filename2)
|
||||
assert.equals('unstaged', section2)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('detects section header for Staged', function()
|
||||
local buf = create_status_buffer({
|
||||
'Head: main',
|
||||
'',
|
||||
'Staged (2)',
|
||||
'M file1.lua',
|
||||
})
|
||||
local filename, section, is_header = fugitive.get_file_at_line(buf, 3)
|
||||
assert.is_nil(filename)
|
||||
assert.equals('staged', section)
|
||||
assert.is_true(is_header)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('detects section header for Unstaged', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (3)',
|
||||
'M file1.lua',
|
||||
})
|
||||
local filename, section, is_header = fugitive.get_file_at_line(buf, 1)
|
||||
assert.is_nil(filename)
|
||||
assert.equals('unstaged', section)
|
||||
assert.is_true(is_header)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('detects section header for Untracked', function()
|
||||
local buf = create_status_buffer({
|
||||
'Untracked (1)',
|
||||
'? newfile.lua',
|
||||
})
|
||||
local filename, section, is_header = fugitive.get_file_at_line(buf, 1)
|
||||
assert.is_nil(filename)
|
||||
assert.equals('untracked', section)
|
||||
assert.is_true(is_header)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns is_header=false for file lines', function()
|
||||
local buf = create_status_buffer({
|
||||
'Staged (1)',
|
||||
'M file.lua',
|
||||
})
|
||||
local filename, section, is_header = fugitive.get_file_at_line(buf, 2)
|
||||
assert.equals('file.lua', filename)
|
||||
assert.equals('staged', section)
|
||||
assert.is_false(is_header)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('get_hunk_position', function()
|
||||
local function create_status_buffer(lines)
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||
return buf
|
||||
end
|
||||
|
||||
it('returns nil when on file header line', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M file.lua',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' local M = {}',
|
||||
'+local new = true',
|
||||
})
|
||||
local pos = fugitive.get_hunk_position(buf, 2)
|
||||
assert.is_nil(pos)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns nil when on @@ header line', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M file.lua',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' local M = {}',
|
||||
})
|
||||
local pos = fugitive.get_hunk_position(buf, 3)
|
||||
assert.is_nil(pos)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns hunk header and offset for + line', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M file.lua',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' local M = {}',
|
||||
'+local new = true',
|
||||
' return M',
|
||||
})
|
||||
local pos = fugitive.get_hunk_position(buf, 5)
|
||||
assert.is_not_nil(pos)
|
||||
assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header)
|
||||
assert.equals(2, pos.offset)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns hunk header and offset for - line', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M file.lua',
|
||||
'@@ -1,3 +1,3 @@',
|
||||
' local M = {}',
|
||||
'-local old = false',
|
||||
' return M',
|
||||
})
|
||||
local pos = fugitive.get_hunk_position(buf, 5)
|
||||
assert.is_not_nil(pos)
|
||||
assert.equals('@@ -1,3 +1,3 @@', pos.hunk_header)
|
||||
assert.equals(2, pos.offset)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns hunk header and offset for context line', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M file.lua',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' local M = {}',
|
||||
'+local new = true',
|
||||
' return M',
|
||||
})
|
||||
local pos = fugitive.get_hunk_position(buf, 6)
|
||||
assert.is_not_nil(pos)
|
||||
assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header)
|
||||
assert.equals(3, pos.offset)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns correct offset for first line after @@', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M file.lua',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' local M = {}',
|
||||
})
|
||||
local pos = fugitive.get_hunk_position(buf, 4)
|
||||
assert.is_not_nil(pos)
|
||||
assert.equals(1, pos.offset)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('handles @@ header with context text', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M file.lua',
|
||||
'@@ -10,3 +10,4 @@ function M.hello()',
|
||||
' print("hi")',
|
||||
'+ print("world")',
|
||||
})
|
||||
local pos = fugitive.get_hunk_position(buf, 5)
|
||||
assert.is_not_nil(pos)
|
||||
assert.equals('@@ -10,3 +10,4 @@ function M.hello()', pos.hunk_header)
|
||||
assert.equals(2, pos.offset)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
|
||||
it('returns nil when section header interrupts search', function()
|
||||
local buf = create_status_buffer({
|
||||
'Unstaged (1)',
|
||||
'M file.lua',
|
||||
' some orphan line',
|
||||
})
|
||||
local pos = fugitive.get_hunk_position(buf, 3)
|
||||
assert.is_nil(pos)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
require('spec.helpers')
|
||||
local git = require('diffs.git')
|
||||
|
||||
describe('git', function()
|
||||
describe('get_repo_root', function()
|
||||
it('returns repo root for current repo', function()
|
||||
local cwd = vim.fn.getcwd()
|
||||
local root = git.get_repo_root(cwd .. '/lua/diffs/init.lua')
|
||||
assert.is_not_nil(root)
|
||||
assert.are.equal(cwd, root)
|
||||
end)
|
||||
|
||||
it('returns nil for non-git directory', function()
|
||||
local root = git.get_repo_root('/tmp')
|
||||
assert.is_nil(root)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('get_file_content', function()
|
||||
it('returns file content at HEAD', function()
|
||||
local cwd = vim.fn.getcwd()
|
||||
local content, err = git.get_file_content('HEAD', cwd .. '/lua/diffs/init.lua')
|
||||
assert.is_nil(err)
|
||||
assert.is_not_nil(content)
|
||||
assert.is_true(#content > 0)
|
||||
end)
|
||||
|
||||
it('returns error for non-existent file', function()
|
||||
local cwd = vim.fn.getcwd()
|
||||
local content, err = git.get_file_content('HEAD', cwd .. '/does_not_exist.lua')
|
||||
assert.is_nil(content)
|
||||
assert.is_not_nil(err)
|
||||
end)
|
||||
|
||||
it('returns error for non-git directory', function()
|
||||
local content, err = git.get_file_content('HEAD', '/tmp/some_file.txt')
|
||||
assert.is_nil(content)
|
||||
assert.is_not_nil(err)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('get_relative_path', function()
|
||||
it('returns relative path within repo', function()
|
||||
local cwd = vim.fn.getcwd()
|
||||
local rel = git.get_relative_path(cwd .. '/lua/diffs/init.lua')
|
||||
assert.are.equal('lua/diffs/init.lua', rel)
|
||||
end)
|
||||
|
||||
it('returns nil for non-git directory', function()
|
||||
local rel = git.get_relative_path('/tmp/some_file.txt')
|
||||
assert.is_nil(rel)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
|
@ -2,8 +2,6 @@ local plugin_dir = vim.fn.getcwd()
|
|||
vim.opt.runtimepath:prepend(plugin_dir)
|
||||
vim.opt.packpath = {}
|
||||
|
||||
vim.cmd('filetype on')
|
||||
|
||||
local function ensure_parser(lang)
|
||||
local ok = pcall(vim.treesitter.language.inspect, lang)
|
||||
if not ok then
|
||||
|
|
@ -12,7 +10,6 @@ local function ensure_parser(lang)
|
|||
end
|
||||
|
||||
ensure_parser('lua')
|
||||
ensure_parser('vim')
|
||||
|
||||
local M = {}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,33 +2,26 @@ require('spec.helpers')
|
|||
local diffs = require('diffs')
|
||||
|
||||
describe('diffs', function()
|
||||
describe('vim.g.diffs config', function()
|
||||
after_each(function()
|
||||
vim.g.diffs = nil
|
||||
end)
|
||||
|
||||
it('accepts nil config', function()
|
||||
vim.g.diffs = nil
|
||||
describe('setup', function()
|
||||
it('accepts empty config', function()
|
||||
assert.has_no.errors(function()
|
||||
diffs.attach()
|
||||
diffs.setup({})
|
||||
end)
|
||||
end)
|
||||
|
||||
it('accepts empty config', function()
|
||||
vim.g.diffs = {}
|
||||
it('accepts nil config', function()
|
||||
assert.has_no.errors(function()
|
||||
diffs.attach()
|
||||
diffs.setup()
|
||||
end)
|
||||
end)
|
||||
|
||||
it('accepts full config', function()
|
||||
vim.g.diffs = {
|
||||
debug = true,
|
||||
debounce_ms = 100,
|
||||
hide_prefix = false,
|
||||
highlights = {
|
||||
background = true,
|
||||
gutter = true,
|
||||
assert.has_no.errors(function()
|
||||
diffs.setup({
|
||||
enabled = false,
|
||||
debug = true,
|
||||
debounce_ms = 100,
|
||||
hide_prefix = false,
|
||||
treesitter = {
|
||||
enabled = true,
|
||||
max_lines = 1000,
|
||||
|
|
@ -37,19 +30,19 @@ describe('diffs', function()
|
|||
enabled = false,
|
||||
max_lines = 200,
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.has_no.errors(function()
|
||||
diffs.attach()
|
||||
highlights = {
|
||||
background = true,
|
||||
gutter = true,
|
||||
},
|
||||
})
|
||||
end)
|
||||
end)
|
||||
|
||||
it('accepts partial config', function()
|
||||
vim.g.diffs = {
|
||||
debounce_ms = 25,
|
||||
}
|
||||
assert.has_no.errors(function()
|
||||
diffs.attach()
|
||||
diffs.setup({
|
||||
debounce_ms = 25,
|
||||
})
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
|
@ -67,6 +60,10 @@ describe('diffs', function()
|
|||
end
|
||||
end
|
||||
|
||||
before_each(function()
|
||||
diffs.setup({ enabled = true })
|
||||
end)
|
||||
|
||||
it('does not error on empty buffer', function()
|
||||
local bufnr = create_buffer({})
|
||||
assert.has_no.errors(function()
|
||||
|
|
@ -112,6 +109,10 @@ describe('diffs', function()
|
|||
end
|
||||
end
|
||||
|
||||
before_each(function()
|
||||
diffs.setup({ enabled = true })
|
||||
end)
|
||||
|
||||
it('does not error on unattached buffer', function()
|
||||
local bufnr = create_buffer({})
|
||||
assert.has_no.errors(function()
|
||||
|
|
@ -130,6 +131,26 @@ describe('diffs', function()
|
|||
end)
|
||||
end)
|
||||
|
||||
describe('config options', function()
|
||||
it('enabled=false prevents highlighting', function()
|
||||
diffs.setup({ enabled = false })
|
||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
|
||||
'M test.lua',
|
||||
'@@ -1,1 +1,2 @@',
|
||||
' local x = 1',
|
||||
'+local y = 2',
|
||||
})
|
||||
diffs.attach(bufnr)
|
||||
|
||||
local ns = vim.api.nvim_create_namespace('diffs')
|
||||
local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, {})
|
||||
assert.are.equal(0, #extmarks)
|
||||
|
||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('is_fugitive_buffer', function()
|
||||
it('returns true for fugitive:// URLs', function()
|
||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||
|
|
@ -167,6 +188,10 @@ describe('diffs', function()
|
|||
end
|
||||
end
|
||||
|
||||
before_each(function()
|
||||
diffs.setup({ enabled = true })
|
||||
end)
|
||||
|
||||
describe('attach_diff', function()
|
||||
it('applies winhighlight to diff windows', function()
|
||||
local win, _ = create_diff_window()
|
||||
|
|
@ -179,6 +204,17 @@ describe('diffs', function()
|
|||
close_window(win)
|
||||
end)
|
||||
|
||||
it('does nothing when enabled=false', function()
|
||||
diffs.setup({ enabled = false })
|
||||
local win, _ = create_diff_window()
|
||||
diffs.attach_diff()
|
||||
|
||||
local whl = vim.api.nvim_get_option_value('winhighlight', { win = win })
|
||||
assert.are.equal('', whl)
|
||||
|
||||
close_window(win)
|
||||
end)
|
||||
|
||||
it('is idempotent', function()
|
||||
local win, _ = create_diff_window()
|
||||
assert.has_no.errors(function()
|
||||
|
|
|
|||
|
|
@ -215,290 +215,5 @@ describe('parser', function()
|
|||
assert.are.equal(1, #hunks[2].lines)
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('attaches header_lines to first hunk only', function()
|
||||
local bufnr = create_buffer({
|
||||
'diff --git a/parser.lua b/parser.lua',
|
||||
'index 3e8afa0..018159c 100644',
|
||||
'--- a/parser.lua',
|
||||
'+++ b/parser.lua',
|
||||
'@@ -1,2 +1,3 @@',
|
||||
' local M = {}',
|
||||
'+local x = 1',
|
||||
'@@ -10,2 +11,3 @@',
|
||||
' function M.foo()',
|
||||
'+ return true',
|
||||
' end',
|
||||
})
|
||||
local hunks = parser.parse_buffer(bufnr)
|
||||
|
||||
assert.are.equal(2, #hunks)
|
||||
assert.is_not_nil(hunks[1].header_start_line)
|
||||
assert.is_not_nil(hunks[1].header_lines)
|
||||
assert.are.equal(1, hunks[1].header_start_line)
|
||||
assert.is_nil(hunks[2].header_start_line)
|
||||
assert.is_nil(hunks[2].header_lines)
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('header_lines contains only diff metadata, not hunk content', function()
|
||||
local bufnr = create_buffer({
|
||||
'diff --git a/parser.lua b/parser.lua',
|
||||
'index 3e8afa0..018159c 100644',
|
||||
'--- a/parser.lua',
|
||||
'+++ b/parser.lua',
|
||||
'@@ -1,2 +1,3 @@',
|
||||
' local M = {}',
|
||||
'+local x = 1',
|
||||
})
|
||||
local hunks = parser.parse_buffer(bufnr)
|
||||
|
||||
assert.are.equal(1, #hunks)
|
||||
assert.are.equal(4, #hunks[1].header_lines)
|
||||
assert.are.equal('diff --git a/parser.lua b/parser.lua', hunks[1].header_lines[1])
|
||||
assert.are.equal('index 3e8afa0..018159c 100644', hunks[1].header_lines[2])
|
||||
assert.are.equal('--- a/parser.lua', hunks[1].header_lines[3])
|
||||
assert.are.equal('+++ b/parser.lua', hunks[1].header_lines[4])
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('handles fugitive status format with diff headers', function()
|
||||
local bufnr = create_buffer({
|
||||
'Head: main',
|
||||
'Push: origin/main',
|
||||
'',
|
||||
'Unstaged (1)',
|
||||
'M parser.lua',
|
||||
'diff --git a/parser.lua b/parser.lua',
|
||||
'index 3e8afa0..018159c 100644',
|
||||
'--- a/parser.lua',
|
||||
'+++ b/parser.lua',
|
||||
'@@ -1,2 +1,3 @@',
|
||||
' local M = {}',
|
||||
'+local x = 1',
|
||||
})
|
||||
local hunks = parser.parse_buffer(bufnr)
|
||||
|
||||
assert.are.equal(1, #hunks)
|
||||
assert.are.equal(6, hunks[1].header_start_line)
|
||||
assert.are.equal(4, #hunks[1].header_lines)
|
||||
assert.are.equal('diff --git a/parser.lua b/parser.lua', hunks[1].header_lines[1])
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('emits hunk for files with unknown filetype', function()
|
||||
local bufnr = create_buffer({
|
||||
'M config.obscuretype',
|
||||
'@@ -1,2 +1,3 @@',
|
||||
' setting1 = value1',
|
||||
'-setting2 = value2',
|
||||
'+setting2 = MODIFIED',
|
||||
'+setting4 = newvalue',
|
||||
})
|
||||
local hunks = parser.parse_buffer(bufnr)
|
||||
|
||||
assert.are.equal(1, #hunks)
|
||||
assert.are.equal('config.obscuretype', hunks[1].filename)
|
||||
assert.is_nil(hunks[1].ft)
|
||||
assert.is_nil(hunks[1].lang)
|
||||
assert.are.equal(4, #hunks[1].lines)
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('uses filetype from existing buffer when available', function()
|
||||
local repo_root = '/tmp/test-repo'
|
||||
local file_path = repo_root .. '/build'
|
||||
|
||||
local file_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_name(file_buf, file_path)
|
||||
vim.api.nvim_set_option_value('filetype', 'bash', { buf = file_buf })
|
||||
|
||||
local diff_buf = create_buffer({
|
||||
'M build',
|
||||
'@@ -1,2 +1,3 @@',
|
||||
' echo "hello"',
|
||||
'+set -e',
|
||||
' echo "done"',
|
||||
})
|
||||
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
|
||||
|
||||
local hunks = parser.parse_buffer(diff_buf)
|
||||
|
||||
assert.are.equal(1, #hunks)
|
||||
assert.are.equal('build', hunks[1].filename)
|
||||
assert.are.equal('bash', hunks[1].ft)
|
||||
|
||||
delete_buffer(file_buf)
|
||||
delete_buffer(diff_buf)
|
||||
end)
|
||||
|
||||
it('uses filetype from existing buffer via git_dir', function()
|
||||
local git_dir = '/tmp/test-repo/.git'
|
||||
local repo_root = '/tmp/test-repo'
|
||||
local file_path = repo_root .. '/script'
|
||||
|
||||
local file_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_name(file_buf, file_path)
|
||||
vim.api.nvim_set_option_value('filetype', 'python', { buf = file_buf })
|
||||
|
||||
local diff_buf = create_buffer({
|
||||
'M script',
|
||||
'@@ -1,2 +1,3 @@',
|
||||
' def main():',
|
||||
'+ print("hi")',
|
||||
' pass',
|
||||
})
|
||||
vim.api.nvim_buf_set_var(diff_buf, 'git_dir', git_dir)
|
||||
|
||||
local hunks = parser.parse_buffer(diff_buf)
|
||||
|
||||
assert.are.equal(1, #hunks)
|
||||
assert.are.equal('script', hunks[1].filename)
|
||||
assert.are.equal('python', hunks[1].ft)
|
||||
|
||||
delete_buffer(file_buf)
|
||||
delete_buffer(diff_buf)
|
||||
end)
|
||||
|
||||
it('detects filetype from file content shebang without open buffer', function()
|
||||
local repo_root = '/tmp/diffs-test-shebang'
|
||||
vim.fn.mkdir(repo_root, 'p')
|
||||
|
||||
local file_path = repo_root .. '/build'
|
||||
local f = io.open(file_path, 'w')
|
||||
f:write('#!/bin/bash\n')
|
||||
f:write('set -e\n')
|
||||
f:write('echo "hello"\n')
|
||||
f:close()
|
||||
|
||||
local diff_buf = create_buffer({
|
||||
'M build',
|
||||
'@@ -1,2 +1,3 @@',
|
||||
' #!/bin/bash',
|
||||
'+set -e',
|
||||
' echo "hello"',
|
||||
})
|
||||
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
|
||||
|
||||
local hunks = parser.parse_buffer(diff_buf)
|
||||
|
||||
assert.are.equal(1, #hunks)
|
||||
assert.are.equal('build', hunks[1].filename)
|
||||
assert.are.equal('sh', hunks[1].ft)
|
||||
|
||||
delete_buffer(diff_buf)
|
||||
os.remove(file_path)
|
||||
vim.fn.delete(repo_root, 'rf')
|
||||
end)
|
||||
|
||||
it('detects python from shebang without open buffer', function()
|
||||
local repo_root = '/tmp/diffs-test-shebang-py'
|
||||
vim.fn.mkdir(repo_root, 'p')
|
||||
|
||||
local file_path = repo_root .. '/deploy'
|
||||
local f = io.open(file_path, 'w')
|
||||
f:write('#!/usr/bin/env python3\n')
|
||||
f:write('import sys\n')
|
||||
f:write('print("hi")\n')
|
||||
f:close()
|
||||
|
||||
local diff_buf = create_buffer({
|
||||
'M deploy',
|
||||
'@@ -1,2 +1,3 @@',
|
||||
' #!/usr/bin/env python3',
|
||||
'+import sys',
|
||||
' print("hi")',
|
||||
})
|
||||
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
|
||||
|
||||
local hunks = parser.parse_buffer(diff_buf)
|
||||
|
||||
assert.are.equal(1, #hunks)
|
||||
assert.are.equal('deploy', hunks[1].filename)
|
||||
assert.are.equal('python', hunks[1].ft)
|
||||
|
||||
delete_buffer(diff_buf)
|
||||
os.remove(file_path)
|
||||
vim.fn.delete(repo_root, 'rf')
|
||||
end)
|
||||
|
||||
it('extracts file line numbers from @@ header', function()
|
||||
local bufnr = create_buffer({
|
||||
'M lua/test.lua',
|
||||
'@@ -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)
|
||||
|
|
|
|||
|
|
@ -1,400 +0,0 @@
|
|||
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
135
spec/ux_spec.lua
|
|
@ -1,135 +0,0 @@
|
|||
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