Compare commits
No commits in common. "doc/merge-conflicts" and "feat/misc-config-options" have entirely different histories.
doc/merge-
...
feat/misc-
47 changed files with 728 additions and 8777 deletions
9
.busted
9
.busted
|
|
@ -1,9 +0,0 @@
|
||||||
return {
|
|
||||||
_all = {
|
|
||||||
lua = 'nvim -l',
|
|
||||||
ROOT = { './spec/' },
|
|
||||||
},
|
|
||||||
default = {
|
|
||||||
verbose = true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
17
.github/DISCUSSION_TEMPLATE/q-a.yaml
vendored
17
.github/DISCUSSION_TEMPLATE/q-a.yaml
vendored
|
|
@ -1,17 +0,0 @@
|
||||||
title: 'Q&A'
|
|
||||||
labels: []
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Use this space for questions, ideas, and general discussion about diffs.nvim.
|
|
||||||
For bug reports, please [open an issue](https://github.com/barrettruth/diffs.nvim/issues/new/choose) instead.
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Question or topic
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Context
|
|
||||||
description: Any relevant details (Neovim version, config, screenshots)
|
|
||||||
79
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
79
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
|
@ -1,79 +0,0 @@
|
||||||
name: Bug Report
|
|
||||||
description: Report a bug
|
|
||||||
title: 'bug: '
|
|
||||||
labels: [bug]
|
|
||||||
body:
|
|
||||||
- type: checkboxes
|
|
||||||
attributes:
|
|
||||||
label: Prerequisites
|
|
||||||
options:
|
|
||||||
- label:
|
|
||||||
I have searched [existing
|
|
||||||
issues](https://github.com/barrettruth/diffs.nvim/issues)
|
|
||||||
required: true
|
|
||||||
- label: I have updated to the latest version
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 'Neovim version'
|
|
||||||
description: 'Output of `nvim --version`'
|
|
||||||
render: text
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
attributes:
|
|
||||||
label: 'Operating system'
|
|
||||||
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04'
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Description
|
|
||||||
description: What happened? What did you expect?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Steps to reproduce
|
|
||||||
description: Minimal steps to trigger the bug
|
|
||||||
value: |
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 'Health check'
|
|
||||||
description: 'Output of `:checkhealth diffs`'
|
|
||||||
render: text
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Minimal reproduction
|
|
||||||
description: |
|
|
||||||
Save the script below as `repro.lua`, edit if needed, and run:
|
|
||||||
```
|
|
||||||
nvim -u repro.lua
|
|
||||||
```
|
|
||||||
Confirm the bug reproduces with this config before submitting.
|
|
||||||
render: lua
|
|
||||||
value: |
|
|
||||||
vim.env.LAZY_STDPATH = '.repro'
|
|
||||||
load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))()
|
|
||||||
require('lazy.nvim').setup({
|
|
||||||
spec = {
|
|
||||||
'tpope/vim-fugitive',
|
|
||||||
{
|
|
||||||
'barrettruth/diffs.nvim',
|
|
||||||
opts = {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
5
.github/ISSUE_TEMPLATE/config.yaml
vendored
5
.github/ISSUE_TEMPLATE/config.yaml
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: Questions
|
|
||||||
url: https://github.com/barrettruth/diffs.nvim/discussions
|
|
||||||
about: Ask questions and discuss ideas
|
|
||||||
30
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
30
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
|
|
@ -1,30 +0,0 @@
|
||||||
name: Feature Request
|
|
||||||
description: Suggest a feature
|
|
||||||
title: 'feat: '
|
|
||||||
labels: [enhancement]
|
|
||||||
body:
|
|
||||||
- type: checkboxes
|
|
||||||
attributes:
|
|
||||||
label: Prerequisites
|
|
||||||
options:
|
|
||||||
- label:
|
|
||||||
I have searched [existing
|
|
||||||
issues](https://github.com/barrettruth/diffs.nvim/issues)
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Problem
|
|
||||||
description: What problem does this solve?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Proposed solution
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Alternatives considered
|
|
||||||
1
.github/workflows/quality.yaml
vendored
1
.github/workflows/quality.yaml
vendored
|
|
@ -1,7 +1,6 @@
|
||||||
name: quality
|
name: quality
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
push:
|
push:
|
||||||
|
|
|
||||||
28
.github/workflows/test.yaml
vendored
28
.github/workflows/test.yaml
vendored
|
|
@ -1,28 +0,0 @@
|
||||||
name: test
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
nvim: [stable, nightly]
|
|
||||||
name: Test (Neovim ${{ matrix.nvim }})
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- 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.version": "Lua 5.1",
|
||||||
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
|
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
|
||||||
"diagnostics.globals": ["vim", "jit"],
|
"diagnostics.globals": ["vim"],
|
||||||
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
|
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
|
||||||
"workspace.checkThirdParty": false,
|
"workspace.checkThirdParty": false,
|
||||||
"completion.callSnippet": "Replace"
|
"completion.callSnippet": "Replace"
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,4 @@ repos:
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
name: prettier
|
name: prettier
|
||||||
files: \.(md|toml|yaml|yml|sh)$
|
files: \.(md|toml|yaml|sh)$
|
||||||
|
|
|
||||||
10
.prettierrc
10
.prettierrc
|
|
@ -5,5 +5,13 @@
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"singleQuote": true
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/*.md"],
|
||||||
|
"options": {
|
||||||
|
"parser": "markdown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
85
README.md
85
README.md
|
|
@ -1,80 +1,49 @@
|
||||||
# diffs.nvim
|
# fugitive-ts.nvim
|
||||||
|
|
||||||
**Syntax highlighting for diffs in Neovim**
|
**Treesitter syntax highlighting for vim-fugitive**
|
||||||
|
|
||||||
Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware
|
Enhance the great `vim-fugitive` with syntax-aware code to easily work with
|
||||||
syntax highlighting.
|
diffs.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Treesitter syntax highlighting in `:Git` diffs and commit views
|
- **Language-aware highlighting**: Full treesitter syntax highlighting for code
|
||||||
- Diff header highlighting (`diff --git`, `index`, `---`, `+++`)
|
in diff hunks
|
||||||
- `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds
|
- **Automatic language detection**: Detects language from filenames using
|
||||||
- `:Gdiff` unified diff against any git revision with syntax highlighting
|
Neovim's filetype detection
|
||||||
- Fugitive status buffer keymaps (`du`/`dU`) for unified diffs
|
- **Header context highlighting**: Highlights function signatures in hunk
|
||||||
- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`)
|
headers (`@@ ... @@ function foo()`)
|
||||||
- Vim syntax fallback for languages without a treesitter parser
|
- **Performance optimized**: Debounced updates, configurable max lines per hunk
|
||||||
- Hunk header context highlighting (`@@ ... @@ function foo()`)
|
- **Zero configuration**: Works out of the box with sensible defaults
|
||||||
- Character-level (intra-line) diff highlighting for changed characters
|
|
||||||
- Inline merge conflict detection, highlighting, and resolution keymaps
|
|
||||||
- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and
|
|
||||||
highlight overrides
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Neovim 0.9.0+
|
- Neovim 0.9.0+
|
||||||
|
- [vim-fugitive](https://github.com/tpope/vim-fugitive)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Install with your package manager of choice or via
|
Using [lazy.nvim](https://github.com/folke/lazy.nvim):
|
||||||
[luarocks](https://luarocks.org/modules/barrettruth/diffs.nvim):
|
|
||||||
|
|
||||||
```
|
```lua
|
||||||
luarocks install diffs.nvim
|
{
|
||||||
|
'barrettruth/fugitive-ts.nvim',
|
||||||
|
dependencies = { 'tpope/vim-fugitive' },
|
||||||
|
opts = {},
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
```vim
|
```vim
|
||||||
:help diffs.nvim
|
:help fugitive-ts.nvim
|
||||||
```
|
```
|
||||||
|
|
||||||
## Known Limitations
|
## Acknowledgements
|
||||||
|
|
||||||
- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation.
|
- [vim-fugitive](https://github.com/tpope/vim-fugitive)
|
||||||
To improve accuracy, `diffs.nvim` reads lines from disk before and after each
|
- [codediff.nvim](https://github.com/esmuellert/codediff.nvim)
|
||||||
hunk for parsing context (`highlights.context`, enabled by default with 25
|
- [diffview.nvim](https://github.com/sindrets/diffview.nvim)
|
||||||
lines). This resolves most boundary issues. Set
|
- [resolve.nvim](https://github.com/spacedentist/resolve.nvim)
|
||||||
`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`.
|
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
rockspec_format = '3.0'
|
|
||||||
package = 'diffs.nvim'
|
|
||||||
version = 'scm-1'
|
|
||||||
|
|
||||||
source = {
|
|
||||||
url = 'git+https://github.com/barrettruth/diffs.nvim.git',
|
|
||||||
}
|
|
||||||
|
|
||||||
description = {
|
|
||||||
summary = 'Syntax highlighting for diffs in Neovim',
|
|
||||||
homepage = 'https://github.com/barrettruth/diffs.nvim',
|
|
||||||
license = 'MIT',
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies = {
|
|
||||||
'lua >= 5.1',
|
|
||||||
}
|
|
||||||
|
|
||||||
test_dependencies = {
|
|
||||||
'nlua',
|
|
||||||
'busted >= 2.1.1',
|
|
||||||
}
|
|
||||||
|
|
||||||
test = {
|
|
||||||
type = 'busted',
|
|
||||||
}
|
|
||||||
|
|
||||||
build = {
|
|
||||||
type = 'builtin',
|
|
||||||
}
|
|
||||||
|
|
@ -1,693 +0,0 @@
|
||||||
*diffs.nvim.txt* Syntax highlighting for diffs in Neovim
|
|
||||||
|
|
||||||
Author: Barrett Ruth <br.barrettruth@gmail.com>
|
|
||||||
License: MIT
|
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
INTRODUCTION *diffs.nvim*
|
|
||||||
|
|
||||||
diffs.nvim adds syntax highlighting to diff views. It overlays language-aware
|
|
||||||
highlights on top of default diff highlighting in vim-fugitive and Neovim's
|
|
||||||
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*
|
|
||||||
|
|
||||||
- Neovim 0.9.0+
|
|
||||||
- vim-fugitive (https://github.com/tpope/vim-fugitive) (optional, for unified
|
|
||||||
diff syntax highlighting in |:Git| and commit views)
|
|
||||||
- Treesitter parsers for languages you want highlighted
|
|
||||||
|
|
||||||
Note: The diff mode feature (background-only colors for |:diffthis|, vimdiff,
|
|
||||||
etc.) works without vim-fugitive.
|
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
SETUP *diffs-setup*
|
|
||||||
|
|
||||||
Using lazy.nvim: >lua
|
|
||||||
{
|
|
||||||
'barrettruth/diffs.nvim',
|
|
||||||
dependencies = { 'tpope/vim-fugitive' },
|
|
||||||
}
|
|
||||||
<
|
|
||||||
The plugin works automatically with no configuration required. For
|
|
||||||
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: ~
|
|
||||||
{debug} (boolean, default: false)
|
|
||||||
Enable debug logging to |:messages| with
|
|
||||||
`[diffs]` prefix.
|
|
||||||
|
|
||||||
{debounce_ms} (integer, default: 0)
|
|
||||||
Debounce delay in milliseconds for re-highlighting
|
|
||||||
after buffer changes. Lower values feel snappier
|
|
||||||
but use more CPU.
|
|
||||||
|
|
||||||
{hide_prefix} (boolean, default: false)
|
|
||||||
Hide diff prefixes (`+`/`-`/` `) using virtual
|
|
||||||
text overlay. Makes code appear without the
|
|
||||||
leading diff character. When `highlights.background`
|
|
||||||
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.
|
|
||||||
|
|
||||||
{vim} (table, default: see below)
|
|
||||||
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.
|
|
||||||
|
|
||||||
*diffs.TreesitterConfig*
|
|
||||||
Treesitter config fields: ~
|
|
||||||
{enabled} (boolean, default: true)
|
|
||||||
Apply treesitter syntax highlighting to code.
|
|
||||||
|
|
||||||
{max_lines} (integer, default: 500)
|
|
||||||
Skip treesitter highlighting for hunks larger than
|
|
||||||
this many lines. Prevents lag on massive diffs.
|
|
||||||
|
|
||||||
*diffs.VimConfig*
|
|
||||||
Vim config fields: ~
|
|
||||||
{enabled} (boolean, default: false)
|
|
||||||
Use vim syntax highlighting as fallback when no
|
|
||||||
treesitter parser is available for a language.
|
|
||||||
Creates a scratch buffer, sets the filetype, and
|
|
||||||
queries |synID()| per character to extract
|
|
||||||
highlight groups. Slower than treesitter but
|
|
||||||
covers languages without a TS parser installed.
|
|
||||||
|
|
||||||
{max_lines} (integer, default: 200)
|
|
||||||
Skip vim syntax highlighting for hunks larger than
|
|
||||||
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.
|
|
||||||
|
|
||||||
{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.
|
|
||||||
|
|
||||||
Note: Header context (e.g., `@@ -10,3 +10,4 @@ func()`) is always
|
|
||||||
highlighted with treesitter when a parser is available.
|
|
||||||
|
|
||||||
Language detection uses Neovim's built-in |vim.filetype.match()| and
|
|
||||||
|vim.treesitter.language.get_lang()|. To customize filetype detection
|
|
||||||
or register treesitter parsers for custom filetypes, use
|
|
||||||
|vim.filetype.add()| and |vim.treesitter.language.register()|.
|
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
COMMANDS *diffs-commands*
|
|
||||||
|
|
||||||
: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.
|
|
||||||
|
|
||||||
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*
|
|
||||||
|
|
||||||
attach({bufnr}) *diffs.attach()*
|
|
||||||
Manually attach highlighting to a buffer. Called automatically for
|
|
||||||
fugitive buffers via the `FileType fugitive` autocmd.
|
|
||||||
|
|
||||||
Parameters: ~
|
|
||||||
{bufnr} (integer, optional) Buffer number. Defaults to current buffer.
|
|
||||||
|
|
||||||
refresh({bufnr}) *diffs.refresh()*
|
|
||||||
Manually refresh highlighting for a buffer. Useful after external changes
|
|
||||||
or for debugging.
|
|
||||||
|
|
||||||
Parameters: ~
|
|
||||||
{bufnr} (integer, optional) Buffer number. Defaults to current buffer.
|
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
IMPLEMENTATION *diffs-implementation*
|
|
||||||
|
|
||||||
Summary / commit detail views: ~
|
|
||||||
1. `FileType fugitive` or `FileType git` (for `fugitive://` buffers)
|
|
||||||
triggers |diffs.attach()|
|
|
||||||
2. The buffer is parsed to detect file headers (`M path/to/file`,
|
|
||||||
`diff --git a/... b/...`) and hunk headers (`@@ -10,3 +10,4 @@`)
|
|
||||||
3. For each hunk:
|
|
||||||
- Language is detected from the filename using |vim.filetype.match()|
|
|
||||||
- Diff prefixes (`+`/`-`/` `) are stripped from code lines
|
|
||||||
- Code is parsed with |vim.treesitter.get_string_parser()|
|
|
||||||
- If no treesitter parser and `vim.enabled`: vim syntax fallback via
|
|
||||||
scratch buffer and |synID()|
|
|
||||||
- `Normal` extmarks at priority 198 clear underlying diff foreground
|
|
||||||
- Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199
|
|
||||||
- Syntax highlights are applied as extmarks at priority 200
|
|
||||||
- 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
|
|
||||||
|
|
||||||
Diff mode views: ~
|
|
||||||
1. `OptionSet diff` detects when any window enters diff mode
|
|
||||||
2. All `&diff` windows in the tabpage receive a window-local 'winhighlight'
|
|
||||||
override that remaps `DiffAdd`/`DiffDelete`/`DiffChange`/`DiffText` to
|
|
||||||
background-only variants, allowing existing treesitter highlighting to
|
|
||||||
show through the diff colors
|
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
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
|
|
||||||
the buffer briefly shows fugitive's default diff highlighting before
|
|
||||||
diffs.nvim applies treesitter highlights.
|
|
||||||
|
|
||||||
This occurs because diffs.nvim hooks into the `FileType fugitive` event,
|
|
||||||
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 = {
|
|
||||||
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.
|
|
||||||
|
|
||||||
Fugitive unified diff highlights: ~
|
|
||||||
*DiffsAdd*
|
|
||||||
DiffsAdd Background for `+` lines. Derived by blending
|
|
||||||
`DiffAdd` background with `Normal` at 40% alpha.
|
|
||||||
|
|
||||||
*DiffsDelete*
|
|
||||||
DiffsDelete Background for `-` lines. Derived by blending
|
|
||||||
`DiffDelete` background with `Normal` at 40% alpha.
|
|
||||||
|
|
||||||
*DiffsAddNr*
|
|
||||||
DiffsAddNr Line number for `+` lines. Foreground from
|
|
||||||
`DiffsAddText`, 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).
|
|
||||||
|
|
||||||
Diff mode window highlights: ~
|
|
||||||
These are used for |winhighlight| remapping in `&diff` windows.
|
|
||||||
|
|
||||||
*DiffsDiffAdd*
|
|
||||||
DiffsDiffAdd Background-only. Derived from `DiffAdd` bg.
|
|
||||||
Treesitter provides foreground syntax highlighting.
|
|
||||||
|
|
||||||
*DiffsDiffDelete*
|
|
||||||
DiffsDiffDelete Foreground and background from `DiffDelete`.
|
|
||||||
Used for filler lines (`/////`) which have no real
|
|
||||||
code content to highlight.
|
|
||||||
|
|
||||||
*DiffsDiffChange*
|
|
||||||
DiffsDiffChange Background-only. Derived from `DiffChange` bg.
|
|
||||||
Treesitter provides foreground syntax highlighting.
|
|
||||||
|
|
||||||
*DiffsDiffText*
|
|
||||||
DiffsDiffText Background-only. Derived from `DiffText` bg.
|
|
||||||
Treesitter provides foreground syntax highlighting.
|
|
||||||
|
|
||||||
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*
|
|
||||||
|
|
||||||
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:
|
|
||||||
85
doc/fugitive-ts.nvim.txt
Normal file
85
doc/fugitive-ts.nvim.txt
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
*fugitive-ts.nvim.txt* Treesitter highlighting for vim-fugitive diffs
|
||||||
|
|
||||||
|
Author: Barrett Ruth <br.barrettruth@gmail.com>
|
||||||
|
License: MIT
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
INTRODUCTION *fugitive-ts.nvim*
|
||||||
|
|
||||||
|
fugitive-ts.nvim adds treesitter-based syntax highlighting to vim-fugitive
|
||||||
|
diff views. It overlays language-aware highlights on top of fugitive's
|
||||||
|
default regex-based diff highlighting.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
REQUIREMENTS *fugitive-ts-requirements*
|
||||||
|
|
||||||
|
- Neovim 0.9.0+
|
||||||
|
- vim-fugitive
|
||||||
|
- Treesitter parsers for languages you want highlighted
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
SETUP *fugitive-ts-setup*
|
||||||
|
|
||||||
|
>lua
|
||||||
|
require('fugitive-ts').setup({
|
||||||
|
-- Enable/disable highlighting (default: true)
|
||||||
|
enabled = true,
|
||||||
|
|
||||||
|
-- Enable debug logging (default: false)
|
||||||
|
-- Outputs to :messages with [fugitive-ts] prefix
|
||||||
|
debug = false,
|
||||||
|
|
||||||
|
-- Custom filename -> language mappings (optional)
|
||||||
|
languages = {},
|
||||||
|
|
||||||
|
-- Languages to skip treesitter highlighting for (default: {})
|
||||||
|
-- Uses treesitter language names, e.g. {"markdown", "vimdoc"}
|
||||||
|
disabled_languages = {},
|
||||||
|
|
||||||
|
-- Highlight context in hunk headers (default: true)
|
||||||
|
-- e.g. "@@ -10,3 +10,4 @@ function foo()" -> "function foo()" gets highlighted
|
||||||
|
highlight_headers = true,
|
||||||
|
|
||||||
|
-- Debounce delay in ms (default: 50)
|
||||||
|
debounce_ms = 50,
|
||||||
|
|
||||||
|
-- Max lines per hunk before skipping treesitter (default: 500)
|
||||||
|
-- Prevents lag on large diffs
|
||||||
|
max_lines_per_hunk = 500,
|
||||||
|
})
|
||||||
|
<
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
COMMANDS *fugitive-ts-commands*
|
||||||
|
|
||||||
|
This plugin works automatically when you open a fugitive buffer. No commands
|
||||||
|
are required.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
API *fugitive-ts-api*
|
||||||
|
|
||||||
|
*fugitive-ts.setup()*
|
||||||
|
setup({opts})
|
||||||
|
Configure the plugin. See |fugitive-ts-setup| for options.
|
||||||
|
|
||||||
|
*fugitive-ts.attach()*
|
||||||
|
attach({bufnr})
|
||||||
|
Manually attach highlighting to a buffer. Called automatically for
|
||||||
|
fugitive buffers.
|
||||||
|
|
||||||
|
*fugitive-ts.refresh()*
|
||||||
|
refresh({bufnr})
|
||||||
|
Manually refresh highlighting for a buffer.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
ROADMAP *fugitive-ts-roadmap*
|
||||||
|
|
||||||
|
Planned features and improvements:
|
||||||
|
|
||||||
|
- Vim syntax fallback: For languages without treesitter parsers, fall back
|
||||||
|
to vim's built-in syntax highlighting via scratch buffers. This would
|
||||||
|
provide highlighting coverage for more languages at the cost of
|
||||||
|
implementation complexity.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
vim:tw=78:ts=8:ft=help:norl:
|
||||||
12
fugitive-ts.nvim-scm-1.rockspec
Normal file
12
fugitive-ts.nvim-scm-1.rockspec
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
rockspec_format = '3.0'
|
||||||
|
package = 'fugitive-ts.nvim'
|
||||||
|
version = 'scm-1'
|
||||||
|
|
||||||
|
source = { url = 'git://github.com/barrettruth/fugitive-ts.nvim' }
|
||||||
|
build = { type = 'builtin' }
|
||||||
|
|
||||||
|
test_dependencies = {
|
||||||
|
'lua >= 5.1',
|
||||||
|
'nlua',
|
||||||
|
'busted >= 2.1.1',
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
local M = {}
|
|
||||||
|
|
||||||
function M.check()
|
|
||||||
vim.health.start('diffs.nvim')
|
|
||||||
|
|
||||||
if vim.fn.has('nvim-0.9.0') == 1 then
|
|
||||||
vim.health.ok('Neovim 0.9.0+ detected')
|
|
||||||
else
|
|
||||||
vim.health.error('diffs.nvim requires Neovim 0.9.0+')
|
|
||||||
end
|
|
||||||
|
|
||||||
local fugitive_loaded = vim.fn.exists(':Git') == 2
|
|
||||||
if fugitive_loaded then
|
|
||||||
vim.health.ok('vim-fugitive detected')
|
|
||||||
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,517 +0,0 @@
|
||||||
local M = {}
|
|
||||||
|
|
||||||
local dbg = require('diffs.log').dbg
|
|
||||||
local diff = require('diffs.diff')
|
|
||||||
|
|
||||||
---@param filepath string
|
|
||||||
---@param from_line integer
|
|
||||||
---@param count integer
|
|
||||||
---@return string[]
|
|
||||||
local function read_line_range(filepath, from_line, count)
|
|
||||||
if count <= 0 then
|
|
||||||
return {}
|
|
||||||
end
|
|
||||||
local f = io.open(filepath, 'r')
|
|
||||||
if not f then
|
|
||||||
return {}
|
|
||||||
end
|
|
||||||
local result = {}
|
|
||||||
local line_num = 0
|
|
||||||
for line in f:lines() do
|
|
||||||
line_num = line_num + 1
|
|
||||||
if line_num >= from_line then
|
|
||||||
table.insert(result, line)
|
|
||||||
if #result >= count then
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
f:close()
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
local PRIORITY_CLEAR = 198
|
|
||||||
local PRIORITY_SYNTAX = 199
|
|
||||||
local PRIORITY_LINE_BG = 200
|
|
||||||
local PRIORITY_CHAR_BG = 201
|
|
||||||
|
|
||||||
---@param bufnr integer
|
|
||||||
---@param ns integer
|
|
||||||
---@param hunk diffs.Hunk
|
|
||||||
---@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)
|
|
||||||
if not ok or not parser_obj then
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
local trees = parser_obj:parse()
|
|
||||||
if not trees or #trees == 0 then
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
local query = vim.treesitter.query.get(lang, 'highlights')
|
|
||||||
if not query then
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
local buf_sr = header_line
|
|
||||||
local buf_er = header_line
|
|
||||||
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
|
|
||||||
end
|
|
||||||
|
|
||||||
return extmark_count
|
|
||||||
end
|
|
||||||
|
|
||||||
---@class diffs.HunkOpts
|
|
||||||
---@field hide_prefix boolean
|
|
||||||
---@field highlights diffs.Highlights
|
|
||||||
|
|
||||||
---@param bufnr integer
|
|
||||||
---@param ns integer
|
|
||||||
---@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 code = table.concat(code_lines, '\n')
|
|
||||||
if code == '' then
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, code, lang)
|
|
||||||
if not ok or not parser_obj then
|
|
||||||
dbg('failed to create parser for lang: %s', lang)
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
local trees = parser_obj:parse(true)
|
|
||||||
if not trees or #trees == 0 then
|
|
||||||
dbg('parse returned no trees for lang: %s', lang)
|
|
||||||
return 0
|
|
||||||
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, 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 = 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)
|
|
||||||
|
|
||||||
return extmark_count
|
|
||||||
end
|
|
||||||
|
|
||||||
---@alias diffs.SyntaxQueryFn fun(line: integer, col: integer): integer, string
|
|
||||||
|
|
||||||
---@param query_fn diffs.SyntaxQueryFn
|
|
||||||
---@param code_lines string[]
|
|
||||||
---@return {line: integer, col_start: integer, col_end: integer, hl_name: string}[]
|
|
||||||
function M.coalesce_syntax_spans(query_fn, code_lines)
|
|
||||||
local spans = {}
|
|
||||||
for i, line in ipairs(code_lines) do
|
|
||||||
local col = 1
|
|
||||||
local line_len = #line
|
|
||||||
|
|
||||||
while col <= line_len do
|
|
||||||
local syn_id, hl_name = query_fn(i, col)
|
|
||||||
if syn_id == 0 then
|
|
||||||
col = col + 1
|
|
||||||
else
|
|
||||||
local span_start = col
|
|
||||||
|
|
||||||
col = col + 1
|
|
||||||
while col <= line_len do
|
|
||||||
local next_id, next_name = query_fn(i, col)
|
|
||||||
if next_id == 0 or next_name ~= hl_name then
|
|
||||||
break
|
|
||||||
end
|
|
||||||
col = col + 1
|
|
||||||
end
|
|
||||||
|
|
||||||
if hl_name ~= '' then
|
|
||||||
table.insert(spans, {
|
|
||||||
line = i,
|
|
||||||
col_start = span_start,
|
|
||||||
col_end = col,
|
|
||||||
hl_name = hl_name,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return spans
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param bufnr integer
|
|
||||||
---@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 ft = hunk.ft
|
|
||||||
if not ft then
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
if #code_lines == 0 then
|
|
||||||
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 })
|
|
||||||
|
|
||||||
local spans = {}
|
|
||||||
|
|
||||||
vim.api.nvim_buf_call(scratch, function()
|
|
||||||
vim.cmd('setlocal syntax=' .. ft)
|
|
||||||
vim.cmd('redraw')
|
|
||||||
|
|
||||||
---@param line integer
|
|
||||||
---@param col integer
|
|
||||||
---@return integer, string
|
|
||||||
local function query_fn(line, col)
|
|
||||||
local syn_id = vim.fn.synID(line, col, 1)
|
|
||||||
if syn_id == 0 then
|
|
||||||
return 0, ''
|
|
||||||
end
|
|
||||||
return syn_id, vim.fn.synIDattr(vim.fn.synIDtrans(syn_id), 'name')
|
|
||||||
end
|
|
||||||
|
|
||||||
spans = M.coalesce_syntax_spans(query_fn, code_lines)
|
|
||||||
end)
|
|
||||||
|
|
||||||
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
|
|
||||||
end
|
|
||||||
|
|
||||||
return extmark_count
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param bufnr integer
|
|
||||||
---@param ns integer
|
|
||||||
---@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 max_lines = use_ts and opts.highlights.treesitter.max_lines or opts.highlights.vim.max_lines
|
|
||||||
if (use_ts or use_vim) and #hunk.lines > max_lines then
|
|
||||||
dbg(
|
|
||||||
'skipping hunk %s:%d (%d lines > %d max)',
|
|
||||||
hunk.filename,
|
|
||||||
hunk.start_line,
|
|
||||||
#hunk.lines,
|
|
||||||
max_lines
|
|
||||||
)
|
|
||||||
use_ts = false
|
|
||||||
use_vim = false
|
|
||||||
end
|
|
||||||
|
|
||||||
---@type table<integer, true>
|
|
||||||
local covered_lines = {}
|
|
||||||
|
|
||||||
local ctx_cfg = opts.highlights.context
|
|
||||||
local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0
|
|
||||||
local leading = {}
|
|
||||||
local trailing = {}
|
|
||||||
if (use_ts or use_vim) and context > 0 and hunk.file_new_start and hunk.repo_root then
|
|
||||||
local filepath = vim.fs.joinpath(hunk.repo_root, hunk.filename)
|
|
||||||
local lead_from = math.max(1, hunk.file_new_start - context)
|
|
||||||
local lead_count = hunk.file_new_start - lead_from
|
|
||||||
if lead_count > 0 then
|
|
||||||
leading = read_line_range(filepath, lead_from, lead_count)
|
|
||||||
end
|
|
||||||
local trail_from = hunk.file_new_start + (hunk.file_new_count or 0)
|
|
||||||
trailing = read_line_range(filepath, trail_from, context)
|
|
||||||
end
|
|
||||||
|
|
||||||
local extmark_count = 0
|
|
||||||
if use_ts then
|
|
||||||
---@type string[]
|
|
||||||
local new_code = {}
|
|
||||||
---@type table<integer, integer>
|
|
||||||
local new_map = {}
|
|
||||||
---@type string[]
|
|
||||||
local old_code = {}
|
|
||||||
---@type table<integer, integer>
|
|
||||||
local old_map = {}
|
|
||||||
|
|
||||||
for _, pad_line in ipairs(leading) do
|
|
||||||
table.insert(new_code, pad_line)
|
|
||||||
table.insert(old_code, pad_line)
|
|
||||||
end
|
|
||||||
|
|
||||||
for i, line in ipairs(hunk.lines) do
|
|
||||||
local prefix = line:sub(1, 1)
|
|
||||||
local stripped = line:sub(2)
|
|
||||||
local buf_line = hunk.start_line + i - 1
|
|
||||||
|
|
||||||
if prefix == '+' then
|
|
||||||
new_map[#new_code] = buf_line
|
|
||||||
table.insert(new_code, stripped)
|
|
||||||
elseif prefix == '-' then
|
|
||||||
old_map[#old_code] = buf_line
|
|
||||||
table.insert(old_code, stripped)
|
|
||||||
else
|
|
||||||
new_map[#new_code] = buf_line
|
|
||||||
table.insert(new_code, stripped)
|
|
||||||
table.insert(old_code, stripped)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
for _, pad_line in ipairs(trailing) do
|
|
||||||
table.insert(new_code, pad_line)
|
|
||||||
table.insert(old_code, pad_line)
|
|
||||||
end
|
|
||||||
|
|
||||||
extmark_count = highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines)
|
|
||||||
extmark_count = extmark_count
|
|
||||||
+ highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines)
|
|
||||||
|
|
||||||
if hunk.header_context and hunk.header_context_col then
|
|
||||||
local header_line = hunk.start_line - 1
|
|
||||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
|
|
||||||
end_col = hunk.header_context_col + #hunk.header_context,
|
|
||||||
hl_group = 'DiffsClear',
|
|
||||||
priority = PRIORITY_CLEAR,
|
|
||||||
})
|
|
||||||
local header_extmarks = highlight_text(
|
|
||||||
bufnr,
|
|
||||||
ns,
|
|
||||||
hunk,
|
|
||||||
hunk.header_context_col,
|
|
||||||
hunk.header_context,
|
|
||||||
hunk.lang,
|
|
||||||
new_code
|
|
||||||
)
|
|
||||||
if header_extmarks > 0 then
|
|
||||||
dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks)
|
|
||||||
end
|
|
||||||
extmark_count = extmark_count + header_extmarks
|
|
||||||
end
|
|
||||||
elseif use_vim then
|
|
||||||
---@type string[]
|
|
||||||
local code_lines = {}
|
|
||||||
for _, pad_line in ipairs(leading) do
|
|
||||||
table.insert(code_lines, pad_line)
|
|
||||||
end
|
|
||||||
for _, line in ipairs(hunk.lines) do
|
|
||||||
table.insert(code_lines, line:sub(2))
|
|
||||||
end
|
|
||||||
for _, pad_line in ipairs(trailing) do
|
|
||||||
table.insert(code_lines, pad_line)
|
|
||||||
end
|
|
||||||
extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, #leading)
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
for i, line in ipairs(hunk.lines) do
|
|
||||||
local buf_line = hunk.start_line + i - 1
|
|
||||||
local line_len = #line
|
|
||||||
local prefix = line:sub(1, 1)
|
|
||||||
|
|
||||||
local is_diff_line = prefix == '+' or prefix == '-'
|
|
||||||
local line_hl = is_diff_line and (prefix == '+' and 'DiffsAdd' or 'DiffsDelete') or nil
|
|
||||||
local number_hl = is_diff_line and (prefix == '+' and 'DiffsAddNr' or 'DiffsDeleteNr') or nil
|
|
||||||
|
|
||||||
if opts.hide_prefix then
|
|
||||||
local virt_hl = (opts.highlights.background and line_hl) or nil
|
|
||||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
|
|
||||||
virt_text = { { ' ', virt_hl } },
|
|
||||||
virt_text_pos = 'overlay',
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
if line_len > 1 and covered_lines[buf_line] then
|
|
||||||
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
|
|
||||||
end_col = line_len,
|
|
||||||
hl_group = 'DiffsClear',
|
|
||||||
priority = PRIORITY_CLEAR,
|
|
||||||
})
|
|
||||||
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)
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
|
|
@ -1,580 +0,0 @@
|
||||||
---@class diffs.TreesitterConfig
|
|
||||||
---@field enabled boolean
|
|
||||||
---@field max_lines integer
|
|
||||||
|
|
||||||
---@class diffs.VimConfig
|
|
||||||
---@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 debug boolean
|
|
||||||
---@field debounce_ms integer
|
|
||||||
---@field hide_prefix boolean
|
|
||||||
---@field highlights diffs.Highlights
|
|
||||||
---@field fugitive diffs.FugitiveConfig
|
|
||||||
---@field conflict diffs.ConflictConfig
|
|
||||||
|
|
||||||
---@class diffs
|
|
||||||
---@field attach fun(bufnr?: integer)
|
|
||||||
---@field refresh fun(bufnr?: integer)
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
local highlight = require('diffs.highlight')
|
|
||||||
local log = require('diffs.log')
|
|
||||||
local parser = require('diffs.parser')
|
|
||||||
|
|
||||||
local ns = vim.api.nvim_create_namespace('diffs')
|
|
||||||
|
|
||||||
---@param hex integer
|
|
||||||
---@param bg_hex integer
|
|
||||||
---@param alpha number
|
|
||||||
---@return integer
|
|
||||||
local function blend_color(hex, bg_hex, alpha)
|
|
||||||
---@diagnostic disable: undefined-global
|
|
||||||
local r = bit.band(bit.rshift(hex, 16), 0xFF)
|
|
||||||
local g = bit.band(bit.rshift(hex, 8), 0xFF)
|
|
||||||
local b = bit.band(hex, 0xFF)
|
|
||||||
|
|
||||||
local bg_r = bit.band(bit.rshift(bg_hex, 16), 0xFF)
|
|
||||||
local bg_g = bit.band(bit.rshift(bg_hex, 8), 0xFF)
|
|
||||||
local bg_b = bit.band(bg_hex, 0xFF)
|
|
||||||
|
|
||||||
local blend_r = math.floor(r * alpha + bg_r * (1 - alpha))
|
|
||||||
local blend_g = math.floor(g * alpha + bg_g * (1 - alpha))
|
|
||||||
local blend_b = math.floor(b * alpha + bg_b * (1 - alpha))
|
|
||||||
|
|
||||||
return bit.bor(bit.lshift(blend_r, 16), bit.lshift(blend_g, 8), blend_b)
|
|
||||||
---@diagnostic enable: undefined-global
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param name string
|
|
||||||
---@return table
|
|
||||||
local function resolve_hl(name)
|
|
||||||
local hl = vim.api.nvim_get_hl(0, { name = name })
|
|
||||||
while hl.link do
|
|
||||||
hl = vim.api.nvim_get_hl(0, { name = hl.link })
|
|
||||||
end
|
|
||||||
return hl
|
|
||||||
end
|
|
||||||
|
|
||||||
---@type diffs.Config
|
|
||||||
local default_config = {
|
|
||||||
debug = false,
|
|
||||||
debounce_ms = 0,
|
|
||||||
hide_prefix = false,
|
|
||||||
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 = {}
|
|
||||||
|
|
||||||
---@type table<integer, boolean>
|
|
||||||
local diff_windows = {}
|
|
||||||
|
|
||||||
---@param bufnr integer
|
|
||||||
---@return boolean
|
|
||||||
function M.is_fugitive_buffer(bufnr)
|
|
||||||
return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local dbg = log.dbg
|
|
||||||
|
|
||||||
---@param bufnr integer
|
|
||||||
local function highlight_buffer(bufnr)
|
|
||||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
|
|
||||||
|
|
||||||
local hunks = parser.parse_buffer(bufnr)
|
|
||||||
dbg('found %d hunks in buffer %d', #hunks, bufnr)
|
|
||||||
for _, hunk in ipairs(hunks) do
|
|
||||||
highlight.highlight_hunk(bufnr, ns, hunk, {
|
|
||||||
hide_prefix = config.hide_prefix,
|
|
||||||
highlights = config.highlights,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param bufnr integer
|
|
||||||
---@return fun()
|
|
||||||
local function create_debounced_highlight(bufnr)
|
|
||||||
local timer = nil ---@type table?
|
|
||||||
return function()
|
|
||||||
if timer then
|
|
||||||
timer:stop() ---@diagnostic disable-line: undefined-field
|
|
||||||
timer:close() ---@diagnostic disable-line: undefined-field
|
|
||||||
timer = nil
|
|
||||||
end
|
|
||||||
local t = vim.uv.new_timer()
|
|
||||||
if not t then
|
|
||||||
highlight_buffer(bufnr)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
timer = t
|
|
||||||
t:start(
|
|
||||||
config.debounce_ms,
|
|
||||||
0,
|
|
||||||
vim.schedule_wrap(function()
|
|
||||||
if timer == t then
|
|
||||||
timer = nil
|
|
||||||
t:close()
|
|
||||||
end
|
|
||||||
highlight_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local 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
|
|
||||||
return
|
|
||||||
end
|
|
||||||
attached_buffers[bufnr] = true
|
|
||||||
|
|
||||||
dbg('attaching to buffer %d', bufnr)
|
|
||||||
|
|
||||||
local debounced = create_debounced_highlight(bufnr)
|
|
||||||
|
|
||||||
highlight_buffer(bufnr)
|
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
|
|
||||||
buffer = bufnr,
|
|
||||||
callback = debounced,
|
|
||||||
})
|
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd('Syntax', {
|
|
||||||
buffer = bufnr,
|
|
||||||
callback = function()
|
|
||||||
dbg('syntax event, re-highlighting buffer %d', bufnr)
|
|
||||||
highlight_buffer(bufnr)
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd('BufReadPost', {
|
|
||||||
buffer = bufnr,
|
|
||||||
callback = function()
|
|
||||||
dbg('BufReadPost event, re-highlighting buffer %d', bufnr)
|
|
||||||
highlight_buffer(bufnr)
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd('BufWipeout', {
|
|
||||||
buffer = bufnr,
|
|
||||||
callback = function()
|
|
||||||
attached_buffers[bufnr] = nil
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param bufnr? integer
|
|
||||||
function M.refresh(bufnr)
|
|
||||||
bufnr = bufnr or vim.api.nvim_get_current_buf()
|
|
||||||
highlight_buffer(bufnr)
|
|
||||||
end
|
|
||||||
|
|
||||||
local DIFF_WINHIGHLIGHT = table.concat({
|
|
||||||
'DiffAdd:DiffsDiffAdd',
|
|
||||||
'DiffDelete:DiffsDiffDelete',
|
|
||||||
'DiffChange:DiffsDiffChange',
|
|
||||||
'DiffText:DiffsDiffText',
|
|
||||||
}, ',')
|
|
||||||
|
|
||||||
function M.attach_diff()
|
|
||||||
init()
|
|
||||||
local tabpage = vim.api.nvim_get_current_tabpage()
|
|
||||||
local wins = vim.api.nvim_tabpage_list_wins(tabpage)
|
|
||||||
|
|
||||||
local diff_wins = {}
|
|
||||||
|
|
||||||
for _, win in ipairs(wins) do
|
|
||||||
if vim.api.nvim_win_is_valid(win) and vim.wo[win].diff then
|
|
||||||
table.insert(diff_wins, win)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if #diff_wins == 0 then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
for _, win in ipairs(diff_wins) do
|
|
||||||
vim.api.nvim_set_option_value('winhighlight', DIFF_WINHIGHLIGHT, { win = win })
|
|
||||||
diff_windows[win] = true
|
|
||||||
dbg('applied diff winhighlight to window %d', win)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.detach_diff()
|
|
||||||
for win, _ in pairs(diff_windows) do
|
|
||||||
if vim.api.nvim_win_is_valid(win) then
|
|
||||||
vim.api.nvim_set_option_value('winhighlight', '', { win = win })
|
|
||||||
end
|
|
||||||
diff_windows[win] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
---@return diffs.FugitiveConfig
|
|
||||||
function M.get_fugitive_config()
|
|
||||||
init()
|
|
||||||
return config.fugitive
|
|
||||||
end
|
|
||||||
|
|
||||||
---@return diffs.ConflictConfig
|
|
||||||
function M.get_conflict_config()
|
|
||||||
init()
|
|
||||||
return config.conflict
|
|
||||||
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
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
local M = {}
|
|
||||||
|
|
||||||
local enabled = false
|
|
||||||
|
|
||||||
---@param val boolean
|
|
||||||
function M.set_enabled(val)
|
|
||||||
enabled = val
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param msg string
|
|
||||||
---@param ... any
|
|
||||||
function M.dbg(msg, ...)
|
|
||||||
if not enabled then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG)
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
---@class diffs.Hunk
|
|
||||||
---@field filename string
|
|
||||||
---@field ft string?
|
|
||||||
---@field lang string?
|
|
||||||
---@field start_line integer
|
|
||||||
---@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 ft = vim.filetype.match({ filename = filename })
|
|
||||||
if ft then
|
|
||||||
dbg('filetype from filename: %s', ft)
|
|
||||||
return ft
|
|
||||||
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
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param ft string
|
|
||||||
---@return string?
|
|
||||||
local function get_lang_from_ft(ft)
|
|
||||||
local lang = vim.treesitter.language.get_lang(ft)
|
|
||||||
if lang then
|
|
||||||
local ok = pcall(vim.treesitter.language.inspect, lang)
|
|
||||||
if ok then
|
|
||||||
return lang
|
|
||||||
end
|
|
||||||
dbg('no parser for lang: %s (ft: %s)', lang, ft)
|
|
||||||
else
|
|
||||||
dbg('no ts lang for filetype: %s', ft)
|
|
||||||
end
|
|
||||||
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 = {}
|
|
||||||
|
|
||||||
---@type string?
|
|
||||||
local current_filename = nil
|
|
||||||
---@type string?
|
|
||||||
local current_ft = nil
|
|
||||||
---@type string?
|
|
||||||
local current_lang = nil
|
|
||||||
---@type integer?
|
|
||||||
local hunk_start = nil
|
|
||||||
---@type string?
|
|
||||||
local hunk_header_context = nil
|
|
||||||
---@type integer?
|
|
||||||
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 = {
|
|
||||||
filename = current_filename,
|
|
||||||
ft = current_ft,
|
|
||||||
lang = current_lang,
|
|
||||||
start_line = hunk_start,
|
|
||||||
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
|
|
||||||
local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$')
|
|
||||||
if filename then
|
|
||||||
flush_hunk()
|
|
||||||
current_filename = filename
|
|
||||||
current_ft = get_ft_from_filename(filename, repo_root)
|
|
||||||
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
|
|
||||||
table.insert(hunk_lines, line)
|
|
||||||
elseif
|
|
||||||
line == ''
|
|
||||||
or line:match('^[MADRC%?!]%s+')
|
|
||||||
or line:match('^diff ')
|
|
||||||
or line:match('^index ')
|
|
||||||
or line:match('^Binary ')
|
|
||||||
then
|
|
||||||
flush_hunk()
|
|
||||||
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()
|
|
||||||
|
|
||||||
return hunks
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
44
lua/fugitive-ts/health.lua
Normal file
44
lua/fugitive-ts/health.lua
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.check()
|
||||||
|
vim.health.start('fugitive-ts.nvim')
|
||||||
|
|
||||||
|
if vim.fn.has('nvim-0.9.0') == 1 then
|
||||||
|
vim.health.ok('Neovim 0.9.0+ detected')
|
||||||
|
else
|
||||||
|
vim.health.error('fugitive-ts.nvim requires Neovim 0.9.0+')
|
||||||
|
end
|
||||||
|
|
||||||
|
local fugitive_loaded = vim.fn.exists(':Git') == 2
|
||||||
|
if fugitive_loaded then
|
||||||
|
vim.health.ok('vim-fugitive detected')
|
||||||
|
else
|
||||||
|
vim.health.warn('vim-fugitive not detected (required for this plugin to be useful)')
|
||||||
|
end
|
||||||
|
|
||||||
|
---@type string[]
|
||||||
|
local common_langs = { 'lua', 'python', 'javascript', 'typescript', 'rust', 'go', 'c', 'cpp' }
|
||||||
|
---@type string[]
|
||||||
|
local available = {}
|
||||||
|
---@type string[]
|
||||||
|
local missing = {}
|
||||||
|
|
||||||
|
for _, lang in ipairs(common_langs) do
|
||||||
|
local ok = pcall(vim.treesitter.language.inspect, lang)
|
||||||
|
if ok then
|
||||||
|
table.insert(available, lang)
|
||||||
|
else
|
||||||
|
table.insert(missing, lang)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #available > 0 then
|
||||||
|
vim.health.ok('Treesitter parsers available: ' .. table.concat(available, ', '))
|
||||||
|
end
|
||||||
|
|
||||||
|
if #missing > 0 then
|
||||||
|
vim.health.info('Treesitter parsers not installed: ' .. table.concat(missing, ', '))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
168
lua/fugitive-ts/highlight.lua
Normal file
168
lua/fugitive-ts/highlight.lua
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
---@param msg string
|
||||||
|
---@param ... any
|
||||||
|
local function dbg(msg, ...)
|
||||||
|
local formatted = string.format(msg, ...)
|
||||||
|
vim.notify('[fugitive-ts] ' .. formatted, vim.log.levels.DEBUG)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param ns integer
|
||||||
|
---@param hunk fugitive-ts.Hunk
|
||||||
|
---@param col_offset integer
|
||||||
|
---@param text string
|
||||||
|
---@param lang string
|
||||||
|
---@param debug? boolean
|
||||||
|
---@return integer
|
||||||
|
local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, debug)
|
||||||
|
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, text, lang)
|
||||||
|
if not ok or not parser_obj then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local trees = parser_obj:parse()
|
||||||
|
if not trees or #trees == 0 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local query = vim.treesitter.query.get(lang, 'highlights')
|
||||||
|
if not query then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local extmark_count = 0
|
||||||
|
local header_line = hunk.start_line - 1
|
||||||
|
|
||||||
|
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 + sr
|
||||||
|
local buf_er = header_line + er
|
||||||
|
local buf_sc = col_offset + sc
|
||||||
|
local buf_ec = col_offset + ec
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param ns integer
|
||||||
|
---@param hunk fugitive-ts.Hunk
|
||||||
|
---@param max_lines integer
|
||||||
|
---@param highlight_headers boolean
|
||||||
|
---@param debug? boolean
|
||||||
|
function M.highlight_hunk(bufnr, ns, hunk, max_lines, highlight_headers, debug)
|
||||||
|
local lang = hunk.lang
|
||||||
|
if not lang then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if #hunk.lines > max_lines then
|
||||||
|
if debug then
|
||||||
|
dbg(
|
||||||
|
'skipping hunk %s:%d (%d lines > %d max)',
|
||||||
|
hunk.filename,
|
||||||
|
hunk.start_line,
|
||||||
|
#hunk.lines,
|
||||||
|
max_lines
|
||||||
|
)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
---@type string[]
|
||||||
|
local code_lines = {}
|
||||||
|
for _, line in ipairs(hunk.lines) do
|
||||||
|
table.insert(code_lines, line:sub(2))
|
||||||
|
end
|
||||||
|
|
||||||
|
local code = table.concat(code_lines, '\n')
|
||||||
|
if code == '' then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, parser_obj = pcall(vim.treesitter.get_string_parser, code, lang)
|
||||||
|
if not ok or not parser_obj then
|
||||||
|
if debug then
|
||||||
|
dbg('failed to create parser for lang: %s', lang)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local trees = parser_obj:parse()
|
||||||
|
if not trees or #trees == 0 then
|
||||||
|
if debug then
|
||||||
|
dbg('parse returned no trees for lang: %s', lang)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local query = vim.treesitter.query.get(lang, 'highlights')
|
||||||
|
if not query then
|
||||||
|
if debug then
|
||||||
|
dbg('no highlights query for lang: %s', lang)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if highlight_headers and hunk.header_context and hunk.header_context_col then
|
||||||
|
local header_line = hunk.start_line - 1
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
|
||||||
|
end_col = hunk.header_context_col + #hunk.header_context,
|
||||||
|
hl_group = 'Normal',
|
||||||
|
priority = 199,
|
||||||
|
})
|
||||||
|
local header_extmarks =
|
||||||
|
highlight_text(bufnr, ns, hunk, hunk.header_context_col, hunk.header_context, lang, debug)
|
||||||
|
if debug and header_extmarks > 0 then
|
||||||
|
dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for i, line in ipairs(hunk.lines) do
|
||||||
|
local buf_line = hunk.start_line + i - 1
|
||||||
|
local line_len = #line
|
||||||
|
if line_len > 1 then
|
||||||
|
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, {
|
||||||
|
end_col = line_len,
|
||||||
|
hl_group = 'Normal',
|
||||||
|
priority = 199,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local extmark_count = 0
|
||||||
|
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()
|
||||||
|
|
||||||
|
local buf_sr = hunk.start_line + sr
|
||||||
|
local buf_er = hunk.start_line + er
|
||||||
|
local buf_sc = sc + 1
|
||||||
|
local buf_ec = ec + 1
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if debug then
|
||||||
|
dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
145
lua/fugitive-ts/init.lua
Normal file
145
lua/fugitive-ts/init.lua
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
---@class fugitive-ts.Config
|
||||||
|
---@field enabled boolean
|
||||||
|
---@field debug boolean
|
||||||
|
---@field languages table<string, string>
|
||||||
|
---@field disabled_languages string[]
|
||||||
|
---@field highlight_headers boolean
|
||||||
|
---@field debounce_ms integer
|
||||||
|
---@field max_lines_per_hunk integer
|
||||||
|
|
||||||
|
---@class fugitive-ts
|
||||||
|
---@field attach fun(bufnr?: integer)
|
||||||
|
---@field refresh fun(bufnr?: integer)
|
||||||
|
---@field setup fun(opts?: fugitive-ts.Config)
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local highlight = require('fugitive-ts.highlight')
|
||||||
|
local parser = require('fugitive-ts.parser')
|
||||||
|
|
||||||
|
local ns = vim.api.nvim_create_namespace('fugitive_ts')
|
||||||
|
|
||||||
|
---@type fugitive-ts.Config
|
||||||
|
local default_config = {
|
||||||
|
enabled = true,
|
||||||
|
debug = false,
|
||||||
|
languages = {},
|
||||||
|
disabled_languages = {},
|
||||||
|
highlight_headers = true,
|
||||||
|
debounce_ms = 50,
|
||||||
|
max_lines_per_hunk = 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
---@type fugitive-ts.Config
|
||||||
|
local config = vim.deepcopy(default_config)
|
||||||
|
|
||||||
|
---@type table<integer, boolean>
|
||||||
|
local attached_buffers = {}
|
||||||
|
|
||||||
|
---@param msg string
|
||||||
|
---@param ... any
|
||||||
|
local function dbg(msg, ...)
|
||||||
|
if not config.debug then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local formatted = string.format(msg, ...)
|
||||||
|
vim.notify('[fugitive-ts] ' .. formatted, vim.log.levels.DEBUG)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@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
|
||||||
|
|
||||||
|
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
|
||||||
|
|
||||||
|
local hunks =
|
||||||
|
parser.parse_buffer(bufnr, config.languages, config.disabled_languages, config.debug)
|
||||||
|
dbg('found %d hunks in buffer %d', #hunks, bufnr)
|
||||||
|
for _, hunk in ipairs(hunks) do
|
||||||
|
highlight.highlight_hunk(
|
||||||
|
bufnr,
|
||||||
|
ns,
|
||||||
|
hunk,
|
||||||
|
config.max_lines_per_hunk,
|
||||||
|
config.highlight_headers,
|
||||||
|
config.debug
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@return fun()
|
||||||
|
local function create_debounced_highlight(bufnr)
|
||||||
|
local timer = nil
|
||||||
|
return function()
|
||||||
|
if timer then
|
||||||
|
timer:stop() ---@diagnostic disable-line: undefined-field
|
||||||
|
timer:close() ---@diagnostic disable-line: undefined-field
|
||||||
|
end
|
||||||
|
timer = vim.uv.new_timer()
|
||||||
|
timer:start(
|
||||||
|
config.debounce_ms,
|
||||||
|
0,
|
||||||
|
vim.schedule_wrap(function()
|
||||||
|
timer:close()
|
||||||
|
timer = nil
|
||||||
|
highlight_buffer(bufnr)
|
||||||
|
end)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr? integer
|
||||||
|
function M.attach(bufnr)
|
||||||
|
bufnr = bufnr or vim.api.nvim_get_current_buf()
|
||||||
|
|
||||||
|
if attached_buffers[bufnr] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
attached_buffers[bufnr] = true
|
||||||
|
|
||||||
|
dbg('attaching to buffer %d', bufnr)
|
||||||
|
|
||||||
|
local debounced = create_debounced_highlight(bufnr)
|
||||||
|
|
||||||
|
highlight_buffer(bufnr)
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
|
||||||
|
buffer = bufnr,
|
||||||
|
callback = debounced,
|
||||||
|
})
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd('Syntax', {
|
||||||
|
buffer = bufnr,
|
||||||
|
callback = function()
|
||||||
|
dbg('syntax event, re-highlighting buffer %d', bufnr)
|
||||||
|
highlight_buffer(bufnr)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd('BufWipeout', {
|
||||||
|
buffer = bufnr,
|
||||||
|
callback = function()
|
||||||
|
attached_buffers[bufnr] = nil
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr? integer
|
||||||
|
function M.refresh(bufnr)
|
||||||
|
bufnr = bufnr or vim.api.nvim_get_current_buf()
|
||||||
|
highlight_buffer(bufnr)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param opts? fugitive-ts.Config
|
||||||
|
function M.setup(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
config = vim.tbl_deep_extend('force', default_config, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
132
lua/fugitive-ts/parser.lua
Normal file
132
lua/fugitive-ts/parser.lua
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
---@class fugitive-ts.Hunk
|
||||||
|
---@field filename string
|
||||||
|
---@field lang string
|
||||||
|
---@field start_line integer
|
||||||
|
---@field header_context string?
|
||||||
|
---@field header_context_col integer?
|
||||||
|
---@field lines string[]
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
---@param msg string
|
||||||
|
---@param ... any
|
||||||
|
local function dbg(msg, ...)
|
||||||
|
local formatted = string.format(msg, ...)
|
||||||
|
vim.notify('[fugitive-ts] ' .. formatted, vim.log.levels.DEBUG)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param filename string
|
||||||
|
---@param custom_langs? table<string, string>
|
||||||
|
---@param disabled_langs? string[]
|
||||||
|
---@param debug? boolean
|
||||||
|
---@return string?
|
||||||
|
local function get_lang_from_filename(filename, custom_langs, disabled_langs, debug)
|
||||||
|
if custom_langs and custom_langs[filename] then
|
||||||
|
return custom_langs[filename]
|
||||||
|
end
|
||||||
|
|
||||||
|
local ft = vim.filetype.match({ filename = filename })
|
||||||
|
if not ft then
|
||||||
|
if debug then
|
||||||
|
dbg('no filetype for: %s', filename)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local lang = vim.treesitter.language.get_lang(ft)
|
||||||
|
if lang then
|
||||||
|
if disabled_langs and vim.tbl_contains(disabled_langs, lang) then
|
||||||
|
if debug then
|
||||||
|
dbg('lang disabled: %s', lang)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local ok = pcall(vim.treesitter.language.inspect, lang)
|
||||||
|
if ok then
|
||||||
|
return lang
|
||||||
|
end
|
||||||
|
if debug then
|
||||||
|
dbg('no parser for lang: %s (ft: %s)', lang, ft)
|
||||||
|
end
|
||||||
|
elseif debug then
|
||||||
|
dbg('no ts lang for filetype: %s', ft)
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param bufnr integer
|
||||||
|
---@param custom_langs? table<string, string>
|
||||||
|
---@param disabled_langs? string[]
|
||||||
|
---@param debug? boolean
|
||||||
|
---@return fugitive-ts.Hunk[]
|
||||||
|
function M.parse_buffer(bufnr, custom_langs, disabled_langs, debug)
|
||||||
|
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||||
|
---@type fugitive-ts.Hunk[]
|
||||||
|
local hunks = {}
|
||||||
|
|
||||||
|
---@type string?
|
||||||
|
local current_filename = nil
|
||||||
|
---@type string?
|
||||||
|
local current_lang = nil
|
||||||
|
---@type integer?
|
||||||
|
local hunk_start = nil
|
||||||
|
---@type string?
|
||||||
|
local hunk_header_context = nil
|
||||||
|
---@type integer?
|
||||||
|
local hunk_header_context_col = nil
|
||||||
|
---@type string[]
|
||||||
|
local hunk_lines = {}
|
||||||
|
|
||||||
|
local function flush_hunk()
|
||||||
|
if hunk_start and #hunk_lines > 0 and current_lang then
|
||||||
|
table.insert(hunks, {
|
||||||
|
filename = current_filename,
|
||||||
|
lang = current_lang,
|
||||||
|
start_line = hunk_start,
|
||||||
|
header_context = hunk_header_context,
|
||||||
|
header_context_col = hunk_header_context_col,
|
||||||
|
lines = hunk_lines,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
hunk_start = nil
|
||||||
|
hunk_header_context = nil
|
||||||
|
hunk_header_context_col = nil
|
||||||
|
hunk_lines = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
for i, line in ipairs(lines) do
|
||||||
|
local filename = line:match('^[MADRC%?!]%s+(.+)$')
|
||||||
|
if filename then
|
||||||
|
flush_hunk()
|
||||||
|
current_filename = filename
|
||||||
|
current_lang = get_lang_from_filename(filename, custom_langs, disabled_langs, debug)
|
||||||
|
if debug and current_lang then
|
||||||
|
dbg('file: %s -> lang: %s', filename, current_lang)
|
||||||
|
end
|
||||||
|
elseif line:match('^@@.-@@') then
|
||||||
|
flush_hunk()
|
||||||
|
hunk_start = i
|
||||||
|
local prefix, context = line:match('^(@@.-@@%s*)(.*)')
|
||||||
|
if context and context ~= '' then
|
||||||
|
hunk_header_context = context
|
||||||
|
hunk_header_context_col = #prefix
|
||||||
|
end
|
||||||
|
elseif hunk_start then
|
||||||
|
local prefix = line:sub(1, 1)
|
||||||
|
if prefix == ' ' or prefix == '+' or prefix == '-' then
|
||||||
|
table.insert(hunk_lines, line)
|
||||||
|
elseif line == '' or line:match('^[MADRC%?!]%s+') or line:match('^%a') then
|
||||||
|
flush_hunk()
|
||||||
|
current_filename = nil
|
||||||
|
current_lang = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
flush_hunk()
|
||||||
|
|
||||||
|
return hunks
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
if vim.g.loaded_diffs then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
vim.g.loaded_diffs = 1
|
|
||||||
|
|
||||||
require('diffs.commands').setup()
|
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd('FileType', {
|
|
||||||
pattern = { 'fugitive', 'git' },
|
|
||||||
callback = function(args)
|
|
||||||
local diffs = require('diffs')
|
|
||||||
if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd('OptionSet', {
|
|
||||||
pattern = 'diff',
|
|
||||||
callback = function()
|
|
||||||
if vim.wo.diff then
|
|
||||||
require('diffs').attach_diff()
|
|
||||||
else
|
|
||||||
require('diffs').detach_diff()
|
|
||||||
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' })
|
|
||||||
11
plugin/fugitive-ts.lua
Normal file
11
plugin/fugitive-ts.lua
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
if vim.g.loaded_fugitive_ts then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
vim.g.loaded_fugitive_ts = 1
|
||||||
|
|
||||||
|
vim.api.nvim_create_autocmd('FileType', {
|
||||||
|
pattern = 'fugitive',
|
||||||
|
callback = function(args)
|
||||||
|
require('fugitive-ts').attach(args.buf)
|
||||||
|
end,
|
||||||
|
})
|
||||||
93
scripts/test-env.sh
Executable file
93
scripts/test-env.sh
Executable file
|
|
@ -0,0 +1,93 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
TEMP_DIR=$(mktemp -d)
|
||||||
|
|
||||||
|
echo "Creating test environment in $TEMP_DIR"
|
||||||
|
|
||||||
|
cd "$TEMP_DIR"
|
||||||
|
git init -q
|
||||||
|
|
||||||
|
cat > test.lua << 'EOF'
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.hello()
|
||||||
|
local msg = "hello world"
|
||||||
|
print(msg)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > test.py << 'EOF'
|
||||||
|
def hello():
|
||||||
|
msg = "hello world"
|
||||||
|
print(msg)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
hello()
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > test.js << 'EOF'
|
||||||
|
function hello() {
|
||||||
|
const msg = "hello world";
|
||||||
|
console.log(msg);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { hello };
|
||||||
|
EOF
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "initial commit"
|
||||||
|
|
||||||
|
cat >> test.lua << 'EOF'
|
||||||
|
|
||||||
|
function M.goodbye()
|
||||||
|
local msg = "goodbye world"
|
||||||
|
print(msg)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >> test.py << 'EOF'
|
||||||
|
|
||||||
|
def goodbye():
|
||||||
|
msg = "goodbye world"
|
||||||
|
print(msg)
|
||||||
|
return False
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >> test.js << 'EOF'
|
||||||
|
|
||||||
|
function goodbye() {
|
||||||
|
const msg = "goodbye world";
|
||||||
|
console.log(msg);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
git add test.lua
|
||||||
|
|
||||||
|
cat > init.lua << EOF
|
||||||
|
vim.opt.rtp:prepend('$PLUGIN_DIR')
|
||||||
|
vim.opt.rtp:prepend(vim.fn.stdpath('data') .. '/lazy/vim-fugitive')
|
||||||
|
|
||||||
|
require('fugitive-ts').setup({
|
||||||
|
debug = true,
|
||||||
|
})
|
||||||
|
|
||||||
|
vim.cmd('Git')
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Test repo created with:"
|
||||||
|
echo " - test.lua (staged changes)"
|
||||||
|
echo " - test.py (unstaged changes)"
|
||||||
|
echo " - test.js (unstaged changes)"
|
||||||
|
echo ""
|
||||||
|
echo "Opening neovim with fugitive..."
|
||||||
|
|
||||||
|
nvim -u init.lua
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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
|
|
||||||
error('Treesitter parser for ' .. lang .. ' not available. Neovim 0.10+ bundles lua parser.')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
ensure_parser('lua')
|
|
||||||
ensure_parser('vim')
|
|
||||||
|
|
||||||
local M = {}
|
|
||||||
|
|
||||||
function M.create_buffer(lines)
|
|
||||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
|
||||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
|
|
||||||
return bufnr
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.delete_buffer(bufnr)
|
|
||||||
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
|
||||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.get_extmarks(bufnr, ns)
|
|
||||||
return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,272 +0,0 @@
|
||||||
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
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.attach()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('accepts empty config', function()
|
|
||||||
vim.g.diffs = {}
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.attach()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('accepts full config', function()
|
|
||||||
vim.g.diffs = {
|
|
||||||
debug = true,
|
|
||||||
debounce_ms = 100,
|
|
||||||
hide_prefix = false,
|
|
||||||
highlights = {
|
|
||||||
background = true,
|
|
||||||
gutter = true,
|
|
||||||
treesitter = {
|
|
||||||
enabled = true,
|
|
||||||
max_lines = 1000,
|
|
||||||
},
|
|
||||||
vim = {
|
|
||||||
enabled = false,
|
|
||||||
max_lines = 200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.attach()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('accepts partial config', function()
|
|
||||||
vim.g.diffs = {
|
|
||||||
debounce_ms = 25,
|
|
||||||
}
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.attach()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
describe('attach', function()
|
|
||||||
local function create_buffer(lines)
|
|
||||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
|
||||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
|
|
||||||
return bufnr
|
|
||||||
end
|
|
||||||
|
|
||||||
local function delete_buffer(bufnr)
|
|
||||||
if vim.api.nvim_buf_is_valid(bufnr) then
|
|
||||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it('does not error on empty buffer', function()
|
|
||||||
local bufnr = create_buffer({})
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.attach(bufnr)
|
|
||||||
end)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('does not error on buffer with content', function()
|
|
||||||
local bufnr = create_buffer({
|
|
||||||
'M test.lua',
|
|
||||||
'@@ -1,1 +1,2 @@',
|
|
||||||
' local x = 1',
|
|
||||||
'+local y = 2',
|
|
||||||
})
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.attach(bufnr)
|
|
||||||
end)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('is idempotent', function()
|
|
||||||
local bufnr = create_buffer({})
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.attach(bufnr)
|
|
||||||
diffs.attach(bufnr)
|
|
||||||
diffs.attach(bufnr)
|
|
||||||
end)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
describe('refresh', function()
|
|
||||||
local function create_buffer(lines)
|
|
||||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
|
||||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
|
|
||||||
return bufnr
|
|
||||||
end
|
|
||||||
|
|
||||||
local function delete_buffer(bufnr)
|
|
||||||
if vim.api.nvim_buf_is_valid(bufnr) then
|
|
||||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it('does not error on unattached buffer', function()
|
|
||||||
local bufnr = create_buffer({})
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.refresh(bufnr)
|
|
||||||
end)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('does not error on attached buffer', function()
|
|
||||||
local bufnr = create_buffer({})
|
|
||||||
diffs.attach(bufnr)
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.refresh(bufnr)
|
|
||||||
end)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
describe('is_fugitive_buffer', function()
|
|
||||||
it('returns true for fugitive:// URLs', function()
|
|
||||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
|
||||||
vim.api.nvim_buf_set_name(bufnr, 'fugitive:///path/to/repo/.git//abc123:file.lua')
|
|
||||||
assert.is_true(diffs.is_fugitive_buffer(bufnr))
|
|
||||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('returns false for normal paths', function()
|
|
||||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
|
||||||
vim.api.nvim_buf_set_name(bufnr, '/home/user/project/file.lua')
|
|
||||||
assert.is_false(diffs.is_fugitive_buffer(bufnr))
|
|
||||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('returns false for empty buffer names', function()
|
|
||||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
|
||||||
assert.is_false(diffs.is_fugitive_buffer(bufnr))
|
|
||||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
describe('diff mode', function()
|
|
||||||
local function create_diff_window()
|
|
||||||
vim.cmd('new')
|
|
||||||
local win = vim.api.nvim_get_current_win()
|
|
||||||
local buf = vim.api.nvim_get_current_buf()
|
|
||||||
vim.wo[win].diff = true
|
|
||||||
return win, buf
|
|
||||||
end
|
|
||||||
|
|
||||||
local function close_window(win)
|
|
||||||
if vim.api.nvim_win_is_valid(win) then
|
|
||||||
vim.api.nvim_win_close(win, true)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe('attach_diff', function()
|
|
||||||
it('applies winhighlight to diff windows', function()
|
|
||||||
local win, _ = create_diff_window()
|
|
||||||
diffs.attach_diff()
|
|
||||||
|
|
||||||
local whl = vim.api.nvim_get_option_value('winhighlight', { win = win })
|
|
||||||
assert.is_not_nil(whl:match('DiffAdd:DiffsDiffAdd'))
|
|
||||||
assert.is_not_nil(whl:match('DiffDelete:DiffsDiffDelete'))
|
|
||||||
|
|
||||||
close_window(win)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('is idempotent', function()
|
|
||||||
local win, _ = create_diff_window()
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.attach_diff()
|
|
||||||
diffs.attach_diff()
|
|
||||||
diffs.attach_diff()
|
|
||||||
end)
|
|
||||||
|
|
||||||
local whl = vim.api.nvim_get_option_value('winhighlight', { win = win })
|
|
||||||
assert.is_not_nil(whl:match('DiffAdd:DiffsDiffAdd'))
|
|
||||||
|
|
||||||
close_window(win)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('applies to multiple diff windows', function()
|
|
||||||
local win1, _ = create_diff_window()
|
|
||||||
local win2, _ = create_diff_window()
|
|
||||||
diffs.attach_diff()
|
|
||||||
|
|
||||||
local whl1 = vim.api.nvim_get_option_value('winhighlight', { win = win1 })
|
|
||||||
local whl2 = vim.api.nvim_get_option_value('winhighlight', { win = win2 })
|
|
||||||
assert.is_not_nil(whl1:match('DiffAdd:DiffsDiffAdd'))
|
|
||||||
assert.is_not_nil(whl2:match('DiffAdd:DiffsDiffAdd'))
|
|
||||||
|
|
||||||
close_window(win1)
|
|
||||||
close_window(win2)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('ignores non-diff windows', function()
|
|
||||||
vim.cmd('new')
|
|
||||||
local non_diff_win = vim.api.nvim_get_current_win()
|
|
||||||
|
|
||||||
local diff_win, _ = create_diff_window()
|
|
||||||
diffs.attach_diff()
|
|
||||||
|
|
||||||
local non_diff_whl = vim.api.nvim_get_option_value('winhighlight', { win = non_diff_win })
|
|
||||||
local diff_whl = vim.api.nvim_get_option_value('winhighlight', { win = diff_win })
|
|
||||||
|
|
||||||
assert.are.equal('', non_diff_whl)
|
|
||||||
assert.is_not_nil(diff_whl:match('DiffAdd:DiffsDiffAdd'))
|
|
||||||
|
|
||||||
close_window(non_diff_win)
|
|
||||||
close_window(diff_win)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
describe('detach_diff', function()
|
|
||||||
it('clears winhighlight from tracked windows', function()
|
|
||||||
local win, _ = create_diff_window()
|
|
||||||
diffs.attach_diff()
|
|
||||||
diffs.detach_diff()
|
|
||||||
|
|
||||||
local whl = vim.api.nvim_get_option_value('winhighlight', { win = win })
|
|
||||||
assert.are.equal('', whl)
|
|
||||||
|
|
||||||
close_window(win)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('does not error when no windows are tracked', function()
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.detach_diff()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('handles already-closed windows gracefully', function()
|
|
||||||
local win, _ = create_diff_window()
|
|
||||||
diffs.attach_diff()
|
|
||||||
close_window(win)
|
|
||||||
|
|
||||||
assert.has_no.errors(function()
|
|
||||||
diffs.detach_diff()
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('clears all tracked windows', function()
|
|
||||||
local win1, _ = create_diff_window()
|
|
||||||
local win2, _ = create_diff_window()
|
|
||||||
diffs.attach_diff()
|
|
||||||
diffs.detach_diff()
|
|
||||||
|
|
||||||
local whl1 = vim.api.nvim_get_option_value('winhighlight', { win = win1 })
|
|
||||||
local whl2 = vim.api.nvim_get_option_value('winhighlight', { win = win2 })
|
|
||||||
assert.are.equal('', whl1)
|
|
||||||
assert.are.equal('', whl2)
|
|
||||||
|
|
||||||
close_window(win1)
|
|
||||||
close_window(win2)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
vim.cmd([[set runtimepath=$VIMRUNTIME]])
|
|
||||||
vim.opt.runtimepath:append('.')
|
|
||||||
vim.opt.packpath = {}
|
|
||||||
vim.opt.loadplugins = false
|
|
||||||
|
|
@ -1,504 +0,0 @@
|
||||||
require('spec.helpers')
|
|
||||||
local parser = require('diffs.parser')
|
|
||||||
|
|
||||||
describe('parser', function()
|
|
||||||
describe('parse_buffer', function()
|
|
||||||
local function create_buffer(lines)
|
|
||||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
|
||||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
|
||||||
return bufnr
|
|
||||||
end
|
|
||||||
|
|
||||||
local function delete_buffer(bufnr)
|
|
||||||
if vim.api.nvim_buf_is_valid(bufnr) then
|
|
||||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it('returns empty table for empty buffer', function()
|
|
||||||
local bufnr = create_buffer({})
|
|
||||||
local hunks = parser.parse_buffer(bufnr)
|
|
||||||
assert.are.same({}, hunks)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('returns empty table for buffer with no hunks', function()
|
|
||||||
local bufnr = create_buffer({
|
|
||||||
'Head: main',
|
|
||||||
'Help: g?',
|
|
||||||
'',
|
|
||||||
'Unstaged (1)',
|
|
||||||
'M lua/test.lua',
|
|
||||||
})
|
|
||||||
local hunks = parser.parse_buffer(bufnr)
|
|
||||||
assert.are.same({}, hunks)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('detects single hunk with lua file', function()
|
|
||||||
local bufnr = create_buffer({
|
|
||||||
'Unstaged (1)',
|
|
||||||
'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('lua/test.lua', hunks[1].filename)
|
|
||||||
assert.are.equal('lua', hunks[1].ft)
|
|
||||||
assert.are.equal('lua', hunks[1].lang)
|
|
||||||
assert.are.equal(3, hunks[1].start_line)
|
|
||||||
assert.are.equal(3, #hunks[1].lines)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('detects multiple hunks in same file', function()
|
|
||||||
local bufnr = create_buffer({
|
|
||||||
'M lua/test.lua',
|
|
||||||
'@@ -1,2 +1,2 @@',
|
|
||||||
' local M = {}',
|
|
||||||
'-local old = false',
|
|
||||||
'+local new = true',
|
|
||||||
'@@ -10,2 +10,3 @@',
|
|
||||||
' function M.foo()',
|
|
||||||
'+ print("hello")',
|
|
||||||
' end',
|
|
||||||
})
|
|
||||||
local hunks = parser.parse_buffer(bufnr)
|
|
||||||
|
|
||||||
assert.are.equal(2, #hunks)
|
|
||||||
assert.are.equal(2, hunks[1].start_line)
|
|
||||||
assert.are.equal(6, hunks[2].start_line)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('detects hunks across multiple files', function()
|
|
||||||
local orig_get_lang = vim.treesitter.language.get_lang
|
|
||||||
local orig_inspect = vim.treesitter.language.inspect
|
|
||||||
vim.treesitter.language.get_lang = function(ft)
|
|
||||||
local result = orig_get_lang(ft)
|
|
||||||
if result then
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
if ft == 'python' then
|
|
||||||
return 'python'
|
|
||||||
end
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
vim.treesitter.language.inspect = function(lang)
|
|
||||||
if lang == 'python' then
|
|
||||||
return {}
|
|
||||||
end
|
|
||||||
return orig_inspect(lang)
|
|
||||||
end
|
|
||||||
|
|
||||||
local bufnr = create_buffer({
|
|
||||||
'M lua/foo.lua',
|
|
||||||
'@@ -1,1 +1,2 @@',
|
|
||||||
' local M = {}',
|
|
||||||
'+local x = 1',
|
|
||||||
'M src/bar.py',
|
|
||||||
'@@ -1,1 +1,2 @@',
|
|
||||||
' def hello():',
|
|
||||||
'+ pass',
|
|
||||||
})
|
|
||||||
local hunks = parser.parse_buffer(bufnr)
|
|
||||||
|
|
||||||
vim.treesitter.language.get_lang = orig_get_lang
|
|
||||||
vim.treesitter.language.inspect = orig_inspect
|
|
||||||
|
|
||||||
assert.are.equal(2, #hunks)
|
|
||||||
assert.are.equal('lua/foo.lua', hunks[1].filename)
|
|
||||||
assert.are.equal('lua', hunks[1].lang)
|
|
||||||
assert.are.equal('src/bar.py', hunks[2].filename)
|
|
||||||
assert.are.equal('python', hunks[2].lang)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('extracts header context', function()
|
|
||||||
local bufnr = create_buffer({
|
|
||||||
'M lua/test.lua',
|
|
||||||
'@@ -10,3 +10,4 @@ function M.hello()',
|
|
||||||
' local msg = "hi"',
|
|
||||||
'+print(msg)',
|
|
||||||
' end',
|
|
||||||
})
|
|
||||||
local hunks = parser.parse_buffer(bufnr)
|
|
||||||
|
|
||||||
assert.are.equal(1, #hunks)
|
|
||||||
assert.are.equal('function M.hello()', hunks[1].header_context)
|
|
||||||
assert.is_not_nil(hunks[1].header_context_col)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('handles header without context', function()
|
|
||||||
local bufnr = create_buffer({
|
|
||||||
'M lua/test.lua',
|
|
||||||
'@@ -1,2 +1,3 @@',
|
|
||||||
' local M = {}',
|
|
||||||
'+local x = 1',
|
|
||||||
})
|
|
||||||
local hunks = parser.parse_buffer(bufnr)
|
|
||||||
|
|
||||||
assert.are.equal(1, #hunks)
|
|
||||||
assert.is_nil(hunks[1].header_context)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('handles all git status prefixes', function()
|
|
||||||
local prefixes = { 'M', 'A', 'D', 'R', 'C', '?', '!' }
|
|
||||||
for _, prefix in ipairs(prefixes) do
|
|
||||||
local bufnr = create_buffer({
|
|
||||||
prefix .. ' test.lua',
|
|
||||||
'@@ -1,1 +1,2 @@',
|
|
||||||
' local x = 1',
|
|
||||||
'+local y = 2',
|
|
||||||
})
|
|
||||||
local hunks = parser.parse_buffer(bufnr)
|
|
||||||
assert.are.equal(1, #hunks, 'Failed for prefix: ' .. prefix)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('stops hunk at blank line', function()
|
|
||||||
local bufnr = create_buffer({
|
|
||||||
'M test.lua',
|
|
||||||
'@@ -1,2 +1,3 @@',
|
|
||||||
' local x = 1',
|
|
||||||
'+local y = 2',
|
|
||||||
'',
|
|
||||||
'Some other content',
|
|
||||||
})
|
|
||||||
local hunks = parser.parse_buffer(bufnr)
|
|
||||||
|
|
||||||
assert.are.equal(1, #hunks)
|
|
||||||
assert.are.equal(2, #hunks[1].lines)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('emits hunk with ft when no ts parser available', function()
|
|
||||||
local bufnr = create_buffer({
|
|
||||||
'M test.xyz_no_parser',
|
|
||||||
'@@ -1,1 +1,2 @@',
|
|
||||||
' some content',
|
|
||||||
'+more content',
|
|
||||||
})
|
|
||||||
|
|
||||||
vim.filetype.add({ extension = { xyz_no_parser = 'xyz_no_parser_ft' } })
|
|
||||||
|
|
||||||
local hunks = parser.parse_buffer(bufnr)
|
|
||||||
|
|
||||||
assert.are.equal(1, #hunks)
|
|
||||||
assert.are.equal('xyz_no_parser_ft', hunks[1].ft)
|
|
||||||
assert.is_nil(hunks[1].lang)
|
|
||||||
assert.are.equal(2, #hunks[1].lines)
|
|
||||||
delete_buffer(bufnr)
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('stops hunk at next file header', function()
|
|
||||||
local bufnr = create_buffer({
|
|
||||||
'M test.lua',
|
|
||||||
'@@ -1,2 +1,3 @@',
|
|
||||||
' local x = 1',
|
|
||||||
'+local y = 2',
|
|
||||||
'M other.lua',
|
|
||||||
'@@ -1,1 +1,1 @@',
|
|
||||||
' local z = 3',
|
|
||||||
})
|
|
||||||
local hunks = parser.parse_buffer(bufnr)
|
|
||||||
|
|
||||||
assert.are.equal(2, #hunks)
|
|
||||||
assert.are.equal(2, #hunks[1].lines)
|
|
||||||
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)
|
|
||||||
3
vim.toml
3
vim.toml
|
|
@ -8,9 +8,6 @@ any = true
|
||||||
[jit]
|
[jit]
|
||||||
any = true
|
any = true
|
||||||
|
|
||||||
[bit]
|
|
||||||
any = true
|
|
||||||
|
|
||||||
[assert]
|
[assert]
|
||||||
any = true
|
any = true
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue