diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index 77049fd..c0b7770 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -25,6 +25,7 @@ jobs: - '*.lua' - '.luarc.json' - '*.toml' + - 'vim.yaml' markdown: - '*.md' @@ -35,11 +36,8 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - uses: JohnnyMorganz/stylua-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - version: 2.1.0 - args: --check . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command stylua --check . lua-lint: name: Lua Lint Check @@ -48,11 +46,8 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - name: Lint with Selene - uses: NTBBloodbath/selene-action@v1.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --display-style quiet . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command selene --display-style quiet . lua-typecheck: name: Lua Type Check @@ -75,15 +70,5 @@ jobs: if: ${{ needs.changes.outputs.markdown == 'true' }} steps: - uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 8 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - name: Install prettier - run: pnpm add -g prettier@3.1.0 - - name: Check markdown formatting with prettier - run: prettier --check . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command prettier --check . diff --git a/.gitignore b/.gitignore index d14787c..b7a91ec 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,10 @@ doc/tags CLAUDE.md .claude/ +bench/ node_modules/ + +result +result-* +.direnv/ +.envrc diff --git a/.luarc.json b/.luarc.json index b438cce..bfbf500 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,8 +1,15 @@ { - "runtime.version": "Lua 5.1", + "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim", "jit"], - "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], + "workspace.library": [ + "$VIMRUNTIME/lua", + "${3rd}/luv/library", + "${3rd}/busted/library", + "${3rd}/luassert/library" + ], "workspace.checkThirdParty": false, + "diagnostics.libraryFiles": "Disable", + "workspace.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" } diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 0000000..9b42106 --- /dev/null +++ b/.styluaignore @@ -0,0 +1 @@ +.direnv/ diff --git a/README.md b/README.md index f0a04a8..2a7bd73 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,24 @@ **Syntax highlighting for diffs in Neovim** -Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware -syntax highlighting. +Enhance [vim-fugitive](https://github.com/tpope/vim-fugitive), +[Neogit](https://github.com/NeogitOrg/neogit), and Neovim's built-in diff mode +with language-aware syntax highlighting. -![diffs.nvim preview](https://github.com/user-attachments/assets/d3d64c96-b824-4fcb-af7f-4aef3f7f498a) + ## Features -- Treesitter syntax highlighting in `:Git` diffs and commit views -- Diff header highlighting (`diff --git`, `index`, `---`, `+++`) -- `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds -- `:Gdiff` unified diff against any git revision with syntax highlighting -- Fugitive status buffer keymaps (`du`/`dU`) for unified diffs -- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`) -- Vim syntax fallback for languages without a treesitter parser -- Hunk header context highlighting (`@@ ... @@ function foo()`) -- Character-level (intra-line) diff highlighting for changed characters -- Inline merge conflict detection, highlighting, and resolution keymaps -- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and - highlight overrides +- Treesitter syntax highlighting in vim-fugitive, Neogit, and `diff` filetype +- Character-level intra-line diff highlighting (with optional + [vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for + word-level accuracy) +- `:Gdiff` unified diff against any revision +- Inline merge conflict detection, highlighting, and resolution +- Email quoting/patch syntax support (`> diff ...`) +- Vim syntax fallback +- Configurable highlighiting blend & priorities +- Context-inclusive, high-accuracy highlights ## Requirements @@ -41,18 +40,58 @@ luarocks install diffs.nvim :help diffs.nvim ``` +## FAQ + +**Q: How do I install with lazy.nvim?** + +```lua +{ + 'barrettruth/diffs.nvim', + init = function() + vim.g.diffs = { + ... + } + end, +} +``` + +Do not lazy load `diffs.nvim` with `event`, `lazy`, `ft`, `config`, or `keys` to +control loading - `diffs.nvim` lazy-loads itself. + +**Q: Does diffs.nvim support vim-fugitive/Neogit?** + +Yes. Enable it in your config: + +```lua +vim.g.diffs = { + fugitive = true, + neogit = true, +} +``` + +See the documentation for more information. + ## Known Limitations - **Incomplete syntax context**: Treesitter parses each diff hunk in isolation. - To improve accuracy, `diffs.nvim` reads lines from disk before and after each - hunk for parsing context (`highlights.context`, enabled by default with 25 - lines). This resolves most boundary issues. Set - `highlights.context.enabled = false` to disable. + Context lines within the hunk provide syntactic context for the parser. In + rare cases, hunks that start or end mid-expression may produce imperfect + highlights due to treesitter error recovery. -- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event +- **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`. + painted. The decoration provider applies highlights on the next redraw cycle, + causing a brief visual "flash". + +- **Cold Start**: Treesitter grammar loading (~10ms) and query compilation + (~4ms) are one-time costs per language per Neovim session. Each language pays + this cost on first encounter, which may cause a brief stutter when a diff + containing a new language first enters the viewport. + +- **Vim syntax fallback is deferred**: The vim syntax fallback (for languages + without a treesitter parser) cannot run inside the decoration provider's + redraw cycle due to Neovim's restriction on buffer mutations. Vim syntax + highlights for these hunks appear slightly delayed. - **Conflicting diff plugins**: `diffs.nvim` may not interact well with other plugins that modify diff highlighting. Known plugins that may conflict: @@ -70,11 +109,14 @@ luarocks install diffs.nvim # Acknowledgements - [`vim-fugitive`](https://github.com/tpope/vim-fugitive) -- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) +- [@esmuellert](https://github.com/esmuellert) / + [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - vscode-diff + algorithm FFI backend for word-level intra-line accuracy - [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) - [`difftastic`](https://github.com/Wilfred/difftastic) - [`mini.diff`](https://github.com/echasnovski/mini.diff) - [`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 + filetype fix, shebang/modeline detection, treesitter injection support, + decoration provider highlighting architecture diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 7663150..6288f0c 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -7,8 +7,8 @@ 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. +highlights on top of default diff highlighting in vim-fugitive, Neogit, and +Neovim's built-in diff mode. Features: ~ - Syntax highlighting in |:Git| summary diffs and commit detail views @@ -22,6 +22,26 @@ Features: ~ - Gutter (line number) highlighting - Inline merge conflict marker detection, highlighting, and resolution +============================================================================== +CONTENTS *diffs-contents* + + 1. Introduction ............................................... |diffs.nvim| + 2. Requirements ....................................... |diffs-requirements| + 3. Setup ..................................................... |diffs-setup| + 4. Configuration ............................................ |diffs-config| + 5. Commands ............................................... |diffs-commands| + 6. Mappings ............................................... |diffs-mappings| + 7. Fugitive Status Keymaps ................................ |diffs-fugitive| + 8. Conflict Resolution .................................... |diffs-conflict| + 9. Merge Diff Resolution ..................................... |diffs-merge| + 10. Neogit ................................................... |diffs-neogit| + 11. API ......................................................... |diffs-api| + 12. Implementation ................................... |diffs-implementation| + 13. Known Limitations ................................... |diffs-limitations| + 14. Highlight Groups ..................................... |diffs-highlights| + 15. Health Check ............................................. |diffs-health| + 16. Acknowledgements ............................... |diffs-acknowledgements| + ============================================================================== REQUIREMENTS *diffs-requirements* @@ -36,7 +56,7 @@ etc.) works without vim-fugitive. ============================================================================== SETUP *diffs-setup* -Using lazy.nvim: >lua +Install with lazy.nvim: >lua { 'barrettruth/diffs.nvim', dependencies = { 'tpope/vim-fugitive' }, @@ -45,6 +65,9 @@ Using lazy.nvim: >lua The plugin works automatically with no configuration required. For customization, see |diffs-config|. +NOTE: Load your colorscheme before `diffs.nvim`. For example, with lazy.nvim, +set `priority = 1000` and `lazy = false` on your colorscheme plugin. + ============================================================================== CONFIGURATION *diffs-config* @@ -52,8 +75,10 @@ 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, + fugitive = false, + neogit = false, + extra_filetypes = {}, highlights = { background = true, gutter = true, @@ -67,7 +92,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: max_lines = 500, }, vim = { - enabled = false, + enabled = true, max_lines = 200, }, intra = { @@ -75,23 +100,26 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: algorithm = 'default', max_lines = 500, }, + priorities = { + clear = 198, + syntax = 199, + line_bg = 200, + char_bg = 201, + }, overrides = {}, }, - fugitive = { - horizontal = 'du', - vertical = 'dU', - }, conflict = { enabled = true, disable_diagnostics = true, show_virtual_text = true, + show_actions = false, keymaps = { ours = 'doo', theirs = 'dot', both = 'dob', none = 'don', - next = ']x', - prev = '[x', + next = ']c', + prev = '[c', }, }, } @@ -102,11 +130,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: 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 @@ -114,14 +137,45 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: is also enabled, the overlay inherits the line's background color. + {fugitive} (boolean|table, default: false) + Enable vim-fugitive integration. Pass `true` + for defaults, `false` to disable, or a table + with sub-options (see |diffs.FugitiveConfig|). + Passing a table implicitly enables the + integration — no `enabled` field needed. + When active, the `fugitive` filetype is + registered and status buffer keymaps are set. >lua + vim.g.diffs = { fugitive = true } + vim.g.diffs = { + fugitive = { horizontal = 'dd' }, + } +< + + {neogit} (boolean|table, default: false) + Enable Neogit integration. Pass `true` or + `{}` to enable, `false` to disable. When + active, `NeogitStatus`, `NeogitCommitView`, + and `NeogitDiffView` filetypes are registered + and Neogit highlight overrides are applied. + See |diffs-neogit|. >lua + vim.g.diffs = { neogit = true } +< + + {extra_filetypes} (table, default: {}) + Additional filetypes to attach to, beyond the + built-in `git`, `gitcommit`, and any enabled + integration filetypes. Use this to enable + highlighting in plain `.diff` / `.patch` + files: >lua + vim.g.diffs = { + extra_filetypes = { 'diff' }, + } +< + {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. @@ -153,13 +207,17 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: See |diffs.TreesitterConfig| for fields. {vim} (table, default: see below) - Vim syntax highlighting options (experimental). + Vim syntax fallback highlighting options. See |diffs.VimConfig| for fields. {intra} (table, default: see below) Character-level (intra-line) diff highlighting. See |diffs.IntraConfig| for fields. + {priorities} (table, default: see below) + Extmark priority values. + See |diffs.PrioritiesConfig| for fields. + {overrides} (table, default: {}) Map of highlight group names to highlight definitions (see |nvim_set_hl()|). Applied @@ -170,16 +228,42 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: *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. + Read surrounding code from the working tree + file and feed it into the treesitter string + parser. Uses the hunk's `@@ +start,count @@` + line numbers to read lines before and after + the hunk from disk. Improves syntax accuracy + when the hunk is inside an incomplete construct + (e.g., a table literal or function body whose + opening is not visible in the hunk's own + context lines). {lines} (integer, default: 25) - Number of context lines to read in each - direction. Lines are read with early exit — - cost scales with this value, not file size. + Max context lines to read in each direction. + Files are read once per parse and cached across + hunks in the same file. + + *diffs.PrioritiesConfig* + Priorities config fields: ~ + {clear} (integer, default: 198) + Priority for `DiffsClear` extmarks that reset + underlying diff foreground colors. Must be + below {syntax}. + + {syntax} (integer, default: 199) + Priority for treesitter and vim syntax extmarks. + Must be below {line_bg} so that colorscheme + backgrounds on syntax groups do not obscure + line-level diff backgrounds. + + {line_bg} (integer, default: 200) + Priority for `DiffsAdd`/`DiffsDelete` line + background extmarks. Must be below {char_bg}. + + {char_bg} (integer, default: 201) + Priority for `DiffsAddText`/`DiffsDeleteText` + character-level background extmarks. Highest + priority so changed characters stand out. *diffs.TreesitterConfig* Treesitter config fields: ~ @@ -192,13 +276,15 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: *diffs.VimConfig* Vim config fields: ~ - {enabled} (boolean, default: false) + {enabled} (boolean, default: true) 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. + highlight groups. Deferred via |vim.schedule()| + so it never blocks the first paint. Slower than + treesitter but covers languages without a TS + parser installed (e.g., COBOL, Fortran). {max_lines} (integer, default: 200) Skip vim syntax highlighting for hunks larger than @@ -311,10 +397,36 @@ Example configuration: >lua vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)') vim.keymap.set('n', 'cb', '(diffs-conflict-both)') vim.keymap.set('n', 'cn', '(diffs-conflict-none)') - vim.keymap.set('n', ']x', '(diffs-conflict-next)') - vim.keymap.set('n', '[x', '(diffs-conflict-prev)') + vim.keymap.set('n', ']c', '(diffs-conflict-next)') + vim.keymap.set('n', '[c', '(diffs-conflict-prev)') < + *(diffs-merge-ours)* +(diffs-merge-ours) + Accept ours in a merge diff view. Resolves the + conflict in the working file with ours content. + + *(diffs-merge-theirs)* +(diffs-merge-theirs) + Accept theirs in a merge diff view. + + *(diffs-merge-both)* +(diffs-merge-both) + Accept both (ours then theirs) in a merge diff view. + + *(diffs-merge-none)* +(diffs-merge-none) + Reject both in a merge diff view. + + *(diffs-merge-next)* +(diffs-merge-next) + Jump to next unresolved conflict hunk in merge diff. + + *(diffs-merge-prev)* +(diffs-merge-prev) + Jump to previous unresolved conflict hunk in merge + diff. + Diff buffer mappings: ~ *diffs-q* q Close the diff window. Available in all `diffs://` @@ -345,6 +457,7 @@ Behavior by file status: ~ A Staged (empty) index file as all-added D Staged HEAD (empty) file as all-removed R Staged HEAD:oldname index:newname content diff + U Unstaged :2: (ours) :3: (theirs) merge diff ? Untracked (empty) working tree file as all-added On section headers, the keymap runs `git diff` (or `git diff --cached` for @@ -361,6 +474,9 @@ Configuration: ~ }, } < + Passing a table enables fugitive integration. Use `fugitive = false` + to disable. There is no `enabled` field; table presence is sufficient. + Fields: ~ {horizontal} (string|false, default: 'du') Keymap for unified diff in horizontal split. @@ -389,13 +505,15 @@ Configuration: ~ enabled = true, disable_diagnostics = true, show_virtual_text = true, + show_actions = false, + priority = 200, keymaps = { ours = 'doo', theirs = 'dot', both = 'dob', none = 'don', - next = ']x', - prev = '[x', + next = ']c', + prev = '[c', }, }, } @@ -415,9 +533,37 @@ Configuration: ~ diagnostics alone. {show_virtual_text} (boolean, default: true) - Show virtual text labels (" current" and - " incoming") at the end of `<<<<<<<` and - `>>>>>>>` marker lines. + Show `(current)` and `(incoming)` labels at + the end of `<<<<<<<` and `>>>>>>>` marker + lines. Also controls hunk hints in merge + diff views. + + {format_virtual_text} (function|nil, default: nil) + Custom formatter for virtual text labels. + Receives `(side, keymap)` where `side` is + `"ours"` or `"theirs"` and `keymap` is the + configured keymap string or `false`. Return + a string (label text without parens) or + `nil` to hide the label. Example: >lua + format_virtual_text = function(side, keymap) + if keymap then + return side .. ' [' .. keymap .. ']' + end + return side + end +< + + {show_actions} (boolean, default: false) + Show a codelens-style action line above each + `<<<<<<<` marker listing available resolution + keymaps. Renders as virtual lines using the + `DiffsConflictActions` highlight group. + Only keymaps that are not `false` appear. + + {priority} (integer, default: 200) + Extmark priority for conflict region + backgrounds and markers. Adjust if other + plugins use the same priority range. {keymaps} (table, default: see above) Buffer-local keymaps for conflict resolution @@ -438,10 +584,10 @@ Configuration: ~ {none} (string|false, default: 'don') Reject both changes (delete entire block). - {next} (string|false, default: ']x') + {next} (string|false, default: ']c') Jump to next conflict marker. Wraps around. - {prev} (string|false, default: '[x') + {prev} (string|false, default: '[c') Jump to previous conflict marker. Wraps around. @@ -458,6 +604,52 @@ User events: ~ }) < +============================================================================== +MERGE DIFF RESOLUTION *diffs-merge* + +When pressing `du`/`dU` on an unmerged (`U`) file in the fugitive status +buffer, diffs.nvim opens a unified diff of ours (`git show :2:path`) vs +theirs (`git show :3:path`) with full treesitter and intra-line highlighting. + +The same conflict resolution keymaps (`doo`/`dot`/`dob`/`don`/`]c`/`[c`) +are available on the diff buffer. They resolve conflicts in the working +file by matching diff hunks to conflict markers: + +- `doo` replaces the conflict region with ours content +- `dot` replaces the conflict region with theirs content +- `dob` replaces with both (ours then theirs) +- `don` removes the conflict region entirely +- `]c`/`[c` navigate between unresolved conflict hunks + +Resolved hunks are marked with `(resolved)` virtual text. Hunks that +correspond to auto-merged content (no conflict markers) show an +informational notification and are left unchanged. + +The working file buffer is modified in place; save it when ready. +Phase 1 inline conflict highlights (see |diffs-conflict|) are refreshed +automatically after each resolution. + +============================================================================== +NEOGIT *diffs-neogit* + +diffs.nvim works with Neogit (https://github.com/NeogitOrg/neogit) out of +the box. Enable Neogit support in your config: >lua + vim.g.diffs = { neogit = true } +< + +When a diff is expanded in a Neogit buffer (e.g., via TAB on a file in the +status view), diffs.nvim applies treesitter syntax highlighting and +intra-line diffs to the hunk lines, just as it does for fugitive. + +Neogit highlight overrides: ~ +On first attach to a Neogit buffer, diffs.nvim overrides Neogit's diff +highlight groups (`NeogitDiffAdd*`, `NeogitDiffDelete*`, +`NeogitDiffContext*`, `NeogitHunkHeader*`, `NeogitDiffHeader*`, etc.) by +setting them to empty (`{}`). This gives diffs.nvim sole control of diff +line visuals. The overrides are reapplied on `ColorScheme` since Neogit +re-defines its groups then. When `neogit = false`, no highlight overrides +are applied. + ============================================================================== API *diffs-api* @@ -479,8 +671,8 @@ refresh({bufnr}) *diffs.refresh()* IMPLEMENTATION *diffs-implementation* Summary / commit detail views: ~ -1. `FileType fugitive` or `FileType git` (for `fugitive://` buffers) - triggers |diffs.attach()| +1. `FileType` autocmd for computed filetypes (see |diffs-config|) triggers + |diffs.attach()|. For `git` buffers, only `fugitive://` URIs are attached. 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: @@ -489,13 +681,14 @@ Summary / commit detail views: ~ - Code is parsed with |vim.treesitter.get_string_parser()| - If no treesitter parser and `vim.enabled`: vim syntax fallback via scratch buffer and |synID()| - - `Normal` extmarks at priority 198 clear underlying diff foreground - - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199 - - Syntax highlights are applied as extmarks at priority 200 + - `DiffsClear` extmarks at priority 198 clear underlying diff foreground + - Syntax highlights are applied as extmarks at priority 199 + - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 200 - Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at priority 201 overlay changed characters with an intense background - Conceal extmarks hide diff prefixes when `hide_prefix` is enabled -4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events + - All priorities are configurable via |diffs.PrioritiesConfig| +4. A decoration provider re-highlights visible hunks on each redraw Diff mode views: ~ 1. `OptionSet diff` detects when any window enters diff mode @@ -509,15 +702,14 @@ KNOWN LIMITATIONS *diffs-limitations* Incomplete Syntax Context ~ *diffs-syntax-context* -Treesitter parses each diff hunk in isolation. To provide surrounding code -context, diffs.nvim reads lines from disk before and after each hunk -(see |diffs.ContextConfig|, enabled by default). This resolves most boundary -issues where incomplete constructs (e.g., a function definition at the edge -of a hunk with no body) would confuse the parser. - -Set `highlights.context.enabled = false` to disable context padding. In rare -cases, context padding may not help if the relevant surrounding code is very -far from the hunk boundaries. +Treesitter parses each diff hunk in isolation. When `highlights.context` is +enabled (the default), surrounding code is read from the working tree file +and fed into the parser to improve accuracy at hunk boundaries. This helps +when a hunk is inside a table, function body, or loop whose opening is +beyond the hunk's own context lines. Requires `repo_root` and +`file_new_start` to be available on the hunk (true for standard unified +diffs). In rare cases, hunks that start or end mid-expression may still +produce imperfect highlights due to treesitter error recovery. Syntax Highlighting Flash ~ *diffs-flash* @@ -526,14 +718,8 @@ 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, - } -< +which fires after vim-fugitive has already painted the buffer. The +decoration provider applies highlights on the next redraw cycle. Conflicting Diff Plugins ~ *diffs-plugin-conflicts* @@ -575,6 +761,7 @@ 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 @@ -635,6 +822,10 @@ Conflict highlights: ~ *DiffsConflictBaseNr* DiffsConflictBaseNr Line number for base content lines (diff3). + *DiffsConflictActions* + DiffsConflictActions Dimmed foreground (no bold) for the codelens-style + action line shown when `show_actions` is true. + Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0c2cb09 --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1770812194, + "narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..2221bdf --- /dev/null +++ b/flake.nix @@ -0,0 +1,53 @@ +{ + description = "diffs.nvim — syntax highlighting for diffs in Neovim"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + systems.url = "github:nix-systems/default"; + }; + + outputs = + { + nixpkgs, + systems, + ... + }: + let + forEachSystem = + f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + in + { + formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); + + devShells = forEachSystem (pkgs: { + default = + let + ts-plugin = pkgs.vimPlugins.nvim-treesitter.withPlugins (p: [ p.diff ]); + diff-grammar = pkgs.vimPlugins.nvim-treesitter-parsers.diff; + luaEnv = pkgs.luajit.withPackages ( + ps: with ps; [ + busted + nlua + ] + ); + busted-with-grammar = pkgs.writeShellScriptBin "busted" '' + nvim_bin=$(which nvim) + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + printf '#!/bin/sh\nexec "%s" --cmd "set rtp+=${ts-plugin}/runtime" --cmd "set rtp+=${diff-grammar}" "$@"\n' "$nvim_bin" > "$tmpdir/nvim" + chmod +x "$tmpdir/nvim" + PATH="$tmpdir:$PATH" exec ${luaEnv}/bin/busted "$@" + ''; + in + pkgs.mkShell { + packages = [ + busted-with-grammar + pkgs.prettier + pkgs.stylua + pkgs.selene + pkgs.lua-language-server + ]; + }; + }); + }; +} diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index dc6cfe2..01f1d3a 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -36,6 +36,24 @@ function M.find_hunk_line(diff_lines, hunk_position) return nil end +---@param lines string[] +---@return string[] +function M.filter_combined_diffs(lines) + local result = {} + local skip = false + for _, line in ipairs(lines) do + if line:match('^diff %-%-cc ') then + skip = true + elseif line:match('^diff %-%-git ') then + skip = false + end + if not skip then + table.insert(result, line) + end + end + return result +end + ---@param old_lines string[] ---@param new_lines string[] ---@param old_name string @@ -69,6 +87,33 @@ local function generate_unified_diff(old_lines, new_lines, old_name, new_name) return result end +---@param raw_lines string[] +---@param repo_root string +---@return string[] +local function replace_combined_diffs(raw_lines, repo_root) + local unmerged_files = {} + for _, line in ipairs(raw_lines) do + local cc_file = line:match('^diff %-%-cc (.+)$') + if cc_file then + table.insert(unmerged_files, cc_file) + end + end + + local result = M.filter_combined_diffs(raw_lines) + + for _, filename in ipairs(unmerged_files) do + local filepath = repo_root .. '/' .. filename + local old_lines = git.get_file_content(':2', filepath) or {} + local new_lines = git.get_file_content(':3', filepath) or {} + local diff_lines = generate_unified_diff(old_lines, new_lines, filename, filename) + for _, dl in ipairs(diff_lines) do + table.insert(result, dl) + end + end + + return result +end + ---@param revision? string ---@param vertical? boolean function M.gdiff(revision, vertical) @@ -138,6 +183,7 @@ end ---@field vertical? boolean ---@field staged? boolean ---@field untracked? boolean +---@field unmerged? boolean ---@field old_filepath? string ---@field hunk_position? { hunk_header: string, offset: integer } @@ -157,7 +203,17 @@ function M.gdiff_file(filepath, opts) local old_lines, new_lines, err local diff_label - if opts.untracked then + if opts.unmerged then + old_lines = git.get_file_content(':2', filepath) + if not old_lines then + old_lines = {} + end + new_lines = git.get_file_content(':3', filepath) + if not new_lines then + new_lines = {} + end + diff_label = 'unmerged' + elseif opts.untracked then old_lines = {} new_lines, err = git.get_working_content(filepath) if not new_lines then @@ -236,6 +292,14 @@ function M.gdiff_file(filepath, opts) end M.setup_diff_buf(diff_buf) + + if diff_label == 'unmerged' then + vim.api.nvim_buf_set_var(diff_buf, 'diffs_unmerged', true) + vim.api.nvim_buf_set_var(diff_buf, 'diffs_working_path', filepath) + local conflict_config = require('diffs').get_conflict_config() + require('diffs.merge').setup_keymaps(diff_buf, conflict_config) + end + dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) vim.schedule(function() @@ -263,6 +327,8 @@ function M.gdiff_section(repo_root, opts) return end + result = replace_combined_diffs(result, repo_root) + if #result == 0 then vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO) return @@ -325,6 +391,8 @@ function M.read_buffer(bufnr) if vim.v.shell_error ~= 0 then diff_lines = {} end + + diff_lines = replace_combined_diffs(diff_lines, repo_root) else local abs_path = repo_root .. '/' .. path @@ -334,7 +402,10 @@ function M.read_buffer(bufnr) local old_lines, new_lines - if label == 'untracked' then + if label == 'unmerged' then + old_lines = git.get_file_content(':2', abs_path) or {} + new_lines = git.get_file_content(':3', abs_path) or {} + elseif label == 'untracked' then old_lines = {} new_lines = git.get_working_content(abs_path) or {} elseif label == 'staged' then diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 9e62a15..5ed8009 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -20,8 +20,6 @@ local attached_buffers = {} ---@type table local diagnostics_suppressed = {} -local PRIORITY_LINE_BG = 200 - ---@param lines string[] ---@return diffs.ConflictRegion[] function M.parse(lines) @@ -92,6 +90,17 @@ local function parse_buffer(bufnr) return M.parse(lines) end +---@param side string +---@param config diffs.ConflictConfig +---@return string? +local function get_virtual_text_label(side, config) + if config.format_virtual_text then + local keymap = side == 'ours' and config.keymaps.ours or config.keymaps.theirs + return config.format_virtual_text(side, keymap) + end + return side == 'ours' and 'current' or 'incoming' +end + ---@param bufnr integer ---@param regions diffs.ConflictRegion[] ---@param config diffs.ConflictConfig @@ -103,14 +112,41 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_ours + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) if config.show_virtual_text then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { - virt_text = { { ' (current)', 'DiffsConflictMarker' } }, - virt_text_pos = 'eol', - }) + local ours_label = get_virtual_text_label('ours', config) + if ours_label then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { + virt_text = { { ' (' .. ours_label .. ')', 'DiffsConflictMarker' } }, + virt_text_pos = 'eol', + }) + end + end + + if config.show_actions then + local parts = {} + local actions = { + { 'Current', config.keymaps.ours }, + { 'Incoming', config.keymaps.theirs }, + { 'Both', config.keymaps.both }, + { 'None', config.keymaps.none }, + } + for _, action in ipairs(actions) do + if action[2] then + if #parts > 0 then + table.insert(parts, { ' \226\148\130 ', 'DiffsConflictActions' }) + end + table.insert(parts, { ('%s (%s)'):format(action[1], action[2]), 'DiffsConflictActions' }) + end + end + if #parts > 0 then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { + virt_lines = { parts }, + virt_lines_above = true, + }) + end end for line = region.ours_start, region.ours_end - 1 do @@ -118,11 +154,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictOurs', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictOursNr', - priority = PRIORITY_LINE_BG, + priority = config.priority, }) end @@ -131,7 +167,7 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_base + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) for line = region.base_start, region.base_end - 1 do @@ -139,11 +175,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictBase', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictBaseNr', - priority = PRIORITY_LINE_BG, + priority = config.priority, }) end end @@ -152,7 +188,7 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_sep + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) for line = region.theirs_start, region.theirs_end - 1 do @@ -160,11 +196,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictTheirs', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictTheirsNr', - priority = PRIORITY_LINE_BG, + priority = config.priority, }) end @@ -172,14 +208,17 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_theirs + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = PRIORITY_LINE_BG, + priority = config.priority, }) if config.show_virtual_text then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { - virt_text = { { ' (incoming)', 'DiffsConflictMarker' } }, - virt_text_pos = 'eol', - }) + local theirs_label = get_virtual_text_label('theirs', config) + if theirs_label then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { + virt_text = { { ' (' .. theirs_label .. ')', 'DiffsConflictMarker' } }, + virt_text_pos = 'eol', + }) + end end end end @@ -199,7 +238,7 @@ end ---@param bufnr integer ---@param region diffs.ConflictRegion ---@param replacement string[] -local function replace_region(bufnr, region, replacement) +function M.replace_region(bufnr, region, replacement) vim.api.nvim_buf_set_lines( bufnr, region.marker_ours, @@ -211,7 +250,7 @@ end ---@param bufnr integer ---@param config diffs.ConflictConfig -local function refresh(bufnr, config) +function M.refresh(bufnr, config) local regions = parse_buffer(bufnr) if #regions == 0 then vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) @@ -244,8 +283,8 @@ function M.resolve_ours(bufnr, config) return end local lines = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false) - replace_region(bufnr, region, lines) - refresh(bufnr, config) + M.replace_region(bufnr, region, lines) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -262,8 +301,8 @@ function M.resolve_theirs(bufnr, config) return end local lines = vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false) - replace_region(bufnr, region, lines) - refresh(bufnr, config) + M.replace_region(bufnr, region, lines) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -288,8 +327,8 @@ function M.resolve_both(bufnr, config) for _, l in ipairs(theirs) do table.insert(combined, l) end - replace_region(bufnr, region, combined) - refresh(bufnr, config) + M.replace_region(bufnr, region, combined) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -305,8 +344,8 @@ function M.resolve_none(bufnr, config) if not region then return end - replace_region(bufnr, region, {}) - refresh(bufnr, config) + M.replace_region(bufnr, region, {}) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -323,6 +362,7 @@ function M.goto_next(bufnr) return end end + vim.notify('[diffs.nvim]: wrapped to first conflict', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { regions[1].marker_ours + 1, 0 }) end @@ -340,6 +380,7 @@ function M.goto_prev(bufnr) return end end + vim.notify('[diffs.nvim]: wrapped to last conflict', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { regions[#regions].marker_ours + 1, 0 }) end @@ -417,7 +458,7 @@ function M.attach(bufnr, config) if not attached_buffers[bufnr] then return true end - refresh(bufnr, config) + M.refresh(bufnr, config) end, }) diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index a588a22..d4c3782 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -26,20 +26,65 @@ function M.get_section_at_line(bufnr, lnum) return nil end +---@param s string +---@return string +local function unquote(s) + if s:sub(1, 1) ~= '"' then + return s + end + local inner = s:sub(2, -2) + local result = {} + local i = 1 + while i <= #inner do + if inner:sub(i, i) == '\\' and i < #inner then + local next_char = inner:sub(i + 1, i + 1) + if next_char == 'n' then + table.insert(result, '\n') + i = i + 2 + elseif next_char == 't' then + table.insert(result, '\t') + i = i + 2 + elseif next_char == '"' then + table.insert(result, '"') + i = i + 2 + elseif next_char == '\\' then + table.insert(result, '\\') + i = i + 2 + elseif next_char:match('%d') then + local oct = inner:match('^(%d%d%d)', i + 1) + if oct then + table.insert(result, string.char(tonumber(oct, 8))) + i = i + 4 + else + table.insert(result, next_char) + i = i + 2 + end + else + table.insert(result, next_char) + i = i + 2 + end + else + table.insert(result, inner:sub(i, i)) + i = i + 1 + end + end + return table.concat(result) +end + ---@param line string ----@return string?, string? +---@return string?, string?, string? local function parse_file_line(line) local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$') if old and new then - return vim.trim(new), vim.trim(old) + return unquote(vim.trim(new)), unquote(vim.trim(old)), 'R' end - local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$') - if filename then - return vim.trim(filename), nil + local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$') + if status and filename then + return unquote(vim.trim(filename)), nil, status end - return nil, nil + return nil, nil, nil end ---@param line string @@ -57,34 +102,34 @@ end ---@param bufnr integer ---@param lnum integer ----@return string?, diffs.FugitiveSection, boolean, string? +---@return string?, diffs.FugitiveSection, boolean, string?, string? function M.get_file_at_line(bufnr, lnum) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local current_line = lines[lnum] if not current_line then - return nil, nil, false, nil + return nil, nil, false, nil, nil end local section_header = parse_section_header(current_line) if section_header then - return nil, section_header, true, nil + return nil, section_header, true, nil, nil end - local filename, old_filename = parse_file_line(current_line) + local filename, old_filename, status = parse_file_line(current_line) if filename then local section = M.get_section_at_line(bufnr, lnum) - return filename, section, false, old_filename + return filename, section, false, old_filename, status end local prefix = current_line:sub(1, 1) if prefix == '+' or prefix == '-' or prefix == ' ' then for i = lnum - 1, 1, -1 do local prev_line = lines[i] - filename, old_filename = parse_file_line(prev_line) + filename, old_filename, status = parse_file_line(prev_line) if filename then local section = M.get_section_at_line(bufnr, i) - return filename, section, false, old_filename + return filename, section, false, old_filename, status end if prev_line:match('^%w+ %(') or prev_line == '' then break @@ -92,7 +137,7 @@ function M.get_file_at_line(bufnr, lnum) end end - return nil, nil, false, nil + return nil, nil, false, nil, nil end ---@class diffs.HunkPosition @@ -150,7 +195,7 @@ function M.diff_file_under_cursor(vertical) local bufnr = vim.api.nvim_get_current_buf() local lnum = vim.api.nvim_win_get_cursor(0)[1] - local filename, section, is_header, old_filename = M.get_file_at_line(bufnr, lnum) + local filename, section, is_header, old_filename, status = M.get_file_at_line(bufnr, lnum) local repo_root = get_repo_root_from_fugitive(bufnr) if not repo_root then @@ -192,6 +237,7 @@ function M.diff_file_under_cursor(vertical) vertical = vertical, staged = section == 'staged', untracked = section == 'untracked', + unmerged = status == 'U', old_filepath = old_filepath, hunk_position = hunk_position, }) diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua index 7695283..1aa6328 100644 --- a/lua/diffs/git.lua +++ b/lua/diffs/git.lua @@ -1,13 +1,19 @@ local M = {} +local repo_root_cache = {} + ---@param filepath string ---@return string? function M.get_repo_root(filepath) local dir = vim.fn.fnamemodify(filepath, ':h') + if repo_root_cache[dir] ~= nil then + return repo_root_cache[dir] + end local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' }) if vim.v.shell_error ~= 0 then return nil end + repo_root_cache[dir] = result[1] return result[1] end diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 306b0e5..493bcfc 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -3,38 +3,6 @@ local M = {} local dbg = require('diffs.log').dbg local diff = require('diffs.diff') ----@param filepath string ----@param from_line integer ----@param count integer ----@return string[] -local function read_line_range(filepath, from_line, count) - if count <= 0 then - return {} - end - local f = io.open(filepath, 'r') - if not f then - return {} - end - local result = {} - local line_num = 0 - for line in f:lines() do - line_num = line_num + 1 - if line_num >= from_line then - table.insert(result, line) - if #result >= count then - break - end - end - end - f:close() - return result -end - -local PRIORITY_CLEAR = 198 -local PRIORITY_SYNTAX = 199 -local PRIORITY_LINE_BG = 200 -local PRIORITY_CHAR_BG = 201 - ---@param bufnr integer ---@param ns integer ---@param hunk diffs.Hunk @@ -42,8 +10,9 @@ local PRIORITY_CHAR_BG = 201 ---@param text string ---@param lang string ---@param context_lines? string[] +---@param priorities diffs.PrioritiesConfig ---@return integer -local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines) +local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines, priorities) local parse_text = text if context_lines and #context_lines > 0 then parse_text = text .. '\n' .. table.concat(context_lines, '\n') @@ -77,7 +46,7 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_l local buf_sc = col_offset + sc local buf_ec = col_offset + ec - local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX + local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or priorities.syntax pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { end_row = buf_er, @@ -95,6 +64,12 @@ end ---@class diffs.HunkOpts ---@field hide_prefix boolean ---@field highlights diffs.Highlights +---@field defer_vim_syntax? boolean +---@field syntax_only? boolean + +---@class diffs.TSContext +---@field before string[]? +---@field after string[]? ---@param bufnr integer ---@param ns integer @@ -103,6 +78,9 @@ end ---@param line_map table ---@param col_offset integer ---@param covered_lines? table +---@param priorities diffs.PrioritiesConfig +---@param force_high_priority? boolean +---@param context? diffs.TSContext ---@return integer local function highlight_treesitter( bufnr, @@ -111,9 +89,36 @@ local function highlight_treesitter( lang, line_map, col_offset, - covered_lines + covered_lines, + priorities, + force_high_priority, + context ) - local code = table.concat(code_lines, '\n') + local prefix_count = 0 + local parse_lines = code_lines + if context then + local before = context.before + local after = context.after + if (before and #before > 0) or (after and #after > 0) then + parse_lines = {} + if before then + prefix_count = #before + for _, l in ipairs(before) do + parse_lines[#parse_lines + 1] = l + end + end + for _, l in ipairs(code_lines) do + parse_lines[#parse_lines + 1] = l + end + if after then + for _, l in ipairs(after) do + parse_lines[#parse_lines + 1] = l + end + end + end + end + + local code = table.concat(parse_lines, '\n') if code == '' then return 0 end @@ -143,6 +148,8 @@ local function highlight_treesitter( if capture ~= 'spell' and capture ~= 'nospell' then local capture_name = '@' .. capture .. '.' .. tree_lang local sr, sc, er, ec = node:range() + sr = sr - prefix_count + er = er - prefix_count local buf_sr = line_map[sr] if buf_sr then @@ -151,8 +158,10 @@ local function highlight_treesitter( 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 + local meta_prio = tonumber(metadata.priority) or 100 + local priority = tree_lang == 'diff' + and ((col_offset > 0 or force_high_priority) and (priorities.syntax + meta_prio - 100) or meta_prio) + or priorities.syntax pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { end_row = buf_er, @@ -219,8 +228,17 @@ end ---@param code_lines string[] ---@param covered_lines? table ---@param leading_offset? integer +---@param priorities diffs.PrioritiesConfig ---@return integer -local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset) +local function highlight_vim_syntax( + bufnr, + ns, + hunk, + code_lines, + covered_lines, + leading_offset, + priorities +) local ft = hunk.ft if not ft then return 0 @@ -238,9 +256,9 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, local spans = {} - vim.api.nvim_buf_call(scratch, function() + pcall(vim.api.nvim_buf_call, scratch, function() vim.cmd('setlocal syntax=' .. ft) - vim.cmd('redraw') + vim.cmd.redraw() ---@param line integer ---@param col integer @@ -256,18 +274,19 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, spans = M.coalesce_syntax_spans(query_fn, code_lines) end) - vim.api.nvim_buf_delete(scratch, { force = true }) + pcall(vim.api.nvim_buf_delete, scratch, { force = true }) local hunk_line_count = #hunk.lines + local col_off = (hunk.prefix_width or 1) + (hunk.quote_width or 0) - 1 local extmark_count = 0 for _, span in ipairs(spans) do local adj = span.line - leading_offset if adj >= 1 and adj <= hunk_line_count then local buf_line = hunk.start_line + adj - 1 - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { - end_col = span.col_end, + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + col_off, { + end_col = span.col_end + col_off, hl_group = span.hl_name, - priority = PRIORITY_SYNTAX, + priority = priorities.syntax, }) extmark_count = extmark_count + 1 if covered_lines then @@ -284,6 +303,9 @@ end ---@param hunk diffs.Hunk ---@param opts diffs.HunkOpts function M.highlight_hunk(bufnr, ns, hunk, opts) + local p = opts.highlights.priorities + local pw = hunk.prefix_width or 1 + local qw = hunk.quote_width or 0 local use_ts = hunk.lang and opts.highlights.treesitter.enabled local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled @@ -300,28 +322,18 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) use_vim = false end + if use_vim and opts.defer_vim_syntax then + use_vim = false + end + ---@type table 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 + ---@type string[] + local new_code = {} + if use_ts then - ---@type string[] - local new_code = {} ---@type table local new_map = {} ---@type string[] @@ -329,20 +341,17 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) ---@type table local old_map = {} - for _, pad_line in ipairs(leading) do - table.insert(new_code, pad_line) - table.insert(old_code, pad_line) - end - for i, line in ipairs(hunk.lines) do - local prefix = line:sub(1, 1) - local stripped = line:sub(2) + local prefix = line:sub(1, pw) + local stripped = line:sub(pw + 1) local buf_line = hunk.start_line + i - 1 + local has_add = prefix:find('+', 1, true) ~= nil + local has_del = prefix:find('-', 1, true) ~= nil - if prefix == '+' then + if has_add and not has_del then new_map[#new_code] = buf_line table.insert(new_code, stripped) - elseif prefix == '-' then + elseif has_del and not has_add then old_map[#old_code] = buf_line table.insert(old_code, stripped) else @@ -352,22 +361,38 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end - for _, pad_line in ipairs(trailing) do - table.insert(new_code, pad_line) - table.insert(old_code, pad_line) + local ts_context = nil + if opts.highlights.context.enabled and (hunk.context_before or hunk.context_after) then + ts_context = { before = hunk.context_before, after = hunk.context_after } end - extmark_count = highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines) + extmark_count = highlight_treesitter( + bufnr, + ns, + new_code, + hunk.lang, + new_map, + pw + qw, + covered_lines, + p, + nil, + ts_context + ) extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines) + + highlight_treesitter( + bufnr, + ns, + old_code, + hunk.lang, + old_map, + pw + qw, + covered_lines, + p, + nil, + ts_context + ) if hunk.header_context and hunk.header_context_col then - 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, @@ -375,7 +400,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) hunk.header_context_col, hunk.header_context, hunk.lang, - new_code + new_code, + p ) if header_extmarks > 0 then dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks) @@ -385,16 +411,10 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) elseif use_vim then ---@type string[] local code_lines = {} - for _, pad_line in ipairs(leading) do - table.insert(code_lines, pad_line) - end for _, line in ipairs(hunk.lines) do - table.insert(code_lines, line:sub(2)) + table.insert(code_lines, line:sub(pw + 1)) end - for _, pad_line in ipairs(trailing) do - table.insert(code_lines, pad_line) - end - extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, #leading) + extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p) end if @@ -409,13 +429,35 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) header_map[i] = hunk.header_start_line - 1 + i end extmark_count = extmark_count - + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0) + + highlight_treesitter( + bufnr, + ns, + hunk.header_lines, + 'diff', + header_map, + qw, + nil, + p, + qw > 0 or pw > 1 + ) + end + + local at_raw_line + if (qw > 0 or pw > 1) and opts.highlights.treesitter.enabled then + local at_buf_line = hunk.start_line - 1 + at_raw_line = vim.api.nvim_buf_get_lines(bufnr, at_buf_line, at_buf_line + 1, false)[1] end ---@type diffs.IntraChanges? local intra = nil local intra_cfg = opts.highlights.intra - if intra_cfg and intra_cfg.enabled and #hunk.lines <= intra_cfg.max_lines then + if + not opts.syntax_only + and intra_cfg + and intra_cfg.enabled + and pw == 1 + and #hunk.lines <= intra_cfg.max_lines + then dbg('computing intra for hunk %s:%d (%d lines)', hunk.filename, hunk.start_line, #hunk.lines) intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm) if intra then @@ -446,68 +488,180 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end + if + (qw > 0 or pw > 1) + and hunk.header_start_line + and hunk.header_lines + and #hunk.header_lines > 0 + and opts.highlights.treesitter.enabled + then + for i = 0, #hunk.header_lines - 1 do + local buf_line = hunk.header_start_line - 1 + i + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_col = #hunk.header_lines[i + 1] + qw, + hl_group = 'DiffsClear', + priority = p.clear, + }) + + if pw > 1 then + local hline = hunk.header_lines[i + 1] + if hline:match('^index ') then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, qw, { + end_col = 5 + qw, + hl_group = '@keyword.diff', + priority = p.syntax, + }) + local dot_pos = hline:find('%.%.', 1, false) + if dot_pos then + local rest = hline:sub(dot_pos + 2) + local hash = rest:match('^(%x+)') + if hash then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, dot_pos + 1 + qw, { + end_col = dot_pos + 1 + #hash + qw, + hl_group = '@constant.diff', + priority = p.syntax, + }) + end + end + end + end + end + end + + if (qw > 0 or pw > 1) and at_raw_line then + local at_buf_line = hunk.start_line - 1 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, { + end_col = #at_raw_line, + hl_group = 'DiffsClear', + priority = p.clear, + }) + if opts.highlights.treesitter.enabled then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, qw, { + end_col = #at_raw_line, + hl_group = '@attribute.diff', + priority = p.syntax, + }) + end + end + + if use_ts and hunk.header_context and hunk.header_context_col then + local header_line = hunk.start_line - 1 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, { + end_col = hunk.header_context_col + #hunk.header_context, + hl_group = 'DiffsClear', + priority = p.clear, + }) + end + + local raw_body_lines + if qw > 0 then + raw_body_lines = + vim.api.nvim_buf_get_lines(bufnr, hunk.start_line, hunk.start_line + #hunk.lines, false) + end + for i, line in ipairs(hunk.lines) do local buf_line = hunk.start_line + i - 1 local line_len = #line - local prefix = line:sub(1, 1) + local raw_len = raw_body_lines and #raw_body_lines[i] or nil + local prefix = line:sub(1, pw) + local has_add = prefix:find('+', 1, true) ~= nil + local has_del = prefix:find('-', 1, true) ~= nil + local is_diff_line = has_add or has_del + local line_hl = is_diff_line and (has_add and 'DiffsAdd' or 'DiffsDelete') or nil + local number_hl = is_diff_line and (has_add and 'DiffsAddNr' or 'DiffsDeleteNr') or nil - local is_diff_line = prefix == '+' or prefix == '-' - local 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', - }) + local is_marker = false + if pw > 1 and line_hl and not prefix:find('[^+]') then + local content = line:sub(pw + 1) + is_marker = content:match('^<<<<<<<') + or content:match('^=======') + or content:match('^>>>>>>>') + or content:match('^|||||||') 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 + if not opts.syntax_only then + 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, { - number_hl_group = number_hl, - priority = PRIORITY_LINE_BG, + virt_text = { { string.rep(' ', pw + qw), virt_hl } }, + virt_text_pos = 'overlay', }) end + + if qw > 0 or pw > 1 then + local prefix_end = pw + qw + if raw_len and prefix_end > raw_len then + prefix_end = raw_len + end + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_col = prefix_end, + hl_group = 'DiffsClear', + priority = p.clear, + }) + for ci = 0, pw - 1 do + local ch = line:sub(ci + 1, ci + 1) + if ch == '+' or ch == '-' then + local char_col = ci + qw + if raw_len and char_col >= raw_len then + break + end + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, char_col, { + end_col = char_col + 1, + hl_group = ch == '+' and '@diff.plus' or '@diff.minus', + priority = p.syntax, + }) + end + end + end + + if opts.highlights.background and is_diff_line then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + line_hl_group = line_hl, + number_hl_group = opts.highlights.gutter and number_hl or nil, + priority = p.line_bg, + }) + end + + if is_marker and line_len > pw then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, { + end_col = line_len + qw, + hl_group = 'DiffsConflictMarker', + priority = p.char_bg, + }) + end + + if char_spans_by_line[i] then + local char_hl = has_add and 'DiffsAddText' or 'DiffsDeleteText' + for _, span in ipairs(char_spans_by_line[i]) do + dbg( + 'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"', + 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 + qw, { + end_col = span.col_end + qw, + hl_group = char_hl, + priority = p.char_bg, + }) + if not ok then + dbg('char extmark FAILED: %s', err) + end + extmark_count = extmark_count + 1 + end + 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 + if line_len > pw and covered_lines[buf_line] then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, { + end_col = line_len + qw, + hl_group = 'DiffsClear', + priority = p.clear, + }) end end diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index cdc29d1..ca20308 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -15,6 +15,12 @@ ---@field enabled boolean ---@field lines integer +---@class diffs.PrioritiesConfig +---@field clear integer +---@field syntax integer +---@field line_bg integer +---@field char_bg integer + ---@class diffs.Highlights ---@field background boolean ---@field gutter boolean @@ -24,11 +30,14 @@ ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig ---@field intra diffs.IntraConfig +---@field priorities diffs.PrioritiesConfig ---@class diffs.FugitiveConfig ---@field horizontal string|false ---@field vertical string|false +---@class diffs.NeogitConfig + ---@class diffs.ConflictKeymaps ---@field ours string|false ---@field theirs string|false @@ -41,14 +50,18 @@ ---@field enabled boolean ---@field disable_diagnostics boolean ---@field show_virtual_text boolean +---@field format_virtual_text? fun(side: string, keymap: string|false): string? +---@field show_actions boolean +---@field priority integer ---@field keymaps diffs.ConflictKeymaps ---@class diffs.Config ----@field debug boolean ----@field debounce_ms integer +---@field debug boolean|string ---@field hide_prefix boolean +---@field extra_filetypes string[] ---@field highlights diffs.Highlights ----@field fugitive diffs.FugitiveConfig +---@field fugitive diffs.FugitiveConfig|false +---@field neogit diffs.NeogitConfig|false ---@field conflict diffs.ConflictConfig ---@class diffs @@ -97,8 +110,8 @@ end ---@type diffs.Config local default_config = { debug = false, - debounce_ms = 0, hide_prefix = false, + extra_filetypes = {}, highlights = { background = true, gutter = true, @@ -111,7 +124,7 @@ local default_config = { max_lines = 500, }, vim = { - enabled = false, + enabled = true, max_lines = 200, }, intra = { @@ -119,22 +132,28 @@ local default_config = { algorithm = 'default', max_lines = 500, }, + priorities = { + clear = 198, + syntax = 199, + line_bg = 200, + char_bg = 201, + }, }, - fugitive = { - horizontal = 'du', - vertical = 'dU', - }, + fugitive = false, + neogit = false, conflict = { enabled = true, disable_diagnostics = true, show_virtual_text = true, + show_actions = false, + priority = 200, keymaps = { ours = 'doo', theirs = 'dot', both = 'dob', none = 'don', - next = ']x', - prev = '[x', + next = ']c', + prev = '[c', }, }, } @@ -143,67 +162,306 @@ local default_config = { local config = vim.deepcopy(default_config) local initialized = false +local hl_retry_pending = false + +---@diagnostic disable-next-line: missing-fields +local fast_hl_opts = {} ---@type diffs.HunkOpts ---@type table local attached_buffers = {} +---@type table +local ft_retry_pending = {} + ---@type table local diff_windows = {} +---@class diffs.HunkCacheEntry +---@field hunks diffs.Hunk[] +---@field tick integer +---@field highlighted table +---@field pending_clear boolean +---@field line_count integer +---@field byte_count integer + +---@type table +local hunk_cache = {} + ---@param bufnr integer ---@return boolean function M.is_fugitive_buffer(bufnr) return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil end +---@param opts table +---@return string[] +function M.compute_filetypes(opts) + local fts = { 'git', 'gitcommit' } + local fug = opts.fugitive + if fug == true or type(fug) == 'table' then + table.insert(fts, 'fugitive') + end + local neo = opts.neogit + if neo == true or type(neo) == 'table' then + table.insert(fts, 'NeogitStatus') + table.insert(fts, 'NeogitCommitView') + table.insert(fts, 'NeogitDiffView') + end + if type(opts.extra_filetypes) == 'table' then + for _, ft in ipairs(opts.extra_filetypes) do + table.insert(fts, ft) + end + end + return fts +end + local dbg = log.dbg ---@param bufnr integer -local function highlight_buffer(bufnr) - if not vim.api.nvim_buf_is_valid(bufnr) then - return +local function invalidate_cache(bufnr) + local entry = hunk_cache[bufnr] + if entry then + entry.tick = -1 + entry.pending_clear = true + end +end + +---@param a diffs.Hunk +---@param b diffs.Hunk +---@return boolean +local function hunks_eq(a, b) + local n = #a.lines + if n ~= #b.lines or a.filename ~= b.filename then + return false + end + if a.lines[1] ~= b.lines[1] then + return false + end + if n > 1 and a.lines[n] ~= b.lines[n] then + return false + end + if n > 2 then + local mid = math.floor(n / 2) + 1 + if a.lines[mid] ~= b.lines[mid] then + return false + end + end + return true +end + +---@param old_entry diffs.HunkCacheEntry +---@param new_hunks diffs.Hunk[] +---@return table? +local function carry_forward_highlighted(old_entry, new_hunks) + local old_hunks = old_entry.hunks + local old_hl = old_entry.highlighted + local old_n = #old_hunks + local new_n = #new_hunks + local highlighted = {} + + local prefix_len = 0 + local limit = math.min(old_n, new_n) + for i = 1, limit do + if not hunks_eq(old_hunks[i], new_hunks[i]) then + break + end + if old_hl[i] then + highlighted[i] = true + end + prefix_len = i end - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + local suffix_len = 0 + local max_suffix = limit - prefix_len + for j = 0, max_suffix - 1 do + local old_idx = old_n - j + local new_idx = new_n - j + if not hunks_eq(old_hunks[old_idx], new_hunks[new_idx]) then + break + end + if old_hl[old_idx] then + highlighted[new_idx] = true + end + suffix_len = j + 1 + end + + dbg( + 'carry_forward: %d prefix + %d suffix of %d old -> %d new hunks', + prefix_len, + suffix_len, + old_n, + new_n + ) + if next(highlighted) == nil then + return nil + end + return highlighted +end + +---@param path string +---@return string[]? +local function read_file_lines(path) + local f = io.open(path, 'r') + if not f then + return nil + end + local lines = {} + for line in f:lines() do + lines[#lines + 1] = line + end + f:close() + return lines +end + +---@param hunks diffs.Hunk[] +---@param max_lines integer +local function compute_hunk_context(hunks, max_lines) + ---@type table + local file_cache = {} - 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, - }) + if not hunk.repo_root or not hunk.filename or not hunk.file_new_start then + goto continue + end + + local path = vim.fs.joinpath(hunk.repo_root, hunk.filename) + local file_lines = file_cache[path] + if file_lines == nil then + file_lines = read_file_lines(path) or false + file_cache[path] = file_lines + end + if not file_lines then + goto continue + end + + local new_start = hunk.file_new_start + local new_count = hunk.file_new_count or 0 + local total = #file_lines + + local before_start = math.max(1, new_start - max_lines) + if before_start < new_start then + local before = {} + for i = before_start, new_start - 1 do + before[#before + 1] = file_lines[i] + end + hunk.context_before = before + end + + local after_start = new_start + new_count + local after_end = math.min(total, after_start + max_lines - 1) + if after_start <= total then + local after = {} + for i = after_start, after_end do + after[#after + 1] = file_lines[i] + end + hunk.context_after = after + end + + ::continue:: 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) +local function ensure_cache(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + local tick = vim.api.nvim_buf_get_changedtick(bufnr) + local entry = hunk_cache[bufnr] + if entry and entry.tick == tick then + return + end + if entry and not entry.pending_clear then + local lc = vim.api.nvim_buf_line_count(bufnr) + local bc = vim.api.nvim_buf_get_offset(bufnr, lc) + if lc == entry.line_count and bc == entry.byte_count then + entry.tick = tick + entry.pending_clear = true + dbg('content unchanged in buffer %d (tick %d), skipping reparse', bufnr, tick) 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 + local hunks = parser.parse_buffer(bufnr) + local lc = vim.api.nvim_buf_line_count(bufnr) + local bc = vim.api.nvim_buf_get_offset(bufnr, lc) + dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick) + if config.highlights.context.enabled then + compute_hunk_context(hunks, config.highlights.context.lines) + end + local carried = entry and not entry.pending_clear and carry_forward_highlighted(entry, hunks) + hunk_cache[bufnr] = { + hunks = hunks, + tick = tick, + highlighted = carried or {}, + pending_clear = not carried, + line_count = lc, + byte_count = bc, + } + + local has_nil_ft = false + for _, hunk in ipairs(hunks) do + if not has_nil_ft and not hunk.ft and hunk.filename then + has_nil_ft = true + end + end + if has_nil_ft and vim.fn.did_filetype() ~= 0 and not ft_retry_pending[bufnr] then + ft_retry_pending[bufnr] = true + vim.schedule(function() + if vim.api.nvim_buf_is_valid(bufnr) and hunk_cache[bufnr] then + dbg('retrying filetype detection for buffer %d (was blocked by did_filetype)', bufnr) + invalidate_cache(bufnr) + vim.cmd('redraw!') + end + ft_retry_pending[bufnr] = nil + end) + end +end + +---@param hunks diffs.Hunk[] +---@param toprow integer +---@param botrow integer +---@return integer first +---@return integer last +local function find_visible_hunks(hunks, toprow, botrow) + local n = #hunks + if n == 0 then + return 0, 0 + end + + local lo, hi = 1, n + 1 + while lo < hi do + local mid = math.floor((lo + hi) / 2) + local h = hunks[mid] + local bottom = h.start_line - 1 + #h.lines - 1 + if bottom < toprow then + lo = mid + 1 + else + hi = mid + end + end + + if lo > n then + return 0, 0 + end + + local first = lo + local h = hunks[first] + local top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1) + if top >= botrow then + return 0, 0 + end + + local last = first + for i = first + 1, n do + h = hunks[i] + top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1) + if top >= botrow then + break + end + last = i + end + + return first, last end local function compute_highlight_groups() @@ -213,11 +471,23 @@ local function compute_highlight_groups() 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 dark = vim.o.background ~= 'light' + local bg = normal.bg or (dark and 0x1a1a1a or 0xf0f0f0) + local add_bg = diff_add.bg or (dark and 0x1a3a1a or 0xd0ffd0) + local del_bg = diff_delete.bg or (dark and 0x3a1a1a or 0xffd0d0) + local add_fg = diff_added.fg or diff_add.fg or (dark and 0x80d080 or 0x206020) + local del_fg = diff_removed.fg or diff_delete.fg or (dark and 0xd08080 or 0x802020) + + if not normal.bg and not hl_retry_pending then + hl_retry_pending = true + vim.schedule(function() + hl_retry_pending = false + compute_highlight_groups() + for bufnr, _ in pairs(attached_buffers) do + invalidate_cache(bufnr) + end + end) + end local blended_add = blend_color(add_bg, bg, 0.4) local blended_del = blend_color(del_bg, bg, 0.4) @@ -226,7 +496,11 @@ local function compute_highlight_groups() 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, + 'DiffsClear', + { default = true, fg = normal.fg or (dark and 0xcccccc or 0x333333), bg = bg } + ) 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 }) @@ -274,6 +548,7 @@ local function compute_highlight_groups() vim.api.nvim_set_hl(0, 'DiffsConflictTheirs', { default = true, bg = blended_theirs }) vim.api.nvim_set_hl(0, 'DiffsConflictBase', { default = true, bg = blended_base }) vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { default = true, fg = 0x808080, bold = true }) + vim.api.nvim_set_hl(0, 'DiffsConflictActions', { default = true, fg = 0x808080 }) vim.api.nvim_set_hl( 0, 'DiffsConflictOursNr', @@ -305,10 +580,41 @@ local function init() local opts = vim.g.diffs or {} + local fugitive_defaults = { horizontal = 'du', vertical = 'dU' } + if opts.fugitive == true then + opts.fugitive = vim.deepcopy(fugitive_defaults) + elseif type(opts.fugitive) == 'table' then + opts.fugitive = vim.tbl_extend('keep', opts.fugitive, fugitive_defaults) + end + + if opts.neogit == true then + opts.neogit = {} + end + vim.validate({ - debug = { opts.debug, 'boolean', true }, - debounce_ms = { opts.debounce_ms, 'number', true }, + debug = { + opts.debug, + function(v) + return v == nil or type(v) == 'boolean' or type(v) == 'string' + end, + 'boolean or string (file path)', + }, hide_prefix = { opts.hide_prefix, 'boolean', true }, + fugitive = { + opts.fugitive, + function(v) + return v == nil or v == false or type(v) == 'table' + end, + 'table or false', + }, + neogit = { + opts.neogit, + function(v) + return v == nil or v == false or type(v) == 'table' + end, + 'table or false', + }, + extra_filetypes = { opts.extra_filetypes, 'table', true }, highlights = { opts.highlights, 'table', true }, }) @@ -322,6 +628,7 @@ local function init() ['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true }, ['highlights.vim'] = { opts.highlights.vim, 'table', true }, ['highlights.intra'] = { opts.highlights.intra, 'table', true }, + ['highlights.priorities'] = { opts.highlights.priorities, 'table', true }, }) if opts.highlights.context then @@ -362,21 +669,32 @@ local function init() ['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true }, }) end + + if opts.highlights.priorities then + vim.validate({ + ['highlights.priorities.clear'] = { opts.highlights.priorities.clear, 'number', true }, + ['highlights.priorities.syntax'] = { opts.highlights.priorities.syntax, 'number', true }, + ['highlights.priorities.line_bg'] = { opts.highlights.priorities.line_bg, 'number', true }, + ['highlights.priorities.char_bg'] = { opts.highlights.priorities.char_bg, 'number', true }, + }) + end end - if opts.fugitive then + if type(opts.fugitive) == 'table' then + ---@type diffs.FugitiveConfig + local fug = opts.fugitive vim.validate({ ['fugitive.horizontal'] = { - opts.fugitive.horizontal, + fug.horizontal, function(v) - return v == false or type(v) == 'string' + return v == nil or v == false or type(v) == 'string' end, 'string or false', }, ['fugitive.vertical'] = { - opts.fugitive.vertical, + fug.vertical, function(v) - return v == false or type(v) == 'string' + return v == nil or v == false or type(v) == 'string' end, 'string or false', }, @@ -388,6 +706,9 @@ local function init() ['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true }, ['conflict.disable_diagnostics'] = { opts.conflict.disable_diagnostics, 'boolean', true }, ['conflict.show_virtual_text'] = { opts.conflict.show_virtual_text, 'boolean', true }, + ['conflict.format_virtual_text'] = { opts.conflict.format_virtual_text, 'function', true }, + ['conflict.show_actions'] = { opts.conflict.show_actions, 'boolean', true }, + ['conflict.priority'] = { opts.conflict.priority, 'number', true }, ['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true }, }) @@ -407,9 +728,6 @@ local function init() 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 @@ -449,17 +767,132 @@ local function init() then error('diffs: highlights.blend_alpha must be >= 0 and <= 1') end + if opts.highlights and opts.highlights.priorities then + for _, key in ipairs({ 'clear', 'syntax', 'line_bg', 'char_bg' }) do + local v = opts.highlights.priorities[key] + if v and v < 0 then + error('diffs: highlights.priorities.' .. key .. ' must be >= 0') + end + end + end + if opts.conflict and opts.conflict.priority and opts.conflict.priority < 0 then + error('diffs: conflict.priority must be >= 0') + end config = vim.tbl_deep_extend('force', default_config, opts) log.set_enabled(config.debug) + fast_hl_opts = { + hide_prefix = config.hide_prefix, + highlights = vim.tbl_deep_extend('force', config.highlights, { + treesitter = { enabled = false }, + }), + defer_vim_syntax = true, + } + compute_highlight_groups() vim.api.nvim_create_autocmd('ColorScheme', { callback = function() compute_highlight_groups() for bufnr, _ in pairs(attached_buffers) do - highlight_buffer(bufnr) + invalidate_cache(bufnr) + end + end, + }) + + vim.api.nvim_set_decoration_provider(ns, { + on_buf = function(_, bufnr) + if not attached_buffers[bufnr] then + return false + end + local t0 = config.debug and vim.uv.hrtime() or nil + ensure_cache(bufnr) + local entry = hunk_cache[bufnr] + if entry and entry.pending_clear then + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + entry.highlighted = {} + entry.pending_clear = false + end + if t0 then + dbg('on_buf %d: %.2fms', bufnr, (vim.uv.hrtime() - t0) / 1e6) + end + end, + on_win = function(_, _, bufnr, toprow, botrow) + if not attached_buffers[bufnr] then + return false + end + local entry = hunk_cache[bufnr] + if not entry then + return + end + local first, last = find_visible_hunks(entry.hunks, toprow, botrow) + if first == 0 then + return + end + local t0 = config.debug and vim.uv.hrtime() or nil + local deferred_syntax = {} + local count = 0 + for i = first, last do + if not entry.highlighted[i] then + local hunk = entry.hunks[i] + local clear_start = hunk.start_line - 1 + local clear_end = hunk.start_line + #hunk.lines + if hunk.header_start_line then + clear_start = hunk.header_start_line - 1 + end + vim.api.nvim_buf_clear_namespace(bufnr, ns, clear_start, clear_end) + highlight.highlight_hunk(bufnr, ns, hunk, fast_hl_opts) + entry.highlighted[i] = true + count = count + 1 + local has_syntax = hunk.lang and config.highlights.treesitter.enabled + local needs_vim = not hunk.lang and hunk.ft and config.highlights.vim.enabled + if has_syntax or needs_vim then + table.insert(deferred_syntax, hunk) + end + end + end + if #deferred_syntax > 0 then + local tick = entry.tick + dbg('deferred syntax scheduled: %d hunks tick=%d', #deferred_syntax, tick) + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + local cur = hunk_cache[bufnr] + if not cur or cur.tick ~= tick then + dbg( + 'deferred syntax stale: cur.tick=%s captured=%d', + cur and tostring(cur.tick) or 'nil', + tick + ) + return + end + local t1 = config.debug and vim.uv.hrtime() or nil + local syntax_opts = { + hide_prefix = config.hide_prefix, + highlights = config.highlights, + syntax_only = true, + } + for _, hunk in ipairs(deferred_syntax) do + highlight.highlight_hunk(bufnr, ns, hunk, syntax_opts) + end + if t1 then + dbg('deferred pass: %d hunks in %.2fms', #deferred_syntax, (vim.uv.hrtime() - t1) / 1e6) + end + end) + end + if t0 and count > 0 then + dbg( + 'on_win %d: %d hunks [%d..%d] in %.2fms (viewport %d-%d)', + bufnr, + count, + first, + last, + (vim.uv.hrtime() - t0) / 1e6, + toprow, + botrow + ) end end, }) @@ -484,37 +917,34 @@ function M.attach(bufnr) end attached_buffers[bufnr] = true + local neogit_augroup = nil + if config.neogit and vim.bo[bufnr].filetype:match('^Neogit') then + vim.b[bufnr].neogit_disable_hunk_highlight = true + neogit_augroup = vim.api.nvim_create_augroup('diffs_neogit_' .. bufnr, { clear = true }) + vim.api.nvim_create_autocmd('User', { + pattern = 'NeogitDiffLoaded', + group = neogit_augroup, + callback = function() + if vim.api.nvim_buf_is_valid(bufnr) and attached_buffers[bufnr] then + M.refresh(bufnr) + end + end, + }) + end + 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, - }) + ensure_cache(bufnr) vim.api.nvim_create_autocmd('BufWipeout', { buffer = bufnr, callback = function() attached_buffers[bufnr] = nil + hunk_cache[bufnr] = nil + ft_retry_pending[bufnr] = nil + if neogit_augroup then + pcall(vim.api.nvim_del_augroup_by_id, neogit_augroup) + end end, }) end @@ -522,7 +952,7 @@ end ---@param bufnr? integer function M.refresh(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() - highlight_buffer(bufnr) + invalidate_cache(bufnr) end local DIFF_WINHIGHLIGHT = table.concat({ @@ -565,7 +995,7 @@ function M.detach_diff() end end ----@return diffs.FugitiveConfig +---@return diffs.FugitiveConfig|false function M.get_fugitive_config() init() return config.fugitive @@ -577,4 +1007,24 @@ function M.get_conflict_config() return config.conflict end +local function process_pending_clear(bufnr) + local entry = hunk_cache[bufnr] + if entry and entry.pending_clear then + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + entry.highlighted = {} + entry.pending_clear = false + end +end + +M._test = { + find_visible_hunks = find_visible_hunks, + hunk_cache = hunk_cache, + ensure_cache = ensure_cache, + invalidate_cache = invalidate_cache, + hunks_eq = hunks_eq, + process_pending_clear = process_pending_clear, + ft_retry_pending = ft_retry_pending, + compute_hunk_context = compute_hunk_context, +} + return M diff --git a/lua/diffs/lib.lua b/lua/diffs/lib.lua index 5b3254b..8376925 100644 --- a/lua/diffs/lib.lua +++ b/lua/diffs/lib.lua @@ -8,6 +8,9 @@ local cached_handle = nil ---@type boolean local download_in_progress = false +---@type fun(handle: table?)[] +local pending_callbacks = {} + ---@return string local function get_os() local os_name = jit.os:lower() @@ -164,9 +167,10 @@ function M.ensure(callback) return end + table.insert(pending_callbacks, callback) + if download_in_progress then - dbg('download already in progress') - callback(nil) + dbg('download already in progress, queued callback') return end @@ -192,21 +196,25 @@ function M.ensure(callback) vim.system(cmd, {}, function(result) download_in_progress = false vim.schedule(function() + local handle = nil if result.code ~= 0 then vim.notify('[diffs] failed to download libvscode_diff', vim.log.levels.WARN) dbg('curl failed: %s', result.stderr or '') - callback(nil) - return + else + local f = io.open(version_path(), 'w') + if f then + f:write(EXPECTED_VERSION) + f:close() + end + vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) + handle = M.load() end - local f = io.open(version_path(), 'w') - if f then - f:write(EXPECTED_VERSION) - f:close() + local cbs = pending_callbacks + pending_callbacks = {} + for _, cb in ipairs(cbs) do + cb(handle) end - - vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) - callback(M.load()) end) end) end diff --git a/lua/diffs/log.lua b/lua/diffs/log.lua index 08abcc6..525f578 100644 --- a/lua/diffs/log.lua +++ b/lua/diffs/log.lua @@ -1,10 +1,17 @@ local M = {} local enabled = false +local log_file = nil ----@param val boolean +---@param val boolean|string function M.set_enabled(val) - enabled = val + if type(val) == 'string' then + enabled = true + log_file = val + else + enabled = val + log_file = nil + end end ---@param msg string @@ -13,7 +20,16 @@ function M.dbg(msg, ...) if not enabled then return end - vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG) + local formatted = '[diffs.nvim]: ' .. string.format(msg, ...) + if log_file then + local f = io.open(log_file, 'a') + if f then + f:write(string.format('%.6fs', vim.uv.hrtime() / 1e9) .. ' ' .. formatted .. '\n') + f:close() + end + else + vim.notify(formatted, vim.log.levels.DEBUG) + end end return M diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua new file mode 100644 index 0000000..daf72ac --- /dev/null +++ b/lua/diffs/merge.lua @@ -0,0 +1,418 @@ +local M = {} + +local conflict = require('diffs.conflict') + +local ns = vim.api.nvim_create_namespace('diffs-merge') + +---@type table> +local resolved_hunks = {} + +---@class diffs.MergeHunkInfo +---@field index integer +---@field start_line integer +---@field end_line integer +---@field del_lines string[] +---@field add_lines string[] + +---@param bufnr integer +---@return diffs.MergeHunkInfo[] +function M.parse_hunks(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local hunks = {} + local current = nil + + for i, line in ipairs(lines) do + local idx = i - 1 + if line:match('^@@') then + if current then + current.end_line = idx - 1 + table.insert(hunks, current) + end + current = { + index = #hunks + 1, + start_line = idx, + end_line = idx, + del_lines = {}, + add_lines = {}, + } + elseif current then + local prefix = line:sub(1, 1) + if prefix == '-' then + table.insert(current.del_lines, line:sub(2)) + elseif prefix == '+' then + table.insert(current.add_lines, line:sub(2)) + elseif prefix ~= ' ' and prefix ~= '\\' then + current.end_line = idx - 1 + table.insert(hunks, current) + current = nil + end + if current then + current.end_line = idx + end + end + end + + if current then + table.insert(hunks, current) + end + + return hunks +end + +---@param bufnr integer +---@return diffs.MergeHunkInfo? +function M.find_hunk_at_cursor(bufnr) + local hunks = M.parse_hunks(bufnr) + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 + + for _, hunk in ipairs(hunks) do + if cursor_line >= hunk.start_line and cursor_line <= hunk.end_line then + return hunk + end + end + + return nil +end + +---@param hunk diffs.MergeHunkInfo +---@param working_bufnr integer +---@return diffs.ConflictRegion? +function M.match_hunk_to_conflict(hunk, working_bufnr) + local working_lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + local regions = conflict.parse(working_lines) + + for _, region in ipairs(regions) do + local ours_lines = {} + for line = region.ours_start + 1, region.ours_end do + table.insert(ours_lines, working_lines[line]) + end + + if #ours_lines == #hunk.del_lines then + local match = true + for j = 1, #ours_lines do + if ours_lines[j] ~= hunk.del_lines[j] then + match = false + break + end + end + if match then + return region + end + end + end + + return nil +end + +---@param diff_bufnr integer +---@return integer? +function M.get_or_load_working_buf(diff_bufnr) + local ok, working_path = pcall(vim.api.nvim_buf_get_var, diff_bufnr, 'diffs_working_path') + if not ok or not working_path then + return nil + end + + local existing = vim.fn.bufnr(working_path) + if existing ~= -1 then + return existing + end + + local bufnr = vim.fn.bufadd(working_path) + vim.fn.bufload(bufnr) + return bufnr +end + +---@param diff_bufnr integer +---@param hunk_index integer +local function mark_resolved(diff_bufnr, hunk_index) + if not resolved_hunks[diff_bufnr] then + resolved_hunks[diff_bufnr] = {} + end + resolved_hunks[diff_bufnr][hunk_index] = true +end + +---@param diff_bufnr integer +---@param hunk_index integer +---@return boolean +function M.is_resolved(diff_bufnr, hunk_index) + return resolved_hunks[diff_bufnr] and resolved_hunks[diff_bufnr][hunk_index] or false +end + +---@param diff_bufnr integer +---@param hunk diffs.MergeHunkInfo +local function add_resolved_virtual_text(diff_bufnr, hunk) + pcall(vim.api.nvim_buf_set_extmark, diff_bufnr, ns, hunk.start_line, 0, { + virt_text = { { ' (resolved)', 'Comment' } }, + virt_text_pos = 'eol', + }) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_ours(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + local lines = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false) + conflict.replace_region(working_bufnr, region, lines) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_theirs(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + local lines = + vim.api.nvim_buf_get_lines(working_bufnr, region.theirs_start, region.theirs_end, false) + conflict.replace_region(working_bufnr, region, lines) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_both(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + local ours = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false) + local theirs = + vim.api.nvim_buf_get_lines(working_bufnr, region.theirs_start, region.theirs_end, false) + local combined = {} + for _, l in ipairs(ours) do + table.insert(combined, l) + end + for _, l in ipairs(theirs) do + table.insert(combined, l) + end + conflict.replace_region(working_bufnr, region, combined) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_none(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + conflict.replace_region(working_bufnr, region, {}) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +function M.goto_next(bufnr) + local hunks = M.parse_hunks(bufnr) + if #hunks == 0 then + return + end + + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 + + local candidates = {} + for _, hunk in ipairs(hunks) do + if not M.is_resolved(bufnr, hunk.index) then + if M.match_hunk_to_conflict(hunk, working_bufnr) then + table.insert(candidates, hunk) + end + end + end + + if #candidates == 0 then + return + end + + for _, hunk in ipairs(candidates) do + if hunk.start_line > cursor_line then + vim.api.nvim_win_set_cursor(0, { hunk.start_line + 1, 0 }) + return + end + end + + vim.notify('[diffs.nvim]: wrapped to first hunk', vim.log.levels.INFO) + vim.api.nvim_win_set_cursor(0, { candidates[1].start_line + 1, 0 }) +end + +---@param bufnr integer +function M.goto_prev(bufnr) + local hunks = M.parse_hunks(bufnr) + if #hunks == 0 then + return + end + + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 + + local candidates = {} + for _, hunk in ipairs(hunks) do + if not M.is_resolved(bufnr, hunk.index) then + if M.match_hunk_to_conflict(hunk, working_bufnr) then + table.insert(candidates, hunk) + end + end + end + + if #candidates == 0 then + return + end + + for i = #candidates, 1, -1 do + if candidates[i].start_line < cursor_line then + vim.api.nvim_win_set_cursor(0, { candidates[i].start_line + 1, 0 }) + return + end + end + + vim.notify('[diffs.nvim]: wrapped to last hunk', vim.log.levels.INFO) + vim.api.nvim_win_set_cursor(0, { candidates[#candidates].start_line + 1, 0 }) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +local function apply_hunk_hints(bufnr, config) + if not config.show_virtual_text then + return + end + + local hunks = M.parse_hunks(bufnr) + for _, hunk in ipairs(hunks) do + if M.is_resolved(bufnr, hunk.index) then + add_resolved_virtual_text(bufnr, hunk) + else + local parts = {} + local actions = { + { 'current', config.keymaps.ours }, + { 'incoming', config.keymaps.theirs }, + { 'both', config.keymaps.both }, + { 'none', config.keymaps.none }, + } + for _, action in ipairs(actions) do + if action[2] then + if #parts > 0 then + table.insert(parts, { ' | ', 'Comment' }) + end + table.insert(parts, { ('%s: %s'):format(action[2], action[1]), 'Comment' }) + end + end + if #parts > 0 then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, hunk.start_line, 0, { + virt_text = parts, + virt_text_pos = 'eol', + }) + end + end + end +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.setup_keymaps(bufnr, config) + resolved_hunks[bufnr] = nil + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + + local km = config.keymaps + + local maps = { + { km.ours, '(diffs-merge-ours)' }, + { km.theirs, '(diffs-merge-theirs)' }, + { km.both, '(diffs-merge-both)' }, + { km.none, '(diffs-merge-none)' }, + { km.next, '(diffs-merge-next)' }, + { km.prev, '(diffs-merge-prev)' }, + } + + for _, map in ipairs(maps) do + if map[1] then + vim.keymap.set('n', map[1], map[2], { buffer = bufnr }) + end + end + + apply_hunk_hints(bufnr, config) + + vim.api.nvim_create_autocmd('BufWipeout', { + buffer = bufnr, + callback = function() + resolved_hunks[bufnr] = nil + end, + }) +end + +---@return integer +function M.get_namespace() + return ns +end + +return M diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 43eb1f6..ccbd46a 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -12,12 +12,19 @@ ---@field file_old_count integer? ---@field file_new_start integer? ---@field file_new_count integer? +---@field prefix_width integer +---@field quote_width integer ---@field repo_root string? +---@field context_before string[]? +---@field context_after string[]? local M = {} local dbg = require('diffs.log').dbg +---@type table +local ft_lang_cache = {} + ---@param filepath string ---@param n integer ---@return string[]? @@ -56,6 +63,15 @@ local function get_ft_from_filename(filename, repo_root) end local ft = vim.filetype.match({ filename = filename }) + if not ft and vim.fn.did_filetype() ~= 0 then + dbg('retrying filetype match for %s (clearing did_filetype)', filename) + local saved = rawget(vim.fn, 'did_filetype') + rawset(vim.fn, 'did_filetype', function() + return 0 + end) + ft = vim.filetype.match({ filename = filename }) + rawset(vim.fn, 'did_filetype', saved) + end if ft then dbg('filetype from filename: %s', ft) return ft @@ -106,7 +122,14 @@ local function get_repo_root(bufnr) return vim.fn.fnamemodify(git_dir, ':h') end - return nil + local ok3, neogit_git_dir = pcall(vim.api.nvim_buf_get_var, bufnr, 'neogit_git_dir') + if ok3 and neogit_git_dir then + return vim.fn.fnamemodify(neogit_git_dir, ':h') + end + + local cwd = vim.fn.getcwd() + local git = require('diffs.git') + return git.get_repo_root(cwd .. '/.') end ---@param bufnr integer @@ -114,6 +137,18 @@ end function M.parse_buffer(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local repo_root = get_repo_root(bufnr) + + local quote_prefix = nil + local quote_width = 0 + for _, l in ipairs(lines) do + local qp = l:match('^(>+ )diff %-%-') or l:match('^(>+ )@@ %-') + if qp then + quote_prefix = qp + quote_width = #qp + break + end + end + ---@type diffs.Hunk[] local hunks = {} @@ -133,6 +168,8 @@ function M.parse_buffer(bufnr) local hunk_lines = {} ---@type integer? local hunk_count = nil + ---@type integer + local hunk_prefix_width = 1 ---@type integer? local header_start = nil ---@type string[] @@ -145,6 +182,11 @@ function M.parse_buffer(bufnr) local file_new_start = nil ---@type integer? local file_new_count = nil + ---@type integer? + local old_remaining = nil + ---@type integer? + local new_remaining = nil + local current_quote_width = 0 local function flush_hunk() if hunk_start and #hunk_lines > 0 then @@ -156,6 +198,8 @@ function M.parse_buffer(bufnr) header_context = hunk_header_context, header_context_col = hunk_header_context_col, lines = hunk_lines, + prefix_width = hunk_prefix_width, + quote_width = current_quote_width, file_old_start = file_old_start, file_old_count = file_old_count, file_new_start = file_new_start, @@ -176,51 +220,121 @@ function M.parse_buffer(bufnr) file_old_count = nil file_new_start = nil file_new_count = nil + old_remaining = nil + new_remaining = nil end for i, line in ipairs(lines) do - local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$') + local logical = line + if quote_prefix then + if line:sub(1, quote_width) == quote_prefix then + logical = line:sub(quote_width + 1) + elseif line:match('^>+$') then + logical = '' + end + end + + local diff_git_file = logical:match('^diff %-%-git a/.+ b/(.+)$') + or logical:match('^diff %-%-combined (.+)$') + or logical:match('^diff %-%-cc (.+)$') + local neogit_file = logical:match('^modified%s+(.+)$') + or (not logical:match('^new file mode') and logical:match('^new file%s+(.+)$')) + or (not logical:match('^deleted file mode') and logical:match('^deleted%s+(.+)$')) + or logical:match('^renamed%s+(.+)$') + or logical:match('^copied%s+(.+)$') + local bare_file = not hunk_start and logical:match('^([^%s]+%.[^%s]+)$') + local filename = logical:match('^[MADRCU%?!]%s+(.+)$') + or diff_git_file + or neogit_file + or bare_file if filename then 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 + current_quote_width = (logical ~= line) and quote_width or 0 + local cache_key = (repo_root or '') .. '\0' .. filename + local cached = ft_lang_cache[cache_key] + if cached then + current_ft = cached.ft + current_lang = cached.lang + else + current_ft = get_ft_from_filename(filename, repo_root) + current_lang = current_ft and get_lang_from_ft(current_ft) or nil + if current_ft or vim.fn.did_filetype() == 0 then + ft_lang_cache[cache_key] = { ft = current_ft, lang = current_lang } + end + end if current_lang then 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 + hunk_prefix_width = 1 header_start = i header_lines = {} - elseif line:match('^@@.-@@') then + elseif logical:match('^@@+') then flush_hunk() hunk_start = i - local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') - if hs then - file_old_start = tonumber(hs) - file_old_count = tonumber(hc) or 1 - file_new_start = tonumber(hs2) - file_new_count = tonumber(hc2) or 1 + local at_prefix = logical:match('^(@@+)') + hunk_prefix_width = #at_prefix - 1 + if #at_prefix == 2 then + local hs, hc, hs2, hc2 = logical:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') + if hs then + file_old_start = tonumber(hs) + file_old_count = tonumber(hc) or 1 + file_new_start = tonumber(hs2) + file_new_count = tonumber(hc2) or 1 + old_remaining = file_old_count + new_remaining = file_new_count + end + else + local hs, hc = logical:match('%-(%d+),?(%d*)') + if hs then + file_old_start = tonumber(hs) + file_old_count = tonumber(hc) or 1 + old_remaining = file_old_count + end + local hs2, hc2 = logical:match('%+(%d+),?(%d*) @@') + if hs2 then + file_new_start = tonumber(hs2) + file_new_count = tonumber(hc2) or 1 + new_remaining = file_new_count + end end - local prefix, context = line:match('^(@@.-@@%s*)(.*)') + local at_end, context = logical:match('^(@@+.-@@+%s*)(.*)') if context and context ~= '' then hunk_header_context = context - hunk_header_context_col = #prefix + hunk_header_context_col = #at_end + current_quote_width end if hunk_count then hunk_count = hunk_count + 1 end elseif hunk_start then - local prefix = line:sub(1, 1) + local prefix = logical:sub(1, 1) if prefix == ' ' or prefix == '+' or prefix == '-' then - table.insert(hunk_lines, line) + table.insert(hunk_lines, logical) + if old_remaining and (prefix == ' ' or prefix == '-') then + old_remaining = old_remaining - 1 + end + if new_remaining and (prefix == ' ' or prefix == '+') then + new_remaining = new_remaining - 1 + end elseif - line == '' - or line:match('^[MADRC%?!]%s+') - or line:match('^diff ') - or line:match('^index ') - or line:match('^Binary ') + logical == '' + and old_remaining + and old_remaining > 0 + and new_remaining + and new_remaining > 0 + then + table.insert(hunk_lines, string.rep(' ', hunk_prefix_width)) + old_remaining = old_remaining - 1 + new_remaining = new_remaining - 1 + elseif + logical == '' + or logical:match('^[MADRC%?!]%s+') + or logical:match('^diff ') + or logical:match('^index ') + or logical:match('^Binary ') then flush_hunk() current_filename = nil @@ -230,7 +344,7 @@ function M.parse_buffer(bufnr) end end if header_start and not hunk_start then - table.insert(header_lines, line) + table.insert(header_lines, logical) end end @@ -239,4 +353,8 @@ function M.parse_buffer(bufnr) return hunks end +M._test = { + ft_lang_cache = ft_lang_cache, +} + return M diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 5d3c8b2..e572cf5 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -6,7 +6,7 @@ vim.g.loaded_diffs = 1 require('diffs.commands').setup() vim.api.nvim_create_autocmd('FileType', { - pattern = { 'fugitive', 'git' }, + pattern = require('diffs').compute_filetypes(vim.g.diffs or {}), callback = function(args) local diffs = require('diffs') if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then @@ -82,3 +82,28 @@ end, { desc = 'Jump to next conflict' }) vim.keymap.set('n', '(diffs-conflict-prev)', function() require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf()) end, { desc = 'Jump to previous conflict' }) + +local function merge_action(fn) + local bufnr = vim.api.nvim_get_current_buf() + local config = require('diffs').get_conflict_config() + fn(bufnr, config) +end + +vim.keymap.set('n', '(diffs-merge-ours)', function() + merge_action(require('diffs.merge').resolve_ours) +end, { desc = 'Accept ours in merge diff' }) +vim.keymap.set('n', '(diffs-merge-theirs)', function() + merge_action(require('diffs.merge').resolve_theirs) +end, { desc = 'Accept theirs in merge diff' }) +vim.keymap.set('n', '(diffs-merge-both)', function() + merge_action(require('diffs.merge').resolve_both) +end, { desc = 'Accept both in merge diff' }) +vim.keymap.set('n', '(diffs-merge-none)', function() + merge_action(require('diffs.merge').resolve_none) +end, { desc = 'Reject both in merge diff' }) +vim.keymap.set('n', '(diffs-merge-next)', function() + require('diffs.merge').goto_next(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to next conflict hunk' }) +vim.keymap.set('n', '(diffs-merge-prev)', function() + require('diffs.merge').goto_prev(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to previous conflict hunk' }) diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..e06bf09 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +nix develop --command stylua --check . +git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet +nix develop --command prettier --check . +nix fmt +git diff --exit-code -- '*.nix' +nix develop --command lua-language-server --check . --checklevel=Warning +nix develop --command busted diff --git a/selene.toml b/selene.toml index 96cf5ab..f2ada4b 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1,4 @@ std = 'vim' + +[lints] +bad_string_escape = 'allow' diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index daba5d5..1bf9e61 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -40,6 +40,78 @@ describe('commands', function() end) end) + describe('filter_combined_diffs', function() + it('strips diff --cc entries entirely', function() + local lines = { + 'diff --cc main.lua', + 'index d13ab94,b113aee..0000000', + '--- a/main.lua', + '+++ b/main.lua', + '@@@ -1,7 -1,7 +1,11 @@@', + ' local M = {}', + '++<<<<<<< HEAD', + ' + return 1', + '++=======', + '+ return 2', + '++>>>>>>> theirs', + ' end', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(0, #result) + end) + + it('preserves diff --git entries', function() + local lines = { + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local x = 1', + '+local x = 2', + ' return M', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(8, #result) + assert.are.same(lines, result) + end) + + it('strips combined but keeps unified in mixed output', function() + local lines = { + 'diff --cc conflict.lua', + 'index aaa,bbb..000', + '@@@ -1,1 -1,1 +1,5 @@@', + '++<<<<<<< HEAD', + 'diff --git a/clean.lua b/clean.lua', + '--- a/clean.lua', + '+++ b/clean.lua', + '@@ -1,1 +1,1 @@', + '-old', + '+new', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(6, #result) + assert.are.equal('diff --git a/clean.lua b/clean.lua', result[1]) + assert.are.equal('+new', result[6]) + end) + + it('returns empty for empty input', function() + local result = commands.filter_combined_diffs({}) + assert.are.equal(0, #result) + end) + + it('returns empty when all entries are combined', function() + local lines = { + 'diff --cc a.lua', + 'some content', + 'diff --cc b.lua', + 'more content', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(0, #result) + end) + end) + describe('find_hunk_line', function() it('finds matching @@ header and returns target line', function() local diff_lines = { diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua index 75eac23..9f3df5d 100644 --- a/spec/conflict_spec.lua +++ b/spec/conflict_spec.lua @@ -6,13 +6,14 @@ local function default_config(overrides) enabled = true, disable_diagnostics = false, show_virtual_text = true, + show_actions = false, keymaps = { ours = 'doo', theirs = 'dot', both = 'dob', none = 'don', - next = ']x', - prev = '[x', + next = ']c', + prev = '[c', }, } if overrides then @@ -234,29 +235,6 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) - it('does not apply virtual text when disabled', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach(bufnr, default_config({ show_virtual_text = false })) - - local extmarks = get_extmarks(bufnr) - local virt_text_count = 0 - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - virt_text_count = virt_text_count + 1 - end - end - assert.are.equal(0, virt_text_count) - - helpers.delete_buffer(bufnr) - end) - it('applies number_hl_group to content lines', function() local bufnr = create_file_buffer({ '<<<<<<< HEAD', @@ -531,6 +509,33 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) + it('goto_next notifies on wrap-around', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to first conflict') then + notified = true + end + end + + conflict.goto_next(bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(bufnr) + end) + it('goto_prev jumps to previous conflict', function() local bufnr = create_file_buffer({ '<<<<<<< HEAD', @@ -575,6 +580,33 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) + it('goto_prev notifies on wrap-around', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to last conflict') then + notified = true + end + end + + conflict.goto_prev(bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(bufnr) + end) + it('goto_next does nothing with no conflicts', function() local bufnr = create_file_buffer({ 'normal line' }) vim.api.nvim_set_current_buf(bufnr) @@ -685,4 +717,158 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) end) + + describe('virtual text formatting', function() + after_each(function() + conflict.detach(vim.api.nvim_get_current_buf()) + end) + + it('default labels show current and incoming without keymaps', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config()) + + local extmarks = get_extmarks(bufnr) + local labels = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + table.insert(labels, mark[4].virt_text[1][1]) + end + end + assert.are.equal(2, #labels) + assert.are.equal(' (current)', labels[1]) + assert.are.equal(' (incoming)', labels[2]) + + helpers.delete_buffer(bufnr) + end) + + it('uses custom format_virtual_text function', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach( + bufnr, + default_config({ + format_virtual_text = function(side) + return side == 'ours' and 'OURS' or 'THEIRS' + end, + }) + ) + + local extmarks = get_extmarks(bufnr) + local labels = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + table.insert(labels, mark[4].virt_text[1][1]) + end + end + assert.are.equal(2, #labels) + assert.are.equal(' (OURS)', labels[1]) + assert.are.equal(' (THEIRS)', labels[2]) + + helpers.delete_buffer(bufnr) + end) + + it('hides label when format_virtual_text returns nil', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach( + bufnr, + default_config({ + format_virtual_text = function() + return nil + end, + }) + ) + + local extmarks = get_extmarks(bufnr) + local virt_text_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + virt_text_count = virt_text_count + 1 + end + end + assert.are.equal(0, virt_text_count) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('action lines', function() + after_each(function() + conflict.detach(vim.api.nvim_get_current_buf()) + end) + + it('adds virt_lines when show_actions is true', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config({ show_actions = true })) + + local extmarks = get_extmarks(bufnr) + local virt_lines_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_lines then + virt_lines_count = virt_lines_count + 1 + end + end + assert.are.equal(1, virt_lines_count) + + helpers.delete_buffer(bufnr) + end) + + it('omits disabled keymaps from action line', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach( + bufnr, + default_config({ show_actions = true, keymaps = { both = false, none = false } }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_lines then + local line = mark[4].virt_lines[1] + local text = '' + for _, chunk in ipairs(line) do + text = text .. chunk[1] + end + assert.is_truthy(text:find('Current')) + assert.is_truthy(text:find('Incoming')) + assert.is_falsy(text:find('Both')) + assert.is_falsy(text:find('None')) + end + end + + helpers.delete_buffer(bufnr) + end) + end) end) diff --git a/spec/context_spec.lua b/spec/context_spec.lua new file mode 100644 index 0000000..237b7f3 --- /dev/null +++ b/spec/context_spec.lua @@ -0,0 +1,382 @@ +require('spec.helpers') +local diffs = require('diffs') +local highlight = require('diffs.highlight') +local compute_hunk_context = diffs._test.compute_hunk_context + +describe('context', function() + describe('compute_hunk_context', function() + local tmpdir + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + end) + + local function write_file(filename, lines) + local path = vim.fs.joinpath(tmpdir, filename) + local dir = vim.fn.fnamemodify(path, ':h') + if vim.fn.isdirectory(dir) == 0 then + vim.fn.mkdir(dir, 'p') + end + local f = io.open(path, 'w') + f:write(table.concat(lines, '\n') .. '\n') + f:close() + end + + local function make_hunk(filename, opts) + return { + filename = filename, + ft = 'lua', + lang = 'lua', + start_line = opts.start_line or 1, + lines = opts.lines, + prefix_width = opts.prefix_width or 1, + quote_width = 0, + repo_root = tmpdir, + file_new_start = opts.file_new_start, + file_new_count = opts.file_new_count, + } + end + + it('reads context_before from file lines preceding the hunk', function() + write_file('a.lua', { + 'local M = {}', + 'function M.foo()', + ' local x = 1', + ' local y = 2', + 'end', + 'return M', + }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 3, + file_new_count = 3, + lines = { ' local x = 1', '+local new = true', ' local y = 2' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.same({ 'local M = {}', 'function M.foo()' }, hunks[1].context_before) + end) + + it('reads context_after from file lines following the hunk', function() + write_file('a.lua', { + 'local M = {}', + 'function M.foo()', + ' local x = 1', + 'end', + 'return M', + }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 2, + file_new_count = 2, + lines = { ' function M.foo()', '+ local x = 1' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.same({ 'end', 'return M' }, hunks[1].context_after) + end) + + it('caps context_before to max_lines', function() + write_file('a.lua', { + 'line1', + 'line2', + 'line3', + 'line4', + 'line5', + 'target', + }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 6, + file_new_count = 1, + lines = { '+target' }, + }), + } + compute_hunk_context(hunks, 2) + + assert.same({ 'line4', 'line5' }, hunks[1].context_before) + end) + + it('caps context_after to max_lines', function() + write_file('a.lua', { + 'target', + 'after1', + 'after2', + 'after3', + 'after4', + }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 1, + file_new_count = 1, + lines = { '+target' }, + }), + } + compute_hunk_context(hunks, 2) + + assert.same({ 'after1', 'after2' }, hunks[1].context_after) + end) + + it('skips hunks without file_new_start', function() + write_file('a.lua', { 'line1', 'line2' }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = nil, + file_new_count = nil, + lines = { '+something' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.is_nil(hunks[1].context_before) + assert.is_nil(hunks[1].context_after) + end) + + it('skips hunks without repo_root', function() + local hunks = { + { + filename = 'a.lua', + ft = 'lua', + lang = 'lua', + start_line = 1, + lines = { '+x' }, + prefix_width = 1, + quote_width = 0, + repo_root = nil, + file_new_start = 1, + file_new_count = 1, + }, + } + compute_hunk_context(hunks, 25) + + assert.is_nil(hunks[1].context_before) + assert.is_nil(hunks[1].context_after) + end) + + it('skips when file does not exist on disk', function() + local hunks = { + make_hunk('nonexistent.lua', { + file_new_start = 1, + file_new_count = 1, + lines = { '+x' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.is_nil(hunks[1].context_before) + assert.is_nil(hunks[1].context_after) + end) + + it('returns nil context_before for hunk at line 1', function() + write_file('a.lua', { 'first', 'second' }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 1, + file_new_count = 1, + lines = { '+first' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.is_nil(hunks[1].context_before) + end) + + it('returns nil context_after for hunk at end of file', function() + write_file('a.lua', { 'first', 'last' }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 1, + file_new_count = 2, + lines = { ' first', '+last' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.is_nil(hunks[1].context_after) + end) + + it('reads file once for multiple hunks in same file', function() + write_file('a.lua', { + 'local M = {}', + 'function M.foo()', + ' return 1', + 'end', + 'function M.bar()', + ' return 2', + 'end', + 'return M', + }) + + local hunks = { + make_hunk('a.lua', { + file_new_start = 2, + file_new_count = 3, + lines = { ' function M.foo()', '+ return 1', ' end' }, + }), + make_hunk('a.lua', { + file_new_start = 5, + file_new_count = 3, + lines = { ' function M.bar()', '+ return 2', ' end' }, + }), + } + compute_hunk_context(hunks, 25) + + assert.same({ 'local M = {}' }, hunks[1].context_before) + assert.same({ 'function M.bar()', ' return 2', 'end', 'return M' }, hunks[1].context_after) + assert.same({ + 'local M = {}', + 'function M.foo()', + ' return 1', + 'end', + }, hunks[2].context_before) + assert.same({ 'return M' }, hunks[2].context_after) + end) + end) + + describe('highlight_treesitter with context', function() + local ns + + before_each(function() + ns = vim.api.nvim_create_namespace('diffs_context_test') + local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) + vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 }) + end) + + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + local function get_extmarks(bufnr) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + end + + local function default_opts(overrides) + local opts = { + hide_prefix = false, + highlights = { + background = false, + gutter = false, + context = { enabled = true, lines = 25 }, + treesitter = { enabled = true, max_lines = 500 }, + vim = { enabled = false, max_lines = 200 }, + intra = { enabled = false, algorithm = 'default', max_lines = 500 }, + priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, + }, + } + if overrides then + if overrides.highlights then + opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights) + end + end + return opts + end + + it('applies extmarks only to hunk lines, not context lines', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,3 @@', + ' local x = 1', + ' local y = 2', + '+local z = 3', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 2, + lines = { ' local x = 1', ' local y = 2', '+local z = 3' }, + prefix_width = 1, + quote_width = 0, + context_before = { 'local function foo()' }, + context_after = { 'end' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local row = mark[2] + assert.is_true(row >= 1 and row <= 3, 'extmark row ' .. row .. ' outside hunk range') + end + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) + + it('does not pass context when context.enabled = false', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 2, + lines = { ' local x = 1', '+local y = 2' }, + prefix_width = 1, + quote_width = 0, + context_before = { 'local function foo()' }, + context_after = { 'end' }, + } + + local opts_enabled = default_opts({ highlights = { context = { enabled = true } } }) + highlight.highlight_hunk(bufnr, ns, hunk, opts_enabled) + local extmarks_with = get_extmarks(bufnr) + + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + + local opts_disabled = default_opts({ highlights = { context = { enabled = false } } }) + highlight.highlight_hunk(bufnr, ns, hunk, opts_disabled) + local extmarks_without = get_extmarks(bufnr) + + assert.is_true(#extmarks_with > 0) + assert.is_true(#extmarks_without > 0) + delete_buffer(bufnr) + end) + + it('skips context fields that are nil', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 2, + lines = { ' local x = 1', '+local y = 2' }, + prefix_width = 1, + quote_width = 0, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/decoration_provider_spec.lua b/spec/decoration_provider_spec.lua new file mode 100644 index 0000000..172ca11 --- /dev/null +++ b/spec/decoration_provider_spec.lua @@ -0,0 +1,329 @@ +require('spec.helpers') +local diffs = require('diffs') + +describe('decoration_provider', function() + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + describe('ensure_cache', function() + it('populates hunk cache for a buffer with diff content', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,3 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + ' local z = 4', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_table(entry.hunks) + assert.is_true(#entry.hunks > 0) + delete_buffer(bufnr) + end) + + it('cache tick matches buffer changedtick after attach', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + local tick = vim.api.nvim_buf_get_changedtick(bufnr) + assert.are.equal(tick, entry.tick) + delete_buffer(bufnr) + end) + + it('re-parses and advances tick when buffer content changes', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local tick_before = diffs._test.hunk_cache[bufnr].tick + vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' }) + diffs._test.ensure_cache(bufnr) + local tick_after = diffs._test.hunk_cache[bufnr].tick + assert.is_true(tick_after > tick_before) + delete_buffer(bufnr) + end) + + it('skips reparse when fingerprint unchanged but sets pending_clear', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + local original_hunks = entry.hunks + entry.pending_clear = false + + local lc = vim.api.nvim_buf_line_count(bufnr) + local bc = vim.api.nvim_buf_get_offset(bufnr, lc) + entry.line_count = lc + entry.byte_count = bc + entry.tick = -1 + + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + local current_tick = vim.api.nvim_buf_get_changedtick(bufnr) + assert.are.equal(original_hunks, updated.hunks) + assert.are.equal(current_tick, updated.tick) + assert.is_true(updated.pending_clear) + delete_buffer(bufnr) + end) + + it('does nothing for invalid buffer', function() + local bufnr = create_buffer({}) + diffs.attach(bufnr) + vim.api.nvim_buf_delete(bufnr, { force = true }) + assert.has_no.errors(function() + diffs._test.ensure_cache(bufnr) + end) + end) + end) + + describe('pending_clear', function() + it('is true after invalidate_cache', function() + local bufnr = create_buffer({}) + diffs.attach(bufnr) + diffs._test.invalidate_cache(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_true(entry.pending_clear) + delete_buffer(bufnr) + end) + + it('is true immediately after fresh ensure_cache', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_true(entry.pending_clear) + delete_buffer(bufnr) + end) + + it('clears namespace extmarks when on_buf processes pending_clear', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local ns_id = vim.api.nvim_create_namespace('diffs') + vim.api.nvim_buf_set_extmark(bufnr, ns_id, 0, 0, { line_hl_group = 'DiffAdd' }) + assert.are.equal(1, #vim.api.nvim_buf_get_extmarks(bufnr, ns_id, 0, -1, {})) + + diffs._test.invalidate_cache(bufnr) + diffs._test.ensure_cache(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_true(entry.pending_clear) + + diffs._test.process_pending_clear(bufnr) + + entry = diffs._test.hunk_cache[bufnr] + assert.is_false(entry.pending_clear) + assert.are.same({}, vim.api.nvim_buf_get_extmarks(bufnr, ns_id, 0, -1, {})) + delete_buffer(bufnr) + end) + end) + + describe('BufWipeout cleanup', function() + it('removes hunk_cache entry after buffer wipeout', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + assert.is_not_nil(diffs._test.hunk_cache[bufnr]) + vim.api.nvim_buf_delete(bufnr, { force = true }) + assert.is_nil(diffs._test.hunk_cache[bufnr]) + end) + end) + + describe('hunk stability', function() + it('carries forward highlighted for stable hunks on section expansion', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(2, #entry.hunks) + + entry.pending_clear = false + entry.highlighted = { [1] = true, [2] = true } + + vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, { + '@@ -5,1 +5,2 @@', + ' local z = 4', + '+local w = 5', + }) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.are.equal(3, #updated.hunks) + assert.is_true(updated.highlighted[1] == true) + assert.is_nil(updated.highlighted[2]) + assert.is_true(updated.highlighted[3] == true) + assert.is_false(updated.pending_clear) + delete_buffer(bufnr) + end) + + it('carries forward highlighted for stable hunks on section collapse', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + '@@ -5,1 +5,2 @@', + ' local z = 4', + '+local w = 5', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(3, #entry.hunks) + + entry.pending_clear = false + entry.highlighted = { [1] = true, [2] = true, [3] = true } + + vim.api.nvim_buf_set_lines(bufnr, 5, 8, false, {}) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.are.equal(2, #updated.hunks) + assert.is_true(updated.highlighted[1] == true) + assert.is_true(updated.highlighted[2] == true) + assert.is_false(updated.pending_clear) + delete_buffer(bufnr) + end) + + it('bypasses carry-forward when pending_clear was true', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + entry.highlighted = { [1] = true, [2] = true } + entry.pending_clear = true + + vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, { + '@@ -5,1 +5,2 @@', + ' local z = 4', + '+local w = 5', + }) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.are.same({}, updated.highlighted) + assert.is_true(updated.pending_clear) + delete_buffer(bufnr) + end) + + it('does not carry forward when all hunks changed', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + + entry.pending_clear = false + entry.highlighted = { [1] = true } + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'M other.lua', + '@@ -1,1 +1,2 @@', + ' local a = 1', + '+local b = 2', + }) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.is_nil(updated.highlighted[1]) + assert.is_true(updated.pending_clear) + delete_buffer(bufnr) + end) + end) + + describe('multiple hunks in cache', function() + it('stores all parsed hunks for a multi-hunk buffer', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.are.equal(2, #entry.hunks) + delete_buffer(bufnr) + end) + + it('stores empty hunks table for buffer with no diff content', function() + local bufnr = create_buffer({ + 'Head: main', + 'Help: g?', + '', + 'Nothing to see here', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.are.same({}, entry.hunks) + delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/email_quote_spec.lua b/spec/email_quote_spec.lua new file mode 100644 index 0000000..e1744a1 --- /dev/null +++ b/spec/email_quote_spec.lua @@ -0,0 +1,477 @@ +require('spec.helpers') +local highlight = require('diffs.highlight') +local parser = require('diffs.parser') + +describe('email-quoted diffs', function() + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + describe('parser', function() + it('parses a fully email-quoted unified diff', function() + local bufnr = create_buffer({ + '> diff --git a/foo.py b/foo.py', + '> index abc1234..def5678 100644', + '> --- a/foo.py', + '> +++ b/foo.py', + '> @@ -0,0 +1,3 @@', + '> +from typing import Annotated, final', + '> +', + '> +class Foo:', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('foo.py', hunks[1].filename) + assert.are.equal(3, #hunks[1].lines) + assert.are.equal('+from typing import Annotated, final', hunks[1].lines[1]) + assert.are.equal(2, hunks[1].quote_width) + delete_buffer(bufnr) + end) + + it('parses a quoted diff embedded in an email reply', function() + local bufnr = create_buffer({ + 'Looks good, one nit:', + '', + '> diff --git a/foo.py b/foo.py', + '> @@ -0,0 +1,3 @@', + '> +from typing import Annotated, final', + '> +', + '> +class Foo:', + '', + 'Maybe rename Foo to Bar?', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('foo.py', hunks[1].filename) + assert.are.equal(3, #hunks[1].lines) + assert.are.equal(2, hunks[1].quote_width) + delete_buffer(bufnr) + end) + + it('sets quote_width = 0 on normal (unquoted) diffs', function() + local bufnr = create_buffer({ + 'diff --git a/bar.lua b/bar.lua', + '@@ -1,2 +1,2 @@', + '-old_line', + '+new_line', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(0, hunks[1].quote_width) + delete_buffer(bufnr) + end) + + it('treats bare > lines as empty quoted lines', function() + local bufnr = create_buffer({ + '> diff --git a/foo.py b/foo.py', + '> @@ -1,3 +1,3 @@', + '> -old', + '>', + '> +new', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(3, #hunks[1].lines) + assert.are.equal('-old', hunks[1].lines[1]) + assert.are.equal(' ', hunks[1].lines[2]) + assert.are.equal('+new', hunks[1].lines[3]) + delete_buffer(bufnr) + end) + + it('handles deeply nested quotes', function() + local bufnr = create_buffer({ + '>> diff --git a/foo.py b/foo.py', + '>> @@ -0,0 +1,2 @@', + '>> +line1', + '>> +line2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(3, hunks[1].quote_width) + assert.are.equal('+line1', hunks[1].lines[1]) + delete_buffer(bufnr) + end) + + it('adjusts header_context_col for quote width', function() + local bufnr = create_buffer({ + '> diff --git a/foo.py b/foo.py', + '> @@ -1,2 +1,2 @@ def hello():', + '> -old', + '> +new', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('def hello():', hunks[1].header_context) + assert.are.equal(#'@@ -1,2 +1,2 @@ ' + 2, hunks[1].header_context_col) + delete_buffer(bufnr) + end) + + it('does not false-positive on prose containing > diff', function() + local bufnr = create_buffer({ + '> diff between approaches is small', + '> I think we should go with option A', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(0, #hunks) + delete_buffer(bufnr) + end) + + it('stores header lines stripped of quote prefix', function() + local bufnr = create_buffer({ + '> diff --git a/foo.lua b/foo.lua', + '> index abc1234..def5678 100644', + '> --- a/foo.lua', + '> +++ b/foo.lua', + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.is_not_nil(hunks[1].header_lines) + for _, hline in ipairs(hunks[1].header_lines) do + assert.is_nil(hline:match('^> ')) + end + delete_buffer(bufnr) + end) + end) + + describe('highlight', function() + local ns + + before_each(function() + ns = vim.api.nvim_create_namespace('diffs_email_test') + vim.api.nvim_set_hl(0, 'DiffsClear', { fg = 0xc0c0c0, bg = 0x1e1e1e }) + vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = 0x1a3a1a }) + vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = 0x3a1a1a }) + vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { fg = 0x808080, bold = true }) + end) + + local function get_extmarks(bufnr) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + end + + local function default_opts(overrides) + local opts = { + hide_prefix = false, + highlights = { + background = true, + gutter = false, + context = { enabled = false, lines = 0 }, + treesitter = { + enabled = true, + max_lines = 500, + }, + vim = { + enabled = false, + max_lines = 200, + }, + intra = { + enabled = false, + algorithm = 'default', + max_lines = 500, + }, + priorities = { + clear = 198, + syntax = 199, + line_bg = 200, + char_bg = 201, + }, + }, + } + if overrides then + if overrides.highlights then + opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights) + end + for k, v in pairs(overrides) do + if k ~= 'highlights' then + opts[k] = v + end + end + end + return opts + end + + it('applies DiffsClear on email-quoted header lines covering full buffer width', function() + local buf_lines = { + '> diff --git a/foo.lua b/foo.lua', + '> index abc1234..def5678 100644', + '> --- a/foo.lua', + '> +++ b/foo.lua', + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + } + local bufnr = create_buffer(buf_lines) + + local hunk = { + filename = 'foo.lua', + lang = 'lua', + ft = 'lua', + start_line = 5, + lines = { '-old', '+new' }, + prefix_width = 1, + quote_width = 2, + header_start_line = 1, + header_lines = { + 'diff --git a/foo.lua b/foo.lua', + 'index abc1234..def5678 100644', + '--- a/foo.lua', + '+++ b/foo.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local header_clears = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and mark[2] < 4 then + table.insert(header_clears, { row = mark[2], col = mark[3], end_col = d.end_col }) + end + end + assert.is_true(#header_clears > 0) + for _, c in ipairs(header_clears) do + assert.are.equal(0, c.col) + local buf_line_len = #buf_lines[c.row + 1] + assert.are.equal(buf_line_len, c.end_col) + end + + delete_buffer(bufnr) + end) + + it('applies body prefix DiffsClear covering [0, pw+qw)', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + }) + + local hunk = { + filename = 'foo.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { '-old', '+new' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local prefix_clears = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and d.end_col == 3 and mark[3] == 0 then + table.insert(prefix_clears, { row = mark[2] }) + end + end + assert.are.equal(2, #prefix_clears) + + delete_buffer(bufnr) + end) + + it('clamps body prefix DiffsClear on bare > lines (1-byte buffer line)', function() + local bufnr = create_buffer({ + '> @@ -1,3 +1,3 @@', + '> -old', + '>', + '> +new', + }) + + local hunk = { + filename = 'foo.lua', + ft = 'lua', + lang = 'lua', + start_line = 1, + lines = { '-old', ' ', '+new' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local bare_line_row = 2 + local bare_clears = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and mark[2] == bare_line_row and mark[3] == 0 then + table.insert(bare_clears, { end_col = d.end_col }) + end + end + assert.are.equal(1, #bare_clears) + assert.are.equal(1, bare_clears[1].end_col) + + delete_buffer(bufnr) + end) + + it('applies per-char @diff.plus/@diff.minus at ci + qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,1 @@', + '> -old', + '> +new', + }) + + local hunk = { + filename = 'foo.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { '-old', '+new' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local diff_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == '@diff.plus' or d.hl_group == '@diff.minus') then + table.insert( + diff_marks, + { row = mark[2], col = mark[3], end_col = d.end_col, hl = d.hl_group } + ) + end + end + assert.is_true(#diff_marks >= 2) + for _, dm in ipairs(diff_marks) do + assert.are.equal(2, dm.col) + assert.are.equal(3, dm.end_col) + end + + delete_buffer(bufnr) + end) + + it('offsets treesitter extmarks by pw + qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,2 @@', + '> local x = 1', + '> +local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local ts_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group and d.hl_group:match('^@.*%.lua$') then + table.insert(ts_marks, { row = mark[2], col = mark[3] }) + end + end + assert.is_true(#ts_marks > 0) + for _, tm in ipairs(ts_marks) do + assert.is_true(tm.col >= 3) + end + + delete_buffer(bufnr) + end) + + it('offsets intra-line char span extmarks by qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,1 @@', + '> -hello world', + '> +hello earth', + }) + + local hunk = { + filename = 'test.txt', + ft = nil, + lang = nil, + start_line = 1, + lines = { '-hello world', '+hello earth' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } }, + }) + ) + + local extmarks = get_extmarks(bufnr) + local char_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then + table.insert(char_marks, { row = mark[2], col = mark[3], end_col = d.end_col }) + end + end + if #char_marks > 0 then + for _, cm in ipairs(char_marks) do + assert.is_true(cm.col >= 2) + end + end + + delete_buffer(bufnr) + end) + + it('does not produce duplicate extmarks with syntax_only + qw', function() + local bufnr = create_buffer({ + '> @@ -1,1 +1,2 @@', + '> local x = 1', + '> +local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + ft = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + prefix_width = 1, + quote_width = 2, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ syntax_only = true })) + + local extmarks = get_extmarks(bufnr) + local line_bg_count = 0 + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.line_hl_group == 'DiffsAdd' then + line_bg_count = line_bg_count + 1 + end + end + assert.are.equal(1, line_bg_count) + + delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index 244e1b7..95b40a3 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -87,28 +87,6 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('parses added file', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'A newfile.lua', - }) - local filename, section = fugitive.get_file_at_line(buf, 2) - assert.equals('newfile.lua', filename) - assert.equals('staged', section) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('parses deleted file', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'D oldfile.lua', - }) - local filename, section = fugitive.get_file_at_line(buf, 2) - assert.equals('oldfile.lua', filename) - assert.equals('staged', section) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - it('parses renamed file and returns both names', function() local buf = create_status_buffer({ 'Staged (1)', @@ -157,28 +135,6 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('handles renamed file in subdirectory', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'R src/old.lua -> src/new.lua', - }) - local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('src/new.lua', filename) - assert.equals('src/old.lua', old_filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles renamed file moved to different directory', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'R old/file.lua -> new/file.lua', - }) - local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('new/file.lua', filename) - assert.equals('old/file.lua', old_filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - it('KNOWN LIMITATION: filename containing arrow parsed incorrectly', function() local buf = create_status_buffer({ 'Staged (1)', @@ -190,77 +146,54 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('handles double extensions', function() + it('unquotes git-quoted filenames with spaces', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "path with spaces/file.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('path with spaces/file.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes escaped quotes in filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "file\\"name.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('file"name.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes octal escapes in filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "\\303\\251le.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('\195\169le.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('passes through unquoted filenames unchanged', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M normal.lua', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('normal.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes renamed files with quotes', function() local buf = create_status_buffer({ 'Staged (1)', - 'M test.spec.lua', + 'R100 "old name.lua" -> "new name.lua"', }) local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('test.spec.lua', filename) - assert.is_nil(old_filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles hyphenated filenames', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M my-component-test.lua', - }) - local filename, section = fugitive.get_file_at_line(buf, 2) - assert.equals('my-component-test.lua', filename) - assert.equals('unstaged', section) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles underscores and numbers', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'A test_file_123.lua', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('test_file_123.lua', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles dotfiles', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M .gitignore', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('.gitignore', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles renamed with complex names', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'R src/old-file.spec.lua -> src/new-file.spec.lua', - }) - local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('src/new-file.spec.lua', filename) - assert.equals('src/old-file.spec.lua', old_filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles deeply nested paths', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M lua/diffs/ui/components/diff-view.lua', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('lua/diffs/ui/components/diff-view.lua', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('parses untracked file', function() - local buf = create_status_buffer({ - 'Untracked (1)', - '? untracked.lua', - }) - local filename, section = fugitive.get_file_at_line(buf, 2) - assert.equals('untracked.lua', filename) - assert.equals('untracked', section) + assert.equals('new name.lua', filename) + assert.equals('old name.lua', old_filename) vim.api.nvim_buf_delete(buf, { force = true }) end) @@ -321,30 +254,6 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('detects section header for Unstaged', function() - local buf = create_status_buffer({ - 'Unstaged (3)', - 'M file1.lua', - }) - local filename, section, is_header = fugitive.get_file_at_line(buf, 1) - assert.is_nil(filename) - assert.equals('unstaged', section) - assert.is_true(is_header) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('detects section header for Untracked', function() - local buf = create_status_buffer({ - 'Untracked (1)', - '? newfile.lua', - }) - local filename, section, is_header = fugitive.get_file_at_line(buf, 1) - assert.is_nil(filename) - assert.equals('untracked', section) - assert.is_true(is_header) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - it('returns is_header=false for file lines', function() local buf = create_status_buffer({ 'Staged (1)', @@ -406,22 +315,6 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('returns hunk header and offset for - line', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M file.lua', - '@@ -1,3 +1,3 @@', - ' local M = {}', - '-local old = false', - ' return M', - }) - local pos = fugitive.get_hunk_position(buf, 5) - assert.is_not_nil(pos) - assert.equals('@@ -1,3 +1,3 @@', pos.hunk_header) - assert.equals(2, pos.offset) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - it('returns hunk header and offset for context line', function() local buf = create_status_buffer({ 'Unstaged (1)', diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 0b9051e..39cc23c 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -13,6 +13,7 @@ describe('highlight', function() vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 }) vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg }) vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg }) + vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { fg = 0x808080, bold = true }) end) local function create_buffer(lines) @@ -51,6 +52,12 @@ describe('highlight', function() algorithm = 'default', max_lines = 500, }, + priorities = { + clear = 198, + syntax = 199, + line_bg = 200, + char_bg = 201, + }, }, } if overrides then @@ -64,27 +71,6 @@ describe('highlight', function() return opts end - it('applies extmarks for lua code', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - assert.is_true(#extmarks > 0) - delete_buffer(bufnr) - end) - it('applies DiffsClear extmarks to clear diff colors', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -190,36 +176,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('highlights header context when enabled', function() - local bufnr = create_buffer({ - '@@ -10,3 +10,4 @@ function hello()', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - header_context = 'function hello()', - header_context_col = 18, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local has_header_extmark = false - for _, mark in ipairs(extmarks) do - if mark[2] == 0 then - has_header_extmark = true - break - end - end - assert.is_true(has_header_extmark) - delete_buffer(bufnr) - end) - it('highlights function keyword in header context', function() local bufnr = create_buffer({ '@@ -5,3 +5,4 @@ function M.setup()', @@ -280,44 +236,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('handles empty hunk lines', function() - local bufnr = create_buffer({ - '@@ -1,0 +1,0 @@', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = {}, - } - - assert.has_no.errors(function() - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - end) - delete_buffer(bufnr) - end) - - it('handles code that is just whitespace', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' ', - '+ ', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' ', '+ ' }, - } - - assert.has_no.errors(function() - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - end) - delete_buffer(bufnr) - end) - it('applies overlay extmarks when hide_prefix enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -345,33 +263,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('does not apply overlay extmarks when hide_prefix disabled', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = false })) - - local extmarks = get_extmarks(bufnr) - local overlay_count = 0 - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text_pos == 'overlay' then - overlay_count = overlay_count + 1 - end - end - assert.are.equal(0, overlay_count) - delete_buffer(bufnr) - end) - it('applies DiffAdd background to + lines when background enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -396,7 +287,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsAdd' then + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -429,7 +320,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_delete = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsDelete' then + if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then has_diff_delete = true break end @@ -438,39 +329,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('does not apply background when background disabled', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = false } }) - ) - - local extmarks = get_extmarks(bufnr) - local has_line_hl = false - for _, mark in ipairs(extmarks) do - if mark[4] and (mark[4].hl_group == 'DiffsAdd' or mark[4].hl_group == 'DiffsDelete') then - has_line_hl = true - break - end - end - assert.is_false(has_line_hl) - delete_buffer(bufnr) - end) - it('applies number_hl_group when gutter enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -504,7 +362,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('does not apply number_hl_group when gutter disabled', function() + it('line bg uses line_hl_group not hl_group with end_row', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' local x = 1', @@ -522,51 +380,101 @@ describe('highlight', function() bufnr, ns, hunk, - default_opts({ highlights = { background = true, gutter = false } }) + default_opts({ highlights = { background = true } }) ) local extmarks = get_extmarks(bufnr) - local has_number_hl = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].number_hl_group then - has_number_hl = true - break - end + local d = mark[4] + assert.is_not_equal('DiffsAdd', d and d.hl_group) + assert.is_not_equal('DiffsDelete', d and d.hl_group) end - assert.is_false(has_number_hl) delete_buffer(bufnr) end) - it('skips treesitter highlights when treesitter disabled', function() + it('line bg extmark survives adjacent clear_namespace starting at next row', function() local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', + 'diff --git a/foo.py b/foo.py', + '@@ -1,2 +1,2 @@', + '-old', + '+new', }) local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, + filename = 'foo.py', + header_start_line = 1, + start_line = 2, + lines = { '-old', '+new' }, + prefix_width = 1, } highlight.highlight_hunk( bufnr, ns, hunk, - default_opts({ highlights = { treesitter = { enabled = false }, background = true } }) + default_opts({ highlights = { background = true, treesitter = { enabled = false } } }) ) - local extmarks = get_extmarks(bufnr) - local has_ts_highlight = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then - has_ts_highlight = true - break + local last_body_row = hunk.start_line + #hunk.lines - 1 + vim.api.nvim_buf_clear_namespace(bufnr, ns, last_body_row + 1, last_body_row + 10) + + local marks = vim.api.nvim_buf_get_extmarks( + bufnr, + ns, + { last_body_row, 0 }, + { last_body_row, -1 }, + { details = true } + ) + local has_line_bg = false + for _, mark in ipairs(marks) do + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + has_line_bg = true end end - assert.is_false(has_ts_highlight) + assert.is_true(has_line_bg) + delete_buffer(bufnr) + end) + + it('clear range covers last body line of hunk with header', function() + local bufnr = create_buffer({ + 'diff --git a/foo.py b/foo.py', + 'index abc..def 100644', + '--- a/foo.py', + '+++ b/foo.py', + '@@ -1,3 +1,3 @@', + ' ctx', + '-old', + '+new', + }) + + local hunk = { + filename = 'foo.py', + header_start_line = 1, + start_line = 5, + lines = { ' ctx', '-old', '+new' }, + prefix_width = 1, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true, treesitter = { enabled = false } } }) + ) + + local last_body_row = hunk.start_line + #hunk.lines - 1 + local clear_start = hunk.header_start_line - 1 + local clear_end = hunk.start_line + #hunk.lines + vim.api.nvim_buf_clear_namespace(bufnr, ns, clear_start, clear_end) + + local marks = vim.api.nvim_buf_get_extmarks( + bufnr, + ns, + { last_body_row, 0 }, + { last_body_row, -1 }, + { details = false } + ) + assert.are.equal(0, #marks) delete_buffer(bufnr) end) @@ -594,7 +502,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsAdd' then + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -654,40 +562,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('skips vim fallback when vim.enabled is false', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - ft = 'abap', - lang = nil, - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { vim = { enabled = false } } }) - ) - - local extmarks = get_extmarks(bufnr) - local has_syntax_hl = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then - has_syntax_hl = true - break - end - end - assert.is_false(has_syntax_hl) - delete_buffer(bufnr) - end) - it('respects vim.max_lines', function() local lines = { '@@ -1,100 +1,101 @@' } local hunk_lines = {} @@ -742,7 +616,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsAdd' then + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -802,7 +676,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('uses hl_group not line_hl_group for line backgrounds', function() + it('uses hl_group with hl_eol for line backgrounds', function() local bufnr = create_buffer({ '@@ -1,2 +1,1 @@', '-local x = 1', @@ -824,44 +698,14 @@ describe('highlight', function() ) local extmarks = get_extmarks(bufnr) + local found = false for _, mark in ipairs(extmarks) do local d = mark[4] - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then - assert.is_true(d.hl_eol == true) - assert.is_nil(d.line_hl_group) - end - end - delete_buffer(bufnr) - end) - - it('hl_eol background extmarks are multiline so hl_eol takes effect', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,1 @@', - '-local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true } }) - ) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then - assert.is_true(d.end_row > mark[2]) + if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then + found = true end end + assert.is_true(found) delete_buffer(bufnr) end) @@ -900,92 +744,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('line bg priority > DiffsClear priority', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,1 @@', - '-local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true } }) - ) - - local extmarks = get_extmarks(bufnr) - local clear_priority = nil - local line_bg_priority = nil - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.hl_group == 'DiffsClear' then - clear_priority = d.priority - end - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then - line_bg_priority = d.priority - end - end - assert.is_not_nil(clear_priority) - assert.is_not_nil(line_bg_priority) - assert.is_true(line_bg_priority > clear_priority) - delete_buffer(bufnr) - end) - - it('char-level extmarks have higher priority than line bg', function() - vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) - vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) - - local bufnr = create_buffer({ - '@@ -1,2 +1,2 @@', - '-local x = 1', - '+local x = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-local x = 1', '+local x = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - highlights = { - background = true, - intra = { enabled = true, algorithm = 'default', max_lines = 500 }, - }, - }) - ) - - local extmarks = get_extmarks(bufnr) - local line_bg_priority = nil - local char_bg_priority = nil - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then - line_bg_priority = d.priority - end - if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then - char_bg_priority = d.priority - end - end - assert.is_not_nil(line_bg_priority) - assert.is_not_nil(char_bg_priority) - assert.is_true(char_bg_priority > line_bg_priority) - delete_buffer(bufnr) - end) - it('creates char-level extmarks for changed characters', function() vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) @@ -1029,38 +787,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('does not create char-level extmarks when intra disabled', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,2 @@', - '-local x = 1', - '+local x = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-local x = 1', '+local x = 2' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - highlights = { intra = { enabled = false, algorithm = 'default', max_lines = 500 } }, - }) - ) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - local d = mark[4] - assert.is_not_equal('DiffsAddText', d and d.hl_group) - assert.is_not_equal('DiffsDeleteText', d and d.hl_group) - end - delete_buffer(bufnr) - end) - it('does not create char-level extmarks for pure additions', function() vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) @@ -1131,7 +857,7 @@ describe('highlight', function() if d then if d.hl_group == 'DiffsClear' then table.insert(priorities.clear, d.priority) - elseif d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete' then + elseif d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete' then table.insert(priorities.line_bg, d.priority) elseif d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then table.insert(priorities.char_bg, d.priority) @@ -1157,142 +883,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('context padding produces no extmarks on padding lines', function() - local repo_root = '/tmp/diffs-test-context' - vim.fn.mkdir(repo_root, 'p') - - local f = io.open(repo_root .. '/test.lua', 'w') - f:write('local M = {}\n') - f:write('function M.hello()\n') - f:write(' return "hi"\n') - f:write('end\n') - f:write('return M\n') - f:close() - - local bufnr = create_buffer({ - '@@ -3,1 +3,2 @@', - ' return "hi"', - '+"bye"', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' return "hi"', '+"bye"' }, - file_old_start = 3, - file_old_count = 1, - file_new_start = 3, - file_new_count = 2, - repo_root = repo_root, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { context = { enabled = true, lines = 25 } } }) - ) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - local row = mark[2] - assert.is_true(row >= 1 and row <= 2) - end - - delete_buffer(bufnr) - os.remove(repo_root .. '/test.lua') - vim.fn.delete(repo_root, 'rf') - end) - - it('context disabled matches behavior without padding', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - file_new_start = 1, - file_new_count = 2, - repo_root = '/nonexistent', - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { context = { enabled = false, lines = 0 } } }) - ) - - local extmarks = get_extmarks(bufnr) - assert.is_true(#extmarks > 0) - delete_buffer(bufnr) - end) - - it('gracefully handles missing file for context padding', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - file_new_start = 1, - file_new_count = 2, - repo_root = '/nonexistent/path', - } - - assert.has_no.errors(function() - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { context = { enabled = true, lines = 25 } } }) - ) - end) - - local extmarks = get_extmarks(bufnr) - assert.is_true(#extmarks > 0) - delete_buffer(bufnr) - end) - - it('highlights treesitter injections', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+vim.cmd([[ echo 1 ]])', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+vim.cmd([[ echo 1 ]])' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local has_vim_capture = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.vim$') then - has_vim_capture = true - break - end - end - assert.is_true(has_vim_capture) - delete_buffer(bufnr) - end) - it('includes captures from both base and injected languages', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -1327,6 +917,313 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('classifies all combined diff prefix types for background', function() + local bufnr = create_buffer({ + '@@@ -1,5 -1,5 +1,9 @@@', + ' local M = {}', + '++<<<<<<< HEAD', + ' + return 1', + '+ local greeting = "hi"', + '++=======', + '+ return 2', + '++>>>>>>> feature', + ' end', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + prefix_width = 2, + lines = { + ' local M = {}', + '++<<<<<<< HEAD', + ' + return 1', + '+ local greeting = "hi"', + '++=======', + '+ return 2', + '++>>>>>>> feature', + ' end', + }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + local line_bgs = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then + line_bgs[mark[2]] = d.line_hl_group + end + end + assert.is_nil(line_bgs[1]) + assert.are.equal('DiffsAdd', line_bgs[2]) + assert.are.equal('DiffsAdd', line_bgs[3]) + assert.are.equal('DiffsAdd', line_bgs[4]) + assert.are.equal('DiffsAdd', line_bgs[5]) + assert.are.equal('DiffsAdd', line_bgs[6]) + assert.are.equal('DiffsAdd', line_bgs[7]) + assert.is_nil(line_bgs[8]) + delete_buffer(bufnr) + end) + + it('conceals full 2-char prefix for all combined diff line types', function() + local bufnr = create_buffer({ + '@@@ -1,3 -1,3 +1,5 @@@', + ' local M = {}', + '++<<<<<<< HEAD', + ' + return 1', + '+ local x = 2', + ' end', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + prefix_width = 2, + lines = { + ' local M = {}', + '++<<<<<<< HEAD', + ' + return 1', + '+ local x = 2', + ' end', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = true })) + + local extmarks = get_extmarks(bufnr) + local overlays = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text_pos == 'overlay' then + overlays[mark[2]] = mark[4].virt_text[1][1] + end + end + assert.are.equal(5, vim.tbl_count(overlays)) + for _, text in pairs(overlays) do + assert.are.equal(' ', text) + end + delete_buffer(bufnr) + end) + + it('places treesitter captures at col_offset 2 for combined diffs', function() + local bufnr = create_buffer({ + '@@@ -1,2 -1,2 +1,2 @@@', + ' local x = 1', + ' +local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + prefix_width = 2, + lines = { ' local x = 1', ' +local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local ts_marks = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then + table.insert(ts_marks, mark) + end + end + assert.is_true(#ts_marks > 0) + for _, mark in ipairs(ts_marks) do + assert.is_true(mark[3] >= 2) + end + delete_buffer(bufnr) + end) + + it('applies DiffsClear starting at col 2 for combined diffs', function() + local bufnr = create_buffer({ + '@@@ -1,1 -1,1 +1,2 @@@', + ' local x = 1', + ' +local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + prefix_width = 2, + lines = { ' local x = 1', ' +local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local content_clear_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group == 'DiffsClear' then + assert.is_true(mark[3] == 0 or mark[3] == 2, 'DiffsClear at unexpected col ' .. mark[3]) + if mark[3] == 2 then + content_clear_count = content_clear_count + 1 + end + end + end + assert.are.equal(2, content_clear_count) + delete_buffer(bufnr) + end) + + it('skips intra-line diffing for combined diffs', function() + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) + + local bufnr = create_buffer({ + '@@@ -1,2 -1,2 +1,3 @@@', + ' local x = 1', + ' +local y = 2', + '+ local y = 3', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + prefix_width = 2, + lines = { ' local x = 1', ' +local y = 2', '+ local y = 3' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } }, + }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local d = mark[4] + assert.is_not_equal('DiffsAddText', d and d.hl_group) + assert.is_not_equal('DiffsDeleteText', d and d.hl_group) + end + delete_buffer(bufnr) + end) + + it('applies DiffsConflictMarker text on markers with DiffsAdd bg', function() + local bufnr = create_buffer({ + '@@@ -1,5 -1,5 +1,9 @@@', + ' local M = {}', + '++<<<<<<< HEAD', + '+ local x = 1', + '++||||||| base', + '++=======', + ' +local y = 2', + '++>>>>>>> feature', + ' return M', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + prefix_width = 2, + lines = { + ' local M = {}', + '++<<<<<<< HEAD', + '+ local x = 1', + '++||||||| base', + '++=======', + ' +local y = 2', + '++>>>>>>> feature', + ' return M', + }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true, gutter = true } }) + ) + + local extmarks = get_extmarks(bufnr) + local line_bgs = {} + local gutter_hls = {} + local marker_text = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then + line_bgs[mark[2]] = d.line_hl_group + end + if d and d.number_hl_group then + gutter_hls[mark[2]] = d.number_hl_group + end + if d and d.hl_group == 'DiffsConflictMarker' then + marker_text[mark[2]] = true + end + end + + assert.is_nil(line_bgs[1]) + assert.are.equal('DiffsAdd', line_bgs[2]) + assert.are.equal('DiffsAdd', line_bgs[3]) + assert.are.equal('DiffsAdd', line_bgs[4]) + assert.are.equal('DiffsAdd', line_bgs[5]) + assert.are.equal('DiffsAdd', line_bgs[6]) + assert.are.equal('DiffsAdd', line_bgs[7]) + assert.is_nil(line_bgs[8]) + + assert.is_nil(gutter_hls[1]) + assert.are.equal('DiffsAddNr', gutter_hls[2]) + assert.are.equal('DiffsAddNr', gutter_hls[3]) + assert.are.equal('DiffsAddNr', gutter_hls[4]) + assert.are.equal('DiffsAddNr', gutter_hls[5]) + assert.are.equal('DiffsAddNr', gutter_hls[6]) + assert.are.equal('DiffsAddNr', gutter_hls[7]) + assert.is_nil(gutter_hls[8]) + + assert.is_true(marker_text[2] ~= nil) + assert.is_nil(marker_text[3]) + assert.is_true(marker_text[4] ~= nil) + assert.is_true(marker_text[5] ~= nil) + assert.is_nil(marker_text[6]) + assert.is_true(marker_text[7] ~= nil) + delete_buffer(bufnr) + end) + + it('does not apply DiffsConflictMarker in unified diffs', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,4 @@', + ' local M = {}', + '+<<<<<<< HEAD', + '+local x = 1', + '+=======', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local M = {}', '+<<<<<<< HEAD', '+local x = 1', '+=======' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local d = mark[4] + assert.is_not_equal('DiffsConflictMarker', d and d.hl_group) + end + delete_buffer(bufnr) + end) + it('filters @spell and @nospell captures from injections', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -1352,6 +1249,123 @@ describe('highlight', function() end delete_buffer(bufnr) end) + + it('two-pass rendering produces no duplicate extmarks', function() + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) + vim.api.nvim_set_hl(0, 'DiffsAddNr', { fg = 0x80c080, bg = 0x2e4a3a }) + vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { fg = 0xc08080, bg = 0x4a2e3a }) + + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + + local fast = default_opts({ + highlights = { + treesitter = { enabled = false }, + background = true, + gutter = true, + intra = { enabled = true, algorithm = 'default', max_lines = 500 }, + }, + }) + + local syntax = default_opts({ + highlights = { + treesitter = { enabled = true }, + background = true, + gutter = true, + intra = { enabled = true, algorithm = 'default', max_lines = 500 }, + }, + }) + syntax.syntax_only = true + + highlight.highlight_hunk(bufnr, ns, hunk, fast) + highlight.highlight_hunk(bufnr, ns, hunk, syntax) + + local extmarks = get_extmarks(bufnr) + for row = 1, 2 do + local line_hl_count = 0 + local number_hl_count = 0 + local intra_count = 0 + for _, mark in ipairs(extmarks) do + if mark[2] == row then + local d = mark[4] + if d.line_hl_group then + line_hl_count = line_hl_count + 1 + end + if d.number_hl_group then + number_hl_count = number_hl_count + 1 + end + if d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then + intra_count = intra_count + 1 + end + end + end + assert.are.equal(1, line_hl_count, 'row ' .. row .. ' has duplicate line_hl_group') + assert.are.equal(1, number_hl_count, 'row ' .. row .. ' has duplicate number_hl_group') + assert.is_true(intra_count <= 1, 'row ' .. row .. ' has duplicate intra extmarks') + end + delete_buffer(bufnr) + end) + + it('syntax_only pass adds treesitter without duplicating backgrounds', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,3 @@', + ' local x = 1', + '+local y = 2', + ' return x', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2', ' return x' }, + } + + local fast = default_opts({ + highlights = { + treesitter = { enabled = false }, + background = true, + }, + }) + + local syntax = default_opts({ + highlights = { + treesitter = { enabled = true }, + background = true, + }, + }) + syntax.syntax_only = true + + highlight.highlight_hunk(bufnr, ns, hunk, fast) + highlight.highlight_hunk(bufnr, ns, hunk, syntax) + + local extmarks = get_extmarks(bufnr) + local has_ts = false + local line_hl_count = 0 + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group and d.hl_group:match('^@.*%.lua$') then + has_ts = true + end + if d and d.line_hl_group then + line_hl_count = line_hl_count + 1 + end + end + assert.is_true(has_ts) + assert.are.equal(1, line_hl_count) + delete_buffer(bufnr) + end) end) describe('diff header highlighting', function() @@ -1386,6 +1400,7 @@ describe('highlight', function() context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, + priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, }, } end @@ -1469,7 +1484,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('does not apply header highlights when treesitter disabled', function() + it('does not apply DiffsClear to header lines for non-quoted diffs', function() local bufnr = create_buffer({ 'diff --git a/parser.lua b/parser.lua', 'index 3e8afa0..018159c 100644', @@ -1494,19 +1509,439 @@ describe('highlight', function() }, } - local opts = default_opts() - opts.highlights.treesitter.enabled = false - - highlight.highlight_hunk(bufnr, ns, hunk, opts) + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) - local header_extmarks = 0 for _, mark in ipairs(extmarks) do - if mark[2] < 4 and mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then - header_extmarks = header_extmarks + 1 + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and mark[3] == 0 and mark[2] < 4 then + error('unexpected DiffsClear on header row ' .. mark[2] .. ' for non-quoted diff') end end - assert.are.equal(0, header_extmarks) + delete_buffer(bufnr) + end) + + it('preserves diff grammar treesitter on headers for non-quoted diffs', function() + local bufnr = create_buffer({ + 'diff --git a/parser.lua b/parser.lua', + '--- a/parser.lua', + '+++ b/parser.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + }) + + local hunk = { + filename = 'parser.lua', + lang = 'lua', + start_line = 4, + lines = { ' local M = {}', '+local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --git a/parser.lua b/parser.lua', + '--- a/parser.lua', + '+++ b/parser.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local header_ts_count = 0 + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] < 3 and d and d.hl_group and d.hl_group:match('^@.*%.diff$') then + header_ts_count = header_ts_count + 1 + end + end + assert.is_true(header_ts_count > 0, 'expected diff grammar treesitter on header lines') + delete_buffer(bufnr) + end) + + it('applies syntax extmarks to combined diff body lines', function() + local bufnr = create_buffer({ + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + ' -local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + prefix_width = 2, + start_line = 1, + lines = { ' local M = {}', '+ local x = 1', ' -local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local syntax_on_body = 0 + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] >= 1 and d and d.hl_group and d.hl_group:match('^@.*%.lua$') then + syntax_on_body = syntax_on_body + 1 + end + end + assert.is_true(syntax_on_body > 0, 'expected lua treesitter syntax on combined diff body') + delete_buffer(bufnr) + end) + + it('applies DiffsClear and per-char diff fg to combined diff body prefixes', function() + local bufnr = create_buffer({ + '@@@', + ' unchanged', + '+ added', + ' -removed', + '++both', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + prefix_width = 2, + start_line = 1, + lines = { ' unchanged', '+ added', ' -removed', '++both' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local prefix_clears = {} + local plus_marks = {} + local minus_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] >= 1 and d then + if d.hl_group == 'DiffsClear' and mark[3] == 0 and d.end_col == 2 then + prefix_clears[mark[2]] = true + end + if d.hl_group == '@diff.plus' and d.priority == 199 then + if not plus_marks[mark[2]] then + plus_marks[mark[2]] = {} + end + table.insert(plus_marks[mark[2]], mark[3]) + end + if d.hl_group == '@diff.minus' and d.priority == 199 then + if not minus_marks[mark[2]] then + minus_marks[mark[2]] = {} + end + table.insert(minus_marks[mark[2]], mark[3]) + end + end + end + + assert.is_true(prefix_clears[1] ~= nil, 'DiffsClear on context prefix') + assert.is_true(prefix_clears[2] ~= nil, 'DiffsClear on add prefix') + assert.is_true(prefix_clears[3] ~= nil, 'DiffsClear on del prefix') + assert.is_true(prefix_clears[4] ~= nil, 'DiffsClear on both-add prefix') + + assert.is_true(plus_marks[2] ~= nil, '@diff.plus on + in "+ added"') + assert.are.equal(0, plus_marks[2][1]) + + assert.is_true(minus_marks[3] ~= nil, '@diff.minus on - in " -removed"') + assert.are.equal(1, minus_marks[3][1]) + + assert.is_true(plus_marks[4] ~= nil, '@diff.plus on ++ in "++both"') + assert.are.equal(2, #plus_marks[4]) + + assert.is_nil(plus_marks[1], 'no @diff.plus on context " unchanged"') + assert.is_nil(minus_marks[1], 'no @diff.minus on context " unchanged"') + delete_buffer(bufnr) + end) + + it('applies DiffsClear to headers for combined diffs', function() + local bufnr = create_buffer({ + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'lua/merge/target.lua', + lang = 'lua', + prefix_width = 2, + start_line = 5, + lines = { ' local M = {}', '+ local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local clear_lines = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' and mark[3] == 0 and mark[2] < 4 then + clear_lines[mark[2]] = true + end + end + assert.is_true(clear_lines[0] ~= nil, 'DiffsClear on diff --combined line') + assert.is_true(clear_lines[1] ~= nil, 'DiffsClear on index line') + assert.is_true(clear_lines[2] ~= nil, 'DiffsClear on --- line') + assert.is_true(clear_lines[3] ~= nil, 'DiffsClear on +++ line') + delete_buffer(bufnr) + end) + + it('applies @attribute.diff at syntax priority to @@@ line for combined diffs', function() + local bufnr = create_buffer({ + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + prefix_width = 2, + start_line = 1, + lines = { ' local M = {}', '+ local x = 1' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_attr = false + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] == 0 and d and d.hl_group == '@attribute.diff' and (d.priority or 0) >= 199 then + has_attr = true + end + end + assert.is_true(has_attr, '@attribute.diff at p>=199 on @@@ line') + delete_buffer(bufnr) + end) + + it('applies DiffsClear to @@@ line for combined diffs', function() + local bufnr = create_buffer({ + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + prefix_width = 2, + start_line = 1, + lines = { ' local M = {}', '+ local x = 1' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_at_clear = false + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] == 0 and d and d.hl_group == 'DiffsClear' and mark[3] == 0 then + has_at_clear = true + end + end + assert.is_true(has_at_clear, 'DiffsClear on @@@ line') + delete_buffer(bufnr) + end) + + it('applies header diff grammar at syntax priority for combined diffs', function() + local bufnr = create_buffer({ + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'lua/merge/target.lua', + lang = 'lua', + prefix_width = 2, + start_line = 5, + lines = { ' local M = {}', '+ local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local high_prio_diff = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if + mark[2] < 4 + and d + and d.hl_group + and d.hl_group:match('^@.*%.diff$') + and (d.priority or 0) >= 199 + then + high_prio_diff[mark[2]] = true + end + end + assert.is_true(high_prio_diff[2] ~= nil, 'diff grammar at p>=199 on --- line') + assert.is_true(high_prio_diff[3] ~= nil, 'diff grammar at p>=199 on +++ line') + delete_buffer(bufnr) + end) + + it('@diff.minus wins over @punctuation.special on combined diff headers', function() + local bufnr = create_buffer({ + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'lua/merge/target.lua', + lang = 'lua', + prefix_width = 2, + start_line = 5, + lines = { ' local M = {}', '+ local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local minus_prio, punct_prio_minus = 0, 0 + local plus_prio, punct_prio_plus = 0, 0 + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group then + if mark[2] == 2 then + if d.hl_group == '@diff.minus.diff' then + minus_prio = math.max(minus_prio, d.priority or 0) + elseif d.hl_group == '@punctuation.special.diff' then + punct_prio_minus = math.max(punct_prio_minus, d.priority or 0) + end + elseif mark[2] == 3 then + if d.hl_group == '@diff.plus.diff' then + plus_prio = math.max(plus_prio, d.priority or 0) + elseif d.hl_group == '@punctuation.special.diff' then + punct_prio_plus = math.max(punct_prio_plus, d.priority or 0) + end + end + end + end + assert.is_true( + minus_prio > punct_prio_minus, + '@diff.minus.diff should beat @punctuation.special.diff on --- line' + ) + assert.is_true( + plus_prio > punct_prio_plus, + '@diff.plus.diff should beat @punctuation.special.diff on +++ line' + ) + delete_buffer(bufnr) + end) + + it('applies @keyword.diff on index word for combined diffs', function() + local bufnr = create_buffer({ + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'lua/merge/target.lua', + lang = 'lua', + prefix_width = 2, + start_line = 5, + lines = { ' local M = {}', '+ local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_keyword = false + for _, mark in ipairs(extmarks) do + local d = mark[4] + if + mark[2] == 1 + and d + and d.hl_group == '@keyword.diff' + and mark[3] == 0 + and (d.end_col or 0) == 5 + then + has_keyword = true + end + end + assert.is_true(has_keyword, '@keyword.diff at row 1, cols 0-5') + delete_buffer(bufnr) + end) + + it('applies @constant.diff on result hash for combined diffs', function() + local bufnr = create_buffer({ + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + '@@@ -1,2 -1,2 +1,3 @@@', + ' local M = {}', + '+ local x = 1', + }) + + local hunk = { + filename = 'lua/merge/target.lua', + lang = 'lua', + prefix_width = 2, + start_line = 5, + lines = { ' local M = {}', '+ local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --combined lua/merge/target.lua', + 'index abc1234,def5678..ghi9012', + '--- a/lua/merge/target.lua', + '+++ b/lua/merge/target.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_constant = false + for _, mark in ipairs(extmarks) do + local d = mark[4] + if mark[2] == 1 and d and d.hl_group == '@constant.diff' and (d.priority or 0) >= 199 then + has_constant = true + end + end + assert.is_true(has_constant, '@constant.diff on result hash') delete_buffer(bufnr) end) end) @@ -1543,40 +1978,11 @@ describe('highlight', function() context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, + priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, }, } end - it('uses priority 199 for code languages', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local has_priority_199 = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then - if mark[4].priority == 199 then - has_priority_199 = true - break - end - end - end - assert.is_true(has_priority_199) - delete_buffer(bufnr) - end) - it('uses treesitter priority for diff language', function() local bufnr = create_buffer({ 'diff --git a/test.lua b/test.lua', diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 1bf71d1..2e1952e 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -24,7 +24,6 @@ describe('diffs', function() it('accepts full config', function() vim.g.diffs = { debug = true, - debounce_ms = 100, hide_prefix = false, highlights = { background = true, @@ -46,7 +45,7 @@ describe('diffs', function() it('accepts partial config', function() vim.g.diffs = { - debounce_ms = 25, + hide_prefix = true, } assert.has_no.errors(function() diffs.attach() @@ -152,6 +151,247 @@ describe('diffs', function() end) end) + describe('find_visible_hunks', function() + local find_visible_hunks = diffs._test.find_visible_hunks + + local function make_hunk(start_row, end_row, opts) + local lines = {} + for i = 1, end_row - start_row + 1 do + lines[i] = 'line' .. i + end + local h = { start_line = start_row + 1, lines = lines } + if opts and opts.header_start_line then + h.header_start_line = opts.header_start_line + end + return h + end + + it('returns (0, 0) for empty hunk list', function() + local first, last = find_visible_hunks({}, 0, 50) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('finds single hunk fully inside viewport', function() + local h = make_hunk(5, 10) + local first, last = find_visible_hunks({ h }, 0, 50) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('returns (0, 0) for single hunk fully above viewport', function() + local h = make_hunk(5, 10) + local first, last = find_visible_hunks({ h }, 20, 50) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('returns (0, 0) for single hunk fully below viewport', function() + local h = make_hunk(50, 60) + local first, last = find_visible_hunks({ h }, 0, 20) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('finds single hunk partially visible at top edge', function() + local h = make_hunk(5, 15) + local first, last = find_visible_hunks({ h }, 10, 30) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('finds single hunk partially visible at bottom edge', function() + local h = make_hunk(25, 35) + local first, last = find_visible_hunks({ h }, 10, 30) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('finds subset of visible hunks', function() + local h1 = make_hunk(5, 10) + local h2 = make_hunk(25, 30) + local h3 = make_hunk(55, 60) + local first, last = find_visible_hunks({ h1, h2, h3 }, 20, 40) + assert.are.equal(2, first) + assert.are.equal(2, last) + end) + + it('finds all hunks when all are visible', function() + local h1 = make_hunk(5, 10) + local h2 = make_hunk(15, 20) + local h3 = make_hunk(25, 30) + local first, last = find_visible_hunks({ h1, h2, h3 }, 0, 50) + assert.are.equal(1, first) + assert.are.equal(3, last) + end) + + it('returns (0, 0) when no hunks are visible', function() + local h1 = make_hunk(5, 10) + local h2 = make_hunk(15, 20) + local first, last = find_visible_hunks({ h1, h2 }, 30, 50) + assert.are.equal(0, first) + assert.are.equal(0, last) + end) + + it('uses header_start_line for top boundary', function() + local h = make_hunk(5, 10, { header_start_line = 4 }) + local first, last = find_visible_hunks({ h }, 0, 50) + assert.are.equal(1, first) + assert.are.equal(1, last) + end) + + it('finds both adjacent hunks at viewport edge', function() + local h1 = make_hunk(10, 20) + local h2 = make_hunk(20, 30) + local first, last = find_visible_hunks({ h1, h2 }, 15, 25) + assert.are.equal(1, first) + assert.are.equal(2, last) + end) + end) + + describe('hunk_cache', function() + local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr + end + + local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + + it('creates entry on attach', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_table(entry.hunks) + assert.is_number(entry.tick) + assert.is_true(entry.tick >= 0) + delete_buffer(bufnr) + end) + + it('is idempotent on repeated attach', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry1 = diffs._test.hunk_cache[bufnr] + local tick1 = entry1.tick + local hunks1 = entry1.hunks + diffs._test.ensure_cache(bufnr) + local entry2 = diffs._test.hunk_cache[bufnr] + assert.are.equal(tick1, entry2.tick) + assert.are.equal(hunks1, entry2.hunks) + delete_buffer(bufnr) + end) + + it('marks stale on invalidate', function() + local bufnr = create_buffer({}) + diffs.attach(bufnr) + diffs._test.invalidate_cache(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(-1, entry.tick) + assert.is_true(entry.pending_clear) + delete_buffer(bufnr) + end) + + it('evicts on buffer wipeout', function() + local bufnr = create_buffer({}) + diffs.attach(bufnr) + assert.is_not_nil(diffs._test.hunk_cache[bufnr]) + vim.api.nvim_buf_delete(bufnr, { force = true }) + assert.is_nil(diffs._test.hunk_cache[bufnr]) + end) + + it('detects content change via tick', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local tick_before = diffs._test.hunk_cache[bufnr].tick + vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' }) + diffs._test.ensure_cache(bufnr) + local tick_after = diffs._test.hunk_cache[bufnr].tick + assert.is_true(tick_after > tick_before) + delete_buffer(bufnr) + end) + end) + + describe('compute_filetypes', function() + local compute = diffs.compute_filetypes + + it('returns core filetypes with empty config', function() + local fts = compute({}) + assert.are.same({ 'git', 'gitcommit' }, fts) + end) + + it('includes fugitive when fugitive = true', function() + local fts = compute({ fugitive = true }) + assert.is_true(vim.tbl_contains(fts, 'fugitive')) + end) + + it('includes fugitive when fugitive is a table', function() + local fts = compute({ fugitive = { horizontal = 'dd' } }) + assert.is_true(vim.tbl_contains(fts, 'fugitive')) + end) + + it('excludes fugitive when fugitive = false', function() + local fts = compute({ fugitive = false }) + assert.is_false(vim.tbl_contains(fts, 'fugitive')) + end) + + it('excludes fugitive when fugitive is nil', function() + local fts = compute({}) + assert.is_false(vim.tbl_contains(fts, 'fugitive')) + end) + + it('includes neogit filetypes when neogit = true', function() + local fts = compute({ neogit = true }) + assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) + assert.is_true(vim.tbl_contains(fts, 'NeogitCommitView')) + assert.is_true(vim.tbl_contains(fts, 'NeogitDiffView')) + end) + + it('includes neogit filetypes when neogit is a table', function() + local fts = compute({ neogit = {} }) + assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) + end) + + it('excludes neogit when neogit = false', function() + local fts = compute({ neogit = false }) + assert.is_false(vim.tbl_contains(fts, 'NeogitStatus')) + end) + + it('excludes neogit when neogit is nil', function() + local fts = compute({}) + assert.is_false(vim.tbl_contains(fts, 'NeogitStatus')) + end) + + it('includes extra_filetypes', function() + local fts = compute({ extra_filetypes = { 'diff' } }) + assert.is_true(vim.tbl_contains(fts, 'diff')) + end) + + it('combines fugitive, neogit, and extra_filetypes', function() + local fts = compute({ fugitive = true, neogit = true, extra_filetypes = { 'diff' } }) + assert.is_true(vim.tbl_contains(fts, 'git')) + assert.is_true(vim.tbl_contains(fts, 'fugitive')) + assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) + assert.is_true(vim.tbl_contains(fts, 'diff')) + end) + end) + describe('diff mode', function() local function create_diff_window() vim.cmd('new') diff --git a/spec/integration_spec.lua b/spec/integration_spec.lua new file mode 100644 index 0000000..23e870b --- /dev/null +++ b/spec/integration_spec.lua @@ -0,0 +1,434 @@ +require('spec.helpers') +local diffs = require('diffs') +local highlight = require('diffs.highlight') + +local function setup_highlight_groups() + local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) + local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' }) + local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' }) + vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 }) + vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg or 0x2e4a3a }) + vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg or 0x4a2e3a }) + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) +end + +local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr +end + +local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end +end + +local function get_diffs_ns() + return vim.api.nvim_get_namespaces()['diffs'] +end + +local function get_extmarks(bufnr, ns) + return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) +end + +local function highlight_opts_with_background() + return { + hide_prefix = false, + highlights = { + background = true, + gutter = false, + context = { enabled = false, lines = 0 }, + treesitter = { enabled = true, max_lines = 500 }, + vim = { enabled = false, max_lines = 200 }, + intra = { enabled = false, algorithm = 'default', max_lines = 500 }, + priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, + }, + } +end + +describe('integration', function() + before_each(function() + setup_highlight_groups() + end) + + describe('attach and parse', function() + it('attach populates hunk cache for unified diff buffer', function() + local bufnr = create_buffer({ + 'diff --git a/foo.lua b/foo.lua', + 'index abc..def 100644', + '--- a/foo.lua', + '+++ b/foo.lua', + '@@ -1,3 +1,3 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + ' local z = 4', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.are.equal(1, #entry.hunks) + assert.are.equal('foo.lua', entry.hunks[1].filename) + delete_buffer(bufnr) + end) + + it('attach parses multiple hunks across multiple files', function() + local bufnr = create_buffer({ + 'M foo.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + 'M bar.lua', + '@@ -1,1 +1,2 @@', + ' local a = 1', + '+local b = 2', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.are.equal(2, #entry.hunks) + delete_buffer(bufnr) + end) + + it('re-attach on same buffer is idempotent', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local entry_before = diffs._test.hunk_cache[bufnr] + local tick_before = entry_before.tick + diffs.attach(bufnr) + local entry_after = diffs._test.hunk_cache[bufnr] + assert.are.equal(tick_before, entry_after.tick) + delete_buffer(bufnr) + end) + + it('refresh after content change invalidates cache', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local tick_before = diffs._test.hunk_cache[bufnr].tick + vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' }) + diffs.refresh(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(-1, entry.tick) + assert.is_true(entry.pending_clear) + assert.is_true(tick_before >= 0) + delete_buffer(bufnr) + end) + end) + + describe('ft_retry_pending', function() + before_each(function() + rawset(vim.fn, 'did_filetype', function() + return 1 + end) + require('diffs.parser')._test.ft_lang_cache = {} + end) + + after_each(function() + rawset(vim.fn, 'did_filetype', nil) + end) + + it('sets ft_retry_pending when nil-ft hunks detected under did_filetype', function() + local bufnr = create_buffer({ + 'diff --git a/app.conf b/app.conf', + '@@ -1,2 +1,2 @@', + ' server {', + '- listen 80;', + '+ listen 8080;', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_nil(entry.hunks[1].ft) + assert.is_true(diffs._test.ft_retry_pending[bufnr] == true) + delete_buffer(bufnr) + end) + + it('clears ft_retry_pending after scheduled callback fires', function() + local bufnr = create_buffer({ + 'diff --git a/app.conf b/app.conf', + '@@ -1,2 +1,2 @@', + ' server {', + '- listen 80;', + '+ listen 8080;', + }) + diffs.attach(bufnr) + assert.is_true(diffs._test.ft_retry_pending[bufnr] == true) + + local done = false + vim.schedule(function() + done = true + end) + vim.wait(1000, function() + return done + end) + + assert.is_nil(diffs._test.ft_retry_pending[bufnr]) + delete_buffer(bufnr) + end) + + it('invalidates cache after scheduled callback fires', function() + local bufnr = create_buffer({ + 'diff --git a/app.conf b/app.conf', + '@@ -1,2 +1,2 @@', + ' server {', + '- listen 80;', + '+ listen 8080;', + }) + diffs.attach(bufnr) + local tick_after_attach = diffs._test.hunk_cache[bufnr].tick + assert.is_true(tick_after_attach >= 0) + + local done = false + vim.schedule(function() + done = true + end) + vim.wait(1000, function() + return done + end) + + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(-1, entry.tick) + assert.is_true(entry.pending_clear) + delete_buffer(bufnr) + end) + + it('does not set ft_retry_pending when did_filetype() is zero', function() + rawset(vim.fn, 'did_filetype', nil) + local bufnr = create_buffer({ + 'diff --git a/test.sh b/test.sh', + '@@ -1,2 +1,3 @@', + ' #!/usr/bin/env bash', + '-old line', + '+new line', + }) + diffs.attach(bufnr) + assert.is_falsy(diffs._test.ft_retry_pending[bufnr]) + delete_buffer(bufnr) + end) + + it('does not set ft_retry_pending for files with resolvable ft', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + assert.is_falsy(diffs._test.ft_retry_pending[bufnr]) + delete_buffer(bufnr) + end) + end) + + describe('extmarks from highlight pipeline', function() + it('DiffsAdd background applied to + lines', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_add') + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local has_diff_add = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + has_diff_add = true + break + end + end + assert.is_true(has_diff_add) + delete_buffer(bufnr) + end) + + it('DiffsDelete background applied to - lines', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,1 @@', + ' local x = 1', + '-local y = 2', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_del') + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '-local y = 2' }, + } + highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local has_diff_delete = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then + has_diff_delete = true + break + end + end + assert.is_true(has_diff_delete) + delete_buffer(bufnr) + end) + + it('mixed hunk produces both DiffsAdd and DiffsDelete backgrounds', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_mixed') + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local has_add = false + local has_delete = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + has_add = true + end + if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then + has_delete = true + end + end + assert.is_true(has_add) + assert.is_true(has_delete) + delete_buffer(bufnr) + end) + + it('no background extmarks for context lines', function() + local bufnr = create_buffer({ + '@@ -1,3 +1,3 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + ' local z = 4', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_ctx') + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '-local y = 2', '+local y = 3', ' local z = 4' }, + } + highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local line_bgs = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then + line_bgs[mark[2]] = d.line_hl_group + end + end + assert.is_nil(line_bgs[1]) + assert.is_nil(line_bgs[4]) + assert.are.equal('DiffsDelete', line_bgs[2]) + assert.are.equal('DiffsAdd', line_bgs[3]) + delete_buffer(bufnr) + end) + + it('treesitter extmarks applied for lua hunks', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,3 @@', + ' local x = 1', + '+local y = 2', + ' return x', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_ts') + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2', ' return x' }, + } + highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local has_ts = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then + has_ts = true + break + end + end + assert.is_true(has_ts) + delete_buffer(bufnr) + end) + + it('diffs namespace exists after attach', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + diffs.attach(bufnr) + local ns = get_diffs_ns() + assert.is_not_nil(ns) + assert.is_number(ns) + delete_buffer(bufnr) + end) + end) + + describe('multiple hunks highlighting', function() + it('both hunks in multi-hunk buffer get background extmarks', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 10', + '@@ -10,2 +10,2 @@', + '-local y = 2', + '+local y = 20', + }) + local ns = vim.api.nvim_create_namespace('diffs_integration_test_multi') + local hunk1 = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 10' }, + } + local hunk2 = { + filename = 'test.lua', + lang = 'lua', + start_line = 4, + lines = { '-local y = 2', '+local y = 20' }, + } + highlight.highlight_hunk(bufnr, ns, hunk1, highlight_opts_with_background()) + highlight.highlight_hunk(bufnr, ns, hunk2, highlight_opts_with_background()) + local extmarks = get_extmarks(bufnr, ns) + local add_lines = {} + local del_lines = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.line_hl_group == 'DiffsAdd' then + add_lines[mark[2]] = true + end + if d and d.line_hl_group == 'DiffsDelete' then + del_lines[mark[2]] = true + end + end + assert.is_true(del_lines[1] ~= nil) + assert.is_true(add_lines[2] ~= nil) + assert.is_true(del_lines[4] ~= nil) + assert.is_true(add_lines[5] ~= nil) + delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua new file mode 100644 index 0000000..5e4b854 --- /dev/null +++ b/spec/merge_spec.lua @@ -0,0 +1,815 @@ +local helpers = require('spec.helpers') +local merge = require('diffs.merge') + +local function default_config(overrides) + local cfg = { + enabled = true, + disable_diagnostics = false, + show_virtual_text = true, + show_actions = false, + keymaps = { + ours = 'doo', + theirs = 'dot', + both = 'dob', + none = 'don', + next = ']c', + prev = '[c', + }, + } + if overrides then + cfg = vim.tbl_deep_extend('force', cfg, overrides) + end + return cfg +end + +local function create_diff_buffer(lines, working_path) + local bufnr = helpers.create_buffer(lines) + if working_path then + vim.api.nvim_buf_set_var(bufnr, 'diffs_working_path', working_path) + end + return bufnr +end + +local function create_working_buffer(lines, name) + local bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + if name then + vim.api.nvim_buf_set_name(bufnr, name) + end + return bufnr +end + +describe('merge', function() + describe('parse_hunks', function() + it('parses a single hunk', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local x = 1', + '+local x = 2', + ' return M', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal(3, hunks[1].start_line) + assert.are.equal(7, hunks[1].end_line) + assert.are.same({ 'local x = 1' }, hunks[1].del_lines) + assert.are.same({ 'local x = 2' }, hunks[1].add_lines) + + helpers.delete_buffer(bufnr) + end) + + it('parses multiple hunks', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local x = 1', + '+local x = 2', + ' return M', + '@@ -10,3 +10,3 @@', + ' function M.foo()', + '- return 1', + '+ return 2', + ' end', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(2, #hunks) + assert.are.equal(3, hunks[1].start_line) + assert.are.equal(8, hunks[2].start_line) + + helpers.delete_buffer(bufnr) + end) + + it('parses add-only hunk', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(1, #hunks) + assert.are.same({}, hunks[1].del_lines) + assert.are.same({ 'local new = true' }, hunks[1].add_lines) + + helpers.delete_buffer(bufnr) + end) + + it('parses delete-only hunk', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,2 @@', + ' local M = {}', + '-local old = false', + ' return M', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(1, #hunks) + assert.are.same({ 'local old = false' }, hunks[1].del_lines) + assert.are.same({}, hunks[1].add_lines) + + helpers.delete_buffer(bufnr) + end) + + it('returns empty for buffer with no hunks', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(0, #hunks) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('match_hunk_to_conflict', function() + it('matches hunk to conflict region', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_match.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 7, + del_lines = { 'local x = 1' }, + add_lines = { 'local x = 2' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + assert.are.equal(0, region.marker_ours) + + helpers.delete_buffer(working_bufnr) + end) + + it('returns nil for auto-merged content', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_auto.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 7, + del_lines = { 'local y = 3' }, + add_lines = { 'local y = 4' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_nil(region) + + helpers.delete_buffer(working_bufnr) + end) + + it('matches with empty ours section', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_empty_ours.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 5, + del_lines = {}, + add_lines = { 'local x = 2' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + + helpers.delete_buffer(working_bufnr) + end) + + it('matches correct region among multiple conflicts', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> feature', + }, '/tmp/diffs_test_multi.lua') + + local hunk = { + index = 2, + start_line = 8, + end_line = 12, + del_lines = { 'local b = 3' }, + add_lines = { 'local b = 4' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + assert.are.equal(6, region.marker_ours) + + helpers.delete_buffer(working_bufnr) + end) + + it('matches with diff3 format', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '||||||| base', + 'local x = 0', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_diff3.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 7, + del_lines = { 'local x = 1' }, + add_lines = { 'local x = 2' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + assert.are.equal(2, region.marker_base) + + helpers.delete_buffer(working_bufnr) + end) + end) + + describe('resolution', function() + local diff_bufnr, working_bufnr + + local function setup_buffers() + local working_path = '/tmp/diffs_test_resolve.lua' + working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + diff_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(diff_bufnr) + end + + local function cleanup() + helpers.delete_buffer(diff_bufnr) + helpers.delete_buffer(working_bufnr) + end + + it('resolve_ours keeps ours content in working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 1', lines[1]) + + cleanup() + end) + + it('resolve_theirs keeps theirs content in working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_theirs(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 2', lines[1]) + + cleanup() + end) + + it('resolve_both keeps ours then theirs in working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_both(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(2, #lines) + assert.are.equal('local x = 1', lines[1]) + assert.are.equal('local x = 2', lines[2]) + + cleanup() + end) + + it('resolve_none removes entire block from working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_none(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('', lines[1]) + + cleanup() + end) + + it('tracks resolved hunks', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + assert.is_false(merge.is_resolved(diff_bufnr, 1)) + merge.resolve_ours(diff_bufnr, default_config()) + assert.is_true(merge.is_resolved(diff_bufnr, 1)) + + cleanup() + end) + + it('adds virtual text for resolved hunks', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(diff_bufnr, default_config()) + + local extmarks = + vim.api.nvim_buf_get_extmarks(diff_bufnr, merge.get_namespace(), 0, -1, { details = true }) + local has_resolved_text = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + for _, chunk in ipairs(mark[4].virt_text) do + if chunk[1]:match('resolved') then + has_resolved_text = true + end + end + end + end + assert.is_true(has_resolved_text) + + cleanup() + end) + + it('notifies when hunk is already resolved', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(diff_bufnr, default_config()) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('already resolved') then + notified = true + end + end + + merge.resolve_ours(diff_bufnr, default_config()) + vim.notify = orig_notify + + assert.is_true(notified) + + cleanup() + end) + + it('notifies when hunk does not match a conflict', function() + local working_path = '/tmp/diffs_test_no_conflict.lua' + local w_bufnr = create_working_buffer({ + 'local y = 1', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('does not correspond') then + notified = true + end + end + + merge.resolve_ours(d_bufnr, default_config()) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + end) + + describe('navigation', function() + it('goto_next jumps to next conflict hunk', function() + local working_path = '/tmp/diffs_test_nav.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local a = 1', + '+local a = 2', + '@@ -5,1 +5,1 @@', + '-local b = 3', + '+local b = 4', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + merge.goto_next(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + merge.goto_next(d_bufnr) + assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('goto_next wraps around', function() + local working_path = '/tmp/diffs_test_wrap.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 6, 0 }) + + merge.goto_next(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('goto_next notifies on wrap-around', function() + local working_path = '/tmp/diffs_test_wrap_notify.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 6, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to first hunk') then + notified = true + end + end + + merge.goto_next(d_bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('goto_prev jumps to previous conflict hunk', function() + local working_path = '/tmp/diffs_test_prev.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local a = 1', + '+local a = 2', + '@@ -5,1 +5,1 @@', + '-local b = 3', + '+local b = 4', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 9, 0 }) + + merge.goto_prev(d_bufnr) + assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) + + merge.goto_prev(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('goto_prev wraps around', function() + local working_path = '/tmp/diffs_test_prev_wrap.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + merge.goto_prev(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('goto_prev notifies on wrap-around', function() + local working_path = '/tmp/diffs_test_prev_wrap_notify.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to last hunk') then + notified = true + end + end + + merge.goto_prev(d_bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('skips resolved hunks', function() + local working_path = '/tmp/diffs_test_skip_resolved.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local a = 1', + '+local a = 2', + '@@ -5,1 +5,1 @@', + '-local b = 3', + '+local b = 4', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(d_bufnr, default_config()) + + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + merge.goto_next(d_bufnr) + assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + end) + + describe('hunk hints', function() + it('adds keymap hints on hunk header lines', function() + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }) + + merge.setup_keymaps(d_bufnr, default_config()) + + local extmarks = + vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) + local hint_marks = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + local text = '' + for _, chunk in ipairs(mark[4].virt_text) do + text = text .. chunk[1] + end + table.insert(hint_marks, { line = mark[2], text = text }) + end + end + assert.are.equal(1, #hint_marks) + assert.are.equal(3, hint_marks[1].line) + assert.is_truthy(hint_marks[1].text:find('doo')) + assert.is_truthy(hint_marks[1].text:find('dot')) + + helpers.delete_buffer(d_bufnr) + end) + end) + + describe('setup_keymaps', function() + it('clears resolved state on re-init', function() + local working_path = '/tmp/diffs_test_reinit.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + local cfg = default_config() + merge.resolve_ours(d_bufnr, cfg) + assert.is_true(merge.is_resolved(d_bufnr, 1)) + + local extmarks = + vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) + assert.is_true(#extmarks > 0) + + merge.setup_keymaps(d_bufnr, cfg) + + assert.is_false(merge.is_resolved(d_bufnr, 1)) + extmarks = + vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) + local resolved_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + for _, chunk in ipairs(mark[4].virt_text) do + if chunk[1]:match('resolved') then + resolved_count = resolved_count + 1 + end + end + end + end + assert.are.equal(0, resolved_count) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + end) + + describe('fugitive integration', function() + it('parse_file_line returns status for unmerged files', function() + local fugitive = require('diffs.fugitive') + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'Unstaged (1)', + 'U conflict.lua', + }) + local filename, section, is_header, old_filename, status = fugitive.get_file_at_line(buf, 2) + assert.are.equal('conflict.lua', filename) + assert.are.equal('unstaged', section) + assert.is_false(is_header) + assert.is_nil(old_filename) + assert.are.equal('U', status) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('walkback from hunk line propagates status', function() + local fugitive = require('diffs.fugitive') + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'Unstaged (1)', + 'U conflict.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + }) + local _, _, _, _, status = fugitive.get_file_at_line(buf, 5) + assert.are.equal('U', status) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + end) +end) diff --git a/spec/neogit_integration_spec.lua b/spec/neogit_integration_spec.lua new file mode 100644 index 0000000..ef33049 --- /dev/null +++ b/spec/neogit_integration_spec.lua @@ -0,0 +1,126 @@ +require('spec.helpers') + +vim.g.diffs = { neogit = true } + +local diffs = require('diffs') +local parser = require('diffs.parser') + +local function create_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr +end + +local function delete_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end +end + +describe('neogit_integration', function() + describe('neogit_disable_hunk_highlight', function() + it('sets neogit_disable_hunk_highlight on NeogitStatus buffer after attach', function() + local bufnr = create_buffer({ + 'modified test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + vim.api.nvim_set_option_value('filetype', 'NeogitStatus', { buf = bufnr }) + diffs.attach(bufnr) + + assert.is_true(vim.b[bufnr].neogit_disable_hunk_highlight) + + delete_buffer(bufnr) + end) + + it('does not set neogit_disable_hunk_highlight on non-Neogit buffer', function() + local bufnr = create_buffer({}) + vim.api.nvim_set_option_value('filetype', 'git', { buf = bufnr }) + diffs.attach(bufnr) + + assert.is_not_true(vim.b[bufnr].neogit_disable_hunk_highlight) + + delete_buffer(bufnr) + end) + end) + + describe('NeogitStatus buffer attach', function() + it('populates hunk_cache for NeogitStatus buffer with diff content', function() + local bufnr = create_buffer({ + 'modified hello.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + vim.api.nvim_set_option_value('filetype', 'NeogitStatus', { buf = bufnr }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_table(entry.hunks) + assert.are.equal(1, #entry.hunks) + assert.are.equal('hello.lua', entry.hunks[1].filename) + delete_buffer(bufnr) + end) + + it('populates hunk_cache for NeogitDiffView buffer', function() + local bufnr = create_buffer({ + 'new file newmod.lua', + '@@ -0,0 +1,2 @@', + '+local M = {}', + '+return M', + }) + vim.api.nvim_set_option_value('filetype', 'NeogitDiffView', { buf = bufnr }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.is_not_nil(entry) + assert.is_table(entry.hunks) + assert.are.equal(1, #entry.hunks) + delete_buffer(bufnr) + end) + end) + + describe('parser neogit patterns', function() + it('detects renamed prefix via parser', function() + local bufnr = create_buffer({ + 'renamed old.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal('old.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('detects copied prefix via parser', function() + local bufnr = create_buffer({ + 'copied orig.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal('orig.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('detects deleted prefix via parser', function() + local bufnr = create_buffer({ + 'deleted gone.lua', + '@@ -1,2 +0,0 @@', + '-local M = {}', + '-return M', + }) + local hunks = parser.parse_buffer(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal('gone.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 11ac3be..adbbd37 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -163,10 +163,10 @@ describe('parser', function() end end) - it('stops hunk at blank line', function() + it('stops hunk at blank line when remaining counts exhausted', function() local bufnr = create_buffer({ 'M test.lua', - '@@ -1,2 +1,3 @@', + '@@ -1,1 +1,2 @@', ' local x = 1', '+local y = 2', '', @@ -391,35 +391,27 @@ describe('parser', function() vim.fn.delete(repo_root, 'rf') end) - it('detects python from shebang without open buffer', function() - local repo_root = '/tmp/diffs-test-shebang-py' - vim.fn.mkdir(repo_root, 'p') + it('detects filetype for .sh files when did_filetype() is non-zero', function() + rawset(vim.fn, 'did_filetype', function() + return 1 + end) - 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")', + parser._test.ft_lang_cache = {} + local bufnr = create_buffer({ + 'diff --git a/test.sh b/test.sh', + '@@ -1,3 +1,4 @@', + ' #!/usr/bin/env bash', + ' set -euo pipefail', + '-echo "running tests..."', + '+echo "running tests with coverage..."', }) - vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) - - local hunks = parser.parse_buffer(diff_buf) + local hunks = parser.parse_buffer(bufnr) 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') + assert.are.equal('test.sh', hunks[1].filename) + assert.are.equal('sh', hunks[1].ft) + delete_buffer(bufnr) + rawset(vim.fn, 'did_filetype', nil) end) it('extracts file line numbers from @@ header', function() @@ -440,22 +432,6 @@ describe('parser', function() delete_buffer(bufnr) end) - it('extracts large line numbers from @@ header', function() - local bufnr = create_buffer({ - 'M lua/test.lua', - '@@ -100,20 +200,30 @@', - ' local M = {}', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(100, hunks[1].file_old_start) - assert.are.equal(20, hunks[1].file_old_count) - assert.are.equal(200, hunks[1].file_new_start) - assert.are.equal(30, hunks[1].file_new_count) - delete_buffer(bufnr) - end) - it('defaults count to 1 when omitted in @@ header', function() local bufnr = create_buffer({ 'M lua/test.lua', @@ -472,6 +448,154 @@ describe('parser', function() delete_buffer(bufnr) end) + it('recognizes U prefix for unmerged files', function() + local bufnr = create_buffer({ + 'U merge_me.lua', + '@@@ -1,3 -1,5 +1,9 @@@', + ' local M = {}', + '++<<<<<<< HEAD', + ' + return 1', + '++=======', + '+ return 2', + '++>>>>>>> feature', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('merge_me.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + delete_buffer(bufnr) + end) + + it('sets prefix_width 2 from @@@ combined diff header', function() + local bufnr = create_buffer({ + 'U test.lua', + '@@@ -1,3 -1,5 +1,9 @@@', + ' local M = {}', + '++<<<<<<< HEAD', + ' + return 1', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(2, hunks[1].prefix_width) + delete_buffer(bufnr) + end) + + it('sets prefix_width 1 for standard @@ unified diff', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,3 @@', + ' local x = 1', + '+local y = 2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(1, hunks[1].prefix_width) + delete_buffer(bufnr) + end) + + it('collects all combined diff line types as hunk content', function() + local bufnr = create_buffer({ + 'U test.lua', + '@@@ -1,3 -1,3 +1,5 @@@', + ' local M = {}', + '++<<<<<<< HEAD', + ' + return 1', + '+ local x = 2', + ' end', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(5, #hunks[1].lines) + assert.are.equal(' local M = {}', hunks[1].lines[1]) + assert.are.equal('++<<<<<<< HEAD', hunks[1].lines[2]) + assert.are.equal(' + return 1', hunks[1].lines[3]) + assert.are.equal('+ local x = 2', hunks[1].lines[4]) + assert.are.equal(' end', hunks[1].lines[5]) + delete_buffer(bufnr) + end) + + it('extracts new range from combined diff header', function() + local bufnr = create_buffer({ + 'U test.lua', + '@@@ -1,3 -1,5 +1,9 @@@', + ' local M = {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(1, hunks[1].file_new_start) + assert.are.equal(9, hunks[1].file_new_count) + assert.are.equal(1, hunks[1].file_old_start) + assert.are.equal(3, hunks[1].file_old_count) + delete_buffer(bufnr) + end) + + it('extracts header context from combined diff header', function() + local bufnr = create_buffer({ + 'U test.lua', + '@@@ -1,3 -1,5 +1,9 @@@ function M.greet()', + ' local M = {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('function M.greet()', hunks[1].header_context) + delete_buffer(bufnr) + end) + + it('resets prefix_width when switching from combined to unified diff', function() + local bufnr = create_buffer({ + 'U merge.lua', + '@@@ -1,1 -1,1 +1,3 @@@', + ' local M = {}', + '++<<<<<<< HEAD', + 'M other.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(2, #hunks) + assert.are.equal(2, hunks[1].prefix_width) + assert.are.equal(1, hunks[2].prefix_width) + delete_buffer(bufnr) + end) + + it('parses diff from gitcommit verbose buffer', function() + local bufnr = create_buffer({ + '', + '# Please enter the commit message for your changes.', + '#', + '# On branch main', + '# Changes to be committed:', + '#\tmodified: test.lua', + '#', + '# ------------------------ >8 ------------------------', + '# Do not modify or remove the line above.', + 'diff --git a/test.lua b/test.lua', + 'index abc1234..def5678 100644', + '--- a/test.lua', + '+++ b/test.lua', + '@@ -1,3 +1,3 @@', + ' local function hello()', + '- print("hello world")', + '+ print("hello universe")', + ' return true', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('test.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(4, #hunks[1].lines) + delete_buffer(bufnr) + end) + it('stores repo_root on hunk when available', function() local bufnr = create_buffer({ 'M lua/test.lua', @@ -488,16 +612,388 @@ describe('parser', function() delete_buffer(bufnr) end) - it('repo_root is nil when not available', function() + it('detects neogit modified prefix', function() local bufnr = create_buffer({ - 'M lua/test.lua', - '@@ -1,3 +1,4 @@', + 'modified hello.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('hello.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(3, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('detects neogit new file prefix', function() + local bufnr = create_buffer({ + 'new file hello.lua', + '@@ -0,0 +1,2 @@', + '+local M = {}', + '+return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('hello.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(2, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('detects neogit deleted prefix', function() + local bufnr = create_buffer({ + 'deleted hello.lua', + '@@ -1,2 +0,0 @@', + '-local M = {}', + '-return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('hello.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(2, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('detects neogit renamed prefix', function() + local bufnr = create_buffer({ + 'renamed old.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('old.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + delete_buffer(bufnr) + end) + + it('detects neogit copied prefix', function() + local bufnr = create_buffer({ + 'copied orig.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('orig.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + delete_buffer(bufnr) + end) + + it('does not treat "new file mode" as a filename', function() + local bufnr = create_buffer({ + 'diff --git a/src/new.lua b/src/new.lua', + 'new file mode 100644', + 'index 0000000..abc1234', + '--- /dev/null', + '+++ b/src/new.lua', + '@@ -0,0 +1,2 @@', + '+local M = {}', + '+return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('src/new.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + delete_buffer(bufnr) + end) + + it('does not treat "new file mode 100755" as a filename', function() + local bufnr = create_buffer({ + 'diff --git a/bin/run b/bin/run', + 'new file mode 100755', + 'index 0000000..abc1234', + '--- /dev/null', + '+++ b/bin/run', + '@@ -0,0 +1,2 @@', + '+#!/bin/bash', + '+echo hello', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('bin/run', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "deleted file mode" as a filename', function() + local bufnr = create_buffer({ + 'diff --git a/src/old.lua b/src/old.lua', + 'deleted file mode 100644', + 'index abc1234..0000000', + '--- a/src/old.lua', + '+++ /dev/null', + '@@ -1,2 +0,0 @@', + '-local M = {}', + '-return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('src/old.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + delete_buffer(bufnr) + end) + + it('does not treat "deleted file mode 100755" as a filename', function() + local bufnr = create_buffer({ + 'diff --git a/bin/old b/bin/old', + 'deleted file mode 100755', + 'index abc1234..0000000', + '--- a/bin/old', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-#!/bin/bash', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('bin/old', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "old mode" or "new mode" as filenames', function() + local bufnr = create_buffer({ + 'diff --git a/script.sh b/script.sh', + 'old mode 100644', + 'new mode 100755', + '@@ -1,1 +1,2 @@', + ' echo hello', + '+echo world', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('script.sh', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "rename from/to" as filenames', function() + local bufnr = create_buffer({ + 'diff --git a/old.lua b/new.lua', + 'similarity index 95%', + 'rename from old.lua', + 'rename to new.lua', + '@@ -1,2 +1,2 @@', + ' local M = {}', + '-local x = 1', + '+local x = 2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('new.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "copy from/to" as filenames', function() + local bufnr = create_buffer({ + 'diff --git a/orig.lua b/copy.lua', + 'similarity index 100%', + 'copy from orig.lua', + 'copy to copy.lua', + '@@ -1,1 +1,1 @@', ' local M = {}', }) local hunks = parser.parse_buffer(bufnr) assert.are.equal(1, #hunks) - assert.is_nil(hunks[1].repo_root) + assert.are.equal('copy.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "similarity index" or "dissimilarity index" as filenames', function() + local bufnr = create_buffer({ + 'diff --git a/foo.lua b/bar.lua', + 'similarity index 85%', + 'rename from foo.lua', + 'rename to bar.lua', + '@@ -1,2 +1,2 @@', + ' local M = {}', + '-return 1', + '+return 2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('bar.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('does not treat "index" line as a filename', function() + local bufnr = create_buffer({ + 'diff --git a/test.lua b/test.lua', + 'index abc1234..def5678 100644', + '--- a/test.lua', + '+++ b/test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('test.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('neogit new file with diff containing new file mode metadata', function() + local bufnr = create_buffer({ + 'new file src/foo.lua', + 'diff --git a/src/foo.lua b/src/foo.lua', + 'new file mode 100644', + 'index 0000000..abc1234', + '--- /dev/null', + '+++ b/src/foo.lua', + '@@ -0,0 +1,3 @@', + '+local M = {}', + '+M.x = 1', + '+return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('src/foo.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(3, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('neogit deleted with diff containing deleted file mode metadata', function() + local bufnr = create_buffer({ + 'deleted src/old.lua', + 'diff --git a/src/old.lua b/src/old.lua', + 'deleted file mode 100644', + 'index abc1234..0000000', + '--- a/src/old.lua', + '+++ /dev/null', + '@@ -1,2 +0,0 @@', + '-local M = {}', + '-return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('src/old.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal(2, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('multiple new files with mode metadata do not corrupt filenames', function() + local bufnr = create_buffer({ + 'diff --git a/a.lua b/a.lua', + 'new file mode 100644', + 'index 0000000..abc1234', + '--- /dev/null', + '+++ b/a.lua', + '@@ -0,0 +1,1 @@', + '+local a = 1', + 'diff --git a/b.lua b/b.lua', + 'new file mode 100644', + 'index 0000000..def5678', + '--- /dev/null', + '+++ b/b.lua', + '@@ -0,0 +1,1 @@', + '+local b = 2', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(2, #hunks) + assert.are.equal('a.lua', hunks[1].filename) + assert.are.equal('b.lua', hunks[2].filename) + delete_buffer(bufnr) + end) + + it('fugitive status with new and deleted files containing mode metadata', function() + local bufnr = create_buffer({ + 'Head: main', + '', + 'Staged (2)', + 'A src/new.lua', + 'diff --git a/src/new.lua b/src/new.lua', + 'new file mode 100644', + 'index 0000000..abc1234', + '--- /dev/null', + '+++ b/src/new.lua', + '@@ -0,0 +1,2 @@', + '+local M = {}', + '+return M', + 'D src/old.lua', + 'diff --git a/src/old.lua b/src/old.lua', + 'deleted file mode 100644', + 'index abc1234..0000000', + '--- a/src/old.lua', + '+++ /dev/null', + '@@ -1,1 +0,0 @@', + '-local x = 1', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(2, #hunks) + assert.are.equal('src/new.lua', hunks[1].filename) + assert.are.equal('lua', hunks[1].ft) + assert.are.equal('src/old.lua', hunks[2].filename) + assert.are.equal('lua', hunks[2].ft) + delete_buffer(bufnr) + end) + + it('neogit new file with deep nested path', function() + local bufnr = create_buffer({ + 'new file src/deep/nested/path/module.lua', + '@@ -0,0 +1,1 @@', + '+return {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('src/deep/nested/path/module.lua', hunks[1].filename) + delete_buffer(bufnr) + end) + + it('detects bare filename for untracked files', function() + local bufnr = create_buffer({ + 'newfile.rs', + '@@ -0,0 +1,3 @@', + '+fn main() {', + '+ println!("hello");', + '+}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('newfile.rs', hunks[1].filename) + assert.are.equal(3, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('does not match section headers as bare filenames', function() + local bufnr = create_buffer({ + 'Untracked files (1)', + 'newfile.rs', + '@@ -0,0 +1,3 @@', + '+fn main() {', + '+ println!("hello");', + '+}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('newfile.rs', hunks[1].filename) delete_buffer(bufnr) end) end) diff --git a/vim.toml b/vim.toml deleted file mode 100644 index 3a84ac2..0000000 --- a/vim.toml +++ /dev/null @@ -1,33 +0,0 @@ -[selene] -base = "lua51" -name = "vim" - -[vim] -any = true - -[jit] -any = true - -[bit] -any = true - -[assert] -any = true - -[describe] -any = true - -[it] -any = true - -[before_each] -any = true - -[after_each] -any = true - -[spy] -any = true - -[stub] -any = true diff --git a/vim.yaml b/vim.yaml new file mode 100644 index 0000000..3821d25 --- /dev/null +++ b/vim.yaml @@ -0,0 +1,26 @@ +--- +base: lua51 +name: vim +lua_versions: + - luajit +globals: + vim: + any: true + jit: + any: true + assert: + any: true + describe: + any: true + it: + any: true + before_each: + any: true + after_each: + any: true + spy: + any: true + stub: + any: true + bit: + any: true