diff --git a/README.md b/README.md index 3a7b7aec..1ed1be60 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,78 @@ # diffs.nvim -**Treesitter-powered Diff Syntax highlighting for Neovim** +Language syntax highlighting for unified diffs in Neovim. -Enhance Neovim's built-in diff mode (and much more!) with language-aware syntax -highlighting driven by treesitter. +Diffs uses Treesitter or Vim syntax to highlight the code inside diff hunks. +It adds line backgrounds and intra-line highlights, and adjusts colors in native +diff windows. image -## Features - -- Treesitter syntax highlighting in - [vim-fugitive](https://github.com/tpope/vim-fugitive), - [Neogit](https://github.com/NeogitOrg/neogit), builtin `diff` filetype, and - more! -- Word- and character-level diff highlighting, driven by `'diffopt'` `inline:` -- `:Diff` for [pierre-style](https://diffs.com) unified, stacked, or split diffs against any revision -- `:Diff review` full-repo review diff with qflist/loclist navigation -- `:Diff files` to diff two arbitrary files, or the buffer against its last save -- Inline and 3-way merge conflict detection, highlighting, and resolution -- Email quoting/patch syntax support (`> diff ...`) -- Vim syntax fallback -- [Difftastic](https://difftastic.wilfred.me.uk/) highlight support -- Configurable highlighting - ## Requirements - Neovim 0.12.0+ -- Optional: the Treesitter `diff` parser for the best experience +- Optional: Treesitter parsers for the languages in your diffs and the `diff` parser ## Installation -With `vim.pack` (Neovim 0.12+): +```lua +vim.pack.add({ 'https://github.com/barrettruth/diffs.nvim' }) +``` + +Diffs attaches to `git` and `gitcommit` buffers by default. Enable integrations +before the plugin loads: + +```lua +vim.g.diffs = { + integrations = { 'fugitive', 'neogit', 'neojj', 'gitsigns', 'committia', 'telescope' }, +} +``` + +To highlight patch files with the `diff` filetype: ```lua -vim.pack.add({ - 'https://github.com/barrettruth/diffs.nvim', +vim.api.nvim_create_autocmd('FileType', { + pattern = 'diff', + callback = function(event) + require('diffs').attach(event.buf) + end, }) ``` -## Documentation +Use `'diffopt'` to select intra-line highlighting: ```vim -:help diffs.nvim +set diffopt+=inline:word ``` -## FAQ +See `:help diffs.nvim` for configuration and the Lua API. Run `:checkhealth diffs` +for dependency information. -**Q: Does diffs.nvim support -[vim-fugitive](https://github.com/tpope/vim-fugitive)/[Neogit](https://github.com/NeogitOrg/neogit)/[neojj](https://github.com/NicholasZolton/neojj)/[gitsigns](https://github.com/lewis6991/gitsigns.nvim)/[fzf-lua](https://github.com/ibhagwan/fzf-lua)?** +## Diff commands and conflicts -Yes. Enable integrations in your config: +[diffs-collection.nvim](https://github.com/barrettruth/diffs-collection.nvim) +contains file comparison, repository review, split views, quickfix navigation, +and conflict resolution. Install it alongside diffs.nvim for these workflows. -```lua -vim.g.diffs = { - integrations = { 'fugitive', 'neogit', 'neojj', 'gitsigns' }, -} -``` +## Limitations + +Treesitter parses each hunk separately. Hunks that start or end inside an +expression can have incomplete syntax highlighting. + +Vim syntax fallback runs after redraw. A new hunk can appear without syntax +colors for one frame. Language parsers and queries also load on first use. + +Other plugins that change diff highlights can produce conflicting colors. + +## Acknowledgements -fzf-lua is supported out-of-the-box. - -See the documentation for more information. - -**Q: Can I use diffs.nvim as a Git mergetool?** - -Yes. Configure Git to open `$MERGED` with Neovim; diffs.nvim will detect -conflict markers automatically. See `:help diffs.nvim-git-mergetool`. - -**Q: Can I exclude untracked files from `:Diff review`?** - -Yes. Run `:Diff review ++nountracked`. To make it the default, wrap it in your -own command, e.g. `:command! Review Diff review ++nountracked`. - -## Known Limitations - -- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation. - Hunks that start or end mid-expression may produce imperfect highlights due to - treesitter error recovery. - -- **Syntax "flashing"**: `diffs.nvim` hooks into the `FileType fugitive` event - triggered by `vim-fugitive`, at which point the buffer is preliminarily - painted. The decoration provider applies highlights on the next redraw cycle, - so a brief first-paint flash may still occur. - -- **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 cold hunks may appear one frame later. Warm hunks can reuse - cached vim syntax spans, and stale deferred renders are ignored after buffer - changes. The plugin reparses hunks after buffer edits and redraws visible - highlights. - -- **Conflicting diff plugins**: `diffs.nvim` may not interact well with other - plugins that modify diff highlighting. Known plugins that may conflict: - - [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) - provides its - own diff highlighting and conflict resolution UI - - [`mini.diff`](https://github.com/echasnovski/mini.diff) - visualizes buffer - differences with its own highlighting system - - [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim) - generally - compatible, but both plugins modifying line highlights may produce - unexpected results - - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - - `diffs.nvim` now includes built-in conflict resolution; disable one or the - other to avoid overlap - -# Acknowledgements - -- [`vim-fugitive`](https://github.com/tpope/vim-fugitive) -- [`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, - decoration provider highlighting architecture, gitsigns blame popup - highlighting, intra-line bg visibility fix -- [@tris203](https://github.com/tris203) - support for transparent backgrounds -- [@letientai299](https://github.com/letientai299) - `diff.mnemonicPrefix` - support +- [vim-fugitive](https://github.com/tpope/vim-fugitive) +- [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) — header highlighting, filetype detection, + Treesitter injections, decoration providers, Gitsigns popups, and intra-line colors +- [@tris203](https://github.com/tris203) — transparent backgrounds +- [@letientai299](https://github.com/letientai299) — `diff.mnemonicPrefix` support diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index afe86470..34b290d9 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -1,1165 +1,132 @@ -*diffs.nvim.txt* Syntax highlighting for diffs in Neovim - -Author: Barrett Ruth License: ISC +*diffs.nvim.txt* Language syntax highlighting for unified diffs ============================================================================== -INTRODUCTION *diffs.nvim* - -diffs.nvim adds language-aware syntax highlighting to unified diff content in -Neovim buffers. It replaces flat `diffAdded`/`diffRemoved` coloring with -treesitter syntax, blended line backgrounds, and character-level intra-line -diffs. - -With no configuration, diffs.nvim provides: -- `gitcommit` diff highlighting (e.g., `git commit --verbose`) -- Inline merge conflict detection, highlighting, and resolution -- Background-only diff colors for `&diff` buffers (vimdiff, diffthis) +CONTENTS *diffs.nvim* -All other integrations are opt-in. See |diffs.nvim-integrations|. + Setup .................................... |diffs.nvim-setup| + Configuration ............................ |diffs.nvim-configuration| + Integrations ............................. |diffs.nvim-integrations| + Lua API .................................. |diffs.nvim-api| + Highlight groups ......................... |diffs.nvim-highlights| + Limitations .............................. |diffs.nvim-limitations| + Health ................................... |diffs.nvim-health| -Features: ~ -- Treesitter syntax highlighting in diff hunks -- Intra-line diff highlighting, character- or word-level per 'diffopt' -- Vim syntax fallback for languages without a treesitter parser -- Blended diff background colors that preserve syntax visibility -- Optional diff prefix (`+`/`-`/` `) concealment -- |:Diff| unified diff against any revision -- |:Diff-review| full-repo review diff with qflist/loclist navigation -- Email quoting/patch syntax support (`> diff ...`) - -============================================================================== -CONTENTS *diffs.nvim-contents* +Diffs highlights the code inside unified diff hunks with Treesitter or Vim +syntax. It adds line backgrounds and intra-line highlights. It adjusts native +diff colors through 'winhighlight'. - 1. Introduction ............................................... |diffs.nvim| - 2. Requirements .................................. |diffs.nvim-requirements| - 3. Setup ................................................ |diffs.nvim-setup| - 4. Configuration ....................................... |diffs.nvim-config| - 5. Commands .......................................... |diffs.nvim-commands| - 6. Mappings .......................................... |diffs.nvim-mappings| - 7. Integrations .................................. |diffs.nvim-integrations| - Fugitive ........................................ |diffs.nvim-fugitive| - Neogit ............................................ |diffs.nvim-neogit| - Neojj .............................................. |diffs.nvim-neojj| - Gitsigns ........................................ |diffs.nvim-gitsigns| - Committia ...................................... |diffs.nvim-committia| - Telescope ...................................... |diffs.nvim-telescope| - Difftastic .................................... |diffs.nvim-difftastic| - 8. Conflict Resolution ............................... |diffs.nvim-conflict| - 9. Merge Diff Resolution ................................ |diffs.nvim-merge| - 10. API .................................................... |diffs.nvim-api| - 11. Known Limitations .............................. |diffs.nvim-limitations| - 12. Highlight Groups ................................ |diffs.nvim-highlights| - 13. Health Check ........................................ |diffs.nvim-health| - 14. Acknowledgements .......................... |diffs.nvim-acknowledgements| +Diff commands, repository review, file comparison, and conflict resolution are +available in diffs-collection.nvim: + https://github.com/barrettruth/diffs-collection.nvim ============================================================================== -REQUIREMENTS *diffs.nvim-requirements* +SETUP *diffs.nvim-setup* -- Neovim 0.12.0+ -- Treesitter parsers for languages you want highlighted -- Optional: the Treesitter `diff` parser for the best experience +Requirements: Neovim 0.12.0 or later. Treesitter parsers are optional. -============================================================================== -SETUP *diffs.nvim-setup* - -With vim.pack (Neovim 0.12+): >lua - vim.pack.add({ - 'https://github.com/barrettruth/diffs.nvim', - }) +Install with |vim.pack|: >lua + vim.pack.add({ 'https://github.com/barrettruth/diffs.nvim' }) < - +Set |g:diffs| before the plugin loads. No setup function is required. Do not lazy load with `event`, `lazy`, `ft`, `config`, or `keys`. +Load your colorscheme before diffs.nvim. -NOTE: Load your colorscheme before diffs.nvim. With lazy.nvim, set `priority = -1000` and `lazy = false` on your colorscheme plugin. - -See |diffs.nvim-config| for customization, |diffs.nvim-integrations| for -plugin support. - +Diffs attaches to `git` and `gitcommit` buffers by default. To attach to patch +files with the `diff` filetype: >lua + vim.api.nvim_create_autocmd('FileType', { + pattern = 'diff', + callback = function(event) + require('diffs').attach(event.buf) + end, + }) +< ============================================================================== -CONFIGURATION *diffs.nvim-config* +CONFIGURATION *diffs.nvim-configuration* *g:diffs* -Configuration is done via `vim.g.diffs`. Set options before the plugin loads: ->lua +Defaults: >lua vim.g.diffs = { debug = false, - view = { - default_layout = 'unified', - prefix = true, - }, - integrations = {}, + view = { prefix = true }, highlights = { background = true, - blend_alpha = 0.6, warn_max_lines = true, - vim = true, + treesitter = { max_lines = 500 }, + vim = { max_lines = 200 }, + intra = { max_lines = 500 }, }, - conflict = true, + integrations = {}, } < +`debug` + Set to `true` for debug messages. Set to a file path to write a log. - *diffs.nvim.Config* - Fields: ~ - {debug} (boolean, default: false) - Enable debug logging to `:messages` with - `[diffs]` prefix. - - {view} (table, default: see below) - Diff view display options. - See |diffs.nvim.ViewConfig| for fields. - - *diffs.nvim.ViewConfig* - View table fields: ~ - {default_layout} (string, default: 'unified') - Default layout for current-file |:Diff| when - `++layout` is omitted. Accepted values are - `unified`, `stacked`, and `split`. An explicit - `++layout` option takes precedence. This does not - affect |:Diff-review|, |diffs.nvim-diff-files|, - Fugitive status mappings, or native diff mode. - - {prefix} (boolean, default: true) - Show diff prefixes (`+`/`-`/` `). Set to - `false` to hide them with virtual text overlay. - When `highlights.background` is also enabled, - the overlay inherits the line's background color. - - {integrations} (table, default: {}) - List of enabled integration names, such as - `{ 'fugitive', 'neogit' }`. - See |diffs.nvim-integrations|. - *diffs.nvim.IntegrationsConfig* - Names: ~ - - {fugitive} - Enable vim-fugitive support. See - |diffs.nvim-fugitive|. - - {neogit} - Enable Neogit support. See |diffs.nvim-neogit|. - - {neojj} - Enable neojj support. See |diffs.nvim-neojj|. - - {gitsigns} - Enable gitsigns.nvim popup support. See - |diffs.nvim-gitsigns|. - - {telescope} - Enable telescope.nvim preview support. See - |diffs.nvim-telescope|. +`view.prefix` + Show unified diff prefixes. Set to `false` to conceal the prefixes. - {difftastic} - Enable structural diffing via the `difft` binary. - Use `{ args = {...} }` to forward flags. See - |diffs.nvim-difftastic|. +`highlights.background` + Apply background colors to added and removed lines. - {highlights} (table, default: see below) - Syntax and diff highlighting. - See |diffs.nvim.Highlights| for fields. +`highlights.blend_alpha` + Blend line backgrounds with the Normal background. The default is 0.6. + Values must be between 0 and 1. Higher values retain more of the diff color. - {conflict} (boolean, default: true) - Highlight and resolve inline merge conflicts. - Set to false to disable. - See |diffs.nvim-conflict|. +`highlights.warn_max_lines` + Show a warning when a hunk exceeds a configured line limit. - *diffs.nvim.Highlights* - Highlights table fields: ~ - {background} (boolean, default: true) - Highlight added and deleted lines with - `DiffsAdd` and `DiffsDelete` backgrounds. +`highlights.treesitter.max_lines` + Maximum hunk size for Treesitter syntax highlighting. - {blend_alpha} (number, default: 0.6) - Blend diff backgrounds with the editor background. - Use 0 for the editor background or 1 for the - full `DiffAdd` and `DiffDelete` colors. +`highlights.vim` + Configure Vim syntax fallback with `{ max_lines = N }`. + Set to `false` to disable fallback. Set to `true` to use its default limit. - {warn_max_lines} (boolean, default: true) - Show a `vim.notify()` warning when syntax - highlighting is skipped because a hunk exceeds - {max_lines}. See |diffs.nvim-max-lines|. +`highlights.intra.max_lines` + Maximum hunk size for intra-line highlights. - {treesitter} (table, default: see below) - Highlight code with Treesitter when a parser - is available. - See |diffs.nvim.TreesitterConfig| for fields. - - {vim} (boolean|table, default: true) - Use Vim syntax when no Treesitter parser is - available. Set to false to disable. - See |diffs.nvim.VimConfig| for fields. - - {intra} (table, default: see below) - Intra-line diff highlighting options. - See |diffs.nvim.IntraConfig| for fields. - - *diffs.nvim.TreesitterConfig* - Treesitter config fields: ~ - {max_lines} (integer, default: 500) - Skip Treesitter highlighting for hunks with more - than this many added and deleted lines. - Context lines do not count toward the limit. - - *diffs.nvim.VimConfig* - Vim config fields: ~ - {max_lines} (integer, default: 200) - Skip Vim syntax highlighting for hunks with more - than this many added and deleted lines. - Context lines do not count toward the limit. - - *diffs.nvim.IntraConfig* - Intra config fields: ~ - {max_lines} (integer, default: 500) - Skip intra-line highlighting for hunks with more - than this many added and deleted lines. - Context lines do not count toward the limit. - - Treesitter also highlights code in hunk headers, such as `func()` in - `@@ -10,3 +10,4 @@ func()`. - - diffs.nvim detects the filetype with |vim.filetype.match()| and selects - the parser with |vim.treesitter.language.get_lang()|. Use - |vim.filetype.add()| for custom filetypes and - |vim.treesitter.language.register()| to associate them with a parser. - -============================================================================== -MAX LINES *diffs.nvim-max-lines* +All line limits must be at least 1. Large hunks retain line backgrounds. -When a hunk contains more highlighted lines (`+`/`-`) than the configured -threshold, diffs.nvim skips syntax highlighting for that hunk to avoid lag. -Context lines (lines with a space prefix) are not counted toward the limit. - -A warning is shown when this happens: > - [diffs]: Syntax highlighting skipped for 1 hunk(s) — too large. +'diffopt' controls intra-line highlighting: >vim + set diffopt+=inline:word < -To increase the threshold: >lua - vim.g.diffs = { - highlights = { - treesitter = { max_lines = 1000 }, -- default: 500 - vim = { max_lines = 500 }, -- default: 200 - }, - } -< To suppress the warning without changing the threshold: >lua - vim.g.diffs = { - highlights = { warn_max_lines = false }, - } -< The `intra.max_lines` threshold (default: 500) is separate and controls -character-level diff highlighting within changed lines. It does not affect the -syntax highlighting warning. +Supported modes are `none`, `simple`, `char`, and `word`. Without an `inline:` +entry, diffs uses `simple`. Whitespace options in 'diffopt' also apply. ============================================================================== -COMMANDS *diffs.nvim-commands* - -diffs.nvim-owned views: ~ - -|:Diff|, |:Diff-review|, split layouts, and Fugitive status maps create -diffs.nvim-owned `diffs://` views. Ordinary `git`, `gitcommit`, `diff`, -custom filetypes, picker previews, and third-party integration buffers remain -highlight-only. - -:Diff [++layout=unified|stacked|split] [object] *:Diff* - Open a diff of the current file against a diff object. The object language - is defined at |diffs.nvim-diff-objects|. Use |:Diff-review| (`:Diff review`) - for repository reviews. - Without `++layout`, the layout comes from `view.default_layout`; an explicit - option takes precedence. The default remains `unified`. - - If `++layout` occurs more than once, the last value applies. Unknown layout - names use the unified layout. - - `++layout=unified` and `++layout=stacked` create generated `diffs://` - buffers. Unified buffers include old and new line-number rails before the - unified diff text. Stacked buffers use the same generated diff text with a - single context-aware line-number rail; see |diffs.nvim-stacked-layout|. - Generated layouts do not enter native Vim `&diff` mode or open writable - Fugitive object buffers. - - `++layout=split` opens plugin-aligned paired old/new endpoint windows. - See |diffs.nvim-paired-windows|. - - Prefix with |:vertical| to open the generated `unified` and `stacked` - layouts in a vertical split (`:vertical Diff`). The modifier is ignored for - `++layout=split`, which manages its own two-window arrangement; - `:vertical Diff ++layout=split` warns and continues. - - Plain direct |:Diff| from a worktree file compares the index to the - worktree and shows only unstaged changes. Staged-only changes report no - changes for that edge. To view staged changes, use a staged Fugitive status - row with |diffs.nvim-du|/|diffs.nvim-dU| or an explicit supported object - form such as `:Diff :0:%`. |:Diff| does not provide a `--staged` flag. - - A single-file |:Diff| populates the window's |location-list| with that - file's hunks and leaves the global |quickfix| list untouched. The quickfix - list is taken over as a repo-wide file index only for multi-file views and - the |:Diff-review| maps, not the review split layout, which navigates files - itself and leaves the quickfix list untouched. - - Parameters: ~ - ++layout=unified (optional) Open a generated `diffs://` view with old - and new line-number rails. - ++layout=stacked (optional) Open a generated `diffs://` view with one - context-aware line-number rail. - ++layout=split (optional) Open the current single-file comparison as - paired old/new endpoint windows. - {object} (string, optional) The object to diff against; see - |diffs.nvim-diff-objects|. Defaults to the current file in - the index. - - Completion covers `++layout=...`, the `review` and `files` subcommands, - supported object forms, and git refs. - - Examples: >vim - :Diff " diff against the index - :Diff main " diff against main branch - :Diff HEAD~3 " diff against 3 commits ago - :Diff :0:% " diff against the index explicitly - :Diff abc123 " diff against specific commit - :Diff ++layout=stacked - :vertical Diff " open the generated diff in a vertical split - :Diff files old.lua new.lua " diff two arbitrary files -< - Edge cases: ~ - - Untracked and added files render as all-added files. - - Deleted files render as all-removed files. - - Files with both staged and unstaged changes require one view per edge: - plain |:Diff| shows unstaged changes; staged status rows show staged - changes. - - Unmerged files open a merge diff view. Native 3-way `:Gdiffsplit!` - windows are not emulated. - - Mode-only changes, binary files, submodules, and pure renames report a - warning or no text changes. - - Hunk actions run `git apply --cached --check` before mutating the index. - Stale hunks, context drift, and index mismatches fail without partial - mutation. `dp` appears only for supported index -> worktree hunks/ranges; - `do` appears only for supported tree -> index hunks/ranges. - -Diff objects: ~ *diffs.nvim-diff-objects* - - `:Diff [object]` takes a single object that names one side of the diff; the - other side is that path's worktree (except the staged-context default). `%` - is the current file. - - Supported objects: ~ - (omitted) Current file: index vs worktree (HEAD vs index in a staged - context). - {rev} Current file at {rev} vs worktree. {rev} is any git - revision: `HEAD`, `@` (alias for HEAD), `HEAD~3`, a branch, - a tag, or a commit hash. - : Current file: index vs worktree. Also `:%` and `:0:%`. - {rev}:% Current file at {rev} vs worktree. Also `@:%`, `HEAD:%`. - {rev}:{path} The {path} at {rev} vs the {path} worktree file, for - example `:Diff HEAD:lua/foo.lua`. - :{path} The {path} in the index vs its worktree file. Also - `:0:{path}`. - :1:% :2:% :3:% - The file at merge stage 1 (base), 2 (ours), or 3 (theirs) - vs the worktree. Read-only, and valid only while the file is - in a merge conflict. Also `:1:{path}` etc. - - Unsupported objects: ~ - These recognizable Fugitive forms are rejected with a specific error: - - `#`, `#N` alternate-buffer expansion - - `!`, `!:{path}` owner-commit expansion - - `` cursor-file expansion - - `./{path}`, `:(top){path}` worktree-relative and pathspec-magic paths - - `:/{message}` commit-message search - - `{a}..{b}`, `{a}...{b}` ranges; use |:Diff-review| instead - - `{rev}:` tree objects - - `-` the previous object - -Diff files: ~ *diffs.nvim-diff-files* - - `:Diff files [{left}] [{right}]` opens a read-only generated diff of two - arbitrary files. Neither file has to be tracked by git, and the command - works outside a repository. It is the file-path counterpart to a plain - |:Diff| object: the first path names the old (left) side, and the second - names the new (right) side. - - A named path is always read from disk. An omitted side falls back to the - current buffer: the new side is the buffer's live content, and the old - side is that buffer's file on disk. Bare `:Diff files` therefore compares - the buffer to its last save, like `:Diff {rev}`, which also diffs against - unsaved buffer content. A buffer-backed side is shown as `buffer:{name}`, - and falls back to disk if that buffer is gone when the view reloads. - - Examples: >vim - :Diff files " last save (old) vs buffer (new) - :Diff files old.lua new.lua " old.lua (old) vs new.lua (new) - :Diff files other.lua " other.lua (old) vs buffer (new) - :Diff files ++layout=stacked a.txt b.txt -< - The `++layout=unified` and `++layout=stacked` layouts and the |:vertical| - modifier apply as they do for |:Diff|. Other layout values use unified. - Use native diff mode (`nvim -d` or |:diffsplit|) for a side-by-side comparison. - The view is read-only, so the hunk-staging maps - (`do`/`dp`) are not available. - - Errors: ~ - - More than two paths reports the expected count. - - An omitted side needs a named current buffer. - - A path that is a directory, missing, unreadable, or binary is refused and - named; directories are intentionally unsupported. A buffer-backed side - does not have to exist on disk. - - Two identical sides report no changes without opening a buffer. - -Stacked generated layout: ~ *diffs.nvim-stacked-layout* - - `++layout=stacked` creates a generated single-column `diffs://` layout. It - keeps normal unified diff text, but replaces the two old/new line-number - rails used by the unified generated layout with one context-aware rail. - - In the stacked rail: - - `-` lines display old-side line numbers. - - `+` lines display new-side line numbers. - - Context lines display new-side line numbers. - - Headers and metadata do not display file line numbers. - - Replacements can therefore repeat a number: a deleted old-side line and the - added new-side line that replaces it may both show the same value. ->diff - | @@ -9,3 +9,3 @@ - 9 | alpha - 10 | -beta - 10 | +beta changed - 11 | gamma -< - -Paired-window behavior: ~ *diffs.nvim-paired-windows* - - `++layout=split` opens a single-file comparison as paired old/new endpoint - windows: the old/removed endpoint on the left, the new/added endpoint on the - right. Endpoint buffers are read-only `diffs://` views holding the real file - content of each side; edits happen in real source buffers opened from the - view. - - The panes are not native `&diff` windows. They are plugin-rendered: the - plugin aligns both sides row-for-row by inserting blank filler rows, then - keeps the windows in lockstep with `scrollbind`/`cursorbind`. Because the - panes hold real, contiguous file content, treesitter highlights them as the - target language. Each changed line is painted with the same palette as - generated buffers, so its background is continuous across the gutter and the - text, and changed regions within a line carry the same intra-line word-diff - highlighting, honoring `highlights.intra`. - - Each pane renders its own side's real line numbers in a `statuscolumn` rail - (blank on filler rows), so the rail does not depend on or conflict with a - global `number`/`statuscolumn` setting; the originals are restored when the - pair closes. Diff folds are disabled. - - `q` closes the pair. `:edit` refreshes the same comparison. `]c`/`[c` - navigate hunks from either side. `` opens a real worktree file only when - the current row maps to worktree content. - - -:Diff review [++layout=...] [++nountracked] [review-spec] *:Diff-review* - Open a repository review. `review` must be the first argument; a revision - literally named `review` must be addressed another way (for example - `refs/heads/review`). - - With no arguments, reviews the current repository state against the - remote default branch (detected via `git -C symbolic-ref - refs/remotes/origin/HEAD`). If the remote HEAD ref is not set, run - `git remote set-head origin -a` first. - - With a single ref, reviews the current repository state against that - base ref. With `A...B`, reviews explicit target `B` against base `A` - using merge-base semantics. With `A..B`, reviews explicit target `B` - against base `A` as a direct two-endpoint comparison. Two separate refs, - such as `:Diff review main feature`, also compare the two endpoints. - Additional revision arguments pass to Git. - - Git resolves the revisions and reports invalid revisions. In `A..B` and - `A...B`, an omitted endpoint defaults to `HEAD`. If `A...B` has multiple - merge bases, Git selects the base for the comparison. - Tree expressions such as `HEAD~:src..HEAD:src` compare paths relative to - the selected trees. - - A current-state review (no explicit target) ends with an Untracked - section listing files not yet tracked by git. Pass `++nountracked` to - omit it. To omit it every time, wrap the command, for example: >vim - command! Review Diff review ++nountracked -< - Explicit-target reviews (`A...B`, `A..B`) never include untracked files, - so the option has no effect there. - - `++layout=unified` and `++layout=stacked` both create full generated review - maps. Unified review maps use old and new line-number rails; stacked review - maps use one context-aware line-number rail as described at - |diffs.nvim-stacked-layout|. These layouts populate the quickfix list with - file entries (one per changed file, with `+N`/`-M` stats) and the location - list with the hunk entries of the file under the cursor (one per `@@` - header), refreshed as you move between files. All entries point into the - diff buffer, so `:cnext`/`:cprev` navigate between files and `:lnext`/ - `:lprev` navigate between hunks of the current file. - - Reloading with `:edit` refreshes the same review. If a `diffs://` window - already exists in the current tabpage, the new diff replaces its buffer - instead of creating another split. - - Reloads require the source metadata stored when the plugin creates the - view. If that metadata is unavailable, reopen the view with |:Diff-review|. - - With `++layout=split`, |:Diff-review| opens one review file as a paired - side-by-side diff instead of the full review map: the old/removed endpoint - on the left, the new/added endpoint on the right. Both panes are read-only - plugin-rendered `diffs://` endpoint views, row-aligned with filler rows and - bound for synchronized scrolling and cursor movement, with the same line and - intra-line word-diff highlighting as generated buffers - (see |diffs.nvim-paired-windows|). - - In generated review maps and split panes, `gs` toggles between the full - review map and the side-by-side split for the selected file. From a generated - map it uses the file under the cursor (or current quickfix item); from a - split it closes the pair and restores the generated layout it came from - (`unified` or `stacked`, defaulting to `unified` for direct split opens). - This is a buffer-local default backed by - |(diffs-review-toggle-layout)|, set only when you have not already - mapped `gs` in that buffer. - - The toggle carries the cursor line in both directions. The split opens on - the source line the map cursor was on, and returning lands on the map line - for the source line the split cursor is on, so moving inside the split is - reflected in the map. Map lines with no source line of their own, such as - file and hunk headers, open the split at the first change of the hunk. - - The split shows a removed line and the line that replaced it on one row, - which the map shows as two. Returning uses the line under the cursor, so - leaving from the right pane lands on the `+` line of such a pair even when - the `-` line is what selected it. Toggling again keeps that line. - - In split layout, `]q`/`[q` (also `]f`/`[f`) step to the next/previous changed - file in the review (wrapping at the ends), swapping the displayed pair in - place and reusing the two windows. Each step (and the initial open) echoes - the file's position in the review, for example `[diffs]: (2 of 5): - [Unstaged] lua/rate.lua`. These are buffer-local defaults backed by - |(diffs-review-next-file)| and |(diffs-review-prev-file)|, set - only when you have not already mapped those keys in the buffer. Counts - repeat the step. `gO` opens |vim.ui.select()| to jump to any file in the review - (see |(diffs-review-select-file)|). These mappings use the review's - file index; the location list holds the active file's hunks - (`:lnext`/`:lprev`). A quickfix from another surface - (such as an open review map) is left untouched, so `:cnext`/`:cprev` follow - that list. If the review map is hidden, on its quickfix file entries - selects the file in the matching visible split review. `]c`/`[c` navigate - hunks within the active file across both panes, and `q` closes the pair. - - Prefix with |:vertical| to open the generated `unified` and `stacked` - review maps in a vertical split (`:vertical Diff review`). As with |:Diff|, - the modifier is ignored for `++layout=split`; - `:vertical Diff review ++layout=split` warns and continues. - - Parameters: ~ - ++layout=unified (optional) Open the default generated review map with - old and new line-number rails. - ++layout=stacked (optional) Open a generated review map with one - context-aware line-number rail. - ++layout=split (optional) Open the first renderable changed review file - as a paired side-by-side endpoint diff. - ++nountracked (optional) Omit the Untracked section of a - current-state review (shown by default). - {review-spec} (string, optional) One of: - - empty: review current repo state against the repo - default branch - - `{base}`: review current repo state against `{base}` - - `{base}...{target}`: merge-base review of explicit - target `{target}` against base `{base}` - - `{base}..{target}` or `{base} {target}`: direct review - of explicit target `{target}` against base `{base}` - - If `++layout` occurs more than once, the last value applies. Unknown layout - names use the unified layout. Repeating `++nountracked` has no additional - effect. - - Completion covers `++layout=...`, `++nountracked`, and git refs. - - Examples: >vim - :Diff review - :Diff review ++layout=stacked origin/main - :Diff review ++layout=split - :Diff review ++nountracked - :Diff review origin/main - :vertical Diff review origin/main - :Diff review origin/main...refs/forge/pr/42 - :Diff review origin/main..feature/topic -< - - Lua command helper: >lua - require('diffs.commands').review({ - base = 'origin/main', - target = 'refs/forge/pr/42', - repo = '/path/to/repo', - }) - - require('diffs.commands').review_split() -< - Fields: ~ - {base} (string, optional) Base ref for the review. Defaults to the - repository's remote default branch. - {target} (string, optional) Explicit target ref to review. Omit to - review the current repository state. - {mode} (string, optional) `'merge-base'` or `'direct'`. Defaults to - `'merge-base'` when `{target}` is set. - {repo} (string, optional) Path inside the repository to review. - Use this from async/plugin contexts when the current buffer - is not a reliable repo hint. - {vertical} (boolean, optional) Open the review in a vertical split. - {untracked} (boolean, optional) Include the Untracked section of a - current-state review. Defaults to true. - - Utilities: ~ - `require('diffs.commands').review_split()` opens the currently - selected |:Diff-review| file in the same paired side-by-side diff used - by `:Diff review ++layout=split`. It uses the review buffer cursor, - quickfix item, or loclist item as the file selection. Once the split is - open, `]q`/`[q` (also `]f`/`[f`) step between the review's files. - - These helpers are command integration helpers for |:Diff-review|. The stable - root Lua API remains the small surface documented at |diffs.nvim-api|. - -Whitespace handling: ~ *diffs.nvim-whitespace* - - Generated diffs honor Neovim's |'diffopt'| whitespace flags, in both the - line-level diff and the intra-line character highlights. There is no - separate plugin option; 'diffopt' is the single lever. - - iwhite Ignore changes in the amount of whitespace (git `-b`). - iwhiteall Ignore all whitespace (git `-w`). - iwhiteeol Ignore whitespace changes at end of line. - iblank Ignore changes whose lines are blank. - - The default is to show every whitespace difference. Set a baseline with - 'diffopt', for example: >vim - set diffopt+=iwhiteall -< - The same flags apply to |:Diff|, |:Diff-review|, and every layout, because - the generated diffs read 'diffopt' as they are rendered. - - Changes to 'diffopt' take effect live: whenever the option changes the - generated `diffs://` buffers regenerate, the decorated surfaces repaint, and - native |:diffsplit| windows are refreshed with |:diffupdate|. There is no - dedicated mapping; toggle whitespace the standard Vim way by adding or - removing a flag from 'diffopt' (for example with a small `:set` mapping of - your own). - - What an ignored whitespace change looks like depends on the surface, - because the plugin removes the noise where it generates the diff and only - re-styles it where the text cannot be regenerated: - - - Generated `diffs://` buffers (|:Diff|, the staged/unstaged sections, and - |:Diff-review|): regenerated with the matching git/|vim.diff()| flag, so - a whitespace-only hunk is dropped entirely and never appears. - - Split layout (`++layout=split`): regenerated the same way, so a - whitespace-only change shows as plain, uncolored context — both sides - stay visible with their own indentation, just without add/delete color. - - Decorated, non-generated buffers (`gitcommit` from `git commit -v`, - vim-fugitive, Neogit, plain `diff` buffers): the text cannot be - regenerated, so a `+`/`-` line whose only change is whitespace is kept - but de-emphasized — the prefix and syntax are kept and the intra-line - emphasis dropped, while the added/removed background becomes a faint, - neutral |DiffsDim|. Lines that mix a real change with whitespace stay - fully highlighted. - - difftastic-rendered diffs: left unchanged; the 'diffopt' whitespace flags - do not apply (see |diffs.nvim-difftastic|). - - Native |:diffsplit| windows: whitespace is handled by Neovim itself. - - Limitations: ~ - - 'diffopt' `algorithm` and `linematch` are honored by the generated diffs - too. `linematch` has no git equivalent, so it shapes the file diffs - computed with |vim.diff()| but not the git-backed surfaces (|:Diff-review| - and the staged/unstaged section diffs). - -Inline granularity: ~ *diffs.nvim-inline* - - How much of a changed line is emphasized comes from 'diffopt' `inline:`, so - diffs.nvim surfaces match native |:diffsplit| windows. As with whitespace, - there is no separate plugin option. - - inline:none No intra-line emphasis. Changed lines keep only their - line-level background. - inline:simple One span per line, from the first differing byte to - the last. - inline:char A span per differing run of characters. - inline:word Spans grown to whole words, with gaps of five bytes or - less of non-word characters merged into one block. - - Neovim treats a 'diffopt' with no `inline:` value as `inline:simple`, and - diffs.nvim resolves it the same way. To emphasize whole words: >vim - set diffopt+=inline:word -< - The setting takes effect live through the same 'diffopt' repaint described - above, so a plain `:set`, an unimpaired-style toggle, or an autocmd all - work. There is no dedicated mapping. - - *diffs.nvim-diffopt* - Intra-line highlighting is controlled by |'diffopt'|. - - Disable intra-line highlighting: >lua - vim.opt.diffopt:remove({ 'inline:simple', 'inline:char', 'inline:word' }) - vim.opt.diffopt:append('inline:none') -< - To enable word-level highlighting when opening a diff buffer: >lua - vim.api.nvim_create_autocmd('FileType', { - pattern = { 'diff', 'git', 'gitcommit' }, - callback = function() - vim.opt.diffopt:remove({ 'inline:none', 'inline:simple', 'inline:char' }) - vim.opt.diffopt:append('inline:word') - end, - }) -< - 'diffopt' is global: this changes all diff views. For attachment to `diff` - buffers, see |diffs.nvim-extra-filetypes|. - -============================================================================== -MAPPINGS *diffs.nvim-mappings* - - *(diffs-diff)* -(diffs-diff) Show index-to-worktree unified diff in a horizontal - split. Equivalent to |:Diff| with no arguments. - - *(diffs-diff-vertical)* -(diffs-diff-vertical) Show index-to-worktree unified diff in a -vertical - split. Equivalent to `:vertical Diff` with no - arguments. - - *(diffs-diff-open-source)* -(diffs-diff-open-source) - Open the real worktree file at the current |:Diff| - hunk location. `diffs://` buffers map this to - by default. - - *(diffs-review-open-split)* -(diffs-review-open-split) - Open the currently selected |:Diff-review| file as a - paired side-by-side diff: the old/removed endpoint on - the left, the new/added endpoint on the right. - - *(diffs-review-toggle-layout)* -(diffs-review-toggle-layout) - Toggle the current review buffer between the generated - review map and split layout. Mapped to `gs` by default - in generated review maps and review split panes, unless - `gs` is already mapped in that buffer. See - |diffs.nvim.review_toggle_layout()|. - - *(diffs-review-next-file)* -(diffs-review-next-file) - In a |:Diff-review| split, swap the displayed pair to - the next changed file, wrapping at the end. Mapped to - `]q` and `]f` by default in the split panes, unless - already mapped in that buffer. Accepts a count. - - *(diffs-review-prev-file)* -(diffs-review-prev-file) - As |(diffs-review-next-file)|, but the previous - changed file. Mapped to `[q` and `[f` by default. - - *(diffs-review-select-file)* -(diffs-review-select-file) - In a |:Diff-review| split, prompt with |vim.ui.select()| - for one of the review's files and jump to it. Mapped to - `gO` by default in the split panes — a review's files are - its table of contents — unless `gO` is already mapped in - that buffer. See |diffs.nvim.select_review_file()|. - - *(diffs-refresh)* -(diffs-refresh) - Re-render the current `diffs://` buffer in place, - preserving the cursor and view. Useful after changing - 'diffopt' directly. Comparable to |:diffupdate|. - Generated diff buffers map to this action, - clear search highlighting, update native diffs, and - redraw. Existing buffer-local mappings are kept. - -Example configuration: >lua - vim.keymap.set('n', 'gd', '(diffs-diff)') - vim.keymap.set('n', 'gD', '(diffs-diff-vertical)') -< - - *(diffs-conflict-ours)* -(diffs-conflict-ours) - Accept current (ours) change. Replaces the - conflict block with ours content. - - *(diffs-conflict-theirs)* -(diffs-conflict-theirs) - Accept incoming (theirs) change. Replaces the - conflict block with theirs content. - - *(diffs-conflict-both)* -(diffs-conflict-both) - Accept both changes (ours then theirs). - - *(diffs-conflict-none)* -(diffs-conflict-none) - Reject both changes (delete entire block). - - *(diffs-conflict-next)* -(diffs-conflict-next) - Jump to next conflict marker. Wraps around. - - *(diffs-conflict-prev)* -(diffs-conflict-prev) - Jump to previous conflict marker. Wraps around. - -Example configuration: >lua - vim.keymap.set('n', 'co', '(diffs-conflict-ours)') - vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)') - vim.keymap.set('n', 'cb', '(diffs-conflict-both)') - vim.keymap.set('n', 'c0', '(diffs-conflict-none)') - vim.keymap.set('n', ']x', '(diffs-conflict-next)') - vim.keymap.set('n', '[x', '(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. +INTEGRATIONS *diffs.nvim-integrations* - *(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.nvim-q* - q Close the diff window. Available in all `diffs://` - buffers created by |:Diff| or the fugitive status - keymaps. In paired split views, close the pair; see - |diffs.nvim-paired-windows|. - ]c Jump to the next hunk boundary in unified |:Diff| - buffers. - [c Jump to the previous hunk boundary in unified |:Diff| - buffers. - Open the real worktree file at the current |:Diff| hunk - location when the current endpoint row is worktree-backed. - dp In Normal mode, stage the current whole hunk when the - buffer compares the index to the worktree. In Visual mode, - stage selected changed lines inside one hunk. - do In Normal mode, unstage the current whole hunk when the - buffer compares a tree to the index. In Visual mode, - unstage selected changed lines inside one hunk. - -============================================================================== -INTEGRATIONS *diffs.nvim-integrations* - -List the integrations to enable: ->lua +Enable integrations by name: >lua vim.g.diffs = { - integrations = { 'fugitive', 'neogit', 'neojj', 'gitsigns' }, + integrations = { 'fugitive', 'neogit', 'neojj', 'gitsigns', 'telescope' }, } < +Fugitive + Attach to `fugitive` buffers. -Opt-in: ~ *diffs.nvim-extra-filetypes* - -For other filetypes, register a FileType autocmd before opening the buffers. ->lua - vim.api.nvim_create_autocmd('FileType', { - group = vim.api.nvim_create_augroup('MyDiffs', { clear = true }), - pattern = { 'diff', 'myplugin-diff' }, - callback = function(event) - require('diffs').attach(event.buf) - end, - }) -< - ------------------------------------------------------------------------------- -FUGITIVE *diffs.nvim-fugitive* - -Enable vim-fugitive (https://github.com/tpope/vim-fugitive) support: >lua - vim.g.diffs = { integrations = { 'fugitive' } } -< - -`:Git` status and commit views receive treesitter syntax, line backgrounds, -and intra-line diffs. |:Diff| opens a unified diff against any revision (see -|diffs.nvim-commands|). - -Fugitive status keymaps: ~ - -When inside a `:Git` status buffer, diffs.nvim provides keymaps to open -unified `diffs://` views for files or entire sections. These keymaps reuse -Fugitive status context for endpoint selection; they do not call `:Gdiffsplit` -or open native Vim diff windows. - -Keymaps: ~ - *diffs.nvim-du* *diffs.nvim-dU* - du Open unified diff in a horizontal split. - dU Open unified diff in a vertical split. - -These keymaps work on: -- File lines (e.g., `M src/foo.lua`) - opens diff for that file -- Section headers (e.g., `Staged (3)`) - opens diff for all files in section -- Hunk/context lines below a file - opens diff for the parent file - -Behavior by file status: ~ - - Status Section Base Current Result ~ - M Unstaged index working tree unstaged changes - M Staged HEAD index staged changes - A Staged (empty) index file as all-added - D Staged HEAD (empty) file as all-removed - R Staged HEAD:oldname index:newname content diff - 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 -staged) and displays all changes in that section as a single unified diff -without hunk actions. Empty staged or unstaged sections report which section -has no changes. Untracked section headers show a warning since there is no -meaningful diff. - -This status-row behavior is intentionally narrower than Fugitive -`:Gdiffsplit`. It preserves the useful edge selection model while keeping -`diffs://` buffers read-only, explicit, and owned by diffs.nvim. - ------------------------------------------------------------------------------- -NEOGIT *diffs.nvim-neogit* - -Enable Neogit (https://github.com/NeogitOrg/neogit) support: >lua - vim.g.diffs = { integrations = { 'neogit' } } -< - -Expanding a diff in a Neogit buffer (e.g., TAB on a file in the status view) -applies treesitter syntax highlighting and intra-line diffs to the hunk lines. - ------------------------------------------------------------------------------- -NEOJJ *diffs.nvim-neojj* - -Enable neojj (https://github.com/NicholasZolton/neojj) support: >lua - vim.g.diffs = { integrations = { 'neojj' } } -< - -Expanding a diff in a neojj buffer (e.g., TAB on a file in the status view) -applies treesitter syntax highlighting and intra-line diffs to the hunk lines. - ------------------------------------------------------------------------------- -GITSIGNS *diffs.nvim-gitsigns* - -Enable gitsigns.nvim (https://github.com/lewis6991/gitsigns.nvim) popup -integration for blame hunk previews: >lua - vim.g.diffs = { integrations = { 'gitsigns' } } -< - -`:Gitsigns blame_line full=true` popups receive treesitter syntax, line -backgrounds, and intra-line diffs. - -Highlights are applied in a separate `diffs-gitsigns` namespace and do not -interfere with the main decoration provider used for diff buffers. - ------------------------------------------------------------------------------- -COMMITTIA *diffs.nvim-committia* - -Committia (https://github.com/rhysd/committia.vim) diff panes receive -highlighting automatically. - ------------------------------------------------------------------------------- -TELESCOPE *diffs.nvim-telescope* - -Enable telescope.nvim (https://github.com/nvim-telescope/telescope.nvim) -preview highlighting: >lua - vim.g.diffs = { integrations = { 'telescope' } } -< - -Pickers that show diff content (e.g. `git_bcommits`, `git_status`) will -receive treesitter syntax, line backgrounds, and intra-line diffs in the -preview pane. - -Known issue: Telescope's previewer may render the first line of the preview -buffer with a black background regardless of colorscheme. This is a Telescope -artifact unrelated to diffs.nvim. Tracked upstream: -https://github.com/nvim-telescope/telescope.nvim/issues/3626 - ------------------------------------------------------------------------------- -DIFFTASTIC *diffs.nvim-difftastic* - -Enable structural diffing via difftastic -(https://github.com/Wilfred/difftastic). This integration is different from -the others: difftastic is not a plugin whose buffers are decorated, but an -external `difft` binary used as a structural diff engine. >lua - vim.g.diffs = { integrations = { 'difftastic' } } - vim.g.diffs = { integrations = { difftastic = { args = { '--ignore-comments' } } } } -< - -The `difft` binary must be installed and on `PATH`. When it is missing, -disabled, errors, or times out, diffs.nvim silently falls back to its normal -git/intra rendering. The table form forwards `args` to difft verbatim; do not -pass output-format flags (`--display`, `--color`), which diffs.nvim controls. - -When enabled, difftastic augments the diffs you explicitly open — |:Diff|, -`:Diff ++layout=split`, `:Diff files`, and staged/untracked file diffs — with: - - - structural intra-line token highlighting (the actual edited tokens, with - reformatting/reindentation recognized as unchanged), and - - structural side-by-side row alignment in the split layout. - -It deliberately does not touch the live/ambient surfaces (fugitive, neogit, -the builtin `diff` filetype), the multi-file review map, conflicts, or binary -files; those render unchanged. In the unified layouts difftastic only refines -the token layer (the +/- line structure stays git's), so its line-level -structural wins appear in the split layout. - -difftastic runs synchronously when you open the diff, with an internal -timeout. It is independent of |diffs.nvim-config| `highlights.intra`, which -configures the built-in engine and is the fallback when difftastic is off. -When difftastic finds no structural change (e.g. a reformat-only edit), it -emits an informational message and renders no token highlights. The 'diffopt' -whitespace handling does not apply to a structural diff; such buffers are left -unchanged. - -============================================================================== -CONFLICT RESOLUTION *diffs.nvim-conflict* - -diffs.nvim highlights merge conflicts in working files. It recognizes standard -markers (`<<<<<<<`, `=======`, `>>>>>>>`) and diff3 base sections (`|||||||`). - -diffs.nvim detects conflicts when you open a file and updates their highlights -as you edit. Resolve conflicts with the mappings below or edit the text directly. - -To disable conflict handling: >lua - vim.g.diffs = { conflict = false } -< - -Buffer-local mappings: ~ *diffs.nvim-conflict-keymaps* - -Set buffer-local mappings when conflicts are detected: >lua - vim.api.nvim_create_autocmd('User', { - pattern = 'DiffsConflictDetected', - callback = function(event) - local opts = { buffer = event.data.bufnr } - vim.keymap.set('n', 'co', '(diffs-conflict-ours)', opts) - vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)', opts) - vim.keymap.set('n', 'cb', '(diffs-conflict-both)', opts) - vim.keymap.set('n', 'c0', '(diffs-conflict-none)', opts) - vim.keymap.set('n', ']x', '(diffs-conflict-next)', opts) - vim.keymap.set('n', '[x', '(diffs-conflict-prev)', opts) - end, - }) -< - -Diagnostic suppression: ~ *diffs.nvim-conflict-diagnostics* - -Hide diagnostics while a buffer contains merge conflicts: >lua - vim.api.nvim_create_autocmd('User', { - pattern = { - 'DiffsConflictDetected', - 'DiffsConflictResolved', - 'DiffsConflictDetached', - }, - callback = function(event) - local bufnr = event.data.bufnr - if event.match == 'DiffsConflictDetected' then - if vim.diagnostic.is_enabled({ bufnr = bufnr }) then - vim.b[bufnr].diffs_restore_diagnostics = true - vim.diagnostic.enable(false, { bufnr = bufnr }) - end - elseif vim.b[bufnr].diffs_restore_diagnostics then - vim.b[bufnr].diffs_restore_diagnostics = nil - vim.diagnostic.enable(true, { bufnr = bufnr }) - end - end, - }) -< - -User events: ~ - -All events below include their target buffer number in `event.data.bufnr`. -Conflict events identify the working buffer. `DiffsMergeReady` identifies the -generated merge buffer. +Neogit + Attach to `NeogitStatus`, `NeogitCommitView`, and `NeogitDiffView` buffers. + Diffs renders intra-line colors in place of Neogit's inline groups. - *DiffsConflictDetected* - DiffsConflictDetected Fired when conflicts first appear in an attached - buffer, including when undo restores markers. +neojj + Attach to `NeojjStatus`, `NeojjCommitView`, and `NeojjDiffView` buffers. - *DiffsConflictResolved* - DiffsConflictResolved Fired when the last conflict in a buffer is resolved. +Gitsigns + Highlight unified diff previews and blame popups. - *DiffsConflictDetached* - DiffsConflictDetached Fired when conflict highlighting is explicitly - detached from a buffer. +Committia + Diff panes receive highlighting automatically. - *DiffsMergeReady* - DiffsMergeReady Fired after a generated merge view is initialized - or refreshed. +Telescope + Highlight diff buffers in Telescope previews. ============================================================================== -MERGE DIFF RESOLUTION *diffs.nvim-merge* - -When running plain |:Diff| or an explicit index-object comparison (for example -`:Diff :%`) on an unmerged worktree file, or 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. Revision comparisons such as `:Diff HEAD` keep -rendering that revision against the conflicted worktree buffer. - -The |(diffs-merge-ours)| family resolves conflicts in the working file -by matching diff hunks to conflict markers. - -Set buffer-local mappings for merge views: >lua - vim.api.nvim_create_autocmd('User', { - pattern = 'DiffsMergeReady', - callback = function(event) - local opts = { buffer = event.data.bufnr } - vim.keymap.set('n', 'co', '(diffs-merge-ours)', opts) - vim.keymap.set('n', 'ct', '(diffs-merge-theirs)', opts) - vim.keymap.set('n', 'cb', '(diffs-merge-both)', opts) - vim.keymap.set('n', 'c0', '(diffs-merge-none)', opts) - vim.keymap.set('n', ']x', '(diffs-merge-next)', opts) - vim.keymap.set('n', '[x', '(diffs-merge-prev)', opts) - end, - }) -< +LUA API *diffs.nvim-api* -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. Inline -conflict highlights (see |diffs.nvim-conflict|) are refreshed automatically -after each resolution. - -Git mergetool: *diffs.nvim-git-mergetool* - -diffs.nvim can be used with `git mergetool` by configuring Git to open the -merged working file in Neovim: ->gitconfig - [merge] - tool = diffs-nvim - - [mergetool "diffs-nvim"] - cmd = nvim "$MERGED" -< - -When the file opens, diffs.nvim detects conflict markers automatically. -For buffer-local resolution mappings, see |diffs.nvim-conflict-keymaps|. -From a Git-unmerged file, |:Diff| can also open the -generated ours-vs-theirs merge diff view documented above. - -Save and quit after resolving conflicts. Use `:cq` to abort. - -============================================================================== -API *diffs.nvim-api* +Use `require('diffs')` to access the public API. attach({bufnr}, {options}) *diffs.nvim.attach()* Attach highlighting to a buffer. Called automatically when the buffer's filetype matches `git`, `gitcommit`, or an enabled integration filetype. - Direct calls work with any filetype. For automatic attachment to other - filetypes, see |diffs.nvim-extra-filetypes|. + Direct calls work with any filetype. Parameters: ~ {bufnr} (integer, optional) Buffer number. Omit or use 0 for current. @@ -1167,7 +134,7 @@ attach({bufnr}, {options}) *diffs.nvim.attach()* • {hunks} (function, optional) Receives the buffer number. Returns |diffs.Hunk| tables in buffer order. • {intra} (boolean, optional) Set false to disable intra-line - highlighting. Omit for automatic Difftastic detection. + highlighting. Pass {options} to replace an attachment. Without it, repeated calls do nothing. The {hunks} callback runs on attachment and on redraw after edits @@ -1229,240 +196,23 @@ whitespace_only_lines({lines}) *diffs.nvim.whitespace_only_lines()* ignored by the whitespace options in 'diffopt'. {lines} has the same format as |diffs.nvim.compute_intra_hunks()|. - *diffs.ReviewSpec* -A `diffs.ReviewSpec` says what to review, as the arguments to |:Diff-review| -do: ~ - {args} (string[], optional) Git revision arguments. Overrides {base}, - {target}, and {mode}. - {base} (string, optional) The ref to review against. - {target} (string, optional) The ref to review. Omit for the current - repository state. - {mode} ("merge-base"|"direct", optional) How {base} and {target} - relate, matching `A...B` and `A..B` respectively. - {repo} (string, optional) Repository root. Defaults to the one the - current buffer is in. - {untracked} (boolean, optional) Include untracked files in a - current-state review. Ignored when {target} is given. - -open_review({spec}, {opts}) *diffs.nvim.open_review()* - Open a review, as |:Diff-review| does, for a caller that already knows - what it wants reviewed. Returns the review buffer, or `nil` on failure. - - {opts.replace_win} is why this exists alongside the command: |:Diff| - has nowhere to say "this window", so with no review already open it - splits. A plugin handing a review off from its own buffer already has a - window and wants that one used, rather than a new one and the rest of the - tab closed to compensate. The window is not closed or created, only its - buffer replaced. - - Parameters: ~ - {spec} (|diffs.ReviewSpec|, optional) What to review. Omit to - review the current repository state against the remote - default branch, as `:Diff review` with no arguments does. - {opts} (table, optional) ~ - • {layout} ("unified"|"stacked"|"split", optional) As - `++layout`. Defaults to `view.default_layout`. - • {replace_win} (integer, optional) Show the review in this - window instead of splitting. - - Example, reviewing a pull request in the window you are standing in: >lua - require('diffs').open_review( - { base = 'main', target = 'refs/forge/pull/12', mode = 'merge-base' }, - { replace_win = vim.api.nvim_get_current_win() } - ) -< The rest of the review API is buffer-centric. Navigation functions resolve -the review split that {bufnr} (default: current buffer) belongs to, and are -no-ops (`nil`/`false`) elsewhere. |diffs.nvim.review_toggle_layout()| also -accepts a generated review map buffer and can move between map and split -surfaces. Those helpers are only meaningful inside an open review. - - *diffs.ReviewFile* -A `diffs.ReviewFile` describes one changed file in a review: ~ - {path} (string) Repo-relative path, e.g. `lua/rate.lua`. - {key} (string) Opaque, stable identifier; pass it to - |diffs.nvim.review_goto()|. Prefer it over {path} as a - target, since a path can recur across sections. Do not - parse it; read {section}/{path} from the fields instead. - {section} (string|nil) Section id for logic, e.g. `unstaged`. - {section_label} (string|nil) Display label, e.g. `Unstaged`. - {added} (integer) Added line count. - {removed} (integer) Removed line count. - {skipped} (boolean) True when split review navigation skips this file. - -review_files({bufnr}) *diffs.nvim.review_files()* - List the changed files in the review split {bufnr} belongs to, as - |diffs.ReviewFile| items, or `nil` when {bufnr} is not a review split. - - Parameters: ~ - {bufnr} (integer, optional) Buffer number. Defaults to current buffer. - -review_current({bufnr}) *diffs.nvim.review_current()* - Return `{ index, count, file }` for the file currently shown in the review - split {bufnr} belongs to (`file` is a |diffs.ReviewFile|), or `nil` - otherwise. Handy for statuslines. - - Parameters: ~ - {bufnr} (integer, optional) Buffer number. Defaults to current buffer. - -review_goto({target}, {bufnr}) *diffs.nvim.review_goto()* - Switch the review split {bufnr} belongs to so it shows {target}, swapping - both panes in place. Returns `true` on success, `false` when {bufnr} is not - a review split, no file matches, or {target} is skipped. Never opens a split. - - Parameters: ~ - {target} (string) A |diffs.ReviewFile| `key` (preferred) or path. - {bufnr} (integer, optional) Buffer number. Defaults to current buffer. - -review_next_file() *diffs.nvim.review_next_file()* -review_prev_file() *diffs.nvim.review_prev_file()* - Step the current review split to the next/previous changed file, wrapping - at the ends and skipping unsupported files. Backing functions for `]q`/`[q`, - `]f`/`[f`, - |(diffs-review-next-file)|, and |(diffs-review-prev-file)|. - -review_toggle_layout({opts}) *diffs.nvim.review_toggle_layout()* - Toggle the current review surface between the generated review map and split - layout. From a generated review map, opens the selected file in split layout. - From a review split, closes the pair and restores the generated map layout - recorded on the split (`unified` or `stacked`). Either direction keeps the - cursor on the line it was on, mapped through the diff. Returns the opened - buffer number or `nil` if the current buffer is not a review surface. - - Parameters: ~ - {opts} (table, optional) Supports `{ bufnr = }`. Defaults to - the current buffer. - -select_review_file({bufnr}) *diffs.nvim.select_review_file()* - Prompt with |vim.ui.select()| over the review split's files and jump to the - chosen one. Skipped files are shown as `(skipped)` and do not jump. Backing - function for |(diffs-review-select-file)| (`gO`). - It uses whatever |vim.ui.select()| backend you have configured, so no extra - dependency is required. To build a bespoke picker, call the data and action - functions directly, capturing the review buffer before the picker opens: >lua - local diffs = require('diffs') - local bufnr = vim.api.nvim_get_current_buf() - local files = diffs.review_files(bufnr) - if files then - vim.ui.select(files, { - format_item = function(f) - return ('[%s] %s'):format(f.section_label or '', f.path) - end, - }, function(choice) - if choice then - diffs.review_goto(choice.key, bufnr) - end - end) - end -< - Parameters: ~ - {bufnr} (integer, optional) Buffer number. Defaults to current buffer. - ============================================================================== -KNOWN LIMITATIONS *diffs.nvim-limitations* - -Incomplete Syntax Context ~ - *diffs.nvim-syntax-context* -Treesitter parses each diff hunk in isolation. Hunks that start or end -mid-expression may produce imperfect highlights. - -Syntax Highlighting Flash ~ - *diffs.nvim-flash* -When opening a fugitive buffer, there is an unavoidable visual "flash" where -the buffer briefly shows fugitive's default diff highlighting before -diffs.nvim applies its own highlights. - -============================================================================== -HIGHLIGHT GROUPS *diffs.nvim-highlights* - -Diff and generated rail highlights: ~ - *DiffsClear* - DiffsClear Clears underlying diff foreground colors while preserving - the normal foreground. - - *DiffsAdd* - DiffsAdd Background for `+` lines. - - *DiffsDelete* - DiffsDelete Background for `-` lines. - - *DiffsDim* - DiffsDim Faint, neutral background for a whitespace-only changed - line on a decorated (non-generated) surface while - 'diffopt' ignores whitespace. See - |diffs.nvim-whitespace|. - - *DiffsRail* - DiffsRail Generated line-number rails and separators. - - *DiffsRailNr* - DiffsRailNr Context line numbers in generated rails. - - *DiffsAddRailNr* - DiffsAddRailNr Add line-number rails on generated `+` lines. - - *DiffsDeleteRailNr* - DiffsDeleteRailNr Delete line-number rails on generated `-` lines. - - *DiffsAddBar* - DiffsAddBar Change bar for `+` lines in diffs.nvim-owned views. - - *DiffsDeleteBar* - DiffsDeleteBar Change bar for `-` lines in diffs.nvim-owned views. +HIGHLIGHT GROUPS *diffs.nvim-highlights* - *DiffsAddCount* - DiffsAddCount Added-line counts in the quickfix file list. +Diffs derives colors from Normal, DiffAdd, DiffDelete, DiffChange, and DiffText. +It updates its groups after ColorScheme events. - *DiffsDeleteCount* - DiffsDeleteCount Deleted-line counts in the quickfix file list. + DiffsClear Base text color + DiffsAdd Added line background + DiffsDelete Removed line background + DiffsAddText Changed text on added lines + DiffsDeleteText Changed text on removed lines + DiffsDiffAdd Native diff added line background + DiffsDiffDelete Native diff deleted line colors + DiffsDiffChange Native diff changed line background + DiffsDiffText Native diff changed text background - *DiffsAddText* - DiffsAddText Character-level background for changed characters - within `+` lines. - - *DiffsDeleteText* - DiffsDeleteText Character-level background for changed characters - within `-` lines. - -Conflict highlights: ~ - *DiffsConflictOurs* - DiffsConflictOurs Background for "ours" (current) content lines. - - *DiffsConflictTheirs* - DiffsConflictTheirs Background for "theirs" (incoming) content lines. - - *DiffsConflictBase* - DiffsConflictBase Background for base (ancestor) content lines in - diff3 conflicts. - - *DiffsConflictMarker* - DiffsConflictMarker Dimmed foreground with bold for `<<<<<<<`, - `=======`, `>>>>>>>`, and `|||||||` marker lines. - - *DiffsConflictOursNr* - DiffsConflictOursNr Line number for "ours" content lines. - - *DiffsConflictTheirsNr* - DiffsConflictTheirsNr Line number for "theirs" content lines. - - *DiffsConflictBaseNr* - DiffsConflictBaseNr Line number for base content lines (diff3). - -Diff mode window highlights: ~ These are used for 'winhighlight' remapping in -`&diff` windows. - - *DiffsDiffAdd* - DiffsDiffAdd Background-only replacement for `DiffAdd`. - - *DiffsDiffDelete* - DiffsDiffDelete Replacement for `DiffDelete`. - - *DiffsDiffChange* - DiffsDiffChange Background-only replacement for `DiffChange`. - - *DiffsDiffText* - DiffsDiffText Background-only replacement for `DiffText`. - - *DiffsHighlights* + *DiffsHighlights* diffs.nvim triggers `User DiffsHighlights` after each highlight update, including initialization and |ColorScheme| changes. Register this autocmd before loading diffs.nvim to apply your colors after each update: >lua @@ -1475,21 +225,19 @@ diffs.nvim to apply your colors after each update: >lua end, }) < - ============================================================================== -HEALTH CHECK *diffs.nvim-health* +LIMITATIONS *diffs.nvim-limitations* -Run `:checkhealth diffs` to check the required Neovim version (0.12.0+). +Treesitter parses each hunk separately. Hunks that start or end inside an +expression can have incomplete syntax highlighting. + +Vim syntax fallback runs after redraw. A new hunk can appear without syntax +colors for one frame. Language parsers and queries load on first use. ============================================================================== -ACKNOWLEDGEMENTS *diffs.nvim-acknowledgements* +HEALTH *diffs.nvim-health* -- vim-fugitive (https://github.com/tpope/vim-fugitive) -- diffview.nvim (https://github.com/sindrets/diffview.nvim) -- @phanen (https://github.com/phanen) - diff header highlighting, - treesitter injection support, blame_hl.nvim (gitsigns blame popup - highlighting inspiration) -- @tris203 (https://github.com/tris203) - support for transparent backgrounds +Run `:checkhealth diffs` to check the required Neovim version (0.12.0+). ============================================================================== - vim:tw=78:ts=8:ft=help:norl: +vim:tw=78:ts=8:ft=help:norl: diff --git a/lua/diffs/actions.lua b/lua/diffs/actions.lua deleted file mode 100644 index 39f345a0..00000000 --- a/lua/diffs/actions.lua +++ /dev/null @@ -1,476 +0,0 @@ -local M = {} - -local diffspec = require('diffs.spec') -local git = require('diffs.git') -local hunk_model = require('diffs.hunks') -local notify = require('diffs.log').notify - ----@param bufnr integer ----@param name string ----@return any -local function get_buf_var(bufnr, name) - local ok, value = pcall(vim.api.nvim_buf_get_var, bufnr, name) - if ok then - return value - end - return nil -end - ----@param bufnr integer ----@return diffs.DiffHunk[] -local function buffer_hunks(bufnr) - local hunks = get_buf_var(bufnr, 'diffs_hunks') - if type(hunks) == 'table' then - return hunks - end - return {} -end - ----@param bufnr integer ----@return diffs.DiffHunk? -local function hunk_under_cursor(bufnr) - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - return hunk_model.hunk_at_line(buffer_hunks(bufnr), cursor_line) -end - ----@param hunk diffs.DiffHunk ----@return diffs.DiffSpec? -local function hunk_diff_spec(hunk) - if not hunk.diff_spec then - return nil - end - local ok, spec = pcall(diffspec.new, hunk.diff_spec) - if ok then - return spec - end - return nil -end - ----@param lines string[] -local function extend_patch_lines(patch, lines) - for _, line in ipairs(lines or {}) do - patch[#patch + 1] = line - end -end - ----@param line diffs.DiffHunkLine ----@return boolean -local function is_change_line(line) - return line.kind == 'add' or line.kind == 'delete' -end - ----@param line diffs.DiffHunkLine ----@return string -local function context_text(line) - return ' ' .. line.text:sub(2) -end - ----@param lnum integer ----@param range_start integer ----@param range_finish integer ----@return boolean -local function in_range(lnum, range_start, range_finish) - return lnum >= range_start and lnum <= range_finish -end - ----@param hunk diffs.DiffHunk ----@return string?, string? -function M.patch_for_hunk(hunk) - if not hunk then - return nil, 'no diff hunk under cursor' - end - if type(hunk.file_header_lines) ~= 'table' or #hunk.file_header_lines == 0 then - return nil, 'cannot build hunk patch without file headers' - end - - local patch_lines = {} - extend_patch_lines(patch_lines, hunk.file_header_lines) - for _, line in ipairs(hunk.lines or {}) do - patch_lines[#patch_lines + 1] = line.text - end - - return table.concat(patch_lines, '\n') .. '\n', nil -end - ----@class diffs.DiffChangeGroup ----@field lines diffs.DiffHunkLine[] ----@field has_add boolean ----@field has_delete boolean - ----@param hunk diffs.DiffHunk ----@return diffs.DiffChangeGroup[] -local function change_groups(hunk) - local groups = {} - local current = nil - - local function finish_group() - if current then - groups[#groups + 1] = current - current = nil - end - end - - for _, line in ipairs(hunk.lines or {}) do - if is_change_line(line) then - current = current or { lines = {}, has_add = false, has_delete = false } - current.lines[#current.lines + 1] = line - current.has_add = current.has_add or line.kind == 'add' - current.has_delete = current.has_delete or line.kind == 'delete' - elseif line.kind ~= 'meta' then - finish_group() - end - end - - finish_group() - return groups -end - ----@param hunk diffs.DiffHunk ----@param range_start integer ----@param range_finish integer ----@return string? -local function validate_range(hunk, range_start, range_finish) - for _, group in ipairs(change_groups(hunk)) do - if group.has_add and group.has_delete then - local selected = 0 - for _, line in ipairs(group.lines) do - if in_range(line.lnum, range_start, range_finish) then - selected = selected + 1 - end - end - if selected > 0 and selected < #group.lines then - return 'select the complete replacement group before applying it' - end - end - end - return nil -end - ----@param hunk diffs.DiffHunk ----@param range_start integer ----@param range_finish integer ----@param opts? { target?: "left"|"right" } ----@return string?, string? -function M.patch_for_range(hunk, range_start, range_finish, opts) - opts = opts or {} - local target = opts.target or 'left' - if not hunk then - return nil, 'no diff hunk under cursor' - end - if target ~= 'left' and target ~= 'right' then - return nil, 'invalid diff range patch target' - end - if type(range_start) ~= 'number' or type(range_finish) ~= 'number' then - return nil, 'invalid diff visual range' - end - if range_finish < range_start then - range_start, range_finish = range_finish, range_start - end - if range_start < hunk.buffer_range.start or range_finish > hunk.buffer_range.finish then - return nil, 'visual selection must stay within one diff hunk' - end - - local range_err = validate_range(hunk, range_start, range_finish) - if range_err then - return nil, range_err - end - if type(hunk.file_header_lines) ~= 'table' or #hunk.file_header_lines == 0 then - return nil, 'cannot build hunk patch without file headers' - end - - local patch_lines = {} - local has_change = false - local previous_emitted_change = false - extend_patch_lines(patch_lines, hunk.file_header_lines) - patch_lines[#patch_lines + 1] = hunk.header - - for _, line in ipairs(hunk.lines or {}) do - local emitted = nil - local emitted_change = false - if line.kind == 'context' or (line.kind == 'meta' and previous_emitted_change) then - emitted = line.text - elseif line.kind == 'add' then - if in_range(line.lnum, range_start, range_finish) then - emitted = line.text - emitted_change = true - has_change = true - elseif target == 'right' then - emitted = context_text(line) - end - elseif line.kind == 'delete' then - if in_range(line.lnum, range_start, range_finish) then - emitted = line.text - emitted_change = true - has_change = true - elseif target == 'left' then - emitted = context_text(line) - end - end - - if emitted then - patch_lines[#patch_lines + 1] = emitted - end - previous_emitted_change = emitted_change - end - - if not has_change then - return nil, 'visual selection does not include changed diff lines' - end - - return table.concat(patch_lines, '\n') .. '\n', nil -end - ----@param output string[]? ----@return string -local function format_git_output(output) - local message = table.concat(output or {}, '\n') - if message == '' then - return 'git apply failed' - end - return message -end - ----@param repo_root string ----@param patch string ----@param operation "stage"|"unstage" ----@param opts? { recount?: boolean } ----@return boolean, string? -local function apply(repo_root, patch, operation, opts) - opts = opts or {} - local ok, output = git.apply_patch(repo_root, patch, { - cached = true, - reverse = operation == 'unstage', - recount = opts.recount, - }) - if not ok then - return false, format_git_output(output) - end - - return true, nil -end - ----@param bufnr integer ----@return diffs.DiffHunk?, diffs.DiffSpec?, string? -local function current_action_context(bufnr) - local hunk = hunk_under_cursor(bufnr) - if not hunk then - return nil, nil, 'no diff hunk under cursor' - end - - local spec = hunk_diff_spec(hunk) - if not spec then - return nil, nil, 'cannot resolve diff hunk edge' - end - - return hunk, spec, nil -end - ----@param bufnr integer ----@param range_start integer ----@param range_finish integer ----@return diffs.DiffHunk?, diffs.DiffSpec?, string? -local function range_action_context(bufnr, range_start, range_finish) - if range_finish < range_start then - range_start, range_finish = range_finish, range_start - end - - local parsed = buffer_hunks(bufnr) - local start_hunk = hunk_model.hunk_at_line(parsed, range_start) - local finish_hunk = hunk_model.hunk_at_line(parsed, range_finish) - if not start_hunk or not finish_hunk or start_hunk.index ~= finish_hunk.index then - return nil, nil, 'visual selection must stay within one diff hunk' - end - - local spec = hunk_diff_spec(start_hunk) - if not spec then - return nil, nil, 'cannot resolve diff hunk edge' - end - - return start_hunk, spec, nil -end - ----@param bufnr integer ----@param hunk diffs.DiffHunk ----@param operation "stage"|"unstage" ----@param opts? { patch?: string, recount?: boolean } ----@return boolean -local function mutate_hunk(bufnr, hunk, operation, opts) - opts = opts or {} - local source = get_buf_var(bufnr, 'diffs_source') - local repo_root = source and source.repo_root - if not repo_root then - notify('cannot mutate diff hunk without a source repository', vim.log.levels.ERROR) - return false - end - - local patch, patch_err = opts.patch, nil - if not patch then - patch, patch_err = M.patch_for_hunk(hunk) - end - if not patch then - notify(patch_err or 'cannot build hunk patch', vim.log.levels.ERROR) - return false - end - - local ok, err = apply(repo_root, patch, operation, { recount = opts.recount }) - if not ok then - notify('failed to ' .. operation .. ' diff hunk: ' .. err, vim.log.levels.ERROR) - return false - end - - return true -end - ----@param bufnr integer ----@return boolean -function M.put_hunk(bufnr) - local hunk, spec, err = current_action_context(bufnr) - if err then - notify(err, vim.log.levels.WARN) - return false - end - if not hunk or not spec then - notify('cannot resolve diff hunk edge', vim.log.levels.WARN) - return false - end - - local patch_actions = diffspec.patch_actions(spec) - if patch_actions.can_put then - return mutate_hunk(bufnr, hunk, 'stage') - end - - if patch_actions.can_obtain then - notify('diff hunk is already in the index', vim.log.levels.WARN) - return false - end - - notify('cannot put read-only diff hunk', vim.log.levels.WARN) - return false -end - ----@param bufnr integer ----@return boolean -function M.obtain_hunk(bufnr) - local hunk, spec, err = current_action_context(bufnr) - if err then - notify(err, vim.log.levels.WARN) - return false - end - if not hunk or not spec then - notify('cannot resolve diff hunk edge', vim.log.levels.WARN) - return false - end - - local patch_actions = diffspec.patch_actions(spec) - if patch_actions.can_obtain then - return mutate_hunk(bufnr, hunk, 'unstage') - end - - if patch_actions.can_put then - notify('restoring worktree hunks is not supported', vim.log.levels.WARN) - return false - end - - notify('cannot obtain read-only diff hunk', vim.log.levels.WARN) - return false -end - ----@param bufnr integer ----@param range_start integer ----@param range_finish integer ----@return diffs.DiffHunk?, diffs.DiffSpec?, string? -local function range_context_or_notify(bufnr, range_start, range_finish) - local hunk, spec, err = range_action_context(bufnr, range_start, range_finish) - if err then - notify(err, vim.log.levels.WARN) - return nil, nil, err - end - if not hunk or not spec then - local fallback = 'cannot resolve diff hunk edge' - notify(fallback, vim.log.levels.WARN) - return nil, nil, fallback - end - - return hunk, spec, nil -end - ----@param hunk diffs.DiffHunk ----@param range_start integer ----@param range_finish integer ----@param target "left"|"right" ----@return string? -local function range_patch_or_notify(hunk, range_start, range_finish, target) - local patch, patch_err = M.patch_for_range(hunk, range_start, range_finish, { - target = target, - }) - if not patch then - notify(patch_err or 'cannot build hunk patch', vim.log.levels.WARN) - return nil - end - - return patch -end - ----@param bufnr integer ----@param range_start integer ----@param range_finish integer ----@return boolean -function M.put_range(bufnr, range_start, range_finish) - local hunk, spec = range_context_or_notify(bufnr, range_start, range_finish) - if not hunk or not spec then - return false - end - - local patch_actions = diffspec.patch_actions(spec) - if patch_actions.can_put then - local patch = range_patch_or_notify(hunk, range_start, range_finish, 'left') - if not patch then - return false - end - return mutate_hunk(bufnr, hunk, 'stage', { - patch = patch, - recount = true, - }) - end - - if patch_actions.can_obtain then - notify('diff hunk is already in the index', vim.log.levels.WARN) - return false - end - - notify('cannot put read-only diff hunk', vim.log.levels.WARN) - return false -end - ----@param bufnr integer ----@param range_start integer ----@param range_finish integer ----@return boolean -function M.obtain_range(bufnr, range_start, range_finish) - local hunk, spec = range_context_or_notify(bufnr, range_start, range_finish) - if not hunk or not spec then - return false - end - - local patch_actions = diffspec.patch_actions(spec) - if patch_actions.can_obtain then - local patch = range_patch_or_notify(hunk, range_start, range_finish, 'right') - if not patch then - return false - end - return mutate_hunk(bufnr, hunk, 'unstage', { - patch = patch, - recount = true, - }) - end - - if patch_actions.can_put then - notify('restoring worktree hunks is not supported', vim.log.levels.WARN) - return false - end - - notify('cannot obtain read-only diff hunk', vim.log.levels.WARN) - return false -end - -return M diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua deleted file mode 100644 index d114c974..00000000 --- a/lua/diffs/commands.lua +++ /dev/null @@ -1,2460 +0,0 @@ -local M = {} - -local actions = require('diffs.actions') -local content = require('diffs.content') -local diff_parser = require('diffs.diffargs') -local diffopt = require('diffs.diffopt') -local diffspec = require('diffs.spec') -local difftastic = require('diffs.difftastic') -local generated = require('diffs.generated') -local git = require('diffs.git') -local hunk_model = require('diffs.hunks') -local lists = require('diffs.lists') -local log = require('diffs.log') -local rails = require('diffs.rails') -local render = require('diffs.render') -local review = require('diffs.review') -local runtime = require('diffs.runtime') -local split = require('diffs.split') - -local dbg = log.dbg -local notify = log.notify -local review_split_group = vim.api.nvim_create_augroup('diffs_review_split', { clear = false }) - ---- Paint difftastic structural intra spans onto a generated unified buffer and ---- emit the formatting-only notice when difft saw no structural change. ----@param diff_buf integer ----@param diff_lines string[] ----@param diff_spec diffs.DiffSpec? ----@param lhs table ----@param rhs table -local function paint_difft_unified(diff_buf, diff_lines, diff_spec, lhs, rhs) - difftastic.apply_unified( - diff_buf, - lhs, - rhs, - diff_lines, - diff_spec, - rails.width_for_buffer(diff_buf) - ) - if not difftastic.has_changes(lhs, rhs) then - notify('difftastic: no structural changes (formatting only)', vim.log.levels.INFO) - end -end - ---- Apply difftastic to a generated unified buffer built from two content ---- arrays. No-op when difftastic is disabled/unavailable or content is missing. ----@param diff_buf integer ----@param diff_lines string[] ----@param diff_spec diffs.DiffSpec? ----@param old_lines string[]? ----@param new_lines string[]? ----@param relpath string -local function apply_difft_unified(diff_buf, diff_lines, diff_spec, old_lines, new_lines, relpath) - if not difftastic.available() or not old_lines or not new_lines then - return - end - local lhs, rhs = difftastic.span_maps_for_content(old_lines, new_lines, relpath) - if lhs and rhs then - paint_difft_unified(diff_buf, diff_lines, diff_spec, lhs, rhs) - end -end - ----@class diffs.HunkKeymap ----@field mode string ----@field lhs string ----@field callback function - ----@type table -local hunk_keymaps = {} ----@type table -local hunk_keymap_autocmds = {} - ----@alias diffs.ReviewMapLayout "unified"|"stacked" - ----@class diffs.ReviewSplitState ----@field left_buf integer ----@field right_buf integer ----@field left_win integer ----@field right_win integer ----@field review diffs.ReviewSpec ----@field repo_root string ----@field display string ----@field review_lines string[] ----@field list_opts table? ----@field map_layout diffs.ReviewMapLayout ----@field selected_file string ----@field selected_key string? ----@field selected_diff_spec diffs.DiffSpec ----@field autocmds integer[] - ----@type table -local review_split_states = {} - ----@param bufnr integer ----@param mode string ----@param lhs string ----@return table? -local function get_buffer_keymap(bufnr, mode, lhs) - for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, mode)) do - if keymap.lhs == lhs then - return keymap - end - end - return nil -end - ----@param bufnr integer -local function clear_hunk_keymaps(bufnr) - local registered = hunk_keymaps[bufnr] - if not registered then - return - end - for _, keymap in ipairs(registered) do - local current = get_buffer_keymap(bufnr, keymap.mode, keymap.lhs) - if current and current.callback == keymap.callback then - pcall(vim.keymap.del, keymap.mode, keymap.lhs, { buffer = bufnr }) - end - end - hunk_keymaps[bufnr] = nil -end - ----@param bufnr integer -local function ensure_hunk_keymap_cleanup(bufnr) - if hunk_keymap_autocmds[bufnr] then - return - end - - hunk_keymap_autocmds[bufnr] = vim.api.nvim_create_autocmd('BufWipeout', { - buffer = bufnr, - once = true, - callback = function() - hunk_keymaps[bufnr] = nil - hunk_keymap_autocmds[bufnr] = nil - end, - }) -end - ----@param bufnr integer ----@param mode string ----@param lhs string ----@param callback function ----@param desc string -local function set_hunk_keymap(bufnr, mode, lhs, callback, desc) - if get_buffer_keymap(bufnr, mode, lhs) then - return - end - - vim.keymap.set(mode, lhs, callback, { buffer = bufnr, desc = desc }) - hunk_keymaps[bufnr] = hunk_keymaps[bufnr] or {} - hunk_keymaps[bufnr][#hunk_keymaps[bufnr] + 1] = { - mode = mode, - lhs = lhs, - callback = callback, - } -end - ----@param bufnr integer ----@return integer, integer -local function visual_range(bufnr) - local start_line = vim.api.nvim_buf_get_mark(bufnr, '<')[1] - local finish_line = vim.api.nvim_buf_get_mark(bufnr, '>')[1] - return math.min(start_line, finish_line), math.max(start_line, finish_line) -end - ----@return integer? -function M.find_diffs_window() - local tabpage = vim.api.nvim_get_current_tabpage() - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do - if vim.api.nvim_win_is_valid(win) then - local buf = vim.api.nvim_win_get_buf(win) - local name = vim.api.nvim_buf_get_name(buf) - if name:match('^diffs://') then - return win - end - end - end - return nil -end - ----@param bufnr integer ----@return integer? -local function first_window_for_buffer(bufnr) - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then - return win - end - end - return nil -end - ----@param bufnr integer -function M.setup_diff_buf(bufnr) - if not get_buffer_keymap(bufnr, 'n', '') then - vim.keymap.set( - 'n', - '', - 'nohlsearchdiffupdate(diffs-refresh)', - { buffer = bufnr, silent = true, desc = 'Refresh diff and redraw' } - ) - end - if not get_buffer_keymap(bufnr, 'n', 'q') then - vim.keymap.set('n', 'q', 'close', { buffer = bufnr }) - end - local has_hunks, parsed_hunks = generated.raw_hunks(bufnr) - if not has_hunks then - clear_hunk_keymaps(bufnr) - return - end - - clear_hunk_keymaps(bufnr) - local can_put = false - local can_obtain = false - for _, hunk in ipairs(type(parsed_hunks) == 'table' and parsed_hunks or {}) do - can_put = can_put or hunk.can_put == true - can_obtain = can_obtain or hunk.can_obtain == true - end - - set_hunk_keymap(bufnr, 'n', ']c', function() - hunk_model.goto_next(bufnr) - end, 'Next diff hunk') - set_hunk_keymap(bufnr, 'n', '[c', function() - hunk_model.goto_prev(bufnr) - end, 'Previous diff hunk') - set_hunk_keymap(bufnr, 'n', '', function() - hunk_model.open_source(bufnr) - end, 'Open source file') - if can_obtain then - set_hunk_keymap(bufnr, 'n', 'do', function() - if actions.obtain_hunk(bufnr) then - M.read_buffer(bufnr) - end - end, 'Unstage diff hunk') - set_hunk_keymap(bufnr, 'x', 'do', function() - local range_start, range_finish = visual_range(bufnr) - if actions.obtain_range(bufnr, range_start, range_finish) then - M.read_buffer(bufnr) - end - end, 'Unstage selected diff lines') - end - if can_put then - set_hunk_keymap(bufnr, 'n', 'dp', function() - if actions.put_hunk(bufnr) then - M.read_buffer(bufnr) - end - end, 'Stage diff hunk') - set_hunk_keymap(bufnr, 'x', 'dp', function() - local range_start, range_finish = visual_range(bufnr) - if actions.put_range(bufnr, range_start, range_finish) then - M.read_buffer(bufnr) - end - end, 'Stage selected diff lines') - end - - ensure_hunk_keymap_cleanup(bufnr) -end - ----@param diff_lines string[] ----@param hunk_position { hunk_header: string, offset: integer } ----@return integer? -function M.find_hunk_line(diff_lines, hunk_position) - for i, line in ipairs(diff_lines) do - if line == hunk_position.hunk_header then - return i + hunk_position.offset - end - end - return nil -end - ----@param 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 diff_spec diffs.DiffSpec ----@return string -local function diff_buffer_label(diff_spec) - diff_spec = diffspec.new(diff_spec) - local left = diff_spec.left - local right = diff_spec.right - - if - left.kind == diffspec.endpoint_kind.index and right.kind == diffspec.endpoint_kind.worktree - then - return 'unstaged' - end - - if - left.kind == diffspec.endpoint_kind.tree - and left.rev == 'HEAD' - and right.kind == diffspec.endpoint_kind.index - then - return 'staged' - end - - if left.kind == diffspec.endpoint_kind.tree and right.kind == diffspec.endpoint_kind.worktree then - return left.rev - end - - if - left.kind == diffspec.endpoint_kind.stage and right.kind == diffspec.endpoint_kind.worktree - then - return 'stage' .. left.stage - end - - return diffspec.label(diff_spec) -end - ----@param bufnr integer ----@param diff_lines string[] ----@param diff_spec diffs.DiffSpec -local function set_diff_hunks_var(bufnr, diff_lines, diff_spec) - generated.set_hunks_from_lines(bufnr, diff_lines, diff_spec) -end - ----@param bufnr integer ----@param info diffs.RailInfo? -local function set_diff_rails_var(bufnr, info) - if info then - generated.set_var(bufnr, 'diffs_rail_width', info.prefix_width) - if info.style == 'single' or info.style == 'dual' then - generated.set_var(bufnr, 'diffs_rail_style', info.style) - else - generated.del_var(bufnr, 'diffs_rail_style') - end - else - generated.del_var(bufnr, 'diffs_rail_width') - generated.del_var(bufnr, 'diffs_rail_style') - end -end - ----@param bufnr integer -local function clear_diff_hunks_var(bufnr) - generated.clear_hunks(bufnr) -end - ----@param bufnr integer ----@return diffs.GeneratedBufferSource?, string? -local function get_source_var(bufnr) - return generated.source(bufnr) -end - ----@param bufnr integer -local function set_generated_diff_buffer_options(bufnr) - vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr }) - vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = bufnr }) - vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr }) - vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) -end - ----@param bufnr integer -local function set_generated_diff_buffer_filetype(bufnr) - vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr }) -end - ----@param win integer -local function clear_generated_diff_window_bindings(win) - if not vim.api.nvim_win_is_valid(win) then - return - end - - vim.api.nvim_set_option_value('scrollbind', false, { win = win }) - vim.api.nvim_set_option_value('cursorbind', false, { win = win }) -end - ----@class diffs.GeneratedDiffBufferOpts ----@field name string ----@field lines string[] ----@field repo_root? string ----@field diff_spec? diffs.DiffSpec ----@field source? diffs.GeneratedBufferSource ----@field vars? table ----@field rail_style? diffs.RailStyle - ----@param opts diffs.GeneratedDiffBufferOpts ----@return integer -local function create_generated_diff_buffer(opts) - local bufnr = vim.api.nvim_create_buf(false, true) - local display_lines, rail_info = rails.annotate(opts.lines, { - rail_style = opts.rail_style, - }) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, display_lines) - vim.api.nvim_buf_set_name(bufnr, opts.name) - set_diff_rails_var(bufnr, rail_info) - - if opts.diff_spec then - set_diff_hunks_var(bufnr, opts.lines, opts.diff_spec) - end - if opts.source then - generated.set_source(bufnr, opts.source) - end - for name, value in pairs(opts.vars or {}) do - if value ~= nil then - generated.set_var(bufnr, name, value) - end - end - - set_generated_diff_buffer_options(bufnr) - set_generated_diff_buffer_filetype(bufnr) - - return bufnr -end - ----@param bufnr integer ----@param vertical? boolean ----@param replace_win? integer ----@return boolean -local function show_generated_diff_buffer(bufnr, vertical, replace_win) - if replace_win and not vim.api.nvim_win_is_valid(replace_win) then - notify('replacement diff window is no longer valid', vim.log.levels.WARN) - return false - end - - local existing_win = replace_win or M.find_diffs_window() - if existing_win then - vim.api.nvim_set_current_win(existing_win) - split.release_pair_window_options(existing_win) - clear_generated_diff_window_bindings(existing_win) - vim.api.nvim_win_set_buf(existing_win, bufnr) - else - vim.cmd(vertical and 'vsplit' or 'split') - clear_generated_diff_window_bindings(vim.api.nvim_get_current_win()) - vim.api.nvim_win_set_buf(0, bufnr) - end - return true -end - ----@param bufnr integer ----@param diff_lines string[] ----@param diff_spec? diffs.DiffSpec ----@param opts? { rail_style?: diffs.RailStyle } -local function replace_generated_diff_buffer_lines(bufnr, diff_lines, diff_spec, opts) - opts = opts or {} - difftastic.clear_active(bufnr) - local display_lines, rail_info = rails.annotate(diff_lines, { - rail_style = opts.rail_style or rails.style_for_buffer(bufnr), - }) - vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr }) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, display_lines) - set_diff_rails_var(bufnr, rail_info) - - if diff_spec then - set_diff_hunks_var(bufnr, diff_lines, diff_spec) - else - clear_diff_hunks_var(bufnr) - end - - set_generated_diff_buffer_options(bufnr) - set_generated_diff_buffer_filetype(bufnr) - runtime.refresh(bufnr) -end - ----@param bufnr integer -local function attach_generated_diff_buffer(bufnr) - M.setup_diff_buf(bufnr) - - vim.schedule(function() - runtime.attach(bufnr) - end) -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 = render.unified_lines(old_lines, new_lines, filename, filename) - for _, dl in ipairs(diff_lines) do - table.insert(result, dl) - end - end - - return result -end - ----@class diffs.ReviewDepsOpts ----@field rail_style? diffs.RailStyle ----@field review_layout? diffs.ReviewMapLayout ----@field replace_win? integer - ----@param layout? string ----@return diffs.ReviewMapLayout -local function normalize_review_map_layout(layout) - return layout == 'stacked' and 'stacked' or 'unified' -end - ----@param rail_style? diffs.RailStyle ----@return diffs.ReviewMapLayout -local function review_layout_for_rail_style(rail_style) - return rail_style == 'single' and 'stacked' or 'unified' -end - ----@param bufnr integer ----@param display string ----@param layout diffs.ReviewMapLayout -local function setup_review_map_buffer(bufnr, display, layout) - generated.set_var(bufnr, 'diffs_review', { display = display, layout = layout }) - if not get_buffer_keymap(bufnr, 'n', 'gs') then - vim.keymap.set( - 'n', - 'gs', - '(diffs-review-toggle-layout)', - { buffer = bufnr, remap = true } - ) - end -end - ----@param opts? diffs.ReviewDepsOpts ----@return diffs.ReviewDeps -local function review_deps(opts) - opts = opts or {} - return { - create_generated_diff_buffer = create_generated_diff_buffer, - show_generated_diff_buffer = function(bufnr, vertical) - return show_generated_diff_buffer(bufnr, vertical, opts.replace_win) - end, - attach_generated_diff_buffer = attach_generated_diff_buffer, - replace_combined_diffs = replace_combined_diffs, - rail_style = opts.rail_style, - review_layout = opts.review_layout or review_layout_for_rail_style(opts.rail_style), - setup_review_map_buffer = setup_review_map_buffer, - } -end - ----@param repo_root string ----@param section "staged"|"unstaged" ----@return string[] -local function render_section_source(repo_root, section) - local cmd = { - 'git', - '-C', - repo_root, - 'diff', - '--no-ext-diff', - '--no-color', - '--src-prefix=a/', - '--dst-prefix=b/', - } - vim.list_extend(cmd, diffopt.git_flags()) - if section == 'staged' then - table.insert(cmd, '--cached') - end - - local diff_lines = vim.fn.systemlist(cmd) - if vim.v.shell_error ~= 0 then - diff_lines = {} - end - - return replace_combined_diffs(diff_lines, repo_root) -end - ----@param name string ----@param buffered boolean ----@return string -local function files_side_display(name, buffered) - return buffered and ('buffer:' .. name) or name -end - ----@param source diffs.GeneratedBufferSource ----@return diffs.ContentLines?, boolean -local function files_source_new_lines(source) - local bufnr = source.right_buf - if - bufnr - and vim.api.nvim_buf_is_loaded(bufnr) - and vim.api.nvim_buf_get_name(bufnr) == source.right_path - then - return content.from_buffer(bufnr), true - end - return git.get_working_content(source.right_path), false -end - ----@param source diffs.GeneratedBufferSource ----@return string[]?, diffs.DiffSpec?, string?, table?, diffs.ContentLines|string[]?, diffs.ContentLines|string[]? -local function render_source(source) - if source.kind == 'file' then - local diff_lines, read_err, old_lines, new_lines = render.file(source.spec, source.repo_root, { - worktree_buf = source.worktree_buf, - empty_on_missing = true, - }) - if not diff_lines then - return nil, nil, read_err, nil - end - return diff_lines, source.spec, diffspec.label(source.spec), nil, old_lines, new_lines - end - - if source.kind == 'files' then - local old_lines = git.get_working_content(source.left_path) - if not old_lines then - return nil, nil, source.left_name .. ': file not readable', nil - end - local new_lines, buffered = files_source_new_lines(source) - if not new_lines then - return nil, nil, source.right_name .. ': file not readable', nil - end - return render.unified_lines(old_lines, new_lines, source.left_name, source.right_name), - nil, - 'files:' .. source.left_name .. ' -> ' .. files_side_display(source.right_name, buffered), - nil, - old_lines, - new_lines - end - - if source.kind == 'file_pair' then - local abs_path = source.repo_root .. '/' .. source.path - local old_abs_path = source.repo_root .. '/' .. source.old_path - local old_lines, new_lines - if source.edge == 'staged' then - old_lines = git.get_file_content('HEAD', old_abs_path) or {} - new_lines = git.get_index_content(abs_path) or {} - else - old_lines = git.get_index_content(old_abs_path) - if not old_lines then - old_lines = git.get_file_content('HEAD', old_abs_path) or {} - end - new_lines = git.get_working_content(abs_path) or {} - end - return render.unified_lines(old_lines, new_lines, source.old_path, source.path), - nil, - source.edge .. ':' .. source.path, - nil, - old_lines, - new_lines - end - - if source.kind == 'section' then - return render_section_source(source.repo_root, source.section), - nil, - source.section .. ':all', - nil - end - - if source.kind == 'review' then - local review_lines, review_err, list_opts = review.reload_source(source, review_deps()) - if not review_lines then - return nil, nil, review_err, nil - end - return review_lines, nil, 'review', list_opts - end - - local abs_path = source.repo_root .. '/' .. source.path - local old_lines = git.get_file_content(':2', abs_path) or {} - local new_lines = git.get_file_content(':3', abs_path) or {} - return render.unified_lines(old_lines, new_lines, source.path, source.path), - nil, - 'unmerged:' .. source.path, - nil -end - ----@param diff_label string ----@param rel_path string ----@param old_rel_path string ----@return string -local function file_pair_label(diff_label, rel_path, old_rel_path) - return diff_label .. ' rename/copy ' .. old_rel_path .. ' -> ' .. rel_path -end - ----@param spec? diffs.ReviewSpec ----@param opts? diffs.ReviewDepsOpts ----@return integer? -function M.review(spec, opts) - return review.open(spec, review_deps(opts)) -end - -local layout_options = { - '++layout=unified', - '++layout=stacked', - '++layout=split', -} - -local untracked_options = { - '++nountracked', -} - ----@param layout? "unified"|"stacked"|"split" ----@return diffs.RailStyle -local function rail_style_for_layout(layout) - if layout == 'stacked' then - return 'single' - end - return 'dual' -end - -local function warn_vertical_split_ignored() - notify( - '++layout=split ignores the :vertical modifier; the split layout manages its own windows', - vim.log.levels.WARN - ) -end - --- Always-available object literals offered in completion. Merge-stage objects --- (`:1:%`/`:2:%`/`:3:%`) are valid only during a conflict, so they are accepted --- by the parser but not advertised here. -local diff_objects = { - ':', - ':%', - ':0:%', - '@:%', -} - -local command_names = { - Diff = true, -} - ----@param value string ----@param prefix string ----@return boolean -local function starts_with(value, prefix) - return value:find(prefix, 1, true) == 1 -end - ----@param candidates string[] ----@param arglead string ----@return string[] -local function prefix_matches(candidates, arglead) - local matches = {} - for _, candidate in ipairs(candidates) do - if starts_with(candidate, arglead) then - matches[#matches + 1] = candidate - end - end - return matches -end - ----@param arglead string ----@return string[] -local function complete_diff_object(arglead) - local matches = prefix_matches(diff_objects, arglead) - for _, ref in ipairs(review.complete(arglead)) do - if not ref:find('..', 1, true) then - matches[#matches + 1] = ref - end - end - return matches -end - ----@param arglead string ----@param cmdline? string ----@param cursorpos? integer ----@return string[] # completed argument tokens before the cursor, excluding the command name -local function command_arg_tokens(arglead, cmdline, cursorpos) - local before = cmdline or '' - if type(cursorpos) == 'number' and cursorpos > 0 then - before = before:sub(1, cursorpos) - end - if arglead ~= '' and before:sub(-#arglead) == arglead then - before = before:sub(1, #before - #arglead) - end - - local tokens = vim.split(vim.trim(before), '%s+', { trimempty = true }) - local command_index = 0 - for i = #tokens, 1, -1 do - if command_names[tokens[i]] then - command_index = i - break - end - end - if command_index == 0 and #tokens > 0 then - command_index = 1 - end - - local args = {} - for i = command_index + 1, #tokens do - args[#args + 1] = tokens[i] - end - return args -end - ----@param args string[] ----@param from? integer # first argument index to scan (defaults to 1) ----@return { has_layout: boolean, has_untracked: boolean, has_value: boolean } -local function args_context(args, from) - local has_layout = false - local has_untracked = false - local has_value = false - for i = from or 1, #args do - local token = args[i] - if token:match('^%+%+layout=') then - has_layout = true - elseif token == '++nountracked' then - has_untracked = true - elseif not token:match('^%+%+') then - has_value = true - end - end - return { - has_layout = has_layout, - has_untracked = has_untracked, - has_value = has_value, - } -end - ----@param arglead string ----@param cmdline? string ----@param cursorpos? integer ----@return { has_layout: boolean, has_untracked: boolean, has_value: boolean } -local function completion_context(arglead, cmdline, cursorpos) - return args_context(command_arg_tokens(arglead, cmdline, cursorpos)) -end - ----@param arglead string ----@param cmdline? string ----@param cursorpos? integer ----@return string[] -local function complete_diff_args(arglead, cmdline, cursorpos) - local context = completion_context(arglead, cmdline, cursorpos) - if context.has_value then - return {} - end - if arglead:match('^%+%+') then - if context.has_layout then - return {} - end - return prefix_matches(layout_options, arglead) - end - local matches = {} - if arglead == '' and not context.has_layout then - vim.list_extend(matches, layout_options) - end - vim.list_extend(matches, complete_diff_object(arglead)) - return matches -end - ----@param arglead string ----@param context { has_layout: boolean, has_untracked: boolean, has_value: boolean } ----@return string[] -local function complete_review_args(arglead, context) - if context.has_value then - return review.complete(arglead) - end - if arglead:match('^%+%+') then - local matches = {} - if not context.has_layout then - vim.list_extend(matches, prefix_matches(layout_options, arglead)) - end - if not context.has_untracked then - vim.list_extend(matches, prefix_matches(untracked_options, arglead)) - end - vim.list_extend(matches, review.complete(arglead)) - return matches - end - local matches = {} - if arglead == '' and not context.has_layout then - vim.list_extend(matches, layout_options) - end - if arglead == '' and not context.has_untracked then - vim.list_extend(matches, untracked_options) - end - vim.list_extend(matches, review.complete(arglead)) - return matches -end - -local files_layout_options = { - '++layout=unified', - '++layout=stacked', -} - ----@param arglead string ----@return string[] -local function complete_files_args(arglead) - if arglead:match('^%+%+') then - return prefix_matches(files_layout_options, arglead) - end - return vim.fn.getcompletion(arglead, 'file') -end - ----@param arglead string ----@param cmdline? string ----@param cursorpos? integer ----@return string[] -local function complete_diff_command(arglead, cmdline, cursorpos) - local args = command_arg_tokens(arglead, cmdline, cursorpos) - if args[1] == 'review' then - return complete_review_args(arglead, args_context(args, 2)) - end - if args[1] == 'files' then - return complete_files_args(arglead) - end - local matches = {} - if #args == 0 and not arglead:match('^%+%+') then - if starts_with('review', arglead) then - matches[#matches + 1] = 'review' - end - if starts_with('files', arglead) then - matches[#matches + 1] = 'files' - end - end - vim.list_extend(matches, complete_diff_args(arglead, cmdline, cursorpos)) - return matches -end - ----@class diffs.OpenReviewSplitOpts ----@field selection? diffs.GeneratedFileSelection ----@field source_position? diffs.SourcePosition ----@field replace_win? integer ----@field map_layout? diffs.ReviewMapLayout - ----@type fun(spec?: diffs.ReviewSpec, opts?: diffs.OpenReviewSplitOpts): integer? -local open_review_split - ----@param args? string ----@param vertical? boolean ----@param opts? { warn_vertical_split?: boolean } ----@return integer? -function M.review_command(args, vertical, opts) - opts = opts or {} - local parsed = review.parse_command_args(args) - - if parsed.layout == 'split' then - if opts.warn_vertical_split then - warn_vertical_split_ignored() - end - return open_review_split(parsed.spec) - end - - parsed.spec.vertical = vertical or false - local bufnr = M.review(parsed.spec, { - rail_style = rail_style_for_layout(parsed.layout), - review_layout = normalize_review_map_layout(parsed.layout), - }) - return bufnr -end - ----@class diffs.ReviewOpts ----@field layout? "unified"|"stacked"|"split" ----@field replace_win? integer - ---- Open a review from Lua, for a caller that already knows what it wants ---- reviewed. ---- ---- `replace_win` is why this exists as well as `:Diff review`: a command has ---- nowhere to say "this window", so with no review open it splits. An embedder ---- handing a review off from its own buffer already has a window and wants that ---- one, not a new one and not the rest of the tab closed to compensate. ----@param spec? diffs.ReviewSpec ----@param opts? diffs.ReviewOpts ----@return integer? -function M.open_review(spec, opts) - opts = opts or {} - if opts.layout == 'split' then - return open_review_split(spec, { replace_win = opts.replace_win }) - end - return M.review(spec, { - rail_style = rail_style_for_layout(opts.layout), - review_layout = normalize_review_map_layout(opts.layout), - replace_win = opts.replace_win, - }) -end - ---- Primary `:Diff` command handler. Routes `:Diff review ...` to the review ---- surface and everything else to the current-file diff, threading the ---- `:vertical` modifier through to generated layouts. ----@param args? string ----@param vertical? boolean ----@return integer? -function M.diff_command(args, vertical) - vertical = vertical or false - if args then - local sub, remainder = args:match('^%s*(%S+)%s*(.*)$') - if sub == 'review' then - return M.review_command( - remainder ~= '' and remainder or nil, - vertical, - { warn_vertical_split = vertical } - ) - end - if sub == 'files' then - return M.diff_files_command(remainder ~= '' and remainder or nil, vertical) - end - end - return M.diff(args, vertical, { warn_vertical_split = vertical }) -end - ----@param args? string ----@param vertical? boolean ----@return integer? -function M.diff_files_command(args, vertical) - local parsed, err = diff_parser.parse_files(args) - if not parsed then - notify(err, vim.log.levels.ERROR) - return nil - end - - return M.diff_files(parsed.left, parsed.right, { - layout = parsed.layout, - vertical = vertical, - }) -end - ----@class diffs.DiffFilesViewOpts ----@field layout "unified"|"stacked"|"split" ----@field vertical? boolean - ----@param path string ----@return string, string -local function files_side_names(path) - local abs = vim.fn.fnamemodify(vim.fn.expand(path), ':p') - local name = vim.fn.fnamemodify(abs, ':~:.') - return abs, name ~= '' and name or abs -end - ----@param left string? ----@param right string? ----@param opts? diffs.DiffFilesViewOpts ----@return integer? -function M.diff_files(left, right, opts) - opts = opts or {} - - local current_buf = vim.api.nvim_get_current_buf() - local current_name = vim.api.nvim_buf_get_name(current_buf) - local right_buf = right == nil and current_buf or nil - if (left == nil or right == nil) and current_name == '' then - notify('cannot diff unnamed buffer', vim.log.levels.ERROR) - return nil - end - - local left_abs, left_name = files_side_names(left or current_name) - local right_abs, right_name = files_side_names(right or current_name) - local right_display = files_side_display(right_name, right_buf ~= nil) - - for _, side in ipairs({ - { abs = left_abs, name = left_name, buffered = false }, - { abs = right_abs, name = right_name, buffered = right_buf ~= nil }, - }) do - if vim.fn.isdirectory(side.abs) == 1 then - notify(side.name .. ' is a directory; :Diff files compares two files', vim.log.levels.ERROR) - return nil - end - if not side.buffered and vim.fn.filereadable(side.abs) ~= 1 then - notify(side.name .. ': file not readable', vim.log.levels.ERROR) - return nil - end - end - - local old_lines = git.get_working_content(left_abs) or {} - local new_lines = right_buf and content.from_buffer(right_buf) - or git.get_working_content(right_abs) - or {} - if render.has_binary_lines(old_lines) or render.has_binary_lines(new_lines) then - notify('diff does not support binary files', vim.log.levels.ERROR) - return nil - end - - local diff_lines = render.unified_lines(old_lines, new_lines, left_name, right_name) - if #diff_lines == 0 then - notify('no changes between ' .. left_name .. ' and ' .. right_display, vim.log.levels.INFO) - return nil - end - - local diff_buf = create_generated_diff_buffer({ - name = 'diffs://files:' .. left_name .. ' -> ' .. right_display, - lines = diff_lines, - source = generated.files_source(left_abs, right_abs, left_name, right_name, right_buf), - rail_style = rail_style_for_layout(opts.layout), - }) - show_generated_diff_buffer(diff_buf, opts.vertical) - lists.set_for_unified_buffer(diff_buf, diff_lines, { - title = 'diff: files ' .. left_name .. ' -> ' .. right_display, - }) - attach_generated_diff_buffer(diff_buf) - - if right_buf then - apply_difft_unified(diff_buf, diff_lines, nil, old_lines, new_lines, right_name) - elseif difftastic.available() then - local lhs, rhs = difftastic.span_maps_for_paths(left_abs, right_abs) - if lhs and rhs then - paint_difft_unified(diff_buf, diff_lines, nil, lhs, rhs) - end - end - - dbg('opened files diff buffer %d (%s -> %s)', diff_buf, left_name, right_display) - return diff_buf -end - ----@param repo_root string ----@param path string ----@return string? -local function filetype_for_path(repo_root, path) - local filepath = repo_root .. '/' .. path - local existing_buf = vim.fn.bufnr(filepath) - if existing_buf ~= -1 then - local ft = vim.api.nvim_get_option_value('filetype', { buf = existing_buf }) - if ft and ft ~= '' then - return ft - end - end - - local ft = vim.filetype.match({ filename = path }) - if ft and ft ~= '' then - return ft - end - return nil -end - ----@param win integer? -local function restore_window(win) - if type(win) == 'number' and vim.api.nvim_win_is_valid(win) then - vim.api.nvim_set_current_win(win) - end -end - ----@class diffs.ReviewSplitOpts ----@field bufnr? integer ----@field lnum? integer ----@field item? table - ----@class diffs.ReviewSplitContext ----@field review_buf integer ----@field replace_win integer ----@field selected diffs.GeneratedFileSelection ----@field source_position? diffs.SourcePosition ----@field repo_root string ----@field map_layout diffs.ReviewMapLayout - ----@param opts? diffs.ReviewSplitOpts ----@return diffs.ReviewSplitContext?, string?, integer?, integer? -local function review_split_context(opts) - opts = opts or {} - - ---@type diffs.GeneratedFileSelectionOpts - local selection_opts = { - bufnr = opts.bufnr, - lnum = opts.lnum, - item = opts.item, - } - local selected, select_err = lists.selected_generated_file(selection_opts) - if not selected then - return nil, select_err or 'no review file selected', vim.log.levels.WARN, nil - end - - local review_buf = selected.bufnr - if not vim.api.nvim_buf_is_valid(review_buf) then - return nil, 'selected review buffer is no longer valid', vim.log.levels.WARN, review_buf - end - - local source, source_err = get_source_var(review_buf) - if source_err then - return nil, - 'invalid diffs_source metadata: ' .. tostring(source_err), - vim.log.levels.WARN, - review_buf - end - if not source or source.kind ~= 'review' then - return nil, 'selected file is not from a review buffer', vim.log.levels.WARN, nil - end - local marker = vim.b[review_buf].diffs_review - local map_layout = normalize_review_map_layout(type(marker) == 'table' and marker.layout or nil) - - local review_win = first_window_for_buffer(review_buf) - if not review_win then - return nil, - 'selected review buffer is not visible; open the review buffer before splitting it', - vim.log.levels.WARN, - review_buf - end - - local selected_line = selected.lnum - and hunk_model.line_at(lists.generated_hunks(review_buf), selected.lnum) - - return { - review_buf = review_buf, - replace_win = review_win, - selected = selected, - source_position = hunk_model.source_position_for(selected_line), - repo_root = source.repo_root, - map_layout = map_layout, - }, - nil, - nil, - review_buf -end - ----@param review_spec diffs.ReviewSpec ----@param repo_root string ----@param selected diffs.GeneratedFileSelection ----@return diffs.DiffSpec?, diffs.NormalizedReview?, string? -local function selected_review_diff_spec(review_spec, repo_root, selected) - return review.diff_spec_for_file(review_spec, repo_root, selected.file, selected) -end - ----@param review_spec diffs.ReviewSpec ----@param repo_root string ----@param selected diffs.GeneratedFileSelection ----@return diffs.DiffSpec?, string[]?, string?, integer? -local function render_review_file_selection(review_spec, repo_root, selected) - local diff_spec, _, spec_err = selected_review_diff_spec(review_spec, repo_root, selected) - if not diff_spec then - return nil, nil, spec_err or 'cannot build review split diff spec', vim.log.levels.ERROR - end - - local diff_lines, render_err = render.file(diff_spec, repo_root, { empty_on_missing = true }) - if not diff_lines then - return nil, nil, render_err or 'cannot render review split file', vim.log.levels.ERROR - end - if #diff_lines == 0 then - return nil, nil, 'no changes for ' .. diffspec.label(diff_spec), vim.log.levels.INFO - end - return diff_spec, diff_lines, nil, nil -end - ----@param list_opts table? ----@return diffs.GeneratedListOptions -local function review_generated_list_opts(list_opts) - return { - metadata_for_line = list_opts and list_opts.metadata_for_line, - sections = list_opts and list_opts.sections, - store_hunks = list_opts and list_opts.store_hunks, - is_skipped = list_opts and list_opts.is_skipped, - } -end - ----@param review_spec diffs.ReviewSpec ----@param repo_root string ----@param review_lines string[] ----@param list_opts table? ----@return diffs.GeneratedFileSelection?, diffs.DiffSpec?, string[]?, string?, integer?, diffs.GeneratedFileSelection[] -local function first_renderable_review_file(review_spec, repo_root, review_lines, list_opts) - local last_err - local last_level - local skipped = {} - for _, selected in - ipairs(lists.generated_files(review_lines, review_generated_list_opts(list_opts))) - do - if selected.skipped then - skipped[#skipped + 1] = selected - else - local diff_spec, diff_lines, err, level = - render_review_file_selection(review_spec, repo_root, selected) - if diff_spec and diff_lines then - return selected, diff_spec, diff_lines, nil, nil, skipped - end - skipped[#skipped + 1] = selected - last_err = err - last_level = level - end - end - - return nil, - nil, - nil, - last_err or 'no renderable review file selected', - last_level or vim.log.levels.INFO, - skipped -end - ----@param state diffs.ReviewSplitState -local function sync_review_quickfix(state) - local qf = vim.fn.getqflist({ items = 0, idx = 0 }) - for index, item in ipairs(qf.items) do - local selected = lists.selected_generated_file({ item = item }) - if selected and (selected.key or selected.file) == state.selected_key then - local source = generated.source(selected.bufnr) - if - source - and source.kind == 'review' - and source.repo_root == state.repo_root - and vim.deep_equal(source.review, state.review) - then - if qf.idx ~= index then - vim.fn.setqflist({}, 'a', { idx = index }) - end - return - end - end - end -end - ----@param state diffs.ReviewSplitState -local function clear_review_split_autocmds(state) - for _, id in ipairs(state.autocmds or {}) do - pcall(vim.api.nvim_del_autocmd, id) - end - state.autocmds = {} -end - ----@param state diffs.ReviewSplitState -local function forget_review_split(state) - clear_review_split_autocmds(state) - review_split_states[state.left_buf] = nil - review_split_states[state.right_buf] = nil -end - ----@param state diffs.ReviewSplitState -local function setup_review_split_panes(state) - for _, buf in ipairs({ state.left_buf, state.right_buf }) do - if vim.api.nvim_buf_is_valid(buf) then - vim.b[buf].diffs_review = { display = state.display, layout = 'split' } - for _, key in ipairs({ ']f', ']q' }) do - if not get_buffer_keymap(buf, 'n', key) then - vim.keymap.set('n', key, '(diffs-review-next-file)', { buffer = buf, remap = true }) - end - end - for _, key in ipairs({ '[f', '[q' }) do - if not get_buffer_keymap(buf, 'n', key) then - vim.keymap.set('n', key, '(diffs-review-prev-file)', { buffer = buf, remap = true }) - end - end - if not get_buffer_keymap(buf, 'n', 'gO') then - vim.keymap.set( - 'n', - 'gO', - '(diffs-review-select-file)', - { buffer = buf, remap = true } - ) - end - if not get_buffer_keymap(buf, 'n', 'gs') then - vim.keymap.set( - 'n', - 'gs', - '(diffs-review-toggle-layout)', - { buffer = buf, remap = true } - ) - end - end - end -end - ----@param state diffs.ReviewSplitState -local function attach_review_split_autocmds(state) - clear_review_split_autocmds(state) - setup_review_split_panes(state) - for _, buf in ipairs({ state.left_buf, state.right_buf }) do - state.autocmds[#state.autocmds + 1] = vim.api.nvim_create_autocmd('BufWipeout', { - group = review_split_group, - buffer = buf, - once = true, - callback = function() - local current = review_split_states[buf] - if current then - forget_review_split(current) - end - end, - }) - end -end - ----@param state diffs.ReviewSplitState ----@param selected diffs.GeneratedFileSelection ----@param opts? { quiet?: boolean } ----@return boolean, boolean -local function switch_review_split_file(state, selected, opts) - opts = opts or {} - local diff_spec, diff_lines, err, level = - render_review_file_selection(state.review, state.repo_root, selected) - if not diff_spec or not diff_lines then - if not opts.quiet then - notify(err or 'cannot render review split file', level or vim.log.levels.WARN) - end - return false, true - end - - local left_win = first_window_for_buffer(state.left_buf) - local right_win = first_window_for_buffer(state.right_buf) - if not left_win or not right_win then - return false, false - end - - local opened, split_err = split.open({ - spec = diff_spec, - repo_root = state.repo_root, - filetype = filetype_for_path(state.repo_root, selected.file), - diff_lines = diff_lines, - quickfix = false, - title = 'review: ' .. state.display, - hunk_index = selected.hunk_index or 1, - reuse_wins = { left = left_win, right = right_win }, - }) - if not opened then - notify(split_err or 'cannot open review split', vim.log.levels.ERROR) - return false, false - end - - forget_review_split(state) - state.left_buf = opened.left_buf - state.right_buf = opened.right_buf - state.left_win = opened.left_win - state.right_win = opened.right_win - state.selected_file = selected.file - state.selected_key = selected.key or selected.file - state.selected_diff_spec = diff_spec - review_split_states[state.left_buf] = state - review_split_states[state.right_buf] = state - attach_review_split_autocmds(state) - sync_review_quickfix(state) - return true, false -end - ----@param state diffs.ReviewSplitState ----@return diffs.GeneratedFileSelection[] -local function review_split_files(state) - return lists.generated_files(state.review_lines, review_generated_list_opts(state.list_opts)) -end - ----@param files diffs.GeneratedFileSelection[] ----@param state diffs.ReviewSplitState ----@return integer -local function review_index_of_current(files, state) - for i, selection in ipairs(files) do - if (selection.key or selection.file) == state.selected_key then - return i - end - end - return 1 -end - ----@param selection diffs.GeneratedFileSelection ----@param index integer ----@param count integer ----@return string -local function review_file_message(selection, index, count) - local label = selection.section_label - local entry = (type(label) == 'string' and label ~= '') - and ('[%s] %s'):format(label, selection.file) - or selection.file - return ('(%d of %d): %s'):format(index, count, entry) -end - ----@param selection diffs.GeneratedFileSelection ----@param index integer ----@param count integer -local function announce_review_file(selection, index, count) - vim.api.nvim_echo({ { '[diffs]: ' .. review_file_message(selection, index, count) } }, false, {}) -end - ----@param skipped diffs.GeneratedFileSelection[] ----@return string? -local function skipped_review_files_message(skipped) - if #skipped == 0 then - return nil - end - local paths = {} - for _, selection in ipairs(skipped) do - paths[#paths + 1] = selection.file - end - return ('review skipped %d file(s): %s'):format(#skipped, table.concat(paths, ', ')) -end - ----@param skipped diffs.GeneratedFileSelection[] -local function notify_skipped_review_files(skipped) - local message = skipped_review_files_message(skipped) - if message then - notify(message, vim.log.levels.INFO) - end -end - ----@param skipped diffs.GeneratedFileSelection[] ----@param selection diffs.GeneratedFileSelection ----@param index integer ----@param count integer -local function notify_skipped_review_file(skipped, selection, index, count) - local skipped_message = skipped_review_files_message(skipped) - if not skipped_message then - announce_review_file(selection, index, count) - return - end - notify( - skipped_message .. '\n[diffs]: ' .. review_file_message(selection, index, count), - vim.log.levels.INFO - ) -end - ----@param state diffs.ReviewSplitState ----@param selection diffs.GeneratedFileSelection ----@param index integer ----@param count integer ----@return boolean -local function goto_selection(state, selection, index, count) - if switch_review_split_file(state, selection) then - announce_review_file(selection, index, count) - return true - end - return false -end - ----@param state diffs.ReviewSplitState ----@param delta integer -local function step_review_split_file(state, delta) - local files = review_split_files(state) - local count = #files - if count < 2 then - return - end - local current = review_index_of_current(files, state) - local skipped = {} - for offset = 1, count - 1 do - local target = ((current - 1 + delta * offset) % count) + 1 - local selected = files[target] - if selected.skipped then - skipped[#skipped + 1] = selected - else - local switched, unsupported = switch_review_split_file(state, selected, { quiet = true }) - if switched then - notify_skipped_review_file(skipped, selected, target, count) - return - end - if not unsupported then - notify_skipped_review_files(skipped) - return - end - skipped[#skipped + 1] = selected - end - end - notify_skipped_review_files(skipped) -end - ----@param delta integer -local function step_current_review_split(delta) - local state = review_split_states[vim.api.nvim_get_current_buf()] - if state then - step_review_split_file(state, delta) - end -end - -function M.review_next_file() - step_current_review_split(1) -end - -function M.review_prev_file() - step_current_review_split(-1) -end - ----@class diffs.ReviewFile ----@field path string ----@field key string ----@field section? string ----@field section_label? string ----@field added integer ----@field removed integer ----@field skipped boolean - ----@param selection diffs.GeneratedFileSelection ----@return diffs.ReviewFile -local function project_review_file(selection) - return { - path = selection.file, - key = selection.key or selection.file, - section = selection.section, - section_label = selection.section_label, - added = selection.added or 0, - removed = selection.removed or 0, - skipped = selection.skipped == true, - } -end - ----@param bufnr integer? ----@return diffs.ReviewSplitState? -local function resolve_review_split(bufnr) - return review_split_states[bufnr or vim.api.nvim_get_current_buf()] -end - ----@param bufnr integer? Defaults to the current buffer. ----@return diffs.ReviewFile[]? files Nil when the buffer is not a review split. -function M.review_files(bufnr) - local state = resolve_review_split(bufnr) - if not state then - return nil - end - local files = {} - for _, selection in ipairs(review_split_files(state)) do - files[#files + 1] = project_review_file(selection) - end - return files -end - ----@param bufnr integer? Defaults to the current buffer. ----@return { index: integer, count: integer, file: diffs.ReviewFile }? -function M.review_current(bufnr) - local state = resolve_review_split(bufnr) - if not state then - return nil - end - local files = review_split_files(state) - if #files == 0 then - return nil - end - local index = review_index_of_current(files, state) - return { index = index, count = #files, file = project_review_file(files[index]) } -end - ----@param target string A review file key (preferred) or path. ----@param bufnr integer? Defaults to the current buffer. ----@return boolean switched -function M.review_goto(target, bufnr) - local state = resolve_review_split(bufnr) - if not state then - return false - end - local files = review_split_files(state) - for i, selection in ipairs(files) do - if selection.key == target or selection.file == target then - if (selection.key or selection.file) == state.selected_key then - return true - end - if selection.skipped then - notify_skipped_review_files({ selection }) - return false - end - return goto_selection(state, selection, i, #files) - end - end - return false -end - ----@param item table? ----@return boolean handled -function M.review_quickfix_jump(item) - local selected = lists.selected_generated_file({ item = item }) - if not selected then - return false - end - local source = generated.source(selected.bufnr) - if not source or source.kind ~= 'review' then - return false - end - - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - local state = review_split_states[vim.api.nvim_win_get_buf(win)] - if - state - and state.repo_root == source.repo_root - and vim.deep_equal(state.review, source.review) - then - if M.review_goto(selected.key or selected.file, state.right_buf) then - vim.api.nvim_set_current_win(state.right_win) - end - return true - end - end - return false -end - ----@param file diffs.ReviewFile ----@return string -local function review_file_label(file) - local path = file.skipped and (file.path .. ' (skipped)') or file.path - if file.section_label and file.section_label ~= '' then - return ('[%s] %s'):format(file.section_label, path) - end - return path -end - ----@param files diffs.ReviewFile[] ----@return fun(file: diffs.ReviewFile): string -local function review_file_formatter(files) - local labels = {} - local max_label, max_add, max_del = 0, 0, 0 - for _, file in ipairs(files) do - local label = review_file_label(file) - labels[file.key] = label - max_label = math.max(max_label, #label) - if file.added > 0 then - max_add = math.max(max_add, #tostring(file.added) + 1) - end - if file.removed > 0 then - max_del = math.max(max_del, #tostring(file.removed) + 1) - end - end - return function(file) - local label = labels[file.key] or review_file_label(file) - local parts = { label .. string.rep(' ', max_label - #label) } - if max_add > 0 then - parts[#parts + 1] = file.added > 0 and string.format('%' .. max_add .. 's', '+' .. file.added) - or string.rep(' ', max_add) - end - if max_del > 0 then - parts[#parts + 1] = file.removed > 0 - and string.format('%' .. max_del .. 's', '-' .. file.removed) - or string.rep(' ', max_del) - end - return (table.concat(parts, ' '):gsub('%s+$', '')) - end -end - ----@param bufnr integer? Defaults to the current buffer. -function M.select_review_file(bufnr) - bufnr = bufnr or vim.api.nvim_get_current_buf() - local files = M.review_files(bufnr) - if not files or #files == 0 then - return - end - vim.ui.select(files, { - prompt = 'Review file:', - format_item = review_file_formatter(files), - }, function(choice) - if choice then - M.review_goto(choice.key, bufnr) - end - end) -end - -local function close_existing_review_splits() - ---@type table - local seen = {} - for _, state in pairs(review_split_states) do - if not seen[state] then - seen[state] = true - forget_review_split(state) - if vim.api.nvim_buf_is_valid(state.left_buf) then - split.close_pair(state.left_buf) - elseif vim.api.nvim_buf_is_valid(state.right_buf) then - split.close_pair(state.right_buf) - end - end - end -end - ----@param spec diffs.ReviewSpec? ----@param opts? diffs.OpenReviewSplitOpts ----@return integer? -open_review_split = function(spec, opts) - opts = opts or {} - local normalized, normalize_err = review.normalize(spec) - if not normalized then - notify(normalize_err, vim.log.levels.ERROR) - return nil - end - - local review_lines, render_err, list_opts = review.render(normalized, review_deps()) - if not review_lines then - notify(render_err, vim.log.levels.ERROR) - return nil - end - if #review_lines == 0 then - notify('no diff against ' .. normalized.display, vim.log.levels.INFO) - return nil - end - - local review_spec = review.stored_review_spec(normalized) - local first = opts.selection - local diff_spec, diff_lines - local skipped = {} - if first then - if first.skipped then - notify_skipped_review_files({ first }) - return nil - end - local err, level - diff_spec, diff_lines, err, level = - render_review_file_selection(review_spec, normalized.repo_root, first) - if not diff_spec or not diff_lines then - notify(err or 'cannot render review split file', level or vim.log.levels.ERROR) - return nil - end - else - local err, level - first, diff_spec, diff_lines, err, level, skipped = - first_renderable_review_file(review_spec, normalized.repo_root, review_lines, list_opts) - if not first or not diff_spec or not diff_lines then - notify_skipped_review_files(skipped) - if #skipped == 0 then - notify(err or 'no review file selected', level or vim.log.levels.INFO) - end - return nil - end - end - - local restore_win = vim.api.nvim_get_current_win() - close_existing_review_splits() - if type(opts.replace_win) == 'number' then - if not vim.api.nvim_win_is_valid(opts.replace_win) then - notify('review split replacement window is no longer valid', vim.log.levels.WARN) - return nil - end - vim.api.nvim_set_current_win(opts.replace_win) - else - restore_window(restore_win) - end - - local opened, split_err = split.open({ - spec = diff_spec, - repo_root = normalized.repo_root, - filetype = filetype_for_path(normalized.repo_root, first.file), - diff_lines = diff_lines, - quickfix = false, - title = 'review: ' .. normalized.display, - hunk_index = first.hunk_index or 1, - }) - if not opened then - notify(split_err or 'cannot open review split', vim.log.levels.ERROR) - return nil - end - - ---@type diffs.ReviewSplitState - local state = { - left_buf = opened.left_buf, - right_buf = opened.right_buf, - left_win = opened.left_win, - right_win = opened.right_win, - review = review_spec, - repo_root = normalized.repo_root, - display = normalized.display, - review_lines = review_lines, - list_opts = list_opts, - map_layout = normalize_review_map_layout(opts.map_layout), - selected_file = first.file, - selected_key = first.key or first.file, - selected_diff_spec = diff_spec, - autocmds = {}, - } - review_split_states[opened.left_buf] = state - review_split_states[opened.right_buf] = state - attach_review_split_autocmds(state) - sync_review_quickfix(state) - split.move_pair_to_source(opened.right_buf, opts.source_position) - - local files = lists.generated_files(review_lines, review_generated_list_opts(list_opts)) - local index = review_index_of_current(files, state) - notify_skipped_review_file(skipped, first, index, #files) - - dbg('opened review split %d/%d (%s)', opened.left_buf, opened.right_buf, normalized.display) - return opened.left_buf -end - ----@param opts? diffs.ReviewSplitOpts ----@return integer? -function M.review_split(opts) - local restore_win = vim.api.nvim_get_current_win() - local context, err, level = review_split_context(opts) - if not context then - restore_window(restore_win) - notify(err or 'cannot open review split', level or vim.log.levels.WARN) - return nil - end - - local source, source_err = get_source_var(context.review_buf) - if source_err or not source or source.kind ~= 'review' then - restore_window(restore_win) - notify( - source_err and ('invalid diffs_source metadata: ' .. tostring(source_err)) - or 'selected file is not from a review buffer', - vim.log.levels.WARN - ) - return nil - end - - local review_spec = vim.deepcopy(source.review) - review_spec.repo = context.repo_root - return open_review_split(review_spec, { - selection = context.selected, - source_position = context.source_position, - replace_win = context.replace_win, - map_layout = context.map_layout, - }) -end - ----@param state diffs.ReviewSplitState ----@return integer? -local function review_split_keep_window(state) - local current_win = vim.api.nvim_get_current_win() - local current_buf = vim.api.nvim_get_current_buf() - if - (current_buf == state.left_buf or current_buf == state.right_buf) - and vim.api.nvim_win_is_valid(current_win) - then - return current_win - end - for _, win in ipairs({ state.left_win, state.right_win }) do - if vim.api.nvim_win_is_valid(win) then - local buf = vim.api.nvim_win_get_buf(win) - if buf == state.left_buf or buf == state.right_buf then - return win - end - end - end - return first_window_for_buffer(state.left_buf) or first_window_for_buffer(state.right_buf) -end - ----@param bufnr integer ----@param selected_file string ----@param selected_key string? ----@param position diffs.SourcePosition? ----@return boolean -local function goto_review_map_selection(bufnr, selected_file, selected_key, position) - local win = first_window_for_buffer(bufnr) - if not win then - return false - end - local matched_line = position - and hunk_model.line_at_source( - lists.generated_hunks(bufnr), - { file = selected_file, key = selected_key }, - position - ) - for _, item in ipairs(vim.fn.getqflist({ items = 0 }).items) do - local data = item.user_data and item.user_data.diffs - if item.bufnr == bufnr and type(data) == 'table' then - local matches = selected_key and data.key == selected_key - or (not selected_key and data.file == selected_file) - if matches then - local lnum = (matched_line and matched_line.lnum) or item.lnum or 1 - vim.api.nvim_set_current_win(win) - vim.api.nvim_win_set_cursor(win, { math.max(1, lnum), 0 }) - vim.api.nvim_exec_autocmds('CursorMoved', { buffer = bufnr }) - return true - end - end - end - return false -end - ----@param state diffs.ReviewSplitState ----@param layout diffs.ReviewMapLayout ----@return integer? -local function open_review_map_from_split(state, layout) - local keep_win = review_split_keep_window(state) - if not keep_win then - notify('review split window is no longer visible', vim.log.levels.WARN) - return nil - end - local source_buf = vim.api.nvim_win_get_buf(keep_win) - local selected_file = state.selected_file - local selected_key = state.selected_key - local position = split.displayed_position(source_buf, vim.api.nvim_win_get_cursor(keep_win)[1]) - local review_spec = vim.deepcopy(state.review) - review_spec.repo = state.repo_root - forget_review_split(state) - local split_buffers = split.close_pair_into_window(source_buf, keep_win) - if not split_buffers then - review_split_states[state.left_buf] = state - review_split_states[state.right_buf] = state - attach_review_split_autocmds(state) - notify('cannot close review split before opening review map', vim.log.levels.WARN) - return nil - end - local bufnr = M.review(review_spec, { - rail_style = rail_style_for_layout(layout), - review_layout = layout, - replace_win = keep_win, - }) - split.delete_pair_buffers(split_buffers) - if bufnr then - goto_review_map_selection(bufnr, selected_file, selected_key, position) - end - return bufnr -end - ----@class diffs.ReviewToggleLayoutOpts ----@field bufnr? integer - ----@param opts? diffs.ReviewToggleLayoutOpts ----@return integer? -function M.review_toggle_layout(opts) - opts = opts or {} - local bufnr = opts.bufnr or vim.api.nvim_get_current_buf() - local state = resolve_review_split(bufnr) - if state then - return open_review_map_from_split(state, state.map_layout) - end - return M.review_split({ bufnr = bufnr }) -end - ----@param args? string ----@param vertical? boolean ----@param opts? { warn_vertical_split?: boolean } -function M.diff(args, vertical, opts) - opts = opts or {} - local bufnr = vim.api.nvim_get_current_buf() - local filepath = vim.api.nvim_buf_get_name(bufnr) - - if filepath == '' then - notify('cannot diff unnamed buffer', vim.log.levels.ERROR) - return - end - - local rel_path = git.get_relative_path(filepath) - if not rel_path then - notify('not in a git repository', vim.log.levels.ERROR) - return - end - - local parsed, parse_err = diff_parser.parse(args, { - path = rel_path, - current = diffspec.worktree(), - default_layout = runtime.get_view_config().default_layout, - }) - if not parsed then - notify(parse_err, vim.log.levels.ERROR) - return - end - - if parsed.layout == 'split' and opts.warn_vertical_split then - warn_vertical_split_ignored() - end - - local rail_style = rail_style_for_layout(parsed.layout) - local diff_spec = parsed.spec - local diff_label = diff_buffer_label(diff_spec) - local diff_path = diff_spec.scope.path - local repo_root = git.get_repo_root(filepath) - if not repo_root then - notify('not in a git repository', vim.log.levels.ERROR) - return - end - - local diff_filepath = repo_root .. '/' .. diff_path - - if diff_spec.left.kind == diffspec.endpoint_kind.stage and not git.is_unmerged(diff_filepath) then - notify(diff_path .. ' is not in a merge conflict', vim.log.levels.ERROR) - return - end - - if - diff_spec.left.kind == diffspec.endpoint_kind.index - and diff_spec.right.kind == diffspec.endpoint_kind.worktree - and diff_spec.scope.kind == diffspec.scope_kind.file - and diff_path == rel_path - and git.is_unmerged(filepath) - then - if parsed.layout == 'split' then - notify('split diff does not support unmerged files yet', vim.log.levels.ERROR) - return - end - M.diff_file(filepath, { - vertical = vertical, - unmerged = true, - rail_style = rail_style, - }) - return - end - - -- Only the current buffer's content stands in for the worktree side; an - -- explicit-path object targets a different file that must be read from disk. - local worktree_lines = diff_path == rel_path and content.from_buffer(bufnr) or nil - local diff_lines, render_err = render.file(diff_spec, repo_root, { - worktree_lines = worktree_lines, - }) - if not diff_lines then - notify(render_err or 'unknown error', vim.log.levels.ERROR) - return - end - - if #diff_lines == 0 then - notify('no changes for ' .. diffspec.label(diff_spec), vim.log.levels.INFO) - return - end - - if parsed.layout == 'split' then - local opened, split_err = split.open({ - spec = diff_spec, - repo_root = repo_root, - filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }), - worktree_lines = worktree_lines, - worktree_buf = worktree_lines and bufnr or nil, - diff_lines = diff_lines, - }) - if not opened then - notify(split_err or 'cannot open split diff', vim.log.levels.ERROR) - end - return - end - - local diff_buf = create_generated_diff_buffer({ - name = 'diffs://' .. diff_label .. ':' .. diff_path, - lines = diff_lines, - repo_root = repo_root, - diff_spec = diff_spec, - source = generated.file_source(repo_root, diff_spec, worktree_lines and bufnr or nil), - rail_style = rail_style, - }) - show_generated_diff_buffer(diff_buf, vertical) - lists.set_for_unified_buffer(diff_buf, diff_lines, { - title = 'diff: ' .. diffspec.label(diff_spec), - }) - attach_generated_diff_buffer(diff_buf) - - if difftastic.available() then - local abs = repo_root and (repo_root .. '/' .. diff_path) or diff_path - local old_lines = render.read_endpoint(diff_spec.left, abs, { empty_on_missing = true }) - local new_lines = render.read_endpoint(diff_spec.right, abs, { - worktree_lines = worktree_lines, - empty_on_missing = true, - }) - apply_difft_unified(diff_buf, diff_lines, diff_spec, old_lines, new_lines, diff_path) - end - - dbg('opened diff buffer %d for %s (%s)', diff_buf, diff_path, diffspec.label(diff_spec)) -end - ----@class diffs.DiffFileOpts ----@field vertical? boolean ----@field staged? boolean ----@field untracked? boolean ----@field unmerged? boolean ----@field old_filepath? string ----@field hunk_position? { hunk_header: string, offset: integer } ----@field rail_style? diffs.RailStyle - ----@param filepath string ----@param opts? diffs.DiffFileOpts -function M.diff_file(filepath, opts) - opts = opts or {} - - local rel_path = git.get_relative_path(filepath) - if not rel_path then - notify('not in a git repository', vim.log.levels.ERROR) - return - end - - local repo_root = git.get_repo_root(filepath) - if not repo_root then - notify('not in a git repository', vim.log.levels.ERROR) - return - end - - local old_rel_path = opts.old_filepath and git.get_relative_path(opts.old_filepath) or rel_path - - local old_lines, new_lines, err, diff_lines - local diff_label - local diff_spec - - 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 - diff_spec = diffspec.index_to_worktree(rel_path) - diff_label = 'untracked' - diff_lines, err = render.file(diff_spec, repo_root) - elseif opts.staged then - diff_label = 'staged' - if old_rel_path == rel_path then - diff_spec = diffspec.head_to_index(rel_path) - diff_lines, err = render.file(diff_spec, repo_root) - else - old_lines = git.get_file_content('HEAD', opts.old_filepath or filepath) or {} - new_lines = git.get_index_content(filepath) or {} - end - else - diff_label = 'unstaged' - if old_rel_path == rel_path then - diff_spec = diffspec.index_to_worktree(rel_path) - diff_lines, err = render.file(diff_spec, repo_root) - else - old_lines = git.get_index_content(opts.old_filepath or filepath) - if not old_lines then - old_lines = git.get_file_content('HEAD', opts.old_filepath or filepath) or {} - end - new_lines = git.get_working_content(filepath) or {} - end - end - - if not diff_lines then - if err then - notify(err, vim.log.levels.ERROR) - return - end - diff_lines = render.unified_lines(old_lines or {}, new_lines or {}, old_rel_path, rel_path) - end - - if #diff_lines == 0 then - if diff_spec then - notify('no changes for ' .. diffspec.label(diff_spec), vim.log.levels.INFO) - elseif opts.unmerged then - notify('no changes for unmerged file:' .. rel_path, vim.log.levels.INFO) - else - notify( - 'no text changes for ' - .. file_pair_label(diff_label, rel_path, old_rel_path) - .. '; generated hunk actions are unavailable', - vim.log.levels.INFO - ) - end - return - end - - local source - if diff_spec then - source = generated.file_source(repo_root, diff_spec) - elseif opts.unmerged then - source = generated.unmerged_source(repo_root, rel_path, filepath) - else - local edge = opts.staged and 'staged' or 'unstaged' - source = generated.file_pair_source(repo_root, edge, rel_path, old_rel_path) - end - - local diff_buf = create_generated_diff_buffer({ - name = 'diffs://' .. diff_label .. ':' .. rel_path, - lines = diff_lines, - repo_root = repo_root, - diff_spec = diff_spec, - source = source, - rail_style = opts.rail_style, - }) - show_generated_diff_buffer(diff_buf, opts.vertical) - - if opts.hunk_position then - local target_line = M.find_hunk_line(diff_lines, opts.hunk_position) - if target_line then - vim.api.nvim_win_set_cursor(0, { target_line, 0 }) - dbg('jumped to line %d for hunk', target_line) - end - end - - lists.set_for_unified_buffer(diff_buf, diff_lines, { - title = 'diff: ' .. diff_label .. ':' .. rel_path, - diff_spec = diff_spec, - }) - - attach_generated_diff_buffer(diff_buf) - - if difftastic.available() and diff_label ~= 'unmerged' then - ---@type diffs.ContentLines|string[]|nil - local dold = old_lines - ---@type diffs.ContentLines|string[]|nil - local dnew = new_lines - if (not dold or not dnew) and diff_spec then - local abs = repo_root .. '/' .. rel_path - dold = render.read_endpoint(diff_spec.left, abs, { empty_on_missing = true }) - dnew = render.read_endpoint(diff_spec.right, abs, { empty_on_missing = true }) - end - apply_difft_unified(diff_buf, diff_lines, diff_spec, dold, dnew, rel_path) - end - - if diff_label == 'unmerged' then - local conflict_config = runtime.get_conflict_config() - if conflict_config then - require('diffs.merge').attach(diff_buf) - end - end - - dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) -end - ----@class diffs.DiffSectionOpts ----@field vertical? boolean ----@field staged? boolean - ----@param repo_root string ----@param opts? diffs.DiffSectionOpts -function M.diff_section(repo_root, opts) - opts = opts or {} - - local cmd = { - 'git', - '-C', - repo_root, - 'diff', - '--no-ext-diff', - '--no-color', - '--src-prefix=a/', - '--dst-prefix=b/', - } - vim.list_extend(cmd, diffopt.git_flags()) - if opts.staged then - table.insert(cmd, '--cached') - end - - local result = vim.fn.systemlist(cmd) - if vim.v.shell_error ~= 0 then - notify('git diff failed', vim.log.levels.ERROR) - return - end - - result = replace_combined_diffs(result, repo_root) - - local diff_label = opts.staged and 'staged' or 'unstaged' - if #result == 0 then - notify('no changes in ' .. diff_label .. ' section', vim.log.levels.INFO) - return - end - - local diff_buf = create_generated_diff_buffer({ - name = 'diffs://' .. diff_label .. ':all', - lines = result, - repo_root = repo_root, - source = generated.section_source(repo_root, diff_label), - }) - show_generated_diff_buffer(diff_buf, opts.vertical) - lists.set_for_unified_buffer(diff_buf, result, { - title = 'diff: ' .. diff_label .. ':all', - }) - attach_generated_diff_buffer(diff_buf) - dbg('opened section diff buffer %d (%s)', diff_buf, diff_label) -end - ----@param bufnr integer ----@param opts? { quickfix?: boolean } -function M.read_buffer(bufnr, opts) - opts = opts or {} - local name = vim.api.nvim_buf_get_name(bufnr) - if not name:match('^diffs://') then - return - end - - generated.restore(bufnr) - - local source, source_err = get_source_var(bufnr) - if source_err then - notify('invalid diffs_source metadata: ' .. tostring(source_err), vim.log.levels.WARN) - return - end - - if source and source.kind == 'split_endpoint' then - local ok, err = split.read_buffer(bufnr, source, { - quickfix = opts.quickfix, - }) - if not ok then - notify(err or 'cannot reload split diffs:// buffer', vim.log.levels.WARN) - end - return - end - - if not source then - notify('cannot reload diffs:// buffer without diffs_source metadata', vim.log.levels.WARN) - return - end - - local diff_lines, stored_spec, render_err, list_opts, old_lines, new_lines = render_source(source) - if not diff_lines then - notify(render_err or 'cannot reload diffs:// buffer', vim.log.levels.WARN) - return - end - local debug_label = render_err - - local is_review = source.kind == 'review' - replace_generated_diff_buffer_lines(bufnr, diff_lines, stored_spec) - lists.set_for_unified_buffer(bufnr, diff_lines, { - title = 'diff: ' .. (debug_label or name:gsub('^diffs://', '')), - diff_spec = stored_spec, - metadata_for_line = list_opts and list_opts.metadata_for_line, - sections = list_opts and list_opts.sections, - store_hunks = list_opts and list_opts.store_hunks, - quickfix = opts.quickfix ~= false and (is_review or nil), - }) - M.setup_diff_buf(bufnr) - - local review_marker = generated.get_var(bufnr, 'diffs_review') - if type(review_marker) == 'table' and review_marker.layout ~= 'split' then - setup_review_map_buffer( - bufnr, - review_marker.display, - normalize_review_map_layout(review_marker.layout) - ) - end - - dbg('reloaded diff buffer %d (%s)', bufnr, debug_label) - - runtime.attach(bufnr) - local relpath = stored_spec and stored_spec.scope.path or source.right_name or source.path - if relpath then - apply_difft_unified(bufnr, diff_lines, stored_spec, old_lines, new_lines, relpath) - end -end - ---- Re-render every visible diffs:// buffer in place, preserving each window's ---- view. Used after a global change such as 'diffopt', so the new setting is ---- reflected consistently across all open diff surfaces. -function M.refresh_visible() - ---@type { win: integer, view: table }[] - local views = {} - for _, win in ipairs(vim.api.nvim_list_wins()) do - local buf = vim.api.nvim_win_get_buf(win) - if vim.api.nvim_buf_get_name(buf):match('^diffs://') then - views[#views + 1] = { win = win, view = vim.api.nvim_win_call(win, vim.fn.winsaveview) } - end - end - - ---@type table - local done = {} - for _, entry in ipairs(views) do - local buf = vim.api.nvim_win_is_valid(entry.win) and vim.api.nvim_win_get_buf(entry.win) - if buf and not done[buf] then - done[buf] = true - local ok, peer = pcall(vim.api.nvim_buf_get_var, buf, 'diffs_split_peer') - if ok and type(peer) == 'number' then - done[peer] = true - end - M.read_buffer(buf) - end - end - - for _, entry in ipairs(views) do - if vim.api.nvim_win_is_valid(entry.win) then - vim.api.nvim_win_call(entry.win, function() - vim.fn.winrestview(entry.view) - end) - end - end -end - ---- Re-render a single diffs:// buffer in place, preserving its window view. ----@param bufnr? integer -function M.refresh(bufnr) - bufnr = bufnr or vim.api.nvim_get_current_buf() - if not vim.api.nvim_buf_get_name(bufnr):match('^diffs://') then - return - end - local win = first_window_for_buffer(bufnr) - local view = win and vim.api.nvim_win_call(win, vim.fn.winsaveview) or nil - M.read_buffer(bufnr) - if win and view and vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_call(win, function() - vim.fn.winrestview(view) - end) - end -end - ---- React to a runtime change of the global 'diffopt'. Generated diffs:// buffers ---- regenerate (their content honors the whitespace/algorithm flags), attached ---- repaint surfaces are invalidated so whitespace-only lines are re-classified, ---- and native &diff windows are refreshed so they pick up the new options ---- immediately. Deferred so buffer rewrites do not run inside the OptionSet ---- callback, then a redraw flushes the new highlighting. -function M.on_diffopt_changed() - vim.schedule(function() - M.refresh_visible() - runtime.invalidate_attached() - pcall(vim.cmd.diffupdate) - vim.cmd.redraw({ bang = true }) - end) -end - -M.complete_diff_command = complete_diff_command - -M._test = { - complete_diff = complete_diff_command, - complete_diff_args = complete_diff_args, - complete_diff_object = complete_diff_object, - complete_review = review.complete, - create_generated_diff_buffer = create_generated_diff_buffer, - diff_buffer_label = diff_buffer_label, - replace_generated_diff_buffer_lines = replace_generated_diff_buffer_lines, - review_split_state = function(bufnr) - return review_split_states[bufnr] - end, - normalize_review = review.normalize, -} - -return M diff --git a/lua/diffs/config.lua b/lua/diffs/config.lua index a54f5ff7..36e89bab 100644 --- a/lua/diffs/config.lua +++ b/lua/diffs/config.lua @@ -12,7 +12,6 @@ local integration_metadata = require('diffs.integrations') ---@field max_lines integer ---@class diffs.ViewConfig ----@field default_layout "unified"|"stacked"|"split" ---@field prefix boolean ---@class diffs.Highlights @@ -23,29 +22,23 @@ local integration_metadata = require('diffs.integrations') ---@field vim diffs.VimConfig|false ---@field intra diffs.IntraConfig ----@class diffs.DifftasticConfig ----@field args? string[] - ---@class diffs.IntegrationsConfig ---@field fugitive? boolean ---@field neogit? boolean ---@field neojj? boolean ---@field gitsigns? boolean ---@field telescope? boolean ----@field difftastic? boolean|diffs.DifftasticConfig ---@class diffs.Config ---@field debug boolean|string ---@field view diffs.ViewConfig ---@field highlights diffs.Highlights ---@field integrations diffs.IntegrationsConfig ----@field conflict boolean ---@type diffs.Config local DEFAULTS = { debug = false, view = { - default_layout = 'unified', prefix = true, }, highlights = { @@ -62,7 +55,6 @@ local DEFAULTS = { }, }, integrations = {}, - conflict = true, } ---@param opts? table diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua deleted file mode 100644 index c93f54e8..00000000 --- a/lua/diffs/conflict.lua +++ /dev/null @@ -1,411 +0,0 @@ ----@class diffs.ConflictRegion ----@field marker_open integer? ----@field marker_ours integer ----@field ours_start integer ----@field ours_end integer ----@field marker_base integer? ----@field base_start integer? ----@field base_end integer? ----@field marker_sep integer ----@field theirs_start integer ----@field theirs_end integer ----@field marker_theirs integer - -local M = {} - -local notify = require('diffs.log').notify - -local ns = vim.api.nvim_create_namespace('diffs-conflict') -local CONFLICT_PRIORITY = 200 - ----@type table -local attached_buffers = {} - ----@param event string ----@param bufnr integer -local function emit(event, bufnr) - vim.api.nvim_exec_autocmds('User', { pattern = event, data = { bufnr = bufnr } }) -end - ----@param line string ----@param char string ----@return boolean -local function is_marker(line, char) - return line:sub(1, 7) == string.rep(char, 7) -end - ----@param lines string[] ----@return diffs.ConflictRegion[] -function M.parse(lines) - local regions = {} - local state = 'idle' - ---@type table? - local current = nil - - for i, line in ipairs(lines) do - local idx = i - 1 - - if state == 'idle' then - if is_marker(line, '<') then - local following = lines[i + 1] - if following and is_marker(following, '+') then - current = { marker_open = idx } - state = 'in_snapshot_open' - else - current = { marker_ours = idx, ours_start = idx + 1 } - state = 'in_ours' - end - end - elseif state == 'in_ours' then - if line:match('^|||||||') then - current.ours_end = idx - current.marker_base = idx - current.base_start = idx + 1 - state = 'in_base' - elseif line:match('^=======') then - current.ours_end = idx - current.marker_sep = idx - current.theirs_start = idx + 1 - state = 'in_theirs' - elseif line:match('^<<<<<<<') then - current = { marker_ours = idx, ours_start = idx + 1 } - elseif line:match('^>>>>>>>') then - current = nil - state = 'idle' - end - elseif state == 'in_base' then - if line:match('^=======') then - current.base_end = idx - current.marker_sep = idx - current.theirs_start = idx + 1 - state = 'in_theirs' - elseif line:match('^<<<<<<<') then - current = { marker_ours = idx, ours_start = idx + 1 } - state = 'in_ours' - elseif line:match('^>>>>>>>') then - current = nil - state = 'idle' - end - elseif state == 'in_theirs' then - if line:match('^>>>>>>>') then - current.theirs_end = idx - current.marker_theirs = idx - table.insert(regions, current) - current = nil - state = 'idle' - elseif line:match('^<<<<<<<') then - current = { marker_ours = idx, ours_start = idx + 1 } - state = 'in_ours' - end - elseif state == 'in_snapshot_open' then - current.marker_ours = idx - current.ours_start = idx + 1 - state = 'in_snapshot_ours' - elseif state == 'in_snapshot_ours' then - if is_marker(line, '-') then - current.ours_end = idx - current.marker_base = idx - current.base_start = idx + 1 - state = 'in_snapshot_base' - elseif is_marker(line, '%') or is_marker(line, '+') or is_marker(line, '>') then - current = nil - state = 'idle' - end - elseif state == 'in_snapshot_base' then - if is_marker(line, '+') then - current.base_end = idx - current.marker_sep = idx - current.theirs_start = idx + 1 - state = 'in_snapshot_theirs' - elseif is_marker(line, '%') or is_marker(line, '-') or is_marker(line, '>') then - current = nil - state = 'idle' - end - elseif state == 'in_snapshot_theirs' then - if is_marker(line, '>') then - current.theirs_end = idx - current.marker_theirs = idx - table.insert(regions, current) - current = nil - state = 'idle' - elseif is_marker(line, '%') or is_marker(line, '+') or is_marker(line, '-') then - current = nil - state = 'idle' - end - end - end - - return regions -end - ----@param region diffs.ConflictRegion ----@return integer -local function region_start(region) - return region.marker_open or region.marker_ours -end - ----@param bufnr integer ----@return diffs.ConflictRegion[] -local function parse_buffer(bufnr) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - return M.parse(lines) -end - ----@param bufnr integer ----@param row integer ----@param hl_group string -local function mark_line(bufnr, row, hl_group) - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, row, 0, { - end_row = row + 1, - hl_group = hl_group, - hl_eol = true, - priority = CONFLICT_PRIORITY, - }) -end - ----@param bufnr integer ----@param start_row integer ----@param end_row integer ----@param hl_group string ----@param number_hl_group string -local function mark_section(bufnr, start_row, end_row, hl_group, number_hl_group) - for row = start_row, end_row - 1 do - mark_line(bufnr, row, hl_group) - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, row, 0, { - number_hl_group = number_hl_group, - priority = CONFLICT_PRIORITY, - }) - end -end - ----@param bufnr integer ----@param regions diffs.ConflictRegion[] -local function apply_highlights(bufnr, regions) - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - - for _, region in ipairs(regions) do - if region.marker_open then - mark_line(bufnr, region.marker_open, 'DiffsConflictMarker') - end - mark_line(bufnr, region.marker_ours, 'DiffsConflictMarker') - mark_section( - bufnr, - region.ours_start, - region.ours_end, - 'DiffsConflictOurs', - 'DiffsConflictOursNr' - ) - - if region.marker_base then - mark_line(bufnr, region.marker_base, 'DiffsConflictMarker') - mark_section( - bufnr, - region.base_start, - region.base_end, - 'DiffsConflictBase', - 'DiffsConflictBaseNr' - ) - end - - mark_line(bufnr, region.marker_sep, 'DiffsConflictMarker') - mark_section( - bufnr, - region.theirs_start, - region.theirs_end, - 'DiffsConflictTheirs', - 'DiffsConflictTheirsNr' - ) - mark_line(bufnr, region.marker_theirs, 'DiffsConflictMarker') - end -end - ----@param cursor_line integer ----@param regions diffs.ConflictRegion[] ----@return diffs.ConflictRegion? -local function find_conflict_at_cursor(cursor_line, regions) - for _, region in ipairs(regions) do - if cursor_line >= region_start(region) and cursor_line <= region.marker_theirs then - return region - end - end - return nil -end - ----@param bufnr integer ----@param region diffs.ConflictRegion ----@param replacement string[] -function M.replace_region(bufnr, region, replacement) - vim.api.nvim_buf_set_lines( - bufnr, - region_start(region), - region.marker_theirs + 1, - false, - replacement - ) -end - ----@param bufnr integer -function M.refresh(bufnr) - local had_conflicts = #vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { limit = 1 }) > 0 - local regions = parse_buffer(bufnr) - if #regions == 0 then - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - if had_conflicts then - emit('DiffsConflictResolved', bufnr) - end - return - end - apply_highlights(bufnr, regions) - if not had_conflicts then - emit('DiffsConflictDetected', bufnr) - end -end - ----@param bufnr integer ----@param region diffs.ConflictRegion ----@param side 'ours'|'theirs'|'both'|'none' ----@return string[] -function M.replacement_lines(bufnr, region, side) - if side == 'none' then - return {} - end - if side == 'theirs' then - return vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false) - end - local lines = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false) - if side == 'both' then - vim.list_extend( - lines, - vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false) - ) - end - return lines -end - ----@param bufnr integer ----@param side 'ours'|'theirs'|'both'|'none' -local function resolve(bufnr, side) - if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then - notify('buffer is not modifiable', vim.log.levels.WARN) - return - end - local regions = parse_buffer(bufnr) - local region = find_conflict_at_cursor(vim.api.nvim_win_get_cursor(0)[1] - 1, regions) - if not region then - return - end - M.replace_region(bufnr, region, M.replacement_lines(bufnr, region, side)) - M.refresh(bufnr) -end - ----@param bufnr integer -function M.resolve_ours(bufnr) - resolve(bufnr, 'ours') -end - ----@param bufnr integer -function M.resolve_theirs(bufnr) - resolve(bufnr, 'theirs') -end - ----@param bufnr integer -function M.resolve_both(bufnr) - resolve(bufnr, 'both') -end - ----@param bufnr integer -function M.resolve_none(bufnr) - resolve(bufnr, 'none') -end - ----@param bufnr integer ----@param forward boolean -local function goto_conflict(bufnr, forward) - local regions = parse_buffer(bufnr) - if #regions == 0 then - return - end - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 - if forward then - for _, region in ipairs(regions) do - if region_start(region) > cursor_line then - vim.api.nvim_win_set_cursor(0, { region_start(region) + 1, 0 }) - return - end - end - notify('wrapped to first conflict', vim.log.levels.INFO) - vim.api.nvim_win_set_cursor(0, { region_start(regions[1]) + 1, 0 }) - else - for i = #regions, 1, -1 do - if region_start(regions[i]) < cursor_line then - vim.api.nvim_win_set_cursor(0, { region_start(regions[i]) + 1, 0 }) - return - end - end - notify('wrapped to last conflict', vim.log.levels.INFO) - vim.api.nvim_win_set_cursor(0, { region_start(regions[#regions]) + 1, 0 }) - end -end - ----@param bufnr integer -function M.goto_next(bufnr) - goto_conflict(bufnr, true) -end - ----@param bufnr integer -function M.goto_prev(bufnr) - goto_conflict(bufnr, false) -end - ----@param bufnr integer -function M.detach(bufnr) - local attached = attached_buffers[bufnr] - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - attached_buffers[bufnr] = nil - - if attached then - emit('DiffsConflictDetached', bufnr) - end -end - ----@param bufnr integer -function M.attach(bufnr) - if attached_buffers[bufnr] then - return - end - - local buftype = vim.api.nvim_get_option_value('buftype', { buf = bufnr }) - if buftype ~= '' then - return - end - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local regions = M.parse(lines) - if #regions == 0 then - return - end - - attached_buffers[bufnr] = true - - apply_highlights(bufnr, regions) - - vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { - buffer = bufnr, - callback = function() - if not attached_buffers[bufnr] then - return true - end - M.refresh(bufnr) - end, - }) - - vim.api.nvim_create_autocmd('BufWipeout', { - buffer = bufnr, - callback = function() - attached_buffers[bufnr] = nil - end, - }) - emit('DiffsConflictDetected', bufnr) -end - -return M diff --git a/lua/diffs/content.lua b/lua/diffs/content.lua deleted file mode 100644 index e2e56c1a..00000000 --- a/lua/diffs/content.lua +++ /dev/null @@ -1,86 +0,0 @@ -local M = {} - ----@class diffs.ContentLines: string[] ----@field _diffs_has_final_newline? boolean - ----@class diffs.ContentFromRawOpts ----@field empty_is_empty? boolean - ----@param lines string[] ----@param has_final_newline boolean? ----@return diffs.ContentLines -local function content_lines(lines, has_final_newline) - local result = {} - for i, line in ipairs(lines) do - result[i] = line - end - if has_final_newline then - result._diffs_has_final_newline = true - end - return result -end - ----@param lines diffs.ContentLines|string[]? ----@return boolean -local function has_final_newline(lines) - return type(lines) == 'table' and lines._diffs_has_final_newline == true -end - ----@param lines string[] ----@param opts? diffs.ContentFromRawOpts ----@return diffs.ContentLines -function M.from_raw_lines(lines, opts) - opts = opts or {} - local result = {} - for i, line in ipairs(lines) do - result[i] = line - end - - if opts.empty_is_empty and #result == 1 and result[1] == '' then - result = {} - end - - local ends_with_final_newline = #result > 0 and result[#result] == '' - if ends_with_final_newline then - result[#result] = nil - end - - return content_lines(result, ends_with_final_newline) -end - ----@param bufnr integer ----@param lines string[] ----@return boolean -local function buffer_has_final_newline(bufnr, lines) - if not vim.api.nvim_get_option_value('endofline', { buf = bufnr }) then - return false - end - - if #lines == 1 and lines[1] == '' then - local byte_count = vim.api.nvim_buf_call(bufnr, function() - return vim.fn.wordcount().bytes - end) - return byte_count > 0 - end - - return true -end - ----@param bufnr integer ----@return diffs.ContentLines -function M.from_buffer(bufnr) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - return content_lines(lines, buffer_has_final_newline(bufnr, lines)) -end - ----@param lines diffs.ContentLines|string[] ----@return string -function M.to_string(lines) - local text = table.concat(lines, '\n') - if has_final_newline(lines) then - text = text .. '\n' - end - return text -end - -return M diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua index 5c7ff644..f6d12011 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -44,7 +44,7 @@ function M.extract_change_groups(hunk_lines) elseif prefix == '+' then in_del = false table.insert(add_buf, { idx = i, text = line:sub(2) }) - else + elseif not line:match('^\\ No newline') then if #del_buf > 0 and #add_buf > 0 then table.insert(groups, { del_lines = del_buf, add_lines = add_buf }) end diff --git a/lua/diffs/diffargs.lua b/lua/diffs/diffargs.lua deleted file mode 100644 index 8283c1ff..00000000 --- a/lua/diffs/diffargs.lua +++ /dev/null @@ -1,239 +0,0 @@ -local M = {} - -local diffspec = require('diffs.spec') - ----@class diffs.DiffParseContext ----@field path string ----@field current? diffs.Endpoint ----@field default_layout? "unified"|"stacked"|"split" - ----@class diffs.DiffParseResult ----@field spec diffs.DiffSpec ----@field layout "unified"|"stacked"|"split" - -local function normalize_rev(rev) - if rev == '@' then - return 'HEAD' - end - return rev -end - ----@param args? string ----@return string[] -local function split_args(args) - args = vim.trim(args or '') - if args == '' then - return {} - end - return vim.split(args, '%s+', { trimempty = true }) -end - ----@param tokens string[] ----@param default_layout? "unified"|"stacked"|"split" ----@return ("unified"|"stacked"|"split")?, string? -local function take_layout(tokens, default_layout) - local layout = default_layout or 'unified' - while tokens[1] and tokens[1]:match('^%+%+') do - if tokens[1]:match('^%+%+layout=') then - layout = tokens[1]:match('^%+%+layout=(.*)$') - table.remove(tokens, 1) - else - return nil, 'unknown option ' .. tokens[1] - end - end - return layout -end - ----@param endpoint diffs.Endpoint ----@return boolean -local function is_index(endpoint) - return endpoint.kind == diffspec.endpoint_kind.index -end - ----@param endpoint diffs.Endpoint ----@return boolean -local function is_worktree(endpoint) - return endpoint.kind == diffspec.endpoint_kind.worktree -end - ----@class diffs.DiffParsedObject ----@field left diffs.Endpoint ----@field path? string # explicit path; nil means the current file ----@field right_worktree? boolean # if set, the right endpoint is the worktree rather than `current` - ---- Parse a single `:Diff` object into its left endpoint, optional explicit ---- path, and whether the right endpoint should be pinned to the worktree. ---- Recognizable Fugitive forms that are intentionally unsupported are rejected ---- with specific messages (see |diffs.nvim-diff-objects|). ----@param object string ----@return diffs.DiffParsedObject?, string? -local function parse_object(object) - if object == '#' or object:match('^#%d+$') then - return nil, 'alternate-buffer objects (#) are not supported' - end - if object:sub(1, 1) == '!' then - return nil, 'owner-commit objects (!) are not supported' - end - if object == '' then - return nil, ' objects are not supported' - end - if object == '-' then - return nil, 'the previous-object form (-) is not supported' - end - if object == '.' or object:sub(1, 2) == './' or object:sub(1, 3) == '../' then - return nil, 'worktree-relative path objects (./) are not supported' - end - if object:find('..', 1, true) then - return nil, 'range objects are not supported; use :Diff review for ranges' - end - if object:match('^:/') then - return nil, 'commit-message search objects (:/) are not supported' - end - if object:match('^:%(') then - return nil, 'pathspec-magic objects (:(...)) are not supported' - end - - if object == ':' or object == ':%' or object == ':0:%' then - return { left = diffspec.index() }, nil - end - - local stage_digit, stage_rest = object:match('^:([0-3]):(.+)$') - if stage_digit then - local left - if stage_digit == '0' then - left = diffspec.index() - elseif stage_digit == '1' then - left = diffspec.stage(1) - elseif stage_digit == '2' then - left = diffspec.stage(2) - else - left = diffspec.stage(3) - end - return { - left = left, - path = stage_rest ~= '%' and stage_rest or nil, - right_worktree = true, - }, - nil - end - - if object:match('^:[123]$') then - return nil, - 'merge stage ' .. object .. ' needs a path; use ' .. object .. ':% for the current file' - end - - if object:sub(1, 1) == ':' then - local object_path = object:sub(2) - if object_path == '%' then - return { left = diffspec.index() }, nil - end - return { left = diffspec.index(), path = object_path, right_worktree = true }, nil - end - - if object:match(':$') then - return nil, 'tree objects (trailing :) are not supported' - end - - -- Greedy: `a:b:c` splits at the last colon. Git revisions rarely contain - -- colons, and an invalid revision surfaces later when its content is read. - local rev, rev_path = object:match('^(.+):(.+)$') - if rev then - if rev_path == '%' then - return { left = diffspec.tree(normalize_rev(rev)) }, nil - end - return { - left = diffspec.tree(normalize_rev(rev)), - path = rev_path, - right_worktree = true, - }, - nil - end - - return { left = diffspec.tree(normalize_rev(object)) }, nil -end - ----@param current diffs.Endpoint ----@param path string ----@return diffs.DiffSpec -local function default_spec(current, path) - if is_worktree(current) then - return diffspec.index_to_worktree(path) - end - if is_index(current) then - return diffspec.head_to_index(path) - end - return diffspec.file(diffspec.worktree(), current, path) -end - ----@param args? string ----@param context diffs.DiffParseContext ----@return diffs.DiffParseResult?, string? -function M.parse(args, context) - local path = context.path - - local current = context.current and diffspec.endpoint(context.current) or diffspec.worktree() - local tokens = split_args(args) - local layout, layout_err = take_layout(tokens, context.default_layout) - if not layout then - return nil, layout_err - end - - if tokens[1] and tokens[1]:match('^%-%-') then - return nil, 'unsupported option ' .. tokens[1] - end - - if #tokens > 1 then - return nil, 'expected at most one Fugitive object' - end - - if #tokens == 0 then - return { - spec = default_spec(current, path), - layout = layout, - }, nil - end - - local object, err = parse_object(tokens[1]) - if not object then - return nil, err - end - - local scope_path = object.path or path - local right = object.right_worktree and diffspec.worktree() or current - return { - spec = diffspec.file(object.left, right, scope_path), - layout = layout, - }, nil -end - ----@class diffs.DiffFilesParseResult ----@field left? string # old/left side path, as typed; nil means the current buffer's file on disk ----@field right? string # new/right side path, as typed; nil means the current buffer ----@field layout "unified"|"stacked"|"split" - ----@param args? string ----@return diffs.DiffFilesParseResult?, string? -function M.parse_files(args) - local tokens = split_args(args) - local layout, layout_err = take_layout(tokens) - if not layout then - return nil, layout_err - end - - if #tokens > 2 then - return nil, 'expected at most two file paths' - end - - return { - left = tokens[1], - right = tokens[2], - layout = layout, - }, nil -end - -M._test = { - normalize_rev = normalize_rev, - split_args = split_args, -} - -return M diff --git a/lua/diffs/diffopt.lua b/lua/diffs/diffopt.lua index ca1e43c5..de45a61c 100644 --- a/lua/diffs/diffopt.lua +++ b/lua/diffs/diffopt.lua @@ -68,29 +68,4 @@ function M.vim_diff_opts() return opts end ---- Equivalent git diff flags for the resolved options. linematch has no git ---- counterpart and is omitted, and inline only shapes highlighting, not the ---- diff git is asked to produce. ----@return string[] -function M.git_flags() - local opts = M.resolve() - local flags = {} - if opts.ignore_whitespace then - flags[#flags + 1] = '--ignore-all-space' - end - if opts.ignore_whitespace_change then - flags[#flags + 1] = '--ignore-space-change' - end - if opts.ignore_whitespace_change_at_eol then - flags[#flags + 1] = '--ignore-space-at-eol' - end - if opts.ignore_blank_lines then - flags[#flags + 1] = '--ignore-blank-lines' - end - if opts.algorithm then - flags[#flags + 1] = '--diff-algorithm=' .. opts.algorithm - end - return flags -end - return M diff --git a/lua/diffs/difftastic.lua b/lua/diffs/difftastic.lua deleted file mode 100644 index 0b912a2a..00000000 --- a/lua/diffs/difftastic.lua +++ /dev/null @@ -1,393 +0,0 @@ -local M = {} - -local diffopt = require('diffs.diffopt') - -local hunk_model = require('diffs.hunks') -local log = require('diffs.log') -local runtime = require('diffs.runtime') - -local dbg = log.dbg - -local unified_ns = vim.api.nvim_create_namespace('diffs_difftastic_unified') -local active_var = 'diffs_difft_active' - -local COMMAND = 'difft' -local TIMEOUT_MS = 5000 - ----@class diffs.DifftAlignment : diffs.SplitAlignment ----@field left_intra table ----@field right_intra table ----@field changed boolean - ---- Mark a buffer as rendered by difftastic. Suppresses the decorator's own ---- intra step and short-circuits whitespace toggling (moot for a structural ---- diff). ----@param bufnr integer -function M.mark_active(bufnr) - pcall(vim.api.nvim_buf_set_var, bufnr, active_var, true) -end - ----@param bufnr integer -function M.clear_active(bufnr) - pcall(vim.api.nvim_buf_del_var, bufnr, active_var) - pcall(vim.api.nvim_buf_clear_namespace, bufnr, unified_ns, 0, -1) -end - ----@return { enabled: boolean, args: string[] } -function M.resolve() - local raw = runtime.get_difftastic_config() - if raw == nil or raw == false then - return { enabled = false, args = {} } - end - local args = {} - if type(raw) == 'table' and type(raw.args) == 'table' then - args = raw.args - end - return { enabled = true, args = args } -end - ----@return boolean -function M.available() - return M.resolve().enabled and vim.fn.executable(COMMAND) == 1 -end - ----@param value any ----@return any -local function nilify(value) - if value == nil or value == vim.NIL then - return nil - end - return value -end - ---- Coerce a ContentLines (a list with extra non-integer metadata keys) into a ---- plain string[] that vim.fn.writefile can convert to a Vim list. ----@param lines string[]|diffs.ContentLines ----@return string[] -local function plain_lines(lines) - local out = {} - for i, line in ipairs(lines) do - out[i] = line - end - return out -end - ----@param old_lines string[] ----@param new_lines string[] ----@param relpath string ----@return string, string, string temp dir, old path, new path -local function write_pair(old_lines, new_lines, relpath) - local tmp = vim.fn.tempname() - vim.fn.mkdir(tmp, 'p') - local base = vim.fn.fnamemodify(relpath, ':t') - if base == '' then - base = 'file' - end - local old_file = tmp .. '/old_' .. base - local new_file = tmp .. '/new_' .. base - vim.fn.writefile(plain_lines(old_lines), old_file) - vim.fn.writefile(plain_lines(new_lines), new_file) - return tmp, old_file, new_file -end - ---- Run difft on two file paths and return the decoded JSON, or nil + error. ---- We always force JSON output, no color, and strip-cr off (so difft's byte ---- offsets stay aligned with our exact line bytes); user `args` are appended. ----@param old_path string ----@param new_path string ----@return table?, string? -function M.run_json(old_path, new_path) - local cfg = M.resolve() - if not cfg.enabled then - return nil, 'difftastic disabled' - end - - local cmd = { COMMAND, '--display', 'json', '--color', 'never', '--strip-cr', 'off' } - for _, arg in ipairs(cfg.args) do - cmd[#cmd + 1] = arg - end - cmd[#cmd + 1] = old_path - cmd[#cmd + 1] = new_path - - local env = vim.tbl_extend('force', vim.fn.environ(), { DFT_UNSTABLE = 'yes' }) - local ok, result = pcall(function() - return vim.system(cmd, { text = true, env = env }):wait(TIMEOUT_MS) - end) - if not ok then - return nil, 'difft invocation failed: ' .. tostring(result) - end - if result.code ~= 0 and result.code ~= 1 then - return nil, 'difft exited ' .. tostring(result.code) .. ': ' .. (result.stderr or '') - end - if not result.stdout or result.stdout == '' then - return nil, 'difft produced no output' - end - - local decoded_ok, decoded = pcall(vim.json.decode, result.stdout) - if not decoded_ok then - return nil, 'invalid difft json: ' .. tostring(decoded) - end - if type(decoded) == 'table' and decoded.aligned_lines == nil and decoded[1] ~= nil then - decoded = decoded[1] - end - if type(decoded) ~= 'table' then - return nil, 'unexpected difft json shape' - end - if decoded.status == 'created' or decoded.status == 'deleted' then - return nil, 'difftastic: no structural diff for ' .. decoded.status .. ' file' - end - if decoded.status ~= 'unchanged' and (decoded.aligned_lines == nil or decoded.chunks == nil) then - return nil, 'unexpected difft json shape' - end - if type(decoded.language) == 'string' and decoded.language:match('^Text') then - dbg('difftastic line-diff fallback: %s', decoded.language) - end - return decoded -end - ---- Per-line change spans, keyed by 0-based line number. Robust to difft's ---- unordered chunk entries (we key by line_number, never rely on order). ----@param json table ----@return table, table -function M.span_maps(json) - local lhs, rhs = {}, {} - local function collect(side_map, side) - local entry = nilify(side) - if not entry then - return - end - local line = nilify(entry.line_number) - if line == nil then - return - end - local spans = {} - for _, change in ipairs(entry.changes or {}) do - local s = nilify(change.start) - local e = nilify(change['end']) - if type(s) == 'number' and type(e) == 'number' and e > s then - spans[#spans + 1] = { col_start = s, col_end = e } - end - end - side_map[line] = spans - end - for _, chunk in ipairs(json.chunks or {}) do - for _, row in ipairs(chunk) do - collect(lhs, row.lhs) - collect(rhs, row.rhs) - end - end - return lhs, rhs -end - ----@param maps table ----@return boolean -local function map_has_spans(maps) - return next(maps) ~= nil -end - ----@param lhs table? ----@param rhs table? ----@return boolean -function M.has_changes(lhs, rhs) - return (lhs ~= nil and map_has_spans(lhs)) or (rhs ~= nil and map_has_spans(rhs)) -end - ----@param spans {col_start: integer, col_end: integer}[]? ----@return boolean -local function has_spans(spans) - return spans ~= nil and #spans > 0 -end - ---- Build a split-compatible alignment (diffs.SplitAlignment shape) from ---- difftastic's whole-file structural alignment, plus per-row intra spans. ----@param json table ----@param old_lines string[] ----@param new_lines string[] ----@return diffs.DifftAlignment -function M.build_alignment(json, old_lines, new_lines) - local lhs, rhs = M.span_maps(json) - local left_lines, right_lines = {}, {} - local left_rows, right_rows = {}, {} - local left_intra, right_intra = {}, {} - local anchors = {} - local prev_changed = false - local changed = false - - for _, pair in ipairs(json.aligned_lines or {}) do - local l, r = nilify(pair[1]), nilify(pair[2]) - local left_content = l ~= nil and old_lines[l + 1] or nil - local right_content = r ~= nil and new_lines[r + 1] or nil - local left_past = l ~= nil and left_content == nil - local right_past = r ~= nil and right_content == nil - local both_virtual = (l == nil or left_past) and (r == nil or right_past) - - if not both_virtual then - local left_changes = (l ~= nil and not left_past) and lhs[l] or nil - local right_changes = (r ~= nil and not right_past) and rhs[r] or nil - local left_filler = l == nil or left_past - local right_filler = r == nil or right_past - - local left_kind = left_filler and 'filler' - or (has_spans(left_changes) and 'delete' or 'context') - local right_kind = right_filler and 'filler' - or (has_spans(right_changes) and 'add' or 'context') - - local idx = #left_lines + 1 - left_lines[idx] = left_content or '' - right_lines[idx] = right_content or '' - left_rows[idx] = { - kind = left_kind, - old_lnum = (not left_filler) and (l + 1) or nil, - new_lnum = (not left_filler and r ~= nil and not right_past) and (r + 1) or nil, - } - right_rows[idx] = { - kind = right_kind, - old_lnum = (not right_filler and l ~= nil and not left_past) and (l + 1) or nil, - new_lnum = (not right_filler) and (r + 1) or nil, - } - if has_spans(left_changes) then - left_intra[idx] = left_changes - end - if has_spans(right_changes) then - right_intra[idx] = right_changes - end - - local row_changed = left_kind == 'delete' - or left_kind == 'filler' - or right_kind == 'add' - or right_kind == 'filler' - if row_changed then - changed = true - if not prev_changed then - anchors[#anchors + 1] = idx - end - end - prev_changed = row_changed - end - end - - return { - left_lines = left_lines, - right_lines = right_lines, - left_rows = left_rows, - right_rows = right_rows, - anchors = anchors, - left_intra = left_intra, - right_intra = right_intra, - changed = changed, - } -end - ---- Structural alignment for the split layout, from two content arrays. ----@param old_lines string[] ----@param new_lines string[] ----@param relpath string ----@return diffs.DifftAlignment?, string? -function M.align(old_lines, new_lines, relpath) - old_lines = plain_lines(old_lines) - new_lines = plain_lines(new_lines) - local tmp, old_file, new_file = write_pair(old_lines, new_lines, relpath) - local json, err = M.run_json(old_file, new_file) - pcall(vim.fn.delete, tmp, 'rf') - if not json then - dbg('difftastic align failed: %s', err or 'unknown') - return nil, err - end - return M.build_alignment(json, old_lines, new_lines) -end - ---- Per-line span maps for two content arrays (materialized to temp files). ----@param old_lines string[] ----@param new_lines string[] ----@param relpath string ----@return table?, table?, string? -function M.span_maps_for_content(old_lines, new_lines, relpath) - local tmp, old_file, new_file = write_pair(old_lines, new_lines, relpath) - local json, err = M.run_json(old_file, new_file) - pcall(vim.fn.delete, tmp, 'rf') - if not json then - dbg('difftastic span maps failed: %s', err or 'unknown') - return nil, nil, err - end - local lhs, rhs = M.span_maps(json) - return lhs, rhs -end - ---- Per-line span maps for two real file paths (`:Diff files` — no temp files). ----@param left_path string ----@param right_path string ----@return table?, table?, string? -function M.span_maps_for_paths(left_path, right_path) - local json, err = M.run_json(left_path, right_path) - if not json then - dbg('difftastic span maps failed: %s', err or 'unknown') - return nil, nil, err - end - local lhs, rhs = M.span_maps(json) - return lhs, rhs -end - ---- Paint structural intra-line spans onto a generated unified diffs:// buffer, ---- mapping difft's per-line spans onto the parsed hunk lines. Spans land in a ---- dedicated namespace; the decorator's own intra step is suppressed for the ---- buffer (diffs_difft_active). Content columns are offset past the rail and the ---- one-character diff prefix (rail_width + 1). ----@param bufnr integer ----@param lhs table ----@param rhs table ----@param diff_lines string[] ----@param spec diffs.DiffSpec? ----@param rail_width integer ----@return integer applied span count -function M.apply_unified(bufnr, lhs, rhs, diff_lines, spec, rail_width) - local opts = runtime.get_highlight_opts() - local priority = opts.highlights.priorities.char_bg - local base = rail_width + 1 - - vim.api.nvim_buf_clear_namespace(bufnr, unified_ns, 0, -1) - M.mark_active(bufnr) - - if diffopt.inline() == 'none' then - return 0 - end - - local line_count = vim.api.nvim_buf_line_count(bufnr) - local applied = 0 - for _, hunk in ipairs(hunk_model.parse(diff_lines, spec)) do - for _, line in ipairs(hunk.lines) do - local spans, hl - if line.kind == 'delete' and line.old_lnum then - spans, hl = lhs[line.old_lnum - 1], 'DiffsDeleteText' - elseif line.kind == 'add' and line.new_lnum then - spans, hl = rhs[line.new_lnum - 1], 'DiffsAddText' - end - if spans and line.lnum >= 1 and line.lnum <= line_count then - for _, span in ipairs(spans) do - local ok = pcall( - vim.api.nvim_buf_set_extmark, - bufnr, - unified_ns, - line.lnum - 1, - base + span.col_start, - { - end_col = base + span.col_end, - hl_group = hl, - priority = priority, - } - ) - if ok then - applied = applied + 1 - end - end - end - end - end - return applied -end - -M._test = { - nilify = nilify, - plain_lines = plain_lines, -} - -return M diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua deleted file mode 100644 index eca27ede..00000000 --- a/lua/diffs/fugitive.lua +++ /dev/null @@ -1,284 +0,0 @@ -local M = {} - -local commands = require('diffs.commands') -local git = require('diffs.git') -local keymaps = require('diffs.keymaps') -local log = require('diffs.log') - -local dbg = log.dbg -local notify = log.notify - ----@type table> -local buffer_keymaps = {} - ----@alias diffs.FugitiveSection 'staged' | 'unstaged' | 'untracked' | nil - ----@param bufnr integer ----@param lnum integer ----@return diffs.FugitiveSection -function M.get_section_at_line(bufnr, lnum) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false) - - for i = #lines, 1, -1 do - local line = lines[i] - if line:match('^Staged ') then - return 'staged' - elseif line:match('^Unstaged ') then - return 'unstaged' - elseif line:match('^Untracked ') then - return 'untracked' - end - end - - return nil -end - ----@param 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?, string? -local function parse_file_line(line) - local rename_status, old, new = line:match('^([RC])%d*%s+(.-)%s+->%s+(.+)$') - if old and new then - return unquote(vim.trim(new)), unquote(vim.trim(old)), rename_status - end - - 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, nil -end - ----@param line string ----@return diffs.FugitiveSection? -local function parse_section_header(line) - if line:match('^Staged %(%d') then - return 'staged' - elseif line:match('^Unstaged %(%d') then - return 'unstaged' - elseif line:match('^Untracked %(%d') then - return 'untracked' - end - return nil -end - ----@param bufnr integer ----@param lnum integer ----@return string?, diffs.FugitiveSection, boolean, string?, 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, nil - end - - local section_header = parse_section_header(current_line) - if section_header then - return nil, section_header, true, nil, nil - end - - 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, 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, status = parse_file_line(prev_line) - if filename then - local section = M.get_section_at_line(bufnr, i) - return filename, section, false, old_filename, status - end - if prev_line:match('^%w+ %(') or prev_line == '' then - break - end - end - end - - return nil, nil, false, nil, nil -end - ----@class diffs.HunkPosition ----@field hunk_header string ----@field offset integer - ----@param bufnr integer ----@param lnum integer ----@return diffs.HunkPosition? -function M.get_hunk_position(bufnr, lnum) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false) - local current = lines[lnum] - - if not current then - return nil - end - - local prefix = current:sub(1, 1) - if prefix ~= '+' and prefix ~= '-' and prefix ~= ' ' then - return nil - end - - for i = lnum - 1, 1, -1 do - local line = lines[i] - if line:match('^@@.-@@') then - return { - hunk_header = line, - offset = lnum - i, - } - end - if line:match('^[MADRCU?!]%s') or line:match('^%w+ %(') then - break - end - end - - return nil -end - ----@param bufnr integer ----@return string? -local function get_repo_root_from_fugitive(bufnr) - local bufname = vim.api.nvim_buf_get_name(bufnr) - local fugitive_path = bufname:match('^fugitive://(.+)///') - if fugitive_path then - return fugitive_path - end - - local cwd = vim.fn.getcwd() - local root = git.get_repo_root(cwd .. '/.') - return root -end - ----@param vertical boolean -function M.diff_file_under_cursor(vertical) - local bufnr = vim.api.nvim_get_current_buf() - local lnum = vim.api.nvim_win_get_cursor(0)[1] - - local filename, section, is_header, old_filename, status = M.get_file_at_line(bufnr, lnum) - - local repo_root = get_repo_root_from_fugitive(bufnr) - if not repo_root then - notify('could not determine repository root', vim.log.levels.ERROR) - return - end - - if is_header then - dbg('diff_section: %s', section or 'unknown') - if section == 'untracked' then - notify( - 'cannot diff untracked section; open an untracked file line instead', - vim.log.levels.WARN - ) - return - end - commands.diff_section(repo_root, { - vertical = vertical, - staged = section == 'staged', - }) - return - end - - if not filename then - notify('no file under cursor', vim.log.levels.WARN) - return - end - - local filepath = repo_root .. '/' .. filename - local old_filepath = old_filename and (repo_root .. '/' .. old_filename) or nil - local hunk_position = M.get_hunk_position(bufnr, lnum) - - dbg( - 'diff_file_under_cursor: %s (section: %s, old: %s, hunk_offset: %s)', - filename, - section or 'unknown', - old_filename or 'none', - hunk_position and tostring(hunk_position.offset) or 'none' - ) - - commands.diff_file(filepath, { - vertical = vertical, - staged = section == 'staged', - untracked = section == 'untracked', - unmerged = status == 'U', - old_filepath = old_filepath, - hunk_position = hunk_position, - }) -end - ----@param bufnr integer -function M.setup_keymaps(bufnr) - keymaps.set_buffer_keymaps(buffer_keymaps, bufnr, { - { - 'du', - function() - M.diff_file_under_cursor(false) - end, - { desc = 'Unified diff (horizontal)' }, - }, - { - 'dU', - function() - M.diff_file_under_cursor(true) - end, - { desc = 'Unified diff (vertical)' }, - }, - }) - - vim.api.nvim_create_autocmd('BufWipeout', { - buffer = bufnr, - callback = function() - keymaps.clear_buffer_keymaps(buffer_keymaps, bufnr) - end, - }) - dbg('set fugitive keymaps for buffer %d', bufnr) -end - -return M diff --git a/lua/diffs/generated/init.lua b/lua/diffs/generated/init.lua deleted file mode 100644 index 9d2d5c47..00000000 --- a/lua/diffs/generated/init.lua +++ /dev/null @@ -1 +0,0 @@ -return require('diffs.generated.source') diff --git a/lua/diffs/generated/source.lua b/lua/diffs/generated/source.lua deleted file mode 100644 index 4eb3a213..00000000 --- a/lua/diffs/generated/source.lua +++ /dev/null @@ -1,323 +0,0 @@ -local M = {} - -local diffspec = require('diffs.spec') -local hunk_model = require('diffs.hunks') - -local group = vim.api.nvim_create_augroup('diffs_generated_source', { clear = false }) - ----@type table> -local restorable = {} - ----@param bufnr integer ----@return table -local function tracked_vars(bufnr) - local vars = restorable[bufnr] - if vars then - return vars - end - - vars = {} - restorable[bufnr] = vars - vim.api.nvim_create_autocmd('BufWipeout', { - group = group, - buffer = bufnr, - once = true, - callback = function() - restorable[bufnr] = nil - end, - }) - return vars -end - ----@param bufnr integer ----@param name string ----@return any -function M.get_var(bufnr, name) - local ok, value = pcall(vim.api.nvim_buf_get_var, bufnr, name) - if ok then - return value - end - return nil -end - ----@param bufnr integer ----@param name string ----@param value any -function M.set_var(bufnr, name, value) - vim.api.nvim_buf_set_var(bufnr, name, value) - tracked_vars(bufnr)[name] = value -end - ----@param bufnr integer ----@param name string -function M.del_var(bufnr, name) - local vars = restorable[bufnr] - if vars then - vars[name] = nil - end - pcall(vim.api.nvim_buf_del_var, bufnr, name) -end - ----@param bufnr integer -function M.restore(bufnr) - for name, value in pairs(restorable[bufnr] or {}) do - if M.get_var(bufnr, name) == nil then - pcall(vim.api.nvim_buf_set_var, bufnr, name, value) - end - end -end - ----@param bufnr integer ----@return boolean, any -function M.raw_hunks(bufnr) - return pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_hunks') -end - ----@param bufnr integer ----@return diffs.DiffHunk[] -function M.hunks(bufnr) - local ok, parsed = M.raw_hunks(bufnr) - if ok and type(parsed) == 'table' then - return parsed - end - return {} -end - ----@param bufnr integer ----@param hunks diffs.DiffHunk[] -function M.set_hunks(bufnr, hunks) - vim.api.nvim_buf_set_var(bufnr, 'diffs_hunks', hunks) -end - ----@param bufnr integer ----@param diff_lines string[] ----@param diff_spec diffs.DiffSpec -function M.set_hunks_from_lines(bufnr, diff_lines, diff_spec) - M.set_hunks(bufnr, hunk_model.parse(diff_lines, diff_spec)) -end - ----@param bufnr integer -function M.clear_hunks(bufnr) - pcall(vim.api.nvim_buf_del_var, bufnr, 'diffs_hunks') -end - ----@class diffs.GeneratedBufferSource ----@field version integer ----@field kind "file"|"file_pair"|"section"|"review"|"unmerged"|"split_endpoint"|"files" ----@field repo_root? string ----@field spec? diffs.DiffSpec ----@field edge? "staged"|"unstaged" ----@field path? string ----@field old_path? string ----@field section? "staged"|"unstaged" ----@field review? diffs.ReviewSpec ----@field working_path? string ----@field side? "left"|"right" ----@field filetype? string ----@field quickfix? boolean ----@field left_path? string ----@field right_path? string ----@field left_name? string ----@field right_name? string ----@field right_buf? integer ----@field worktree_buf? integer - ----@param source table ----@return diffs.GeneratedBufferSource? -function M.normalize_source(source) - if type(source) ~= 'table' then - error('expected table') - end - if source.version ~= 1 then - error('expected version 1') - end - if source.kind ~= 'files' then - if type(source.repo_root) ~= 'string' or source.repo_root == '' then - error('expected repo_root') - end - end - - if source.kind == 'file' then - if source.spec == nil then - error('expected file spec') - end - source.spec = diffspec.new(source.spec) - elseif source.kind == 'split_endpoint' then - if source.spec == nil then - error('expected split endpoint spec') - end - source.spec = diffspec.new(source.spec) - if source.side ~= 'left' and source.side ~= 'right' then - error('expected split endpoint side') - end - if type(source.path) ~= 'string' or source.path == '' then - error('expected split endpoint path') - end - elseif source.kind == 'file_pair' then - if source.edge ~= 'staged' and source.edge ~= 'unstaged' then - error('expected file_pair edge') - end - if type(source.path) ~= 'string' or source.path == '' then - error('expected file_pair path') - end - if type(source.old_path) ~= 'string' or source.old_path == '' then - error('expected file_pair old_path') - end - elseif source.kind == 'section' then - if source.section ~= 'staged' and source.section ~= 'unstaged' then - error('expected section') - end - elseif source.kind == 'review' then - if type(source.review) ~= 'table' then - error('expected review spec') - end - elseif source.kind == 'unmerged' then - if type(source.path) ~= 'string' or source.path == '' then - error('expected unmerged path') - end - elseif source.kind == 'files' then - if type(source.left_path) ~= 'string' or source.left_path == '' then - error('expected files left_path') - end - if type(source.right_path) ~= 'string' or source.right_path == '' then - error('expected files right_path') - end - if source.right_buf ~= nil and type(source.right_buf) ~= 'number' then - error('expected files right_buf') - end - else - error('unknown source kind') - end - - return source -end - ----@param bufnr integer ----@return diffs.GeneratedBufferSource?, string? -function M.source(bufnr) - local raw = M.get_var(bufnr, 'diffs_source') - if raw == nil and not vim.api.nvim_buf_is_loaded(bufnr) then - local vars = restorable[bufnr] - raw = vars and vars.diffs_source - end - if raw == nil then - return nil, nil - end - - local ok, source = pcall(M.normalize_source, raw) - if not ok then - return nil, tostring(source) - end - - return source, nil -end - ----@param bufnr integer ----@param source diffs.GeneratedBufferSource|diffs.SplitEndpointSource -function M.set_source(bufnr, source) - M.set_var(bufnr, 'diffs_source', source) -end - ----@param repo_root string ----@param diff_spec diffs.DiffSpec ----@param worktree_buf? integer ----@return diffs.GeneratedBufferSource -function M.file_source(repo_root, diff_spec, worktree_buf) - return { - version = 1, - kind = 'file', - repo_root = repo_root, - spec = diff_spec, - worktree_buf = worktree_buf, - } -end - ----@param repo_root string ----@param edge "staged"|"unstaged" ----@param path string ----@param old_path string ----@return diffs.GeneratedBufferSource -function M.file_pair_source(repo_root, edge, path, old_path) - return { - version = 1, - kind = 'file_pair', - repo_root = repo_root, - edge = edge, - path = path, - old_path = old_path, - } -end - ----@param repo_root string ----@param section "staged"|"unstaged" ----@return diffs.GeneratedBufferSource -function M.section_source(repo_root, section) - return { - version = 1, - kind = 'section', - repo_root = repo_root, - section = section, - } -end - ----@param repo_root string ----@param path string ----@param working_path string? ----@return diffs.GeneratedBufferSource -function M.unmerged_source(repo_root, path, working_path) - return { - version = 1, - kind = 'unmerged', - repo_root = repo_root, - path = path, - working_path = working_path, - } -end - ----@param left_path string # absolute path of the old/left side ----@param right_path string # absolute path of the new/right side ----@param left_name string # display name for the old/left side ----@param right_name string # display name for the new/right side ----@param right_buf? integer # buffer whose content is the new/right side; nil reads the path ----@return diffs.GeneratedBufferSource -function M.files_source(left_path, right_path, left_name, right_name, right_buf) - return { - version = 1, - kind = 'files', - left_path = left_path, - right_path = right_path, - left_name = left_name, - right_name = right_name, - right_buf = right_buf, - } -end - ----@param repo_root string ----@param review diffs.ReviewSpec ----@return diffs.GeneratedBufferSource -function M.review_source(repo_root, review) - return { - version = 1, - kind = 'review', - repo_root = repo_root, - review = review, - } -end - ----@param opts { repo_root: string, spec: diffs.DiffSpec, side: "left"|"right", path: string, filetype?: string, quickfix?: boolean, worktree_buf?: integer } ----@return diffs.SplitEndpointSource -function M.split_endpoint_source(opts) - return { - version = 1, - kind = 'split_endpoint', - repo_root = opts.repo_root, - spec = opts.spec, - side = opts.side, - path = opts.path, - filetype = opts.filetype, - quickfix = opts.quickfix, - worktree_buf = opts.worktree_buf, - } -end - -return M diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua deleted file mode 100644 index cc7f459e..00000000 --- a/lua/diffs/git.lua +++ /dev/null @@ -1,233 +0,0 @@ -local M = {} - -local content = require('diffs.content') - -local repo_root_cache = {} - -local function is_index_stage(revision) - return type(revision) == 'string' and revision:match('^:%d$') ~= nil -end - ----@param filepath? string ----@return string? -function M.get_repo_root(filepath) - local dir = filepath and vim.fn.fnamemodify(filepath, ':h') or vim.fn.getcwd() - 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 - ----@param repo_root string ----@param filepath string ----@return string? -local function relative_path(repo_root, filepath) - if vim.startswith(filepath, repo_root) then - return filepath:sub(#repo_root + 2) - end - return vim.fn.fnamemodify(filepath, ':.') -end - ----@param revision string ----@param filepath string ----@return diffs.ContentLines?, string? -function M.get_file_content(revision, filepath) - local repo_root = M.get_repo_root(filepath) - if not repo_root then - return nil, 'not in a git repository' - end - - local rel_path = relative_path(repo_root, filepath) - local object = revision - if revision:find(':', 1, true) and not is_index_stage(revision) then - local resolved = vim.fn.systemlist({ - 'git', - '-C', - repo_root, - 'rev-parse', - '--verify', - '--quiet', - '--end-of-options', - revision, - }) - if vim.v.shell_error ~= 0 then - return nil, 'failed to resolve revision: ' .. revision - end - object = resolved[1] - end - - local result = vim.fn.systemlist( - { 'git', '-C', repo_root, 'show', '--end-of-options', object .. ':' .. rel_path }, - nil, - 1 - ) - if vim.v.shell_error ~= 0 then - if not is_index_stage(revision) then - vim.fn.system({ - 'git', - '-C', - repo_root, - 'rev-parse', - '--verify', - '--end-of-options', - object .. '^{tree}', - }) - if vim.v.shell_error ~= 0 then - return nil, 'failed to resolve revision: ' .. revision - end - end - return nil, 'file not in revision: ' .. revision - end - return content.from_raw_lines(result), nil -end - ----@param filepath string ----@return string? -function M.get_relative_path(filepath) - local repo_root = M.get_repo_root(filepath) - if not repo_root then - return nil - end - return relative_path(repo_root, filepath) -end - ----@param filepath string ----@return diffs.ContentLines?, string? -function M.get_index_content(filepath) - local repo_root = M.get_repo_root(filepath) - if not repo_root then - return nil, 'not in a git repository' - end - - local rel_path = M.get_relative_path(filepath) - if not rel_path then - return nil, 'could not determine relative path' - end - - local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'show', ':0:' .. rel_path }, nil, 1) - if vim.v.shell_error ~= 0 then - return nil, 'file not in index' - end - return content.from_raw_lines(result), nil -end - ----@param filepath string ----@return diffs.ContentLines?, string? -function M.get_working_content(filepath) - if vim.fn.filereadable(filepath) ~= 1 then - return nil, 'file not readable' - end - local lines = vim.fn.readfile(filepath, 'b') - return content.from_raw_lines(lines, { empty_is_empty = vim.fn.getfsize(filepath) == 0 }), nil -end - ----@param filepath string ----@return string? -function M.get_index_mode(filepath) - local repo_root = M.get_repo_root(filepath) - if not repo_root then - return nil - end - - local rel_path = M.get_relative_path(filepath) - if not rel_path then - return nil - end - - local result = - vim.fn.systemlist({ 'git', '-C', repo_root, 'ls-files', '--stage', '--', rel_path }) - if vim.v.shell_error ~= 0 or #result == 0 then - return nil - end - return result[1]:match('^(%d+)') -end - ----@param revision string ----@param filepath string ----@return string? -function M.get_tree_mode(revision, filepath) - if is_index_stage(revision) then - return nil - end - - local repo_root = M.get_repo_root(filepath) - if not repo_root then - return nil - end - - local rel_path = M.get_relative_path(filepath) - if not rel_path then - return nil - end - - local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'ls-tree', revision, '--', rel_path }) - if vim.v.shell_error ~= 0 or #result == 0 then - return nil - end - return result[1]:match('^(%d+)') -end - ----@param filepath string ----@return string? -function M.get_working_mode(filepath) - if vim.fn.filereadable(filepath) ~= 1 then - return nil - end - - local perm = vim.fn.getfperm(filepath) - if perm:sub(3, 3) == 'x' then - return '100755' - end - return '100644' -end - ----@param filepath string ----@return boolean -function M.is_unmerged(filepath) - local repo_root = M.get_repo_root(filepath) - if not repo_root then - return false - end - - local rel_path = relative_path(repo_root, filepath) - local result = - vim.fn.systemlist({ 'git', '-C', repo_root, 'ls-files', '--unmerged', '--', rel_path }) - return vim.v.shell_error == 0 and #result > 0 -end - ----@class diffs.GitApplyOpts ----@field cached? boolean ----@field reverse? boolean ----@field check? boolean ----@field recount? boolean - ----@param repo_root string ----@param patch string ----@param opts? diffs.GitApplyOpts ----@return boolean, string[] -function M.apply_patch(repo_root, patch, opts) - opts = opts or {} - local cmd = { 'git', '-C', repo_root, 'apply' } - if opts.cached then - table.insert(cmd, '--cached') - end - if opts.reverse then - table.insert(cmd, '--reverse') - end - if opts.check then - table.insert(cmd, '--check') - end - if opts.recount then - table.insert(cmd, '--recount') - end - - local output = vim.fn.systemlist(cmd, patch) - return vim.v.shell_error == 0, output -end - -return M diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 9723c2d7..ac9de971 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -2,28 +2,6 @@ local M = {} local dbg = require('diffs.log').dbg local diff = require('diffs.diff') -local rails = require('diffs.rails') - ----@param start_col integer? ----@param end_col integer? ----@param raw_len integer? ----@return integer?, integer? -local function clamp_cols(start_col, end_col, raw_len) - if not start_col or not end_col then - return nil, nil - end - if raw_len then - if start_col >= raw_len then - return nil, nil - end - end_col = math.min(end_col, raw_len) - end - if end_col <= start_col then - return nil, nil - end - return start_col, end_col -end - local vim_syntax_cache = {} local vim_syntax_cache_size = 0 local vim_syntax_cache_clock = 0 @@ -547,7 +525,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) elseif has_del and not has_add then old_map[#old_code] = buf_line table.insert(old_code, stripped) - else + elseif prefix:match('^[ +%-]+$') then new_map[#new_code] = buf_line table.insert(new_code, stripped) table.insert(old_code, stripped) @@ -578,7 +556,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) elseif use_vim then local code_lines = {} for _, line in ipairs(hunk.lines) do - table.insert(code_lines, line:sub(pw + 1)) + table.insert(code_lines, line:sub(1, pw):match('^[ +%-]+$') and line:sub(pw + 1) or '') end if opts.defer_vim_syntax then extmark_count, vim_cache_hit = @@ -622,12 +600,10 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) ---@type diffs.IntraChanges? local intra = nil local intra_cfg = opts.highlights.intra - local difft_active = opts.intra == false - or (opts.intra == nil and vim.b[bufnr].diffs_difft_active == true) if not opts.syntax_only and intra_cfg - and not difft_active + and opts.intra ~= false and pw == 1 and (hunk._hl_line_count or #hunk.lines) <= intra_cfg.max_lines then @@ -669,7 +645,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) local ws_only = {} if not opts.syntax_only - and not difft_active + and opts.intra ~= false and pw == 1 and (hunk._hl_line_count or #hunk.lines) <= (intra_cfg and intra_cfg.max_lines or 500) then @@ -758,26 +734,15 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) local dehl = ws_only[i] == true local line_hl = is_diff_line and (has_add and 'DiffsAdd' or 'DiffsDelete') or nil local prefix_hl = is_diff_line and (has_add and '@diff.plus' or '@diff.minus') or nil - local bar_hl = is_diff_line and (has_add and 'DiffsAddBar' or 'DiffsDeleteBar') or nil local dim_hl = is_diff_line and 'DiffsDim' or nil local bg_hl = (dehl and dim_hl) or line_hl - 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 not opts.syntax_only then - if opts.hide_prefix and (not hunk.rail_width or is_diff_line) then + if not opts.syntax_only and prefix:match('^[ +%-]+$') then + if opts.hide_prefix then local virt_hl = (opts.highlights.background and bg_hl) or nil - local rail_width = (hunk.rail_width or 0) + offset - local hide_width = math.max(0, pw + qw - rail_width) + local hide_width = math.max(0, pw + qw - offset) if hide_width > 0 then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, rail_width, { + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, { virt_text = { { string.rep(' ', hide_width), virt_hl } }, virt_text_pos = 'overlay', }) @@ -786,9 +751,6 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) if qw > 0 or pw > 1 then local prefix_end = pw + qw - if hunk.rail_width and not is_diff_line then - prefix_end = hunk.rail_width - end if raw_len and prefix_end > raw_len then prefix_end = raw_len end @@ -797,55 +759,6 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) hl_group = 'DiffsClear', priority = p.clear, }) - if hunk.rail_width and hunk.rail_width > 0 then - local rail_end = hunk.rail_width - if raw_len and rail_end > raw_len then - rail_end = raw_len - end - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, { - end_col = rail_end, - hl_group = 'DiffsRail', - priority = p.syntax, - }) - - local rail_style = hunk.rail_style or 'dual' - local rail_number_ranges = rails.number_ranges(hunk.rail_width, rail_style) - if rail_number_ranges then - for _, range in ipairs(rail_number_ranges) do - local nr_start, nr_end = clamp_cols(range.start, range.finish, raw_len) - if nr_start and nr_end then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, nr_start, { - end_col = nr_end, - hl_group = 'DiffsRailNr', - priority = p.syntax + 1, - }) - end - end - end - local rail_nr_start, rail_nr_end, rail_nr_hl - if (has_del or has_add) and rail_number_ranges and not dehl then - local first_range = rail_number_ranges[1] - local last_range = rail_style == 'single' and first_range - or rail_number_ranges[#rail_number_ranges] - if first_range and last_range then - rail_nr_start = first_range.start - rail_nr_end = last_range.finish - rail_nr_hl = has_del and 'DiffsDeleteRailNr' or 'DiffsAddRailNr' - end - end - if rail_nr_start and rail_nr_end and rail_nr_hl then - local nr_start = rail_nr_start - local nr_end = rail_nr_end - nr_start, nr_end = clamp_cols(nr_start, nr_end, raw_len) - if nr_start and nr_end then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, nr_start, { - end_col = nr_end, - hl_group = rail_nr_hl, - priority = p.syntax + 2, - }) - end - end - end for ci = 0, pw - 1 do local ch = line:sub(ci + 1, ci + 1) if ch == '+' or ch == '-' then @@ -868,15 +781,6 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) }) end - if hunk.rail_width and is_diff_line and opts.highlights.background and not dehl then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, { - virt_text = { { '▏', bar_hl } }, - virt_text_pos = 'overlay', - hl_mode = 'replace', - priority = p.char_bg + 10, - }) - end - if opts.highlights.background and is_diff_line then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, { end_row = buf_line + 1, @@ -887,14 +791,6 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) }) 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] and not dehl then local char_hl = has_add and 'DiffsAddText' or 'DiffsDeleteText' for _, span in ipairs(char_spans_by_line[i]) do @@ -921,7 +817,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end - if not hunk.rail_width and line_len > pw and covered_lines[buf_line] then + 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', diff --git a/lua/diffs/hunks.lua b/lua/diffs/hunks.lua deleted file mode 100644 index a61488da..00000000 --- a/lua/diffs/hunks.lua +++ /dev/null @@ -1,590 +0,0 @@ -local M = {} - -local diffspec = require('diffs.spec') -local notify = require('diffs.log').notify - ----@class diffs.DiffHunkRange ----@field start integer ----@field count integer ----@field finish integer - ----@class diffs.DiffHunkLine ----@field lnum integer ----@field kind "header"|"add"|"delete"|"context"|"meta" ----@field text string ----@field file string? ----@field old_lnum integer? ----@field new_lnum integer? ----@field source_lnum integer? ----@field hunk_index integer - ----@class diffs.DiffHunk ----@field index integer ----@field file string? ----@field path string? ----@field old_range diffs.DiffHunkRange ----@field new_range diffs.DiffHunkRange ----@field buffer_range diffs.DiffHunkRange ----@field file_header_range diffs.DiffHunkRange? ----@field file_header_lines string[] ----@field header string ----@field generated_key string? ----@field section string? ----@field section_label string? ----@field diff_spec diffs.DiffSpec? ----@field edge { left: diffs.Endpoint, right: diffs.Endpoint, mutation_target: "index"|"worktree"|nil }? ----@field can_put boolean ----@field can_obtain boolean ----@field actionable boolean ----@field mutation_target "index"|"worktree"|nil ----@field source_lnum integer ----@field lines diffs.DiffHunkLine[] - ----@param value integer ----@return integer -local function source_lnum(value) - return math.max(value, 1) -end - ----@param start integer ----@param count integer ----@return diffs.DiffHunkRange -local function range(start, count) - return { - start = start, - count = count, - finish = count > 0 and (start + count - 1) or start, - } -end - ----@param text string ----@return integer -local function parse_count(text) - if text == '' then - return 1 - end - return tonumber(text) or 1 -end - ----@param line string ----@return diffs.DiffHunkRange?, diffs.DiffHunkRange? -local function parse_header_ranges(line) - local old_start, old_count, new_start, new_count = - line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') - if not old_start then - return nil, nil - end - - return range(tonumber(old_start) or 0, parse_count(old_count)), - range(tonumber(new_start) or 0, parse_count(new_count)) -end - ----@param path string? ----@param strip_prefix? boolean ----@return string? -local function normalize_path(path, strip_prefix) - if not path or path == '/dev/null' then - return nil - end - path = path:gsub('\t.*$', '') - local normalized = strip_prefix and path:gsub('^%a/', '') or path - return normalized -end - ----@param line string ----@return string?, string? -local function parse_diff_git_paths(line) - local old_path, new_path = line:match('^diff %-%-git %a/(.-) %a/(.+)$') - return normalize_path(old_path), normalize_path(new_path) -end - ----@param line string ----@return string? -local function parse_old_path(line) - return normalize_path(line:match('^%-%-%- (.+)$'), true) -end - ----@param line string ----@return string? -local function parse_new_path(line) - return normalize_path(line:match('^%+%+%+ (.+)$'), true) -end - ----@param line string ----@return boolean -local function is_review_section_header(line) - return line:match('^# Branch:') ~= nil - or line:match('^# Staged:') ~= nil - or line:match('^# Unstaged:') ~= nil - or line:match('^# Untracked:') ~= nil -end - ----@param diff_spec diffs.DiffSpec? ----@return diffs.DiffSpec? -local function normalize_spec(diff_spec) - if not diff_spec then - return nil - end - return diffspec.new(diff_spec) -end - ----@param lines string[] ----@return string[] -local function copy_lines(lines) - local copied = {} - for _, line in ipairs(lines) do - copied[#copied + 1] = line - end - return copied -end - ----@param diff_lines string[] ----@param diff_spec? diffs.DiffSpec ----@return diffs.DiffHunk[] -function M.parse(diff_lines, diff_spec) - local spec = normalize_spec(diff_spec) - local hunks = {} - local current_old_path = spec and spec.scope.kind == diffspec.scope_kind.file and spec.scope.path - or nil - local current_new_path = current_old_path - local current_file = current_new_path or current_old_path - local current_hunk = nil - local current_file_header_start = nil - local current_file_header_lines = {} - local old_lnum = 0 - local new_lnum = 0 - - ---@param finish_lnum integer - local function finish_hunk(finish_lnum) - if not current_hunk then - return - end - current_hunk.buffer_range.finish = finish_lnum - current_hunk.buffer_range.count = finish_lnum - current_hunk.buffer_range.start + 1 - current_hunk = nil - end - - ---@param lnum integer - ---@param line string - local function start_file_header(lnum, line) - current_file_header_start = lnum - current_file_header_lines = { line } - end - - ---@param lnum integer - ---@param line string - local function add_file_header_line(lnum, line) - if not current_file_header_start then - current_file_header_start = lnum - end - current_file_header_lines[#current_file_header_lines + 1] = line - end - - ---@param hunk diffs.DiffHunk - ---@param line diffs.DiffHunkLine - local function add_line(hunk, line) - hunk.lines[#hunk.lines + 1] = line - end - - for lnum, line in ipairs(diff_lines) do - if is_review_section_header(line) then - finish_hunk(lnum - 1) - current_old_path = spec and spec.scope.kind == diffspec.scope_kind.file and spec.scope.path - or nil - current_new_path = current_old_path - current_file = current_new_path or current_old_path - current_hunk = nil - current_file_header_start = nil - current_file_header_lines = {} - old_lnum = 0 - new_lnum = 0 - elseif line:match('^diff %-%-git ') then - finish_hunk(lnum - 1) - current_old_path, current_new_path = parse_diff_git_paths(line) - current_file = current_new_path or current_old_path - start_file_header(lnum, line) - elseif line:match('^%-%-%- ') and not current_hunk then - current_old_path = parse_old_path(line) or current_old_path - current_file = current_new_path or current_old_path - add_file_header_line(lnum, line) - elseif line:match('^%+%+%+ ') and not current_hunk then - current_new_path = parse_new_path(line) or current_new_path - current_file = current_new_path or current_old_path - add_file_header_line(lnum, line) - else - local old_range, new_range = parse_header_ranges(line) - if old_range and new_range then - finish_hunk(lnum - 1) - local index = #hunks + 1 - local file_header_lines = copy_lines(current_file_header_lines) - current_hunk = { - index = index, - file = current_file, - path = current_file, - old_range = old_range, - new_range = new_range, - buffer_range = range(lnum, 1), - file_header_range = current_file_header_start - and range(current_file_header_start, #file_header_lines) - or nil, - file_header_lines = file_header_lines, - header = line, - source_lnum = source_lnum(new_range.start), - lines = {}, - } - old_lnum = old_range.start - new_lnum = new_range.start - add_line(current_hunk, { - lnum = lnum, - kind = 'header', - text = line, - file = current_file, - old_lnum = old_range.start, - new_lnum = new_range.start, - source_lnum = source_lnum(new_range.start), - hunk_index = index, - }) - hunks[#hunks + 1] = current_hunk - elseif current_hunk then - local prefix = line:sub(1, 1) - if prefix == '+' then - add_line(current_hunk, { - lnum = lnum, - kind = 'add', - text = line, - file = current_file, - new_lnum = new_lnum, - source_lnum = source_lnum(new_lnum), - hunk_index = current_hunk.index, - }) - new_lnum = new_lnum + 1 - elseif prefix == '-' then - add_line(current_hunk, { - lnum = lnum, - kind = 'delete', - text = line, - file = current_file, - old_lnum = old_lnum, - source_lnum = source_lnum(new_lnum), - hunk_index = current_hunk.index, - }) - old_lnum = old_lnum + 1 - elseif prefix == ' ' then - add_line(current_hunk, { - lnum = lnum, - kind = 'context', - text = line, - file = current_file, - old_lnum = old_lnum, - new_lnum = new_lnum, - source_lnum = source_lnum(new_lnum), - hunk_index = current_hunk.index, - }) - old_lnum = old_lnum + 1 - new_lnum = new_lnum + 1 - else - add_line(current_hunk, { - lnum = lnum, - kind = 'meta', - text = line, - file = current_file, - source_lnum = source_lnum(new_lnum), - hunk_index = current_hunk.index, - }) - end - elseif current_file_header_start then - add_file_header_line(lnum, line) - end - end - end - - finish_hunk(#diff_lines) - - return M.decorate_actionability(hunks, spec) -end - ----@param hunk diffs.DiffHunk ----@param spec diffs.DiffSpec? ----@param target "index"|"worktree"|nil ----@param can_put boolean ----@param can_obtain boolean -local function decorate_hunk_actionability(hunk, spec, target, can_put, can_obtain) - hunk.diff_spec = spec - hunk.edge = spec - and { - left = diffspec.endpoint(spec.left), - right = diffspec.endpoint(spec.right), - mutation_target = target, - } - or nil - hunk.can_put = can_put - hunk.can_obtain = can_obtain - hunk.actionable = can_put or can_obtain - hunk.mutation_target = target -end - ----@param hunks diffs.DiffHunk[] ----@param diff_spec? diffs.DiffSpec ----@return diffs.DiffHunk[] -function M.decorate_actionability(hunks, diff_spec) - local spec = normalize_spec(diff_spec) - local target = spec and diffspec.mutation_target(spec) or nil - local patch_actions = spec and diffspec.patch_actions(spec) or nil - local can_put = patch_actions and patch_actions.can_put or false - local can_obtain = patch_actions and patch_actions.can_obtain or false - - for _, hunk in ipairs(hunks or {}) do - decorate_hunk_actionability(hunk, spec, target, can_put, can_obtain) - end - return hunks -end - ----@param hunks diffs.DiffHunk[]? ----@param lnum integer ----@return diffs.DiffHunk? -function M.hunk_at_line(hunks, lnum) - for _, hunk in ipairs(hunks or {}) do - if lnum >= hunk.buffer_range.start and lnum <= hunk.buffer_range.finish then - return hunk - end - end - return nil -end - ----@param hunks diffs.DiffHunk[]? ----@param lnum integer ----@return diffs.DiffHunkLine? -function M.line_at(hunks, lnum) - local hunk = M.hunk_at_line(hunks, lnum) - if not hunk then - return nil - end - for _, line in ipairs(hunk.lines) do - if line.lnum == lnum then - return line - end - end - return nil -end - ----@param hunks diffs.DiffHunk[]? ----@param lnum integer ----@return diffs.DiffHunk? -function M.next_hunk(hunks, lnum) - for _, hunk in ipairs(hunks or {}) do - if hunk.buffer_range.start > lnum then - return hunk - end - end - return nil -end - ----@param hunks diffs.DiffHunk[]? ----@param lnum integer ----@return diffs.DiffHunk? -function M.prev_hunk(hunks, lnum) - for i = #(hunks or {}), 1, -1 do - local hunk = hunks[i] - if hunk.buffer_range.start < lnum then - return hunk - end - end - return nil -end - ----@class diffs.SourcePosition ----@field side "left"|"right" ----@field lnum integer - ----@param line diffs.DiffHunkLine? ----@return diffs.SourcePosition? -function M.source_position_for(line) - if not line then - return nil - end - if line.kind == 'delete' and line.old_lnum then - return { side = 'left', lnum = line.old_lnum } - end - if (line.kind == 'add' or line.kind == 'context') and line.new_lnum then - return { side = 'right', lnum = line.new_lnum } - end - return nil -end - ----@param hunks diffs.DiffHunk[]? ----@param selection { file: string, key?: string } ----@param position diffs.SourcePosition ----@return diffs.DiffHunkLine? -function M.line_at_source(hunks, selection, position) - local field = position.side == 'left' and 'old_lnum' or 'new_lnum' - local kind = position.side == 'left' and 'delete' or 'add' - for _, hunk in ipairs(hunks or {}) do - local matches = selection.key and hunk.generated_key == selection.key - or (not selection.key and (hunk.file or hunk.path) == selection.file) - if matches then - for _, line in ipairs(hunk.lines) do - if (line.kind == kind or line.kind == 'context') and line[field] == position.lnum then - return line - end - end - end - end - return nil -end - ----@param item diffs.DiffHunk|diffs.DiffHunkLine|nil ----@return { path: string, lnum: integer }? -function M.source_line_for(item) - if not item then - return nil - end - local path = item.file or item.path - local lnum = item.source_lnum - if not lnum and item.new_range then - lnum = source_lnum(item.new_range.start) - end - if not path or not lnum then - return nil - end - return { path = path, lnum = lnum } -end - ----@param bufnr integer ----@return diffs.DiffHunk[] -local function buffer_hunks(bufnr) - local ok, parsed = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_hunks') - if ok and type(parsed) == 'table' then - return parsed - end - return {} -end - ----@param bufnr integer ----@param name string ----@return any -local function get_buf_var(bufnr, name) - local ok, value = pcall(vim.api.nvim_buf_get_var, bufnr, name) - if ok then - return value - end - return nil -end - ----@param filepath string ----@return integer? -local function find_window_for_file(filepath) - local tabpage = vim.api.nvim_get_current_tabpage() - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do - if vim.api.nvim_win_is_valid(win) then - local buf = vim.api.nvim_win_get_buf(win) - if vim.api.nvim_buf_get_name(buf) == filepath then - return win - end - end - end - return nil -end - ----@param repo_root string ----@param path string ----@return string -local function resolve_worktree_path(repo_root, path) - if path:sub(1, 1) == '/' then - return path - end - return vim.fs.joinpath(repo_root, path) -end - ----@param bufnr integer ----@return { path: string, lnum: integer }?, string? -function M.source_at_cursor(bufnr) - local parsed = buffer_hunks(bufnr) - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - local hunk = M.hunk_at_line(parsed, cursor_line) - if not hunk then - return nil, 'cursor is not on a diff hunk' - end - - local spec = hunk.diff_spec and diffspec.new(hunk.diff_spec) or nil - if not spec then - return nil, 'missing diff specification for hunk' - end - - if spec.right.kind == diffspec.endpoint_kind.index then - return nil, 'cannot open index-backed diff hunk as a worktree file' - end - if spec.right.kind ~= diffspec.endpoint_kind.worktree then - return nil, 'cannot open read-only tree-backed diff hunk as a worktree file' - end - - local source = M.source_line_for(M.line_at(parsed, cursor_line)) or M.source_line_for(hunk) - if not source then - return nil, 'could not resolve source location for hunk' - end - - local metadata = get_buf_var(bufnr, 'diffs_source') - local repo_root = metadata and metadata.repo_root - if type(repo_root) ~= 'string' or repo_root == '' then - return nil, 'cannot open source without a source repository' - end - - return { - path = resolve_worktree_path(repo_root, source.path), - lnum = source.lnum, - }, - nil -end - ----@param bufnr integer ----@return boolean -function M.open_source(bufnr) - local source, err = M.source_at_cursor(bufnr) - if not source then - notify(err, vim.log.levels.WARN) - return false - end - - local win = find_window_for_file(source.path) - if win then - vim.api.nvim_set_current_win(win) - else - vim.cmd.edit(vim.fn.fnameescape(source.path)) - end - - local line_count = vim.api.nvim_buf_line_count(0) - local lnum = math.max(1, math.min(source.lnum, line_count)) - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) - return true -end - ----@param bufnr integer -function M.goto_next(bufnr) - local parsed = buffer_hunks(bufnr) - if #parsed == 0 then - return - end - - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - local next_hunk = M.next_hunk(parsed, cursor_line) or parsed[1] - if next_hunk == parsed[1] and next_hunk.buffer_range.start <= cursor_line then - notify('wrapped to first hunk', vim.log.levels.INFO) - end - vim.api.nvim_win_set_cursor(0, { next_hunk.buffer_range.start, 0 }) -end - ----@param bufnr integer -function M.goto_prev(bufnr) - local parsed = buffer_hunks(bufnr) - if #parsed == 0 then - return - end - - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - local prev_hunk = M.prev_hunk(parsed, cursor_line) or parsed[#parsed] - if prev_hunk == parsed[#parsed] and prev_hunk.buffer_range.start >= cursor_line then - notify('wrapped to last hunk', vim.log.levels.INFO) - end - vim.api.nvim_win_set_cursor(0, { prev_hunk.buffer_range.start, 0 }) -end - -return M diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 38d3cc5b..d4cbb265 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -1,43 +1,11 @@ ----@class diffs.PublicApi ----@field attach fun(bufnr?: integer, options?: diffs.AttachOptions) ----@field detach fun(bufnr?: integer) ----@field highlight_options fun(): diffs.HunkOpts ----@field diff_options fun(): diffs.DiffOpts ----@field compute_intra_hunks fun(lines: string[]): diffs.IntraChanges? ----@field whitespace_only_lines fun(lines: string[]): table ----@field open_review fun(spec?: diffs.ReviewSpec, opts?: diffs.ReviewOpts): integer? ----@field refresh fun(bufnr?: integer) ----@field review_files fun(bufnr?: integer): diffs.ReviewFile[]? ----@field review_current fun(bufnr?: integer): { index: integer, count: integer, file: diffs.ReviewFile }? ----@field review_goto fun(target: string, bufnr?: integer): boolean ----@field review_next_file fun() ----@field review_prev_file fun() ----@field review_toggle_layout fun(opts?: diffs.ReviewToggleLayoutOpts): integer? ----@field select_review_file fun(bufnr?: integer) - local runtime = require('diffs.runtime') -local function command(name) - return function(...) - return require('diffs.commands')[name](...) - end -end - ----@type diffs.PublicApi return { attach = runtime.attach, detach = runtime.detach, + refresh = runtime.refresh, highlight_options = runtime.get_highlight_opts, diff_options = require('diffs.diffopt').resolve, compute_intra_hunks = require('diffs.diff').compute_intra_hunks, whitespace_only_lines = require('diffs.diff').whitespace_only_lines, - open_review = command('open_review'), - refresh = runtime.refresh, - review_files = command('review_files'), - review_current = command('review_current'), - review_goto = command('review_goto'), - review_next_file = command('review_next_file'), - review_prev_file = command('review_prev_file'), - review_toggle_layout = command('review_toggle_layout'), - select_review_file = command('select_review_file'), } diff --git a/lua/diffs/keymaps.lua b/lua/diffs/keymaps.lua deleted file mode 100644 index 43c0bea8..00000000 --- a/lua/diffs/keymaps.lua +++ /dev/null @@ -1,86 +0,0 @@ -local M = {} - ----@class diffs.BufferKeymap ----@field rhs string? ----@field callback function? - ----@param bufnr integer ----@param lhs string ----@return table? -local function get_buffer_keymap(bufnr, lhs) - for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, 'n')) do - if keymap.lhs == lhs then - return keymap - end - end -end - ----@param keymap table ----@return diffs.BufferKeymap -local function keymap_identity(keymap) - if keymap.callback then - return { callback = keymap.callback } - end - return { rhs = keymap.rhs or '' } -end - ----@param keymap table? ----@param identity diffs.BufferKeymap? ----@return boolean -local function keymap_matches(keymap, identity) - if not keymap or not identity then - return false - end - if identity.callback then - return keymap.callback == identity.callback - end - return (keymap.rhs or '') == identity.rhs -end - ----@param registry table> ----@param bufnr integer -function M.clear_buffer_keymaps(registry, bufnr) - local registered = registry[bufnr] - if not registered then - return - end - if not vim.api.nvim_buf_is_valid(bufnr) then - registry[bufnr] = nil - return - end - for lhs, identity in pairs(registered) do - local current = get_buffer_keymap(bufnr, lhs) - if keymap_matches(current, identity) then - pcall(vim.keymap.del, 'n', lhs, { buffer = bufnr }) - end - end - registry[bufnr] = nil -end - ----@param registry table> ----@param bufnr integer ----@param maps [string|false, string|function, table?][] -function M.set_buffer_keymaps(registry, bufnr, maps) - M.clear_buffer_keymaps(registry, bufnr) - - local installed = {} - for _, map in ipairs(maps) do - local lhs = map[1] - if type(lhs) == 'string' and lhs ~= '' then - local current = get_buffer_keymap(bufnr, lhs) - if not current or keymap_matches(current, installed[lhs]) then - local opts = vim.tbl_extend('force', { buffer = bufnr }, map[3] or {}) - vim.keymap.set('n', lhs, map[2], opts) - local installed_keymap = get_buffer_keymap(bufnr, lhs) - if installed_keymap then - installed[lhs] = keymap_identity(installed_keymap) - end - end - end - end - if next(installed) then - registry[bufnr] = installed - end -end - -return M diff --git a/lua/diffs/lists.lua b/lua/diffs/lists.lua deleted file mode 100644 index b76d6ec7..00000000 --- a/lua/diffs/lists.lua +++ /dev/null @@ -1,960 +0,0 @@ -local M = {} - -local generated = require('diffs.generated') -local hunk_model = require('diffs.hunks') - -local generated_list_autocmds = {} -local generated_list_state = {} -local quickfix_keymap_autocmd -local quickfix_enter_desc = 'Open quickfix item' -local quickfix_ns = vim.api.nvim_create_namespace('diffs_quickfix') - -local group = vim.api.nvim_create_augroup('diffs_generated_lists', { clear = false }) - ----@class diffs.GeneratedListOptions ----@field title? string ----@field loclist_title? string ----@field diff_spec? diffs.DiffSpec ----@field metadata_for_line? fun(lnum: integer, file: string): table? ----@field sections? table[] ----@field store_hunks? boolean ----@field quickfix? boolean ----@field is_skipped? fun(entry: table): boolean - ----@param bufnr integer ----@param name string ----@return any -local function get_buf_var(bufnr, name) - return generated.get_var(bufnr, name) -end - ----@param bufnr integer ----@return diffs.DiffSpec? -local function buffer_diff_spec(bufnr) - local source = get_buf_var(bufnr, 'diffs_source') - return source and source.spec -end - ----@param bufnr integer ----@return integer[] -local function windows_for_buffer(bufnr) - local wins = {} - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then - wins[#wins + 1] = win - end - end - return wins -end - ----@param win integer ----@return integer? -local function win_number(win) - local nr = vim.fn.win_id2win(win) - if nr > 0 then - return nr - end - return nil -end - ----@param hunk diffs.DiffHunk ----@return string -local function hunk_file(hunk) - return hunk.file or hunk.path or '(unknown)' -end - ----@param hunk diffs.DiffHunk ----@return integer -local function file_lnum(hunk) - return hunk.file_header_range and hunk.file_header_range.start or hunk.buffer_range.start -end - ----@param line string ----@return string? -local function diff_file(line) - local old_path, new_path = line:match('^diff %-%-git %a/(.-) %a/(.+)$') - return new_path or old_path -end - ----@param hunk diffs.DiffHunk ----@return integer, integer -local function hunk_stats(hunk) - local adds = 0 - local dels = 0 - for _, line in ipairs(hunk.lines or {}) do - if line.kind == 'add' then - adds = adds + 1 - elseif line.kind == 'delete' then - dels = dels + 1 - end - end - return adds, dels -end - ----@param entries table[] ----@param by_file table ----@param file string ----@param lnum integer ----@param meta? table ----@return table -local function ensure_file_entry(entries, by_file, file, lnum, meta) - meta = meta or {} - local key = meta.key or file - local entry = by_file[key] - if entry then - entry.lnum = math.min(entry.lnum, lnum) - entry.skipped = entry.skipped or meta.skipped == true - return entry - end - - entry = { - key = key, - file = file, - lnum = lnum, - adds = 0, - dels = 0, - hunks = {}, - section = meta.section, - section_label = meta.section_label, - diff_spec = meta.diff_spec, - skipped = meta.skipped == true, - } - by_file[key] = entry - entries[#entries + 1] = entry - return entry -end - ----@param opts? table ----@param lnum integer ----@param file string ----@return table -local function metadata_for_line(opts, lnum, file) - local fn = opts and opts.metadata_for_line - if type(fn) ~= 'function' then - return {} - end - - local meta = fn(lnum, file) - if type(meta) == 'table' then - return meta - end - return {} -end - ----@param hunk diffs.DiffHunk? ----@return string? -local function hunk_key(hunk) - if type(hunk) ~= 'table' then - return nil - end - return hunk.generated_key -end - ----@param hunks diffs.DiffHunk[] ----@param diff_lines? string[] ----@param opts? table ----@return table[] -local function file_entries(hunks, diff_lines, opts) - local by_file = {} - local entries = {} - for lnum, line in ipairs(diff_lines or {}) do - local file = diff_file(line) - if file then - ensure_file_entry(entries, by_file, file, lnum, metadata_for_line(opts, lnum, file)) - end - end - - for _, hunk in ipairs(hunks) do - local file = hunk_file(hunk) - local meta = metadata_for_line(opts, hunk.buffer_range.start, file) - local entry = ensure_file_entry(entries, by_file, file, file_lnum(hunk), meta) - local adds, dels = hunk_stats(hunk) - entry.adds = entry.adds + adds - entry.dels = entry.dels + dels - entry.hunks[#entry.hunks + 1] = hunk - hunk.generated_key = entry.key - hunk.section = entry.section - hunk.section_label = entry.section_label - if not hunk.diff_spec and entry.diff_spec then - hunk_model.decorate_actionability({ hunk }, entry.diff_spec) - end - end - return entries -end - ----@param entries table[] ----@param opts? table -local function apply_skipped(entries, opts) - local is_skipped = opts and opts.is_skipped - if type(is_skipped) ~= 'function' then - return - end - for _, entry in ipairs(entries) do - entry.skipped = is_skipped(entry) == true - end -end - ----@param entries table[] -local function format_file_text(entries) - local max_fname, max_section = 0, 0 - local max_add, max_del = 0, 0 - for _, entry in ipairs(entries) do - entry.display_file = entry.skipped and (entry.file .. ' (skipped)') or entry.file - max_fname = math.max(max_fname, vim.fn.strdisplaywidth(entry.display_file)) - max_section = math.max(max_section, entry.section_label and #entry.section_label + 2 or 0) - if entry.adds > 0 then - max_add = math.max(max_add, #tostring(entry.adds) + 1) - end - if entry.dels > 0 then - max_del = math.max(max_del, #tostring(entry.dels) + 1) - end - end - - for _, entry in ipairs(entries) do - entry.display_file = entry.display_file - .. string.rep(' ', max_fname - vim.fn.strdisplaywidth(entry.display_file)) - local parts = {} - if max_section > 0 then - local section = entry.section_label and ('[' .. entry.section_label .. ']') or '' - parts[#parts + 1] = section .. string.rep(' ', max_section - #section) - end - if max_add > 0 then - parts[#parts + 1] = entry.adds > 0 and string.format('%' .. max_add .. 's', '+' .. entry.adds) - or string.rep(' ', max_add) - end - if max_del > 0 then - parts[#parts + 1] = entry.dels > 0 and string.format('%' .. max_del .. 's', '-' .. entry.dels) - or string.rep(' ', max_del) - end - entry.text = table.concat(parts, ' '):gsub('%s+$', '') - end -end - ----@param bufnr integer ----@param entries table[] ----@return table[] -local function quickfix_items(bufnr, entries) - format_file_text(entries) - local items = {} - for _, entry in ipairs(entries) do - items[#items + 1] = { - bufnr = bufnr, - lnum = entry.lnum, - col = 1, - module = entry.display_file, - text = entry.text, - user_data = { - diffs = { - kind = 'file', - file = entry.file, - key = entry.key, - section = entry.section, - section_label = entry.section_label, - diff_spec = entry.diff_spec, - skipped = entry.skipped == true, - }, - }, - } - end - return items -end - ----@param entries table[] ----@param lnum integer ----@param sections? table[] ----@return table? -local function entry_at_lnum(entries, lnum, sections) - if #entries == 0 then - return nil - end - - local section = nil - for _, candidate in ipairs(sections or {}) do - if lnum >= candidate.start and lnum <= candidate.finish then - section = candidate - break - end - end - - local selected = nil - for _, entry in ipairs(entries) do - if section and entry.section ~= section.id then - goto continue - end - if lnum >= entry.lnum then - selected = entry - else - break - end - ::continue:: - end - if selected then - return selected - end - - if section then - for _, entry in ipairs(entries) do - if entry.section == section.id then - return entry - end - end - end - return entries[1] -end - ----@param bufnr integer ----@param hunks diffs.DiffHunk[] ----@param entry table? ----@return table[] -local function loclist_items(bufnr, hunks, entry) - local file_hunk_count = {} - local key = entry and entry.key or nil - local file = entry and entry.file or nil - local items = {} - for _, hunk in ipairs(hunks) do - local hfile = hunk_file(hunk) - local hkey = hunk_key(hunk) - if (key and hkey == key) or (not key and (not file or hfile == file)) then - local count_key = hkey or hfile - file_hunk_count[count_key] = (file_hunk_count[count_key] or 0) + 1 - local display = hunk.section_label and ('[' .. hunk.section_label .. '] ' .. hfile) or hfile - items[#items + 1] = { - bufnr = bufnr, - lnum = hunk.buffer_range.start, - col = 1, - module = hfile, - text = ('%s (hunk %d) %s'):format(display, file_hunk_count[count_key], hunk.header), - user_data = { - diffs = { - kind = 'hunk', - file = hfile, - key = hkey, - section = hunk.section, - section_label = hunk.section_label, - diff_spec = hunk.diff_spec, - hunk = hunk.index, - }, - }, - } - end - end - return items -end - ----@param hunks diffs.DiffHunk[] ----@param lnum integer ----@return diffs.DiffHunk? -local function hunk_at_lnum(hunks, lnum) - for _, hunk in ipairs(hunks) do - if lnum >= hunk.buffer_range.start and lnum <= hunk.buffer_range.finish then - return hunk - end - end - return nil -end - ----@param hunks diffs.DiffHunk[] ----@param index integer? ----@return diffs.DiffHunk? -local function hunk_by_index(hunks, index) - if type(index) ~= 'number' then - return nil - end - for _, hunk in ipairs(hunks) do - if hunk.index == index then - return hunk - end - end - return nil -end - ----@param hunks diffs.DiffHunk[] ----@param target diffs.DiffHunk? ----@return integer? -local function file_hunk_index(hunks, target) - if not target then - return nil - end - - local count = 0 - local file = hunk_file(target) - local key = hunk_key(target) - for _, hunk in ipairs(hunks) do - if (key and hunk_key(hunk) == key) or (not key and hunk_file(hunk) == file) then - count = count + 1 - end - if hunk == target then - return count - end - end - return nil -end - ----@param state table ----@param entry table ----@return diffs.GeneratedFileSelection -local function selection_from_entry(state, entry) - local hunk = entry.hunks[1] - return { - bufnr = 0, - file = entry.file, - key = (hunk and hunk_key(hunk)) or entry.key, - section = (hunk and hunk.section) or entry.section, - section_label = (hunk and hunk.section_label) or entry.section_label, - diff_spec = (hunk and hunk.diff_spec) or entry.diff_spec, - lnum = entry.lnum, - hunk = hunk, - hunk_index = file_hunk_index(state.hunks, hunk), - added = entry.adds, - removed = entry.dels, - skipped = entry.skipped == true, - } -end - ----@param item table? ----@return table? -local function item_diffs_data(item) - if type(item) ~= 'table' or type(item.user_data) ~= 'table' then - return nil - end - - local diffs = item.user_data.diffs - if type(diffs) == 'table' then - return diffs - end - return nil -end - ----@param item table? ----@return table?, string? -local function selection_from_item(item) - local data = item_diffs_data(item) - if not data or (data.kind ~= 'file' and data.kind ~= 'hunk') then - return nil, 'quickfix item is not a generated diff file' - end - if type(item.bufnr) ~= 'number' or item.bufnr <= 0 then - return nil, 'quickfix item is missing a generated buffer' - end - if type(data.file) ~= 'string' or data.file == '' then - return nil, 'quickfix item is missing a generated diff file' - end - - local state = generated_list_state[item.bufnr] - local hunk = state and hunk_by_index(state.hunks, data.hunk) or nil - local key = data.key or hunk_key(hunk) or data.file - return { - bufnr = item.bufnr, - file = data.file, - key = key, - section = data.section, - section_label = data.section_label, - diff_spec = data.diff_spec, - lnum = item.lnum, - hunk = hunk, - hunk_index = state and file_hunk_index(state.hunks, hunk) or nil, - skipped = data.skipped == true, - }, - nil -end - ----@param title string ----@param items table[] -local function set_quickfix(title, items) - M.ensure_quickfix_keymap() - vim.fn.setqflist({}, ' ', { - title = title, - items = items, - quickfixtextfunc = 'v:lua._diffs_qftf', - }) -end - ----@param win integer ----@param title string ----@param items table[] -local function set_loclist(win, title, items) - local nr = win_number(win) - if not nr then - return - end - M.ensure_quickfix_keymap() - vim.fn.setloclist(nr, {}, ' ', { - title = title, - items = items, - quickfixtextfunc = 'v:lua._diffs_qftf', - }) -end - ----@param diff_lines string[] ----@param opts? diffs.GeneratedListOptions ----@return { hunks: diffs.DiffHunk[], entries: table[], sections: table[]?, loclist_title: string } -local function list_state(diff_lines, opts) - opts = opts or {} - local diff_spec = opts.diff_spec - local hunks = hunk_model.parse(diff_lines, diff_spec) - local entries = file_entries(hunks, diff_lines, opts) - apply_skipped(entries, opts) - local title = opts.title or 'diffs' - return { - hunks = hunks, - entries = entries, - sections = opts.sections, - loclist_title = opts.loclist_title or (title .. ' hunks'), - } -end - ----@param bufnr integer ----@param win integer ----@param force? boolean -local function refresh_loclist_for_window(bufnr, win, force) - local state = generated_list_state[bufnr] - if not state or not vim.api.nvim_win_is_valid(win) then - return - end - if vim.api.nvim_win_get_buf(win) ~= bufnr then - return - end - - local cursor = vim.api.nvim_win_get_cursor(win)[1] - local entry = entry_at_lnum(state.entries, cursor, state.sections) - local key = entry and entry.key or nil - local cache = state.win_files or {} - state.win_files = cache - if not force and cache[win] == key then - return - end - cache[win] = key - - set_loclist(win, state.loclist_title, loclist_items(bufnr, state.hunks, entry)) -end - ----@param bufnr integer -local function refresh_current_loclist(bufnr) - local win = vim.api.nvim_get_current_win() - refresh_loclist_for_window(bufnr, win, false) -end - ----@param bufnr integer -local function ensure_generated_autocmds(bufnr) - if generated_list_autocmds[bufnr] then - return - end - - local ids = {} - ids[#ids + 1] = vim.api.nvim_create_autocmd({ 'BufEnter', 'CursorMoved' }, { - group = group, - buffer = bufnr, - callback = function(args) - refresh_current_loclist(args.buf) - end, - }) - ids[#ids + 1] = vim.api.nvim_create_autocmd('BufWipeout', { - group = group, - buffer = bufnr, - once = true, - callback = function(args) - generated_list_state[args.buf] = nil - generated_list_autocmds[args.buf] = nil - end, - }) - generated_list_autocmds[bufnr] = ids -end - ----@param item table? -local function sync_split_item(item) - if type(item) ~= 'table' or type(item.bufnr) ~= 'number' then - return - end - - local source = get_buf_var(item.bufnr, 'diffs_source') - if type(source) ~= 'table' or source.kind ~= 'split_endpoint' then - return - end - - require('diffs.split').sync_cursor_to_hunk(item.bufnr) -end - ----@param info table ----@return table[] -local function quickfix_text_items(info) - if info.quickfix == 1 then - return vim.fn.getqflist({ id = info.id, items = 0 }).items - end - return vim.fn.getloclist(info.winid or 0, { id = info.id, items = 0 }).items -end - ----@return table?, boolean -local function current_quickfix_item() - local info = vim.fn.getwininfo(vim.api.nvim_get_current_win())[1] - if type(info) ~= 'table' or info.quickfix ~= 1 then - return nil, false - end - - local is_loclist = info.loclist == 1 - local row = vim.api.nvim_win_get_cursor(0)[1] - local items = is_loclist and vim.fn.getloclist(0, { items = 0 }).items - or vim.fn.getqflist({ items = 0 }).items - return items[row], is_loclist -end - ----@param bufnr integer ----@param explicit_lnum integer? ----@return integer -local function selection_lnum(bufnr, explicit_lnum) - if type(explicit_lnum) == 'number' then - return explicit_lnum - end - - if vim.api.nvim_get_current_buf() == bufnr then - return vim.api.nvim_win_get_cursor(0)[1] - end - - local win = windows_for_buffer(bufnr)[1] - if win then - return vim.api.nvim_win_get_cursor(win)[1] - end - - return 1 -end - -local function jump_current_quickfix_item() - local item, is_loclist = current_quickfix_item() - local row = vim.api.nvim_win_get_cursor(0)[1] - local command = is_loclist and 'll' or 'cc' - local jumped = false - if type(item) == 'table' and type(item.bufnr) == 'number' then - local item_win = windows_for_buffer(item.bufnr)[1] - if item_win then - local lnum = math.max(1, math.min(item.lnum or 1, vim.api.nvim_buf_line_count(item.bufnr))) - local col = math.max(0, (item.col or 1) - 1) - vim.api.nvim_win_set_cursor(item_win, { lnum, col }) - vim.api.nvim_set_current_win(item_win) - jumped = true - end - end - if not jumped then - if not is_loclist and require('diffs.commands').review_quickfix_jump(item) then - vim.fn.setqflist({}, 'a', { idx = row }) - return - end - vim.cmd(command .. ' ' .. row) - end - sync_split_item(item) -end - ----@class diffs.GeneratedFileSelectionOpts ----@field bufnr? integer ----@field lnum? integer ----@field item? table - ----@class diffs.GeneratedFileSelection ----@field bufnr integer ----@field file string ----@field key? string ----@field section? string ----@field section_label? string ----@field diff_spec? diffs.DiffSpec ----@field lnum integer? ----@field hunk? diffs.DiffHunk ----@field hunk_index? integer ----@field added? integer ----@field removed? integer ----@field skipped? boolean - ----@param opts? diffs.GeneratedFileSelectionOpts ----@return diffs.GeneratedFileSelection?, string? -function M.selected_generated_file(opts) - opts = opts or {} - - if opts.item then - return selection_from_item(opts.item) - end - - if not opts.bufnr and not opts.lnum then - local item = current_quickfix_item() - if item then - return selection_from_item(item) - end - end - - local bufnr = opts.bufnr or vim.api.nvim_get_current_buf() - local state = generated_list_state[bufnr] - if not state then - return nil, 'current buffer has no generated diff file index' - end - - local lnum = selection_lnum(bufnr, opts.lnum) - local entry = entry_at_lnum(state.entries, lnum, state.sections) - if not entry then - return nil, 'no generated diff file selected' - end - - local hunk = hunk_at_lnum(state.hunks, lnum) - return { - bufnr = bufnr, - file = entry.file, - key = (hunk and hunk_key(hunk)) or entry.key, - section = (hunk and hunk.section) or entry.section, - section_label = (hunk and hunk.section_label) or entry.section_label, - diff_spec = (hunk and hunk.diff_spec) or entry.diff_spec, - lnum = lnum, - hunk = hunk, - hunk_index = file_hunk_index(state.hunks, hunk), - skipped = entry.skipped == true, - }, - nil -end - ----@param bufnr integer ----@return table? -local function enter_keymap(bufnr) - for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, 'n')) do - if keymap.lhs == '' then - return keymap - end - end - return nil -end - ----@param bufnr integer -local highlight_quickfix = vim.schedule_wrap(function(bufnr) - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - vim.api.nvim_buf_clear_namespace(bufnr, quickfix_ns, 0, -1) - local qf = vim.fn.getqflist({ qfbufnr = 0, items = 0 }) - if qf.qfbufnr ~= bufnr then - return - end - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - for row, item in ipairs(qf.items) do - local data = item_diffs_data(item) - if data and data.kind == 'file' and lines[row] then - local offset = #lines[row] - #item.text - for first, count, last in item.text:gmatch('()([+-]%d+)()') do - vim.api.nvim_buf_set_extmark(bufnr, quickfix_ns, row - 1, offset + first - 1, { - end_col = offset + last - 1, - hl_group = count:sub(1, 1) == '+' and 'DiffsAddCount' or 'DiffsDeleteCount', - }) - end - end - end -end) - ----@param bufnr integer -local function set_quickfix_keymap(bufnr) - local existing = enter_keymap(bufnr) - if existing and existing.desc ~= quickfix_enter_desc then - return - end - vim.keymap.set('n', '', jump_current_quickfix_item, { - buffer = bufnr, - desc = quickfix_enter_desc, - }) -end - -function M.ensure_quickfix_keymap() - if quickfix_keymap_autocmd then - return - end - - quickfix_keymap_autocmd = vim.api.nvim_create_autocmd('FileType', { - group = group, - pattern = 'qf', - callback = function(args) - set_quickfix_keymap(args.buf) - highlight_quickfix(args.buf) - end, - }) - - for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do - if - vim.api.nvim_buf_is_valid(bufnr) - and vim.api.nvim_get_option_value('filetype', { buf = bufnr }) == 'qf' - then - set_quickfix_keymap(bufnr) - highlight_quickfix(bufnr) - end - end -end - --- selene: allow(global_usage) -function _G._diffs_qftf(info) - local items = quickfix_text_items(info) - local lines = {} - for i = info.start_idx, info.end_idx do - local e = items[i] or {} - local data = item_diffs_data(e) - lines[#lines + 1] = data - and data.kind == 'file' - and ('%s|| %s'):format(e.module or '', e.text or '') - or '' - end - return lines -end - ----@param bufnr integer ----@param diff_lines string[] ----@param opts? diffs.GeneratedListOptions -function M.set_for_unified_buffer(bufnr, diff_lines, opts) - opts = opts or {} - local title = opts.title or 'diffs' - opts.diff_spec = opts.diff_spec or buffer_diff_spec(bufnr) - local state = list_state(diff_lines, opts) - - if opts.store_hunks then - generated.set_hunks(bufnr, state.hunks) - end - - generated_list_state[bufnr] = { - hunks = state.hunks, - entries = state.entries, - sections = state.sections, - loclist_title = state.loclist_title, - win_files = {}, - } - ensure_generated_autocmds(bufnr) - - local want_quickfix = opts.quickfix - if want_quickfix == nil then - want_quickfix = #state.entries > 1 - end - if want_quickfix then - set_quickfix(title, quickfix_items(bufnr, state.entries)) - end - for _, win in ipairs(windows_for_buffer(bufnr)) do - refresh_loclist_for_window(bufnr, win, true) - end -end - ----@param bufnr integer ----@return diffs.DiffHunk[] -function M.generated_hunks(bufnr) - local state = generated_list_state[bufnr] - return state and state.hunks or {} -end - ----@param diff_lines string[] ----@param opts? diffs.GeneratedListOptions ----@return diffs.GeneratedFileSelection[] -function M.generated_files(diff_lines, opts) - local state = list_state(diff_lines, opts) - local files = {} - for _, entry in ipairs(state.entries) do - files[#files + 1] = selection_from_entry(state, entry) - end - return files -end - ----@param hunk diffs.DiffHunk ----@param anchors integer[]? ----@return integer -local function split_anchor_lnum(hunk, anchors) - return (anchors and anchors[hunk.index]) or 1 -end - ----@param hunk diffs.DiffHunk ----@param side "left"|"right" ----@param left_buf integer ----@param right_buf integer ----@return integer -local function split_target_buf(hunk, side, left_buf, right_buf) - local range = side == 'left' and hunk.old_range or hunk.new_range - if range.count > 0 then - return side == 'left' and left_buf or right_buf - end - return side == 'left' and right_buf or left_buf -end - ----@param left_buf integer ----@param right_buf integer ----@param hunks diffs.DiffHunk[] ----@param anchors integer[]? ----@param side "left"|"right" ----@return table[] -local function split_loclist_items(left_buf, right_buf, hunks, anchors, side) - local items = {} - for i, hunk in ipairs(hunks) do - items[#items + 1] = { - bufnr = split_target_buf(hunk, side, left_buf, right_buf), - lnum = split_anchor_lnum(hunk, anchors), - col = 1, - module = hunk_file(hunk), - text = ('%s (hunk %d) %s'):format(hunk_file(hunk), i, hunk.header), - user_data = { - diffs = { - kind = 'split_hunk', - file = hunk_file(hunk), - hunk = i, - side = side, - }, - }, - } - end - return items -end - ----@param opts { left_buf: integer, right_buf: integer, hunks: diffs.DiffHunk[], anchors: integer[]? } ----@return table[] -local function split_quickfix_items(opts) - local entries = file_entries(opts.hunks) - format_file_text(entries) - local items = {} - for _, entry in ipairs(entries) do - local first_hunk = entry.hunks[1] - local lnum = entry.lnum - local target_buf = opts.right_buf - if first_hunk then - lnum = split_anchor_lnum(first_hunk, opts.anchors) - target_buf = split_target_buf(first_hunk, 'right', opts.left_buf, opts.right_buf) - end - items[#items + 1] = { - bufnr = target_buf, - lnum = lnum, - col = 1, - module = entry.display_file, - text = entry.text, - user_data = { - diffs = { - kind = 'file', - file = entry.file, - }, - }, - } - end - return items -end - ----@param opts { title: string, loclist_title?: string, left_buf: integer, right_buf: integer, left_win?: integer, right_win?: integer, hunks: diffs.DiffHunk[], anchors?: integer[], quickfix?: boolean } -function M.set_for_split_pair(opts) - local title = opts.title - local loclist_title = opts.loclist_title or (title .. ' hunks') - - local quickfix_entries = split_quickfix_items(opts) - if opts.quickfix ~= false and #quickfix_entries > 1 then - set_quickfix(title, quickfix_entries) - end - for _, win in ipairs(opts.left_win and { opts.left_win } or windows_for_buffer(opts.left_buf)) do - set_loclist( - win, - loclist_title, - split_loclist_items(opts.left_buf, opts.right_buf, opts.hunks, opts.anchors, 'left') - ) - end - for _, win in ipairs(opts.right_win and { opts.right_win } or windows_for_buffer(opts.right_buf)) do - set_loclist( - win, - loclist_title, - split_loclist_items(opts.left_buf, opts.right_buf, opts.hunks, opts.anchors, 'right') - ) - end -end - -M._test = { - file_entries = file_entries, - hunk_at_lnum = hunk_at_lnum, - loclist_items = loclist_items, - quickfix_items = quickfix_items, - selected_generated_file = M.selected_generated_file, -} - -return M diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua deleted file mode 100644 index d61f1c4e..00000000 --- a/lua/diffs/merge.lua +++ /dev/null @@ -1,263 +0,0 @@ -local M = {} - -local conflict = require('diffs.conflict') -local notify = require('diffs.log').notify -local rails = require('diffs.rails') - ----@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 rail_width = rails.width_for_buffer(bufnr) - local hunks = {} - local current = nil - - for i, line in ipairs(lines) do - line = rails.strip(line, rail_width) - 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, source = pcall(vim.api.nvim_buf_get_var, diff_bufnr, 'diffs_source') - local working_path = ok and source.working_path - if 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 side 'ours'|'theirs'|'both'|'none' -local function resolve(diff_bufnr, side) - local hunk = M.find_hunk_at_cursor(diff_bufnr) - if not hunk then - return - end - if M.is_resolved(diff_bufnr, hunk.index) then - notify('hunk already resolved', vim.log.levels.INFO) - return - end - local working_bufnr = M.get_or_load_working_buf(diff_bufnr) - if not working_bufnr then - return - end - local region = M.match_hunk_to_conflict(hunk, working_bufnr) - if not region then - notify('hunk does not correspond to a conflict region', vim.log.levels.INFO) - return - end - conflict.replace_region( - working_bufnr, - region, - conflict.replacement_lines(working_bufnr, region, side) - ) - conflict.refresh(working_bufnr) - mark_resolved(diff_bufnr, hunk.index) -end - ----@param bufnr integer -function M.resolve_ours(bufnr) - resolve(bufnr, 'ours') -end - ----@param bufnr integer -function M.resolve_theirs(bufnr) - resolve(bufnr, 'theirs') -end - ----@param bufnr integer -function M.resolve_both(bufnr) - resolve(bufnr, 'both') -end - ----@param bufnr integer -function M.resolve_none(bufnr) - resolve(bufnr, 'none') -end - ----@param bufnr integer ----@param forward boolean -local function goto_hunk(bufnr, forward) - 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 candidates = {} - for _, hunk in ipairs(hunks) do - if not M.is_resolved(bufnr, hunk.index) and M.match_hunk_to_conflict(hunk, working_bufnr) then - table.insert(candidates, hunk) - end - end - - if #candidates == 0 then - return - end - - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 - - if forward then - 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 - notify('wrapped to first hunk', vim.log.levels.INFO) - vim.api.nvim_win_set_cursor(0, { candidates[1].start_line + 1, 0 }) - else - 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 - notify('wrapped to last hunk', vim.log.levels.INFO) - vim.api.nvim_win_set_cursor(0, { candidates[#candidates].start_line + 1, 0 }) - end -end - ----@param bufnr integer -function M.goto_next(bufnr) - goto_hunk(bufnr, true) -end - ----@param bufnr integer -function M.goto_prev(bufnr) - goto_hunk(bufnr, false) -end - ----@param bufnr integer -function M.attach(bufnr) - resolved_hunks[bufnr] = nil - - vim.api.nvim_create_autocmd('BufWipeout', { - buffer = bufnr, - callback = function() - resolved_hunks[bufnr] = nil - end, - }) - vim.api.nvim_exec_autocmds('User', { pattern = 'DiffsMergeReady', data = { bufnr = bufnr } }) -end - -return M diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 5a772d49..a975443f 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -11,15 +11,12 @@ ---@field prefix_width integer ---@field quote_width integer ---@field column_offset integer? ----@field rail_width integer? ----@field rail_style diffs.RailStyle? ---@field _hl_line_count integer? ---@field _skipped_max_lines boolean? local M = {} local dbg = require('diffs.log').dbg -local rails = require('diffs.rails') ---@type table local ft_lang_cache = {} @@ -111,11 +108,6 @@ end ---@param bufnr integer ---@return string? local function get_repo_root(bufnr) - local ok, source = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_source') - if ok and source.repo_root then - return source.repo_root - end - local ok2, git_dir = pcall(vim.api.nvim_buf_get_var, bufnr, 'git_dir') if ok2 and git_dir then return vim.fn.fnamemodify(git_dir, ':h') @@ -139,8 +131,7 @@ local function get_repo_root(bufnr) end local cwd = vim.fn.getcwd() - local git = require('diffs.git') - return git.get_repo_root(cwd .. '/.') + return vim.fs.root(cwd, '.git') end ---@param bufnr integer @@ -148,13 +139,11 @@ 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 rail_width = rails.width_for_buffer(bufnr) - local rail_style = rail_width > 0 and rails.style_for_buffer(bufnr) or nil local quote_prefix = nil local quote_width = 0 for _, l in ipairs(lines) do - local logical = rails.strip(l, rail_width) + local logical = l local qp = logical:match('^(>+ )diff %-%-') or logical:match('^(>+ )@@ %-') if qp then quote_prefix = qp @@ -206,8 +195,6 @@ function M.parse_buffer(bufnr) lines = hunk_lines, prefix_width = hunk_prefix_width, quote_width = current_quote_width, - rail_width = rail_width > 0 and rail_width or nil, - rail_style = rail_style, } if hunk_count == 1 and header_start and #header_lines > 0 then hunk.header_start_line = header_start @@ -224,12 +211,11 @@ function M.parse_buffer(bufnr) end for i, line in ipairs(lines) do - local without_rails = rails.strip(line, rail_width) - local logical = without_rails + local logical = line if quote_prefix then - if without_rails:sub(1, quote_width) == quote_prefix then - logical = without_rails:sub(quote_width + 1) - elseif without_rails:match('^>+$') then + if line:sub(1, quote_width) == quote_prefix then + logical = line:sub(quote_width + 1) + elseif line:match('^>+$') then logical = '' end end @@ -255,7 +241,7 @@ function M.parse_buffer(bufnr) if filename then flush_hunk() current_filename = filename - current_quote_width = rail_width + ((logical ~= without_rails) and quote_width or 0) + 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 @@ -316,6 +302,8 @@ function M.parse_buffer(bufnr) if new_remaining and (prefix == ' ' or prefix == '+') then new_remaining = new_remaining - 1 end + elseif logical:match('^\\ No newline') then + table.insert(hunk_lines, logical) elseif logical == '' and old_remaining diff --git a/lua/diffs/rails.lua b/lua/diffs/rails.lua deleted file mode 100644 index 085578ca..00000000 --- a/lua/diffs/rails.lua +++ /dev/null @@ -1,273 +0,0 @@ -local M = {} - -local hunk_model = require('diffs.hunks') - -local separator = ' │ ' -local bar_slot = ' ' - ----@alias diffs.RailStyle "dual"|"single" - ----@class diffs.RailInfo ----@field width integer ----@field prefix_width integer ----@field style diffs.RailStyle - ----@class diffs.RailRanges ----@field old_start integer ----@field old_end integer ----@field new_start integer ----@field new_end integer - ----@class diffs.RailNumberRange ----@field start integer ----@field finish integer - ----@param style diffs.RailStyle? ----@return diffs.RailStyle -local function normalize_rail_style(style) - if style == 'single' then - return 'single' - end - return 'dual' -end - ----@param value integer? ----@param width integer ----@return string -local function format_lnum(value, width) - if not value then - return string.rep(' ', width) - end - return string.format('%' .. width .. 'd', value) -end - ----@param line diffs.DiffHunkLine? ----@return integer? -local function old_lnum(line) - if line and (line.kind == 'context' or line.kind == 'delete') then - return line.old_lnum - end - return nil -end - ----@param line diffs.DiffHunkLine? ----@return integer? -local function new_lnum(line) - if line and (line.kind == 'context' or line.kind == 'add') then - return line.new_lnum - end - return nil -end - ----@param line diffs.DiffHunkLine? ----@return integer? -local function single_lnum(line) - if not line then - return nil - end - if line.kind == 'delete' then - return line.old_lnum - end - if line.kind == 'context' or line.kind == 'add' then - return line.new_lnum - end - return nil -end - ----@param line diffs.DiffHunkLine? ----@param text string ----@return boolean -local function is_empty_context_line(line, text) - return line ~= nil and line.kind == 'context' and text == ' ' -end - ----@param line string ----@param start_col integer ----@param end_col integer ----@return boolean -local function has_number(line, start_col, end_col) - return line:sub(start_col + 1, end_col):find('%d') ~= nil -end - ----@param line string ----@param ranges diffs.RailNumberRange[]? ----@return boolean -local function any_range_has_number(line, ranges) - for _, range in ipairs(ranges or {}) do - if has_number(line, range.start, range.finish) then - return true - end - end - return false -end - ----@param line string ----@param prefix_width integer ----@return boolean -local function has_rail_number(line, prefix_width) - return any_range_has_number(line, M.number_ranges(prefix_width, 'dual')) - or any_range_has_number(line, M.number_ranges(prefix_width, 'single')) -end - ----@param lines string[] ----@return table, integer -local function collect_lines(lines) - local by_lnum = {} - local max_lnum = 0 - - for _, hunk in ipairs(hunk_model.parse(lines)) do - for _, line in ipairs(hunk.lines) do - by_lnum[line.lnum] = line - max_lnum = math.max(max_lnum, line.old_lnum or 0, line.new_lnum or 0) - end - end - - return by_lnum, max_lnum -end - ----@param lines string[] ----@param opts? { rail_style?: diffs.RailStyle } ----@return string[], diffs.RailInfo? -function M.annotate(lines, opts) - opts = opts or {} - local rail_style = normalize_rail_style(opts.rail_style) - local compact_separator = separator:gsub('%s+$', '') - - local by_lnum, max_lnum = collect_lines(lines) - if vim.tbl_isempty(by_lnum) then - return lines, nil - end - - local width = math.max(1, #tostring(max_lnum)) - local prefix_width = rail_style == 'single' and (#bar_slot + width + #separator) - or (#bar_slot + width + 1 + width + #separator) - local annotated = {} - - for lnum, text in ipairs(lines) do - local line = by_lnum[lnum] - local line_separator = is_empty_context_line(line, text) and compact_separator or separator - local display_text = is_empty_context_line(line, text) and '' or text - if rail_style == 'single' then - annotated[lnum] = bar_slot - .. format_lnum(single_lnum(line), width) - .. line_separator - .. display_text - else - annotated[lnum] = bar_slot - .. format_lnum(old_lnum(line), width) - .. ' ' - .. format_lnum(new_lnum(line), width) - .. line_separator - .. display_text - end - end - - return annotated, - { - width = width, - prefix_width = prefix_width, - style = rail_style, - } -end - ----@param line string ----@param prefix_width integer? ----@return string -function M.strip(line, prefix_width) - if type(prefix_width) ~= 'number' or prefix_width <= 0 then - return line - end - local stripped = line:sub(prefix_width + 1) - if stripped == '' and has_rail_number(line, prefix_width) then - return ' ' - end - return stripped -end - ----@param prefix_width integer? ----@return diffs.RailRanges? -function M.ranges(prefix_width) - if type(prefix_width) ~= 'number' or prefix_width <= 0 then - return nil - end - local width = (prefix_width - #bar_slot - 1 - #separator) / 2 - if width < 1 or width % 1 ~= 0 then - return nil - end - - local old_start = #bar_slot - local old_end = old_start + width - local new_start = old_end + 1 - local new_end = new_start + width - - return { - old_start = old_start, - old_end = old_end, - new_start = new_start, - new_end = new_end, - } -end - ----@param prefix_width integer? ----@param rail_style? diffs.RailStyle ----@return diffs.RailNumberRange[]? -function M.number_ranges(prefix_width, rail_style) - if type(prefix_width) ~= 'number' or prefix_width <= 0 then - return nil - end - if normalize_rail_style(rail_style) == 'single' then - local width = prefix_width - #bar_slot - #separator - if width < 1 or width % 1 ~= 0 then - return nil - end - - return { - { - start = #bar_slot, - finish = #bar_slot + width, - }, - } - end - - local ranges = M.ranges(prefix_width) - if not ranges then - return nil - end - - return { - { - start = ranges.old_start, - finish = ranges.old_end, - }, - { - start = ranges.new_start, - finish = ranges.new_end, - }, - } -end - ----@param bufnr integer ----@return integer -function M.width_for_buffer(bufnr) - local ok, width = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_rail_width') - if ok and type(width) == 'number' and width > 0 then - return width - end - return 0 -end - ----@param bufnr integer ----@return diffs.RailStyle -function M.style_for_buffer(bufnr) - local ok, style = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_rail_style') - if ok and style == 'single' then - return 'single' - end - return 'dual' -end - -M._test = { - format_lnum = format_lnum, -} - -return M diff --git a/lua/diffs/render.lua b/lua/diffs/render.lua deleted file mode 100644 index 8bffa0f9..00000000 --- a/lua/diffs/render.lua +++ /dev/null @@ -1,348 +0,0 @@ -local M = {} - -local content = require('diffs.content') -local diffopt = require('diffs.diffopt') -local diffspec = require('diffs.spec') -local git = require('diffs.git') - ----@class diffs.RenderFileOpts ----@field worktree_lines? diffs.ContentLines|string[] ----@field worktree_buf? integer ----@field empty_on_missing? boolean ----@field old_path? string ----@field new_path? string - ----@class diffs.UnifiedLinesOpts ----@field old_missing? boolean ----@field new_missing? boolean ----@field new_mode? string ----@field deleted_mode? string - -local missing_errors = { - ['file not in index'] = true, - ['file not readable'] = true, -} - ----@param err string? ----@return boolean -local function is_missing_error(err) - return err ~= nil and (missing_errors[err] or err:match('^file not in revision: ') ~= nil) -end - ----@param lines string[]? ----@return boolean -function M.has_binary_lines(lines) - for _, line in ipairs(lines or {}) do - if line:find('\0', 1, true) then - return true - end - end - return false -end - ----@param old_lines diffs.ContentLines|string[] ----@param new_lines diffs.ContentLines|string[] ----@param old_name string ----@param new_name string ----@param opts? diffs.UnifiedLinesOpts ----@return string[] -function M.unified_lines(old_lines, new_lines, old_name, new_name, opts) - opts = opts or {} - local old_content = content.to_string(old_lines) - local new_content = content.to_string(new_lines) - - local diff_output = vim.text.diff( - old_content, - new_content, - vim.tbl_extend('force', { - result_type = 'unified', - ctxlen = 3, - }, diffopt.vim_diff_opts()) - ) - - ---@cast diff_output string - if diff_output == '' then - return {} - end - - local diff_lines = vim.split(diff_output, '\n', { plain = true }) - - local result = { - 'diff --git a/' .. old_name .. ' b/' .. new_name, - } - - if opts.old_missing then - result[#result + 1] = 'new file mode ' .. (opts.new_mode or '100644') - result[#result + 1] = '--- /dev/null' - else - result[#result + 1] = '--- a/' .. old_name - end - - if opts.new_missing then - if not opts.old_missing then - table.insert(result, 2, 'deleted file mode ' .. (opts.deleted_mode or '100644')) - end - result[#result + 1] = '+++ /dev/null' - else - result[#result + 1] = '+++ b/' .. new_name - end - - for _, line in ipairs(diff_lines) do - table.insert(result, line) - end - - return result -end - ----@param repo_root string ----@param path string ----@return string -local function abs_path(repo_root, path) - return repo_root .. '/' .. path -end - ----@param endpoint diffs.Endpoint ----@param filepath string ----@return string? -local function file_mode(endpoint, filepath) - endpoint = diffspec.endpoint(endpoint) - - if endpoint.kind == diffspec.endpoint_kind.tree then - return git.get_tree_mode(endpoint.rev, filepath) - end - - if endpoint.kind == diffspec.endpoint_kind.index then - return git.get_index_mode(filepath) - end - - if endpoint.kind == diffspec.endpoint_kind.worktree then - return git.get_working_mode(filepath) - end - - return nil -end - ----@param endpoint diffs.Endpoint ----@param filepath string ----@param opts? diffs.RenderFileOpts ----@return diffs.ContentLines|string[]?, string?, boolean -function M.read_endpoint(endpoint, filepath, opts) - endpoint = diffspec.endpoint(endpoint) - opts = opts or {} - - local lines, err - if endpoint.kind == diffspec.endpoint_kind.tree then - lines, err = git.get_file_content(endpoint.rev, filepath) - elseif endpoint.kind == diffspec.endpoint_kind.stage then - lines, err = git.get_file_content(':' .. endpoint.stage, filepath) - elseif endpoint.kind == diffspec.endpoint_kind.index then - lines, err = git.get_index_content(filepath) - elseif endpoint.kind == diffspec.endpoint_kind.worktree then - lines = opts.worktree_lines - local buf = opts.worktree_buf - if - not lines - and buf - and vim.api.nvim_buf_is_loaded(buf) - and vim.api.nvim_buf_get_name(buf) == filepath - then - lines = content.from_buffer(buf) - end - if not lines then - lines, err = git.get_working_content(filepath) - end - else - return nil, 'unsupported endpoint: ' .. tostring(endpoint.kind), false - end - - if not lines and opts.empty_on_missing and is_missing_error(err) then - return {}, nil, true - end - - return lines, err, false -end - ----@param diff_spec diffs.DiffSpec ----@param repo_root string ----@param args string[] ----@return string[]? -local function build_git_diff_cmd(diff_spec, repo_root, args) - local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' } - for _, arg in ipairs(args) do - table.insert(cmd, arg) - end - - local left = diff_spec.left - local right = diff_spec.right - - if - left.kind == diffspec.endpoint_kind.index and right.kind == diffspec.endpoint_kind.worktree - then - return cmd - end - - if left.kind == diffspec.endpoint_kind.tree and right.kind == diffspec.endpoint_kind.index then - table.insert(cmd, '--cached') - table.insert(cmd, left.rev) - return cmd - end - - if left.kind == diffspec.endpoint_kind.tree and right.kind == diffspec.endpoint_kind.worktree then - table.insert(cmd, left.rev) - return cmd - end - - if left.kind == diffspec.endpoint_kind.tree and right.kind == diffspec.endpoint_kind.tree then - table.insert(cmd, left.rev) - table.insert(cmd, right.rev) - return cmd - end - - return nil -end - ----@param diff_spec diffs.DiffSpec ----@param repo_root string ----@param path string ----@param flag string ----@return string[] -local function git_diff_lines(diff_spec, repo_root, path, flag) - local cmd = build_git_diff_cmd(diff_spec, repo_root, { flag }) - if not cmd then - return {} - end - - table.insert(cmd, '--') - table.insert(cmd, path) - local result = vim.fn.systemlist(cmd) - if vim.v.shell_error ~= 0 then - return {} - end - return result -end - ----@param diff_spec diffs.DiffSpec ----@param repo_root string ----@param path string ----@return boolean -local function has_raw_changes(diff_spec, repo_root, path) - return #git_diff_lines(diff_spec, repo_root, path, '--raw') > 0 -end - ----@param diff_spec diffs.DiffSpec ----@param repo_root string ----@param path string ----@return boolean -local function has_binary_changes(diff_spec, repo_root, path) - for _, line in ipairs(git_diff_lines(diff_spec, repo_root, path, '--numstat')) do - if line:match('^%-%s+%-%s+') then - return true - end - end - return false -end - ----@param diff_spec diffs.DiffSpec ----@param repo_root string ----@param path string ----@return boolean -local function has_renamed_or_copied_path(diff_spec, repo_root, path) - local cmd = build_git_diff_cmd(diff_spec, repo_root, { '--name-status', '-M', '-C' }) - if not cmd then - return false - end - - local result = vim.fn.systemlist(cmd) - if vim.v.shell_error ~= 0 then - return false - end - - for _, line in ipairs(result) do - local status = line:match('^([RC]%d*)%s') - if status then - local fields = vim.split(line, '\t', { plain = true }) - for i = 2, #fields do - if fields[i] == path then - return true - end - end - end - end - - return false -end - ----@param diff_spec diffs.DiffSpec ----@param repo_root string ----@param opts? diffs.RenderFileOpts ----@return string[]?, string?, diffs.ContentLines|string[]?, diffs.ContentLines|string[]? -function M.file(diff_spec, repo_root, opts) - diff_spec = diffspec.new(diff_spec) - opts = opts or {} - - if diff_spec.scope.kind ~= diffspec.scope_kind.file then - return nil, 'unsupported DiffSpec scope: ' .. tostring(diff_spec.scope.kind) - end - - local path = diff_spec.scope.path - local old_path = opts.old_path or path - local new_path = opts.new_path or path - local old_filepath = abs_path(repo_root, old_path) - local new_filepath = abs_path(repo_root, new_path) - - local old_mode = file_mode(diff_spec.left, old_filepath) - local new_mode = file_mode(diff_spec.right, new_filepath) - if old_mode == '160000' or new_mode == '160000' then - return nil, 'diff does not support submodule changes' - end - - if has_renamed_or_copied_path(diff_spec, repo_root, path) then - return nil, 'diff does not support rename or copy changes' - end - - if has_binary_changes(diff_spec, repo_root, path) then - return nil, 'diff does not support binary files' - end - - local old_lines, old_err, old_missing = M.read_endpoint(diff_spec.left, old_filepath, { - worktree_lines = opts.worktree_lines, - worktree_buf = opts.worktree_buf, - empty_on_missing = true, - }) - if not old_lines then - return nil, old_err or 'could not read left endpoint' - end - - local new_lines, new_err, new_missing = M.read_endpoint(diff_spec.right, new_filepath, { - worktree_lines = opts.worktree_lines, - worktree_buf = opts.worktree_buf, - empty_on_missing = true, - }) - if not new_lines then - return nil, new_err or 'could not read right endpoint' - end - - if M.has_binary_lines(old_lines) or M.has_binary_lines(new_lines) then - return nil, 'diff does not support binary files' - end - - local diff_lines = M.unified_lines(old_lines, new_lines, old_path, new_path, { - old_missing = old_missing, - new_missing = new_missing, - new_mode = new_mode, - deleted_mode = old_mode, - }) - if - #diff_lines == 0 - and old_mode - and new_mode - and old_mode ~= new_mode - and has_raw_changes(diff_spec, repo_root, path) - then - return nil, 'diff does not support mode-only changes' - end - - return diff_lines, nil, old_lines, new_lines -end - -return M diff --git a/lua/diffs/review.lua b/lua/diffs/review.lua deleted file mode 100644 index deae3425..00000000 --- a/lua/diffs/review.lua +++ /dev/null @@ -1,721 +0,0 @@ -local M = {} - -local diffopt = require('diffs.diffopt') -local diffspec = require('diffs.spec') -local generated = require('diffs.generated') -local git = require('diffs.git') -local lists = require('diffs.lists') -local log = require('diffs.log') -local render = require('diffs.render') - -local dbg = log.dbg -local notify = log.notify - ----@param entry table ----@return boolean -local function is_skipped_entry(entry) - return type(entry.hunks) == 'table' and #entry.hunks == 0 -end - ----@class diffs.ReviewSpec ----@field args? string[] ----@field base? string ----@field target? string ----@field repo? string ----@field mode? string ----@field vertical? boolean ----@field untracked? boolean - ----@class diffs.ReviewCommandParseResult ----@field spec diffs.ReviewSpec ----@field layout "unified"|"stacked"|"split" - ----@class diffs.NormalizedReview ----@field args? string[] ----@field base string ----@field target string? ----@field repo_root string ----@field mode string? ----@field vertical boolean ----@field untracked boolean ----@field display string - ----@class diffs.ReviewDeps ----@field create_generated_diff_buffer fun(opts: diffs.GeneratedDiffBufferOpts): integer ----@field show_generated_diff_buffer fun(bufnr: integer, vertical?: boolean): boolean ----@field attach_generated_diff_buffer fun(bufnr: integer) ----@field replace_combined_diffs fun(lines: string[], repo_root: string): string[] ----@field rail_style? diffs.RailStyle ----@field review_layout? diffs.ReviewMapLayout ----@field setup_review_map_buffer? function - ----@param args? string ----@return string[] -local function split_args(args) - args = vim.trim(args or '') - if args == '' then - return {} - end - return vim.split(args, '%s+', { trimempty = true }) -end - ----@param repo? string ----@return string? -local function resolve_repo_root(repo) - if repo and repo ~= '' then - return git.get_repo_root(repo .. '/.') - end - - local bufnr = vim.api.nvim_get_current_buf() - local filepath = vim.api.nvim_buf_get_name(bufnr) - local repo_root = git.get_repo_root(filepath ~= '' and filepath or nil) - if repo_root then - return repo_root - end - - local cwd = vim.fn.getcwd() - return git.get_repo_root(cwd .. '/.') -end - ----@param repo_root string ----@return string? -local function default_branch(repo_root) - local ref = - vim.fn.systemlist({ 'git', '-C', repo_root, 'symbolic-ref', 'refs/remotes/origin/HEAD' }) - if vim.v.shell_error ~= 0 or not ref[1] or ref[1] == '' then - return nil - end - return (ref[1]:gsub('^refs/remotes/', '')) -end - ----@param cmd string[] ----@return string[]?, string? -local function git_lines(cmd) - local stdout, stderr = {}, {} - local job = vim.fn.jobstart(cmd, { - stdout_buffered = true, - stderr_buffered = true, - on_stdout = function(_, data) - stdout = data - end, - on_stderr = function(_, data) - stderr = data - end, - }) - if job <= 0 then - return nil, 'could not start Git' - end - local code = vim.fn.jobwait({ job })[1] - local message = vim.trim(table.concat(stderr, '\n')) - if code ~= 0 then - return nil, message - end - if message ~= '' then - notify(message, vim.log.levels.WARN) - end - if stdout[#stdout] == '' then - table.remove(stdout) - end - return stdout, nil -end - ----@param repo_root string ----@param base string ----@param target string ----@return string?, string? -local function merge_base(repo_root, base, target) - local result, err = - git_lines({ 'git', '-C', repo_root, 'merge-base', '--all', '--end-of-options', base, target }) - if not result then - return nil, err ~= '' and err or 'no merge base for ' .. base .. ' and ' .. target - end - return result[1], nil -end - ----@param arg? string ----@return diffs.ReviewSpec -function M.parse_arg(arg) - if not arg or arg == '' then - return {} - end - - local base, separator, target = arg:match('^(.-)(%.%.%.?)(.*)$') - if base then - return { - base = base ~= '' and base or 'HEAD', - target = target ~= '' and target or 'HEAD', - mode = separator == '...' and 'merge-base' or 'direct', - } - end - - return { base = arg } -end - ----@param args? string ----@return diffs.ReviewCommandParseResult -function M.parse_command_args(args) - local tokens = split_args(args) - local layout = 'unified' - local untracked = nil - - while tokens[1] and (tokens[1]:match('^%+%+layout=') or tokens[1] == '++nountracked') do - local token = tokens[1] - if token:match('^%+%+layout=') then - layout = token:match('^%+%+layout=(.*)$') - else - untracked = false - end - table.remove(tokens, 1) - end - - local spec = #tokens > 1 and { args = tokens } or M.parse_arg(tokens[1]) - spec.untracked = untracked - - return { - spec = spec, - layout = layout, - } -end - ----@param spec? diffs.ReviewSpec ----@param repo_root_override? string ----@return diffs.NormalizedReview?, string? -function M.normalize(spec, repo_root_override) - spec = spec or {} - local repo_root = repo_root_override or resolve_repo_root(spec.repo) - if not repo_root then - return nil, 'not in a git repository' - end - - local base = spec.args and spec.args[1] or spec.base - if not base or base == '' then - base = default_branch(repo_root) - if not base then - return nil, 'cannot detect default branch (try: git remote set-head origin -a)' - end - end - - local target = spec.args and spec.args[2] or spec.target - local mode = spec.args and 'direct' or spec.mode or (target and 'merge-base') - local display = spec.args and table.concat(spec.args, ' ') - or (target and (base .. (mode == 'merge-base' and '...' or '..') .. target) or base) - - return { - args = spec.args, - base = base, - target = target, - repo_root = repo_root, - mode = mode, - vertical = spec.vertical or false, - untracked = spec.untracked ~= false, - display = display, - }, - nil -end - ----@param review diffs.NormalizedReview ----@param args string[] ----@return string[] -local function diff_cmd(review, args) - local cmd = { - 'git', - '-C', - review.repo_root, - 'diff', - '--no-ext-diff', - '--no-color', - '--src-prefix=a/', - '--dst-prefix=b/', - } - vim.list_extend(cmd, diffopt.git_flags()) - vim.list_extend(cmd, args) - cmd[#cmd + 1] = '--' - return cmd -end - ----@param review diffs.NormalizedReview ----@return string[] -function M.build_cmd(review) - local args = { '--end-of-options' } - vim.list_extend(args, review.args or { review.display }) - return diff_cmd(review, args) -end - ----@param line string ----@return string? -local function diff_line_file(line) - local old_path, new_path = line:match('^diff %-%-git %a/(.-) %a/(.+)$') - return new_path or old_path -end - ----@param diff_lines string[] ----@return table -local function combined_diff_files(diff_lines) - local files = {} - for _, line in ipairs(diff_lines) do - local file = line:match('^diff %-%-cc (.+)$') or line:match('^diff %-%-combined (.+)$') - if file then - files[file] = true - end - end - return files -end - ----@param diff_lines string[] ----@return table[] -local function file_records(diff_lines) - local records = {} - local current = nil - for lnum, line in ipairs(diff_lines) do - local file = diff_line_file(line) - if file then - if current then - current.finish = lnum - 1 - end - current = { - file = file, - start = lnum, - finish = #diff_lines, - } - records[#records + 1] = current - end - end - return records -end - ----@param path string ----@return diffs.DiffSpec -local function unmerged_stage_spec(path) - return diffspec.rev_to_rev(':2', ':3', path) -end - ----@param diff_lines string[] ----@param spec_for_file fun(file: string): diffs.DiffSpec ----@return table -local function section_specs(diff_lines, spec_for_file) - local specs = {} - for _, record in ipairs(file_records(diff_lines)) do - specs[record.file] = spec_for_file(record.file) - end - return specs -end - ----@param review diffs.NormalizedReview ----@param base_rev string ----@return string[]?, string? -local function branch_lines(review, base_rev) - return git_lines(diff_cmd(review, { '--end-of-options', base_rev, 'HEAD' })) -end - ----@param review diffs.NormalizedReview ----@param cached boolean ----@return string[]?, string? -local function worktree_edge_lines(review, cached) - return git_lines(diff_cmd(review, cached and { '--cached' } or {})) -end - ----@param review diffs.NormalizedReview ----@return string[]?, string? -local function untracked_paths(review) - local paths = vim.fn.systemlist({ - 'git', - '-C', - review.repo_root, - 'ls-files', - '--others', - '--exclude-standard', - }) - if vim.v.shell_error ~= 0 then - return nil, table.concat(paths or {}, '\n') - end - - table.sort(paths) - return paths, nil -end - ----@param repo_root string ----@param path string ----@return boolean -local function is_untracked_binary(repo_root, path) - local result = vim.fn.systemlist({ - 'git', - '-C', - repo_root, - 'diff', - '--no-ext-diff', - '--no-color', - '--no-index', - '--numstat', - '--', - '/dev/null', - path, - }) - if vim.v.shell_error ~= 0 and vim.v.shell_error ~= 1 then - return false - end - - for _, line in ipairs(result or {}) do - if line:match('^%-%s+%-%s+') then - return true - end - end - return false -end - ----@param lines string[] ----@param section table ----@param specs table ----@param state table -local function append_section(lines, section, specs, state) - local records = file_records(section.lines) - if #records == 0 then - return - end - - local header_lnum = #lines + 1 - lines[#lines + 1] = ('# %s: %s'):format(section.label, section.description) - local section_start = header_lnum - for _, line in ipairs(section.lines) do - lines[#lines + 1] = line - end - - local section_info = { - id = section.id, - label = section.label, - start = section_start, - finish = #lines, - } - state.sections[#state.sections + 1] = section_info - - for _, record in ipairs(records) do - local key = section.id .. ':' .. record.file - state.records[#state.records + 1] = { - key = key, - file = record.file, - section = section.id, - section_label = section.label, - diff_spec = specs[record.file], - start = section_start + record.start, - finish = section_start + record.finish, - } - end -end - ----@param records table[] ----@param lnum integer ----@param file string ----@return table? -local function record_for_line(records, lnum, file) - for _, record in ipairs(records or {}) do - if record.file == file and lnum >= record.start and lnum <= record.finish then - return record - end - end - return nil -end - ----@param records table[] ----@return fun(lnum: integer, file: string): table? -local function metadata_for_records(records) - return function(lnum, file) - return record_for_line(records, lnum, file) - end -end - ----@param review diffs.NormalizedReview ----@param deps diffs.ReviewDeps ----@return string[]?, string?, table? -local function run_current_state(review, deps) - local base_rev, merge_err = merge_base(review.repo_root, review.base, 'HEAD') - if not base_rev then - return nil, merge_err, nil - end - - local lines = {} - local state = { - records = {}, - sections = {}, - } - - local branch, branch_err = branch_lines(review, base_rev) - if not branch then - return nil, branch_err ~= '' and branch_err or 'git diff failed for review Branch section', nil - end - local branch_rendered = deps.replace_combined_diffs(branch, review.repo_root) - local branch_specs = section_specs(branch_rendered, function(file) - return diffspec.rev_to_rev(base_rev, 'HEAD', file) - end) - append_section(lines, { - id = 'branch', - label = 'Branch', - description = review.base .. '...' .. 'HEAD', - lines = branch_rendered, - }, branch_specs, state) - - local staged, staged_err = worktree_edge_lines(review, true) - if not staged then - return nil, staged_err ~= '' and staged_err or 'git diff failed for review Staged section', nil - end - local staged_rendered = deps.replace_combined_diffs(staged, review.repo_root) - local staged_unmerged = combined_diff_files(staged) - local staged_specs = section_specs(staged_rendered, function(file) - return staged_unmerged[file] and unmerged_stage_spec(file) or diffspec.head_to_index(file) - end) - append_section(lines, { - id = 'staged', - label = 'Staged', - description = 'HEAD -> index', - lines = staged_rendered, - }, staged_specs, state) - - local unstaged, unstaged_err = worktree_edge_lines(review, false) - if not unstaged then - return nil, - unstaged_err ~= '' and unstaged_err or 'git diff failed for review Unstaged section', - nil - end - local unstaged_rendered = deps.replace_combined_diffs(unstaged, review.repo_root) - local unstaged_unmerged = combined_diff_files(unstaged) - local unstaged_specs = section_specs(unstaged_rendered, function(file) - return unstaged_unmerged[file] and unmerged_stage_spec(file) or diffspec.index_to_worktree(file) - end) - append_section(lines, { - id = 'unstaged', - label = 'Unstaged', - description = 'index -> worktree', - lines = unstaged_rendered, - }, unstaged_specs, state) - - if review.untracked then - local untracked, untracked_err = untracked_paths(review) - if not untracked then - return nil, - untracked_err ~= '' and untracked_err or 'git ls-files failed for review Untracked section', - nil - end - local untracked_lines = {} - local untracked_specs = {} - for _, path in ipairs(untracked) do - if is_untracked_binary(review.repo_root, path) then - dbg('skipping binary untracked %s', path) - else - local spec = diffspec.index_to_worktree(path) - local rendered, render_err = render.file(spec, review.repo_root) - if rendered then - for _, line in ipairs(rendered) do - untracked_lines[#untracked_lines + 1] = line - end - untracked_specs[path] = spec - elseif render_err then - dbg('skipping untracked %s: %s', path, render_err) - end - end - end - append_section(lines, { - id = 'untracked', - label = 'Untracked', - description = 'empty -> worktree', - lines = untracked_lines, - }, untracked_specs, state) - end - - return lines, - nil, - { - metadata_for_line = metadata_for_records(state.records), - sections = state.sections, - store_hunks = true, - is_skipped = is_skipped_entry, - } -end - ----@param review_spec diffs.ReviewSpec? ----@param repo_root string ----@param path string ----@param selection? diffs.GeneratedFileSelection ----@return diffs.DiffSpec?, diffs.NormalizedReview?, string? -function M.diff_spec_for_file(review_spec, repo_root, path, selection) - local normalized, normalize_err = M.normalize(review_spec, repo_root) - if not normalized then - return nil, nil, normalize_err - end - - if selection and selection.diff_spec then - return diffspec.new(selection.diff_spec), normalized, nil - end - - if not normalized.target then - return diffspec.rev_to_worktree(normalized.base, path), normalized, nil - end - - if normalized.mode == 'merge-base' then - local base_rev, merge_err = merge_base(normalized.repo_root, normalized.base, normalized.target) - if not base_rev then - return nil, normalized, merge_err - end - return diffspec.rev_to_rev(base_rev, normalized.target, path), normalized, nil - end - - return diffspec.rev_to_rev(normalized.base, normalized.target, path), normalized, nil -end - ----@param normalized diffs.NormalizedReview ----@return diffs.ReviewSpec -function M.stored_review_spec(normalized) - local spec = normalized.args and { args = normalized.args } - or { - base = normalized.base, - target = normalized.target, - mode = normalized.mode, - } - spec.untracked = normalized.untracked - return spec -end - ----@param review diffs.NormalizedReview ----@return string -function M.buffer_name(review) - return 'diffs://review:' .. review.display -end - ----@param repo_root? string ----@return string[] -local function list_refs(repo_root) - local cmd = { 'git' } - if repo_root then - table.insert(cmd, '-C') - table.insert(cmd, repo_root) - end - vim.list_extend(cmd, { - 'for-each-ref', - '--format=%(refname:short)', - 'refs/heads/', - 'refs/remotes/', - 'refs/tags/', - }) - local refs = vim.fn.systemlist(cmd) - if vim.v.shell_error ~= 0 then - return {} - end - return refs -end - ----@param arglead string ----@return string[] -function M.complete(arglead) - local prefix, tail = arglead:match('^(.-%.%.%.?)(.*)$') - prefix, tail = prefix or '', tail or arglead - local matches = {} - for _, ref in ipairs(list_refs(resolve_repo_root(nil))) do - if ref:find(tail, 1, true) == 1 then - table.insert(matches, prefix .. ref) - end - end - return matches -end - ----@param review diffs.NormalizedReview ----@param deps diffs.ReviewDeps ----@return string[]?, string? -function M.run(review, deps) - local result, err = git_lines(M.build_cmd(review)) - if not result then - return nil, err - end - return deps.replace_combined_diffs(result, review.repo_root), nil -end - ----@param review diffs.NormalizedReview ----@param deps diffs.ReviewDeps ----@return string[]?, string?, table? -function M.render(review, deps) - if not review.target then - return run_current_state(review, deps) - end - - local result, err = M.run(review, deps) - return result, err, { is_skipped = is_skipped_entry } -end - ----@param spec? diffs.ReviewSpec ----@param deps diffs.ReviewDeps ----@return integer? -function M.open(spec, deps) - local review, err = M.normalize(spec) - if not review then - notify(err, vim.log.levels.ERROR) - return nil - end - - local target_name = M.buffer_name(review) - local existing_buf = vim.fn.bufnr(target_name) - if existing_buf ~= -1 then - pcall(vim.api.nvim_buf_delete, existing_buf, { force = true }) - end - - local result, diff_err, list_opts = M.render(review, deps) - if not result then - notify(diff_err, vim.log.levels.ERROR) - return nil - end - if #result == 0 then - notify('no diff against ' .. review.display, vim.log.levels.INFO) - return nil - end - - local review_layout = deps.review_layout or 'unified' - local diff_buf = deps.create_generated_diff_buffer({ - name = target_name, - lines = result, - repo_root = review.repo_root, - source = generated.review_source(review.repo_root, M.stored_review_spec(review)), - rail_style = deps.rail_style, - vars = { - diffs_review = { display = review.display, layout = review_layout }, - }, - }) - - if deps.setup_review_map_buffer then - deps.setup_review_map_buffer(diff_buf, review.display, review_layout) - end - if not deps.show_generated_diff_buffer(diff_buf, review.vertical) then - pcall(vim.api.nvim_buf_delete, diff_buf, { force = true }) - return nil - end - lists.set_for_unified_buffer(diff_buf, result, { - title = 'review: ' .. review.display, - loclist_title = 'review hunks: ' .. review.display, - metadata_for_line = list_opts and list_opts.metadata_for_line, - sections = list_opts and list_opts.sections, - store_hunks = list_opts and list_opts.store_hunks, - is_skipped = list_opts and list_opts.is_skipped, - quickfix = true, - }) - - deps.attach_generated_diff_buffer(diff_buf) - dbg('opened review buffer %d (%s)', diff_buf, review.display) - - return diff_buf -end - ----@param review_spec diffs.ReviewSpec? ----@param repo_root string ----@param deps diffs.ReviewDeps ----@return string[]?, string?, table? -local function reload_spec(review_spec, repo_root, deps) - local normalized, normalize_err = M.normalize(review_spec, repo_root) - if not normalized then - return nil, normalize_err, nil - end - - return M.render(normalized, deps) -end - ----@param source diffs.GeneratedBufferSource ----@param deps diffs.ReviewDeps ----@return string[]?, string?, table? -function M.reload_source(source, deps) - return reload_spec(source.review, source.repo_root, deps) -end - -return M diff --git a/lua/diffs/runtime/attach.lua b/lua/diffs/runtime/attach.lua index f4131abf..9b362f40 100644 --- a/lua/diffs/runtime/attach.lua +++ b/lua/diffs/runtime/attach.lua @@ -1,5 +1,4 @@ local integrations = require('diffs.integrations') -local log = require('diffs.log') local M = {} @@ -77,7 +76,6 @@ function M.attach(opts, bufnr, options) break end end - log.dbg('attaching to buffer %d', bufnr) vim.api.nvim_create_autocmd('BufWipeout', { buffer = bufnr, group = attachment.augroup, diff --git a/lua/diffs/runtime/diff_windows.lua b/lua/diffs/runtime/diff_windows.lua index b5ae57eb..13a70dff 100644 --- a/lua/diffs/runtime/diff_windows.lua +++ b/lua/diffs/runtime/diff_windows.lua @@ -9,32 +9,6 @@ local WINHIGHLIGHT = table.concat({ 'DiffText:DiffsDiffText', }, ',') -local CONFLICT_WINHIGHLIGHT = table.concat({ - 'DiffAdd:DiffsDiffOff', - 'DiffDelete:DiffsDiffOff', - 'DiffChange:DiffsDiffOff', - 'DiffText:DiffsDiffOff', -}, ',') - ----@param win integer ----@return boolean -local function is_split_pane(win) - local buf = vim.api.nvim_win_get_buf(win) - local ok, source = pcall(vim.api.nvim_buf_get_var, buf, 'diffs_source') - return ok and source.kind == 'split_endpoint' -end - ----@param buf integer ----@return boolean -local function has_conflict_markers(buf) - for _, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do - if line:match('^<<<<<<<') then - return true - end - end - return false -end - ---@param diff_windows table function M.attach(diff_windows) local tabpage = vim.api.nvim_get_current_tabpage() @@ -43,7 +17,7 @@ function M.attach(diff_windows) local diff_wins = {} for _, win in ipairs(wins) do - if vim.api.nvim_win_is_valid(win) and vim.wo[win].diff and not is_split_pane(win) then + if vim.api.nvim_win_is_valid(win) and vim.wo[win].diff then table.insert(diff_wins, win) end end @@ -53,15 +27,11 @@ function M.attach(diff_windows) end for _, win in ipairs(diff_wins) do - local buf = vim.api.nvim_win_get_buf(win) - local winhighlight = has_conflict_markers(buf) and CONFLICT_WINHIGHLIGHT or WINHIGHLIGHT local current = vim.api.nvim_get_option_value('winhighlight', { win = win }) - if - diff_windows[win] == nil or (current ~= WINHIGHLIGHT and current ~= CONFLICT_WINHIGHLIGHT) - then + if diff_windows[win] == nil or current ~= WINHIGHLIGHT then diff_windows[win] = current end - vim.api.nvim_set_option_value('winhighlight', winhighlight, { win = win }) + vim.api.nvim_set_option_value('winhighlight', WINHIGHLIGHT, { win = win }) log.dbg('applied diff winhighlight to window %d', win) end end @@ -71,7 +41,7 @@ function M.detach(diff_windows) for win, previous in pairs(diff_windows) do if vim.api.nvim_win_is_valid(win) then local current = vim.api.nvim_get_option_value('winhighlight', { win = win }) - if current == WINHIGHLIGHT or current == CONFLICT_WINHIGHLIGHT then + if current == WINHIGHLIGHT then vim.api.nvim_set_option_value('winhighlight', previous, { win = win }) end end diff --git a/lua/diffs/runtime/highlight_groups.lua b/lua/diffs/runtime/highlight_groups.lua index eb9f66c2..8e0acdb1 100644 --- a/lua/diffs/runtime/highlight_groups.lua +++ b/lua/diffs/runtime/highlight_groups.lua @@ -32,21 +32,15 @@ function M.apply(config, is_default) local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' }) local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' }) - local line_nr = vim.api.nvim_get_hl(0, { name = 'LineNr' }) - local diff_added = vim.api.nvim_get_hl(0, { name = 'diffAdded', link = false }) - local diff_removed = vim.api.nvim_get_hl(0, { name = 'diffRemoved', link = false }) local dark = vim.o.background ~= 'light' local transparent = not normal.bg 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) local dflt = is_default or false local normal_fg = normal.fg or (dark and 0xcccccc or 0x333333) - local line_nr_fg = line_nr.fg or normal_fg local alpha = config.highlights.blend_alpha or 0.6 local blended_add = blend_color(add_bg, bg, alpha) @@ -58,43 +52,16 @@ function M.apply(config, is_default) if not transparent then clear_hl.bg = bg end - local rail_hl = { default = dflt, fg = normal_fg, nocombine = true } - if not transparent then - rail_hl.bg = bg - end vim.api.nvim_set_hl(0, 'DiffsClear', clear_hl) vim.api.nvim_set_hl(0, 'DiffsAdd', { default = dflt, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsDelete', { default = dflt, bg = blended_del }) - vim.api.nvim_set_hl(0, 'DiffsRail', rail_hl) - vim.api.nvim_set_hl( - 0, - 'DiffsRailNr', - { default = dflt, fg = line_nr_fg, bg = bg, nocombine = true } - ) - vim.api.nvim_set_hl( - 0, - 'DiffsAddRailNr', - { default = dflt, fg = add_fg, bg = blended_add, nocombine = true } - ) - vim.api.nvim_set_hl( - 0, - 'DiffsDeleteRailNr', - { default = dflt, fg = del_fg, bg = blended_del, nocombine = true } - ) - vim.api.nvim_set_hl(0, 'DiffsAddBar', { default = dflt, fg = add_fg, bg = blended_add }) - vim.api.nvim_set_hl(0, 'DiffsDeleteBar', { default = dflt, fg = del_fg, bg = blended_del }) - vim.api.nvim_set_hl(0, 'DiffsAddCount', { default = dflt, fg = add_fg }) - vim.api.nvim_set_hl(0, 'DiffsDeleteCount', { default = dflt, fg = del_fg }) vim.api.nvim_set_hl(0, 'DiffsAddText', { default = dflt, bg = blended_add_text }) vim.api.nvim_set_hl(0, 'DiffsDeleteText', { default = dflt, bg = blended_del_text }) - vim.api.nvim_set_hl(0, 'DiffsDim', { default = dflt, bg = blend_color(normal_fg, bg, 0.04) }) - log.dbg( - 'highlight groups: Normal.bg=%s DiffAdd.bg=#%06x diffAdded.fg=#%06x', + 'highlight groups: Normal.bg=%s DiffAdd.bg=#%06x', normal.bg and string.format('#%06x', normal.bg) or 'NONE', - add_bg, - add_fg + add_bg ) log.dbg('DiffsAdd.bg=#%06x DiffsAddText.bg=#%06x', blended_add, blended_add_text) log.dbg('DiffsDelete.bg=#%06x DiffsDeleteText.bg=#%06x', blended_del, blended_del_text) @@ -110,42 +77,7 @@ function M.apply(config, is_default) ) vim.api.nvim_set_hl(0, 'DiffsDiffChange', { default = dflt, bg = diff_change.bg }) vim.api.nvim_set_hl(0, 'DiffsDiffText', { default = dflt, bg = diff_text.bg }) - vim.api.nvim_set_hl(0, 'DiffsDiffOff', {}) - - local change_bg = diff_change.bg or 0x3a3a4a - local text_bg = diff_text.bg or 0x4a4a5a - local change_fg = diff_change.fg or diff_text.fg or 0x80a0c0 - - local base_alpha = math.max(alpha - 0.1, 0.0) - local blended_ours = blend_color(add_bg, bg, alpha) - local blended_theirs = blend_color(change_bg, bg, alpha) - local blended_base = blend_color(text_bg, bg, base_alpha) - local blended_ours_nr = add_fg - local blended_theirs_nr = change_fg - local blended_base_nr = change_fg - - vim.api.nvim_set_hl(0, 'DiffsConflictOurs', { default = dflt, bg = blended_ours }) - vim.api.nvim_set_hl(0, 'DiffsConflictTheirs', { default = dflt, bg = blended_theirs }) - vim.api.nvim_set_hl(0, 'DiffsConflictBase', { default = dflt, bg = blended_base }) - vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { default = dflt, fg = 0x808080, bold = true }) - vim.api.nvim_set_hl( - 0, - 'DiffsConflictOursNr', - { default = dflt, fg = blended_ours_nr, bg = blended_ours } - ) - vim.api.nvim_set_hl( - 0, - 'DiffsConflictTheirsNr', - { default = dflt, fg = blended_theirs_nr, bg = blended_theirs } - ) - vim.api.nvim_set_hl( - 0, - 'DiffsConflictBaseNr', - { default = dflt, fg = blended_base_nr, bg = blended_base } - ) - - -- Neogit's word-diff highlights changed words with these groups; when our - -- integration owns the buffer we paint intra-line ourselves, so clear them. + -- Diffs renders intra-line highlights in Neogit buffers. -- https://github.com/NeogitOrg/neogit/blob/99326a1310fb2d616b455d2fd16d01bf00682f06/lua/neogit/lib/diff_highlights.lua#L232-L233 if integrations.is_enabled(config.integrations.neogit) then vim.api.nvim_set_hl(0, 'NeogitDiffAddInline', {}) diff --git a/lua/diffs/runtime/init.lua b/lua/diffs/runtime/init.lua index f3f51b52..713b7231 100644 --- a/lua/diffs/runtime/init.lua +++ b/lua/diffs/runtime/init.lua @@ -159,30 +159,6 @@ function M.detach_diff() diff_windows_mod.detach(diff_windows) end ----@return boolean -function M.get_fugitive_config() - init() - return config.integrations.fugitive == true -end - ----@return boolean -function M.get_conflict_config() - init() - return config.conflict -end - ----@return diffs.ViewConfig -function M.get_view_config() - init() - return config.view -end - ----@return boolean|diffs.DifftasticConfig|nil -function M.get_difftastic_config() - init() - return config.integrations.difftastic -end - ---@return diffs.HunkOpts function M.get_highlight_opts() init() diff --git a/lua/diffs/spec.lua b/lua/diffs/spec.lua deleted file mode 100644 index 08057c5b..00000000 --- a/lua/diffs/spec.lua +++ /dev/null @@ -1,252 +0,0 @@ -local M = {} - -M.endpoint_kind = { - tree = 'tree', - index = 'index', - worktree = 'worktree', - stage = 'stage', -} - -M.scope_kind = { - file = 'file', -} - -local function require_table(value, name) - if type(value) ~= 'table' then - error('diffs: ' .. name .. ' must be a table') - end -end - -local function require_non_empty_string(value, name) - if type(value) ~= 'string' or value == '' then - error('diffs: ' .. name .. ' must be a non-empty string') - end -end - ----@class diffs.TreeEndpoint ----@field kind "tree" ----@field rev string - ----@class diffs.IndexEndpoint ----@field kind "index" - ----@class diffs.WorktreeEndpoint ----@field kind "worktree" - ----@class diffs.StageEndpoint ----@field kind "stage" ----@field stage 1|2|3 - ----@alias diffs.Endpoint diffs.TreeEndpoint|diffs.IndexEndpoint|diffs.WorktreeEndpoint|diffs.StageEndpoint - ----@class diffs.FileScope ----@field kind "file" ----@field path string - ----@alias diffs.Scope diffs.FileScope - ----@class diffs.DiffSpec ----@field left diffs.Endpoint ----@field right diffs.Endpoint ----@field scope diffs.Scope ----@field mode "unified" - ----@param rev string ----@return diffs.TreeEndpoint -function M.tree(rev) - require_non_empty_string(rev, 'tree endpoint rev') - return { kind = M.endpoint_kind.tree, rev = rev } -end - -M.revision = M.tree - ----@return diffs.IndexEndpoint -function M.index() - return { kind = M.endpoint_kind.index } -end - ----@return diffs.WorktreeEndpoint -function M.worktree() - return { kind = M.endpoint_kind.worktree } -end - ----@param stage 1|2|3 ----@return diffs.StageEndpoint -function M.stage(stage) - if stage ~= 1 and stage ~= 2 and stage ~= 3 then - error('diffs: stage endpoint must be 1, 2, or 3') - end - return { kind = M.endpoint_kind.stage, stage = stage } -end - ----@param endpoint diffs.Endpoint ----@return diffs.Endpoint -function M.endpoint(endpoint) - require_table(endpoint, 'endpoint') - - if endpoint.kind == M.endpoint_kind.tree then - return M.tree(endpoint.rev) - end - - if endpoint.kind == M.endpoint_kind.index then - return M.index() - end - - if endpoint.kind == M.endpoint_kind.worktree then - return M.worktree() - end - - if endpoint.kind == M.endpoint_kind.stage then - return M.stage(endpoint.stage) - end - - error('diffs: unsupported endpoint kind: ' .. tostring(endpoint.kind)) -end - ----@param path string ----@return diffs.FileScope -function M.file_scope(path) - require_non_empty_string(path, 'file scope path') - return { kind = M.scope_kind.file, path = path } -end - ----@param scope diffs.Scope ----@return diffs.Scope -function M.scope(scope) - require_table(scope, 'scope') - - if scope.kind == M.scope_kind.file then - return M.file_scope(scope.path) - end - - error('diffs: unsupported scope kind: ' .. tostring(scope.kind)) -end - ----@param opts diffs.DiffSpec ----@return diffs.DiffSpec -function M.new(opts) - require_table(opts, 'diff spec') - - local mode = opts.mode or 'unified' - if mode ~= 'unified' then - error('diffs: diff spec mode must be "unified"') - end - - return { - left = M.endpoint(opts.left), - right = M.endpoint(opts.right), - scope = M.scope(opts.scope), - mode = mode, - } -end - ----@param left diffs.Endpoint ----@param right diffs.Endpoint ----@param path string ----@return diffs.DiffSpec -function M.file(left, right, path) - return M.new({ - left = left, - right = right, - scope = M.file_scope(path), - mode = 'unified', - }) -end - ----@param path string ----@return diffs.DiffSpec -function M.index_to_worktree(path) - return M.file(M.index(), M.worktree(), path) -end - ----@param path string ----@return diffs.DiffSpec -function M.head_to_index(path) - return M.file(M.tree('HEAD'), M.index(), path) -end - ----@param rev string ----@param path string ----@return diffs.DiffSpec -function M.rev_to_worktree(rev, path) - return M.file(M.tree(rev), M.worktree(), path) -end - ----@param left_rev string ----@param right_rev string ----@param path string ----@return diffs.DiffSpec -function M.rev_to_rev(left_rev, right_rev, path) - return M.file(M.tree(left_rev), M.tree(right_rev), path) -end - ----@param endpoint diffs.Endpoint ----@return string -function M.endpoint_label(endpoint) - endpoint = M.endpoint(endpoint) - - if endpoint.kind == M.endpoint_kind.tree then - return 'tree:' .. endpoint.rev - end - - if endpoint.kind == M.endpoint_kind.stage then - return 'stage:' .. endpoint.stage - end - - return endpoint.kind -end - ----@param scope diffs.Scope ----@return string -function M.scope_label(scope) - scope = M.scope(scope) - return scope.kind .. ':' .. scope.path -end - ----@param diff_spec diffs.DiffSpec ----@return string -function M.label(diff_spec) - diff_spec = M.new(diff_spec) - return M.endpoint_label(diff_spec.left) - .. ' -> ' - .. M.endpoint_label(diff_spec.right) - .. ' ' - .. M.scope_label(diff_spec.scope) -end - ----@param diff_spec diffs.DiffSpec ----@return "index"|"worktree"|nil -function M.mutation_target(diff_spec) - diff_spec = M.new(diff_spec) - - -- Merge-stage comparisons are read-only: a stage blob cannot be staged or - -- restored through hunk actions. - if diff_spec.left.kind == M.endpoint_kind.stage then - return nil - end - - if diff_spec.right.kind == M.endpoint_kind.index then - return M.endpoint_kind.index - end - - if diff_spec.right.kind == M.endpoint_kind.worktree then - return M.endpoint_kind.worktree - end - - return nil -end - ----@param diff_spec diffs.DiffSpec ----@return { can_put: boolean, can_obtain: boolean } -function M.patch_actions(diff_spec) - diff_spec = M.new(diff_spec) - - return { - can_put = diff_spec.left.kind == M.endpoint_kind.index - and diff_spec.right.kind == M.endpoint_kind.worktree, - can_obtain = diff_spec.left.kind == M.endpoint_kind.tree - and diff_spec.right.kind == M.endpoint_kind.index, - } -end - -return M diff --git a/lua/diffs/split.lua b/lua/diffs/split.lua deleted file mode 100644 index e90de1a5..00000000 --- a/lua/diffs/split.lua +++ /dev/null @@ -1,1442 +0,0 @@ -local M = {} - -local diffopt = require('diffs.diffopt') - -local diff = require('diffs.diff') -local diffspec = require('diffs.spec') -local difftastic = require('diffs.difftastic') -local generated = require('diffs.generated') -local hunk_model = require('diffs.hunks') -local lists = require('diffs.lists') -local log = require('diffs.log') -local render = require('diffs.render') - -local notify = log.notify - -local split_align = require('diffs.split_align') - ----@type table -local closing_buffers = {} ----@type table -local cleanup_autocmds = {} ----@type table -local peer_buffers = {} ----@type table> -local pair_window_options = {} ----@type table -local split_windows = {} -local win_closed_registered = false - ----@class diffs.SplitPaneInfo ----@field rows diffs.SplitRow[] ----@field side "left"|"right" ----@field rail_width integer ----@field anchors integer[] ----@field difft_intra? table - ----@type table -local pane_info = {} -local split_line_ns = vim.api.nvim_create_namespace('diffs_split_line') -local split_intra_ns = vim.api.nvim_create_namespace('diffs_split_intra') -local split_statuscolumn = "%!v:lua.require'diffs.split'.statuscolumn()" - ----@class diffs.SplitReuseWins ----@field left integer ----@field right integer - ----@class diffs.SplitOpenOpts ----@field spec diffs.DiffSpec ----@field repo_root string ----@field filetype? string ----@field worktree_lines? diffs.ContentLines|string[] ----@field worktree_buf? integer ----@field diff_lines? string[] ----@field hunk_index? integer ----@field quickfix? boolean ----@field title? string ----@field reuse_wins? diffs.SplitReuseWins - ----@class diffs.SplitEndpointSource ----@field version integer ----@field kind "split_endpoint" ----@field worktree_buf? integer ----@field repo_root string ----@field spec diffs.DiffSpec ----@field side "left"|"right" ----@field path string ----@field filetype? string ----@field quickfix? boolean - ----@param repo_root string ----@param path string ----@return string -local function abs_path(repo_root, path) - return repo_root .. '/' .. path -end - ----@param endpoint diffs.Endpoint ----@return string -local function endpoint_name(endpoint) - endpoint = diffspec.endpoint(endpoint) - if endpoint.kind == diffspec.endpoint_kind.tree then - return endpoint.rev - end - return endpoint.kind -end - ----@param diff_spec diffs.DiffSpec ----@param side "left"|"right" ----@return diffs.Endpoint -local function side_endpoint(diff_spec, side) - diff_spec = diffspec.new(diff_spec) - return side == 'left' and diff_spec.left or diff_spec.right -end - ----@param lines string[] ----@return string[] -local function plain_lines(lines) - local result = {} - for i, line in ipairs(lines) do - result[i] = line - end - return result -end - ----@param source diffs.SplitEndpointSource ----@param opts? { worktree_lines?: diffs.ContentLines|string[] } ----@return string[]?, string? -local function endpoint_lines(source, opts) - opts = opts or {} - local spec = diffspec.new(source.spec) - local lines, err = render.read_endpoint( - side_endpoint(spec, source.side), - abs_path(source.repo_root, source.path), - { - worktree_lines = opts.worktree_lines, - worktree_buf = source.worktree_buf, - empty_on_missing = true, - } - ) - if not lines then - return nil, err or ('could not read ' .. source.side .. ' endpoint') - end - return plain_lines(lines), nil -end - ----@param diff_lines string[]? ----@param spec diffs.DiffSpec ----@return diffs.DiffHunk[] -local function split_hunks_for(diff_lines, spec) - if not diff_lines then - return {} - end - return hunk_model.parse(diff_lines, spec) -end - ----@class diffs.SplitSideConfig ----@field kind "delete"|"add" ----@field lnum "old_lnum"|"new_lnum" ----@field spans "del_spans"|"add_spans" ----@field line_hl string ----@field text_hl string ----@field bar_hl string ----@field nr_hl string - ----@type table<"left"|"right", diffs.SplitSideConfig> -local side_config = { - left = { - kind = 'delete', - lnum = 'old_lnum', - spans = 'del_spans', - line_hl = 'DiffsDelete', - text_hl = 'DiffsDeleteText', - bar_hl = 'DiffsDeleteBar', - nr_hl = 'DiffsDeleteRailNr', - }, - right = { - kind = 'add', - lnum = 'new_lnum', - spans = 'add_spans', - line_hl = 'DiffsAdd', - text_hl = 'DiffsAddText', - bar_hl = 'DiffsAddBar', - nr_hl = 'DiffsAddRailNr', - }, -} - ----@param source integer? diff-mapped source line number for this row ----@param width integer ----@param number boolean ----@return string -local function rail_number(source, width, number) - if not number then - return '' - end - if not source then - return string.rep(' ', width) - end - return ('%' .. width .. 'd'):format(source) -end - ----@param info diffs.SplitPaneInfo ----@param lnum integer ----@param number boolean ----@return string -local function rail_segment(info, lnum, number) - local width = info.rail_width - local row = info.rows[lnum] - if not row or row.kind == 'filler' then - local pad = number and width + 2 or 2 - return '%C%s%#DiffsRail#' .. string.rep(' ', pad) - end - local cfg = side_config[info.side] - local numstr = rail_number(row[cfg.lnum], width, number) - if row.kind == cfg.kind then - return ('%%C%%s%%#%s#%s%%#%s#%s%%#%s# '):format( - cfg.bar_hl, - '▏', - cfg.nr_hl, - numstr, - cfg.line_hl - ) - end - return ('%%C%%s%%#DiffsRailNr# %s '):format(numstr) -end - ----@return string -function M.statuscolumn() - local win = vim.g.statusline_winid - if not win or win == 0 then - return '' - end - local ok, bufnr = pcall(vim.api.nvim_win_get_buf, win) - if not ok then - return '' - end - local info = pane_info[bufnr] - if not info then - return '' - end - local number = vim.api.nvim_get_option_value('number', { win = win }) - local rendered_ok, rendered = pcall(rail_segment, info, vim.v.lnum, number) - return rendered_ok and rendered or '' -end - ----@param bufnr integer ----@param info diffs.SplitPaneInfo -local function set_split_line_bg(bufnr, info) - vim.api.nvim_buf_clear_namespace(bufnr, split_line_ns, 0, -1) - local priorities = require('diffs.runtime').get_highlight_opts().highlights.priorities - local cfg = side_config[info.side] - for lnum, row in ipairs(info.rows) do - if row.kind == cfg.kind then - pcall(vim.api.nvim_buf_set_extmark, bufnr, split_line_ns, lnum - 1, 0, { - end_row = lnum, - end_col = 0, - hl_group = cfg.line_hl, - hl_eol = true, - priority = priorities.line_bg, - }) - end - end -end - ----@param bufnr integer ----@param info diffs.SplitPaneInfo -local function set_split_intra_difft(bufnr, info) - local opts = require('diffs.runtime').get_highlight_opts() - if diffopt.inline() == 'none' then - return - end - local cfg = side_config[info.side] - local priority = opts.highlights.priorities.char_bg - local line_count = vim.api.nvim_buf_line_count(bufnr) - for row_index, spans in pairs(info.difft_intra) do - if row_index >= 1 and row_index <= line_count then - for _, span in ipairs(spans) do - pcall(vim.api.nvim_buf_set_extmark, bufnr, split_intra_ns, row_index - 1, span.col_start, { - end_col = span.col_end, - hl_group = cfg.text_hl, - priority = priority, - }) - end - end - end -end - ----@param bufnr integer ----@param info diffs.SplitPaneInfo ----@param hunks diffs.DiffHunk[] -local function set_split_intra(bufnr, info, hunks) - vim.api.nvim_buf_clear_namespace(bufnr, split_intra_ns, 0, -1) - if info.difft_intra then - return set_split_intra_difft(bufnr, info) - end - if not hunks or #hunks == 0 then - return - end - - local opts = require('diffs.runtime').get_highlight_opts() - local intra_cfg = opts.highlights.intra - if diffopt.inline() == 'none' then - return - end - - local cfg = side_config[info.side] - local priority = opts.highlights.priorities.char_bg - - ---@type table - local lnum_to_row = {} - for row_index, row in ipairs(info.rows) do - local source_lnum = row[cfg.lnum] - if source_lnum then - lnum_to_row[source_lnum] = row_index - end - end - local line_count = vim.api.nvim_buf_line_count(bufnr) - - for _, hunk in ipairs(hunks) do - ---@type string[] - local texts = {} - ---@type diffs.DiffHunkLine[] - local refs = {} - for _, line in ipairs(hunk.lines or {}) do - if line.kind ~= 'header' then - texts[#texts + 1] = line.text - refs[#refs + 1] = line - end - end - - if #texts > 0 and #texts <= intra_cfg.max_lines then - local intra = diff.compute_intra_hunks(texts) - local spans = intra and intra[cfg.spans] or {} - for _, span in ipairs(spans) do - local ref = refs[span.line] - local source_lnum = ref and ref.kind == cfg.kind and ref[cfg.lnum] - local buf_row = source_lnum and lnum_to_row[source_lnum] - local col_start = span.col_start - 1 - local col_end = span.col_end - 1 - if - buf_row - and buf_row >= 1 - and buf_row <= line_count - and col_start >= 0 - and col_end > col_start - then - pcall(vim.api.nvim_buf_set_extmark, bufnr, split_intra_ns, buf_row - 1, col_start, { - end_col = col_end, - hl_group = cfg.text_hl, - priority = priority, - }) - end - end - end - end -end - ----@param bufnr integer ----@param filetype? string -local function set_buffer_options(bufnr, filetype) - vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr }) - vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = bufnr }) - vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr }) - vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) - if filetype and filetype ~= '' then - vim.api.nvim_set_option_value('filetype', filetype, { buf = bufnr }) - end -end - ----@param bufnr integer ----@param mode string ----@param lhs string ----@return table? -local function get_buffer_keymap(bufnr, mode, lhs) - for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, mode)) do - if keymap.lhs == lhs then - return keymap - end - end - return nil -end - ----@param bufnr integer ----@param lhs string ----@param desc string -local function clear_owned_keymap(bufnr, lhs, desc) - local keymap = get_buffer_keymap(bufnr, 'n', lhs) - if keymap and keymap.desc == desc then - pcall(vim.keymap.del, 'n', lhs, { buffer = bufnr }) - end -end - ----@param bufnr integer -local function clear_owned_split_keymaps(bufnr) - clear_owned_keymap(bufnr, '', 'Refresh diff and redraw') - clear_owned_keymap(bufnr, 'q', 'Close split diff') - clear_owned_keymap(bufnr, '', 'Open source file') - clear_owned_keymap(bufnr, ']c', 'Next diff hunk') - clear_owned_keymap(bufnr, '[c', 'Previous diff hunk') -end - ----@param bufnr integer -local function set_keymaps(bufnr) - if not get_buffer_keymap(bufnr, 'n', '') then - vim.keymap.set( - 'n', - '', - 'nohlsearchdiffupdate(diffs-refresh)', - { buffer = bufnr, silent = true, desc = 'Refresh diff and redraw' } - ) - end - if not get_buffer_keymap(bufnr, 'n', 'q') then - vim.keymap.set('n', 'q', function() - M.close_pair(vim.api.nvim_get_current_buf()) - end, { buffer = bufnr, desc = 'Close split diff' }) - end - if not get_buffer_keymap(bufnr, 'n', '') then - vim.keymap.set('n', '', function() - M.open_source(vim.api.nvim_get_current_buf()) - end, { buffer = bufnr, desc = 'Open source file' }) - end - if not get_buffer_keymap(bufnr, 'n', ']c') then - vim.keymap.set('n', ']c', function() - M.goto_next(vim.api.nvim_get_current_buf()) - end, { buffer = bufnr, desc = 'Next diff hunk' }) - end - if not get_buffer_keymap(bufnr, 'n', '[c') then - vim.keymap.set('n', '[c', function() - M.goto_prev(vim.api.nvim_get_current_buf()) - end, { buffer = bufnr, desc = 'Previous diff hunk' }) - end -end - ----@param source diffs.SplitEndpointSource ----@return string -local function buffer_name(source) - local spec = diffspec.new(source.spec) - local endpoint = side_endpoint(spec, source.side) - return 'diffs://split:' .. source.side .. ':' .. endpoint_name(endpoint) .. ':' .. source.path -end - ----@param bufnr integer ----@param source diffs.SplitEndpointSource ----@param info diffs.SplitPaneInfo ----@param hunks diffs.DiffHunk[] -local function paint_pane(bufnr, source, info, hunks) - pane_info[bufnr] = info - generated.set_source(bufnr, source) - if info.difft_intra then - difftastic.mark_active(bufnr) - end - set_split_line_bg(bufnr, info) - set_split_intra(bufnr, info, hunks) -end - ----@param source diffs.SplitEndpointSource ----@param lines string[] ----@param info diffs.SplitPaneInfo ----@param hunks diffs.DiffHunk[] ----@return integer -local function create_buffer(source, lines, info, hunks) - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - vim.api.nvim_buf_set_name(bufnr, buffer_name(source)) - paint_pane(bufnr, source, info, hunks) - set_buffer_options(bufnr, source.filetype) - set_keymaps(bufnr) - return bufnr -end - ----@param win integer -local function remember_pair_window_options(win) - if pair_window_options[win] or not vim.api.nvim_win_is_valid(win) then - return - end - - pair_window_options[win] = { - scrollbind = vim.api.nvim_get_option_value('scrollbind', { win = win }), - cursorbind = vim.api.nvim_get_option_value('cursorbind', { win = win }), - wrap = vim.api.nvim_get_option_value('wrap', { win = win }), - foldmethod = vim.api.nvim_get_option_value('foldmethod', { win = win }), - foldenable = vim.api.nvim_get_option_value('foldenable', { win = win }), - statuscolumn = vim.api.nvim_get_option_value('statuscolumn', { win = win }), - winhighlight = vim.api.nvim_get_option_value('winhighlight', { win = win }), - } -end - ----@param win integer -local function set_pair_window_options(win) - remember_pair_window_options(win) - vim.api.nvim_set_option_value('scrollbind', true, { win = win, scope = 'local' }) - vim.api.nvim_set_option_value('cursorbind', true, { win = win, scope = 'local' }) - vim.api.nvim_set_option_value('wrap', false, { win = win, scope = 'local' }) - vim.api.nvim_set_option_value('statuscolumn', split_statuscolumn, { win = win, scope = 'local' }) - vim.api.nvim_set_option_value('foldmethod', 'manual', { win = win, scope = 'local' }) - vim.api.nvim_set_option_value('foldenable', false, { win = win, scope = 'local' }) -end - ----@param win integer -local function clear_pair_window_options(win) - local saved = pair_window_options[win] - if saved then - pair_window_options[win] = nil - for name, value in pairs(saved) do - pcall(vim.api.nvim_set_option_value, name, value, { win = win, scope = 'local' }) - end - return - end - - pcall(vim.api.nvim_set_option_value, 'scrollbind', false, { win = win, scope = 'local' }) - pcall(vim.api.nvim_set_option_value, 'cursorbind', false, { win = win, scope = 'local' }) - pcall(vim.api.nvim_set_option_value, 'foldenable', true, { win = win, scope = 'local' }) - pcall(vim.api.nvim_set_option_value, 'statuscolumn', '', { win = win, scope = 'local' }) -end - ----@param bufnr integer ----@return integer? -local function stored_split_peer(bufnr) - local ok, peer = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_split_peer') - if ok and type(peer) == 'number' then - return peer - end - peer = peer_buffers[bufnr] - if type(peer) == 'number' then - return peer - end - return nil -end - ----@param bufnr integer ----@return integer? -local function split_peer(bufnr) - local peer = stored_split_peer(bufnr) - if peer and vim.api.nvim_buf_is_valid(peer) then - return peer - end - return nil -end - ----@type fun(source: table): diffs.SplitEndpointSource -local normalize_source - ----@param bufnr integer ----@return diffs.SplitEndpointSource? -local function buffer_source(bufnr) - local ok, source = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_source') - if ok and type(source) == 'table' and source.kind == 'split_endpoint' then - return normalize_source(source) - end - return nil -end - ----@param bufnr integer -local function ensure_pair_cleanup(bufnr) - if cleanup_autocmds[bufnr] then - return - end - - cleanup_autocmds[bufnr] = vim.api.nvim_create_autocmd('BufWipeout', { - buffer = bufnr, - once = true, - callback = function() - local peer = peer_buffers[bufnr] or split_peer(bufnr) - cleanup_autocmds[bufnr] = nil - peer_buffers[bufnr] = nil - if closing_buffers[bufnr] or not peer or vim.v.exiting ~= vim.NIL then - return - end - vim.schedule(function() - if vim.api.nvim_buf_is_valid(peer) then - M.close_pair(peer) - end - end) - end, - }) -end - -local function register_win_closed() - if win_closed_registered then - return - end - win_closed_registered = true - vim.api.nvim_create_autocmd('WinClosed', { - callback = function(args) - local win = tonumber(args.match) - if not win then - return - end - local bufnr = split_windows[win] - split_windows[win] = nil - if not bufnr or vim.v.exiting ~= vim.NIL then - return - end - if closing_buffers[bufnr] or not vim.api.nvim_buf_is_valid(bufnr) then - return - end - vim.schedule(function() - if vim.api.nvim_buf_is_valid(bufnr) and not closing_buffers[bufnr] then - M.close_pair(bufnr) - end - end) - end, - }) -end - ----@param win integer ----@param bufnr integer -local function track_split_window(win, bufnr) - register_win_closed() - split_windows[win] = bufnr -end - ----@param left_buf integer ----@param right_buf integer -local function set_peers(left_buf, right_buf) - vim.api.nvim_buf_set_var(left_buf, 'diffs_split_peer', right_buf) - vim.api.nvim_buf_set_var(right_buf, 'diffs_split_peer', left_buf) - peer_buffers[left_buf] = right_buf - peer_buffers[right_buf] = left_buf - ensure_pair_cleanup(left_buf) - ensure_pair_cleanup(right_buf) -end - ----@param bufnr integer ----@param keep_closing? boolean -local function clear_pair_tracking(bufnr, keep_closing) - if cleanup_autocmds[bufnr] then - pcall(vim.api.nvim_del_autocmd, cleanup_autocmds[bufnr]) - cleanup_autocmds[bufnr] = nil - end - peer_buffers[bufnr] = nil - pane_info[bufnr] = nil - for win, win_buf in pairs(split_windows) do - if win_buf == bufnr then - split_windows[win] = nil - end - end - if not keep_closing then - closing_buffers[bufnr] = nil - end -end - ----@param bufnr integer -local function clear_split_state(bufnr) - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - - clear_pair_tracking(bufnr) - pcall(vim.api.nvim_buf_del_var, bufnr, 'diffs_split_peer') - vim.api.nvim_buf_clear_namespace(bufnr, split_line_ns, 0, -1) - vim.api.nvim_buf_clear_namespace(bufnr, split_intra_ns, 0, -1) - clear_owned_split_keymaps(bufnr) -end - ----@param bufnr integer -local function detach_split_endpoint(bufnr) - local peer = stored_split_peer(bufnr) - clear_split_state(bufnr) - if peer and peer ~= bufnr and vim.api.nvim_buf_is_valid(peer) then - clear_split_state(peer) - end -end - ----@param bufnr integer ----@return integer[] -local function windows_for_buffer(bufnr) - local wins = {} - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then - wins[#wins + 1] = win - end - end - return wins -end - ----@param bufnr integer ----@return integer? -local function first_window_for_buffer(bufnr) - return windows_for_buffer(bufnr)[1] -end - ----@param bufnr integer ----@return integer? -local function current_or_first_window_for_buffer(bufnr) - local current_win = vim.api.nvim_get_current_win() - if vim.api.nvim_win_is_valid(current_win) and vim.api.nvim_win_get_buf(current_win) == bufnr then - return current_win - end - return first_window_for_buffer(bufnr) -end - ----@param bufnr integer ----@param lnum integer ----@return integer -local function clamp_lnum(bufnr, lnum) - return math.max(1, math.min(lnum, vim.api.nvim_buf_line_count(bufnr))) -end - ----@param anchors integer[] ----@return integer[] -local function sorted_anchors(anchors) - local rows = {} - for _, row in pairs(anchors or {}) do - rows[#rows + 1] = row - end - table.sort(rows) - return rows -end - ----@param win integer ----@param lnum integer -local function set_window_lnum(win, lnum) - if not vim.api.nvim_win_is_valid(win) then - return - end - local bufnr = vim.api.nvim_win_get_buf(win) - vim.api.nvim_win_set_cursor(win, { clamp_lnum(bufnr, lnum), 0 }) -end - ----@param bufnr integer ----@param row integer -local function move_pair_to_row(bufnr, row) - for _, win in ipairs(windows_for_buffer(bufnr)) do - set_window_lnum(win, row) - end - local peer = split_peer(bufnr) - if peer then - for _, win in ipairs(windows_for_buffer(peer)) do - set_window_lnum(win, row) - end - end -end - ----@param bufnr integer ----@param direction integer -local function goto_anchor(bufnr, direction) - local info = pane_info[bufnr] - if not info then - return - end - local anchors = sorted_anchors(info.anchors) - if #anchors == 0 then - return - end - local win = current_or_first_window_for_buffer(bufnr) or vim.api.nvim_get_current_win() - local row = vim.api.nvim_win_get_cursor(win)[1] - local target - if direction > 0 then - for _, anchor in ipairs(anchors) do - if anchor > row then - target = anchor - break - end - end - if not target then - target = anchors[1] - notify('wrapped to first hunk', vim.log.levels.INFO) - end - else - for i = #anchors, 1, -1 do - if anchors[i] < row then - target = anchors[i] - break - end - end - if not target then - target = anchors[#anchors] - notify('wrapped to last hunk', vim.log.levels.INFO) - end - end - move_pair_to_row(bufnr, target) -end - ----@param bufnr integer ----@param source diffs.SplitEndpointSource ----@param lines string[] ----@param info diffs.SplitPaneInfo ----@param hunks diffs.DiffHunk[] -local function apply_buffer_lines(bufnr, source, lines, info, hunks) - vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr }) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - paint_pane(bufnr, source, info, hunks) - set_buffer_options(bufnr, source.filetype) - set_keymaps(bufnr) - ensure_pair_cleanup(bufnr) -end - ----@param bufnr integer -local function refresh_visible_pair_options(bufnr) - for _, buf in ipairs({ bufnr, split_peer(bufnr) }) do - if buf and vim.api.nvim_buf_is_valid(buf) then - for _, win in ipairs(windows_for_buffer(buf)) do - set_pair_window_options(win) - end - end - end -end - ----@param source diffs.SplitEndpointSource -local function delete_existing_buffer(source) - local existing = vim.fn.bufnr(buffer_name(source)) - if existing == -1 or not vim.api.nvim_buf_is_valid(existing) then - return - end - - M.close_pair(existing) - if vim.api.nvim_buf_is_valid(existing) then - pcall(vim.api.nvim_buf_delete, existing, { force = true }) - end -end - ----@param left_source diffs.SplitEndpointSource ----@param right_source diffs.SplitEndpointSource -local function delete_existing_pair_buffers(left_source, right_source) - delete_existing_buffer(left_source) - delete_existing_buffer(right_source) -end - ----@param filepath string ----@return integer? -local function find_window_for_file(filepath) - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_is_valid(win) then - local buf = vim.api.nvim_win_get_buf(win) - if vim.api.nvim_buf_get_name(buf) == filepath then - return win - end - end - end - return nil -end - ----@param repo_root string ----@param path string ----@return string -local function resolve_worktree_path(repo_root, path) - if path:sub(1, 1) == '/' then - return path - end - return abs_path(repo_root, path) -end - ----@param buffers table ----@param bufnr integer? -local function add_valid_buffer(buffers, bufnr) - if type(bufnr) == 'number' and vim.api.nvim_buf_is_valid(bufnr) then - buffers[bufnr] = true - end -end - ----@param win integer ----@param buffers table ----@return boolean -local function window_has_buffer(win, buffers) - return buffers[vim.api.nvim_win_get_buf(win)] == true -end - ----@param bufnr integer ----@return table -local function pair_buffers_for(bufnr) - local buffers = {} - add_valid_buffer(buffers, bufnr) - add_valid_buffer(buffers, split_peer(bufnr)) - return buffers -end - ----@param buffers table ----@return integer[], integer[] -local function tab_windows_for_buffers(buffers) - local pair_wins = {} - local non_pair_wins = {} - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_is_valid(win) then - if window_has_buffer(win, buffers) then - pair_wins[#pair_wins + 1] = win - else - non_pair_wins[#non_pair_wins + 1] = win - end - end - end - return pair_wins, non_pair_wins -end - ----@param bufnr integer ----@param keep_win integer ----@return table? -local function close_pair_into_window(bufnr, keep_win) - if not vim.api.nvim_win_is_valid(keep_win) then - return nil - end - - local buffers = pair_buffers_for(bufnr) - if vim.tbl_isempty(buffers) then - return nil - end - - local keep_is_pair_win = false - local pair_wins = tab_windows_for_buffers(buffers) - for _, win in ipairs(pair_wins) do - keep_is_pair_win = keep_is_pair_win or win == keep_win - end - if not keep_is_pair_win then - return nil - end - - for buf in pairs(buffers) do - closing_buffers[buf] = true - end - - for _, win in ipairs(pair_wins) do - clear_pair_window_options(win) - end - vim.api.nvim_set_current_win(keep_win) - for _, win in ipairs(pair_wins) do - if win ~= keep_win and vim.api.nvim_win_is_valid(win) then - pcall(vim.api.nvim_win_close, win, true) - end - end - - return buffers -end - ----@param buffers table -local function delete_pair_buffers(buffers) - for buf in pairs(buffers) do - clear_pair_tracking(buf, true) - if vim.api.nvim_buf_is_valid(buf) then - pcall(vim.api.nvim_buf_delete, buf, { force = true }) - end - closing_buffers[buf] = nil - end -end - ----@param spec diffs.DiffSpec ----@param old_lines string[] ----@param new_lines string[] ----@return diffs.DifftAlignment? difftastic alignment when enabled/available, else nil -local function difft_alignment(spec, old_lines, new_lines) - if not difftastic.available() then - return nil - end - local relpath = (spec and spec.scope and spec.scope.path) or 'file' - return (difftastic.align(old_lines, new_lines, relpath)) -end - ----@param old_lines string[] ----@param new_lines string[] ----@param hunks diffs.DiffHunk[] ----@param difft? diffs.DifftAlignment precomputed difftastic alignment ----@return diffs.SplitAlignment, diffs.SplitPaneInfo, diffs.SplitPaneInfo -local function build_pane_infos(old_lines, new_lines, hunks, difft) - local use_difft = difft ~= nil and difft.changed - local alignment = use_difft and difft or split_align.align(old_lines, new_lines, hunks) - local max_lnum = math.max(#old_lines, #new_lines, 1) - local rail_width = math.max(2, #tostring(max_lnum)) - local left_info = { - rows = alignment.left_rows, - side = 'left', - rail_width = rail_width, - anchors = alignment.anchors, - difft_intra = use_difft and difft.left_intra or nil, - } - local right_info = { - rows = alignment.right_rows, - side = 'right', - rail_width = rail_width, - anchors = alignment.anchors, - difft_intra = use_difft and difft.right_intra or nil, - } - return alignment, left_info, right_info -end - ----@param opts diffs.SplitOpenOpts ----@return { left_buf: integer, right_buf: integer, left_win: integer, right_win: integer }?, string? -function M.open(opts) - local spec = diffspec.new(opts.spec) - local filetype = opts.filetype - local path = spec.scope.path - local base_source = { - repo_root = opts.repo_root, - spec = spec, - path = path, - filetype = filetype, - quickfix = opts.quickfix, - worktree_buf = opts.worktree_buf, - } - - local left_source = - generated.split_endpoint_source(vim.tbl_extend('force', base_source, { side = 'left' })) - local right_source = - generated.split_endpoint_source(vim.tbl_extend('force', base_source, { side = 'right' })) - local old_content, left_err = - endpoint_lines(left_source, { worktree_lines = opts.worktree_lines }) - if not old_content then - return nil, left_err - end - local new_content, right_err = - endpoint_lines(right_source, { worktree_lines = opts.worktree_lines }) - if not new_content then - return nil, right_err - end - local split_hunks = split_hunks_for(opts.diff_lines, spec) - local difft = difft_alignment(spec, old_content, new_content) - if difft and not difft.changed then - log.notify('difftastic: no structural changes (formatting only)', vim.log.levels.INFO) - end - local alignment, left_info, right_info = - build_pane_infos(old_content, new_content, split_hunks, difft) - - local reuse = opts.reuse_wins - local invoking_win = vim.api.nvim_get_current_win() - - delete_existing_pair_buffers(left_source, right_source) - - local reusing = reuse ~= nil - and vim.api.nvim_win_is_valid(reuse.left) - and vim.api.nvim_win_is_valid(reuse.right) - - ---@type integer[] - local stale_buffers = {} - local left_win, right_win - if reusing then - left_win, right_win = reuse.left, reuse.right - for _, win in ipairs({ left_win, right_win }) do - local buf = vim.api.nvim_win_get_buf(win) - stale_buffers[#stale_buffers + 1] = buf - closing_buffers[buf] = true - end - else - if vim.api.nvim_win_is_valid(invoking_win) then - vim.api.nvim_set_current_win(invoking_win) - end - left_win = vim.api.nvim_get_current_win() - vim.cmd('rightbelow vsplit') - right_win = vim.api.nvim_get_current_win() - end - - local left_buf = create_buffer(left_source, alignment.left_lines, left_info, split_hunks) - local right_buf = create_buffer(right_source, alignment.right_lines, right_info, split_hunks) - - vim.api.nvim_win_set_buf(left_win, left_buf) - vim.api.nvim_win_set_buf(right_win, right_buf) - set_peers(left_buf, right_buf) - track_split_window(left_win, left_buf) - track_split_window(right_win, right_buf) - - set_pair_window_options(left_win) - set_pair_window_options(right_win) - vim.api.nvim_set_current_win(right_win) - lists.set_for_split_pair({ - title = opts.title or ('diff: ' .. diffspec.label(spec)), - left_buf = left_buf, - right_buf = right_buf, - left_win = left_win, - right_win = right_win, - hunks = split_hunks, - anchors = alignment.anchors, - quickfix = opts.quickfix, - }) - - if opts.hunk_index and alignment.anchors[opts.hunk_index] then - move_pair_to_row(right_buf, alignment.anchors[opts.hunk_index]) - end - - for _, buf in ipairs(stale_buffers) do - clear_pair_tracking(buf, true) - if buf ~= left_buf and buf ~= right_buf and vim.api.nvim_buf_is_valid(buf) then - pcall(vim.api.nvim_buf_delete, buf, { force = true }) - end - closing_buffers[buf] = nil - end - - log.dbg('opened split diff buffers %d/%d for %s', left_buf, right_buf, diffspec.label(spec)) - return { - left_buf = left_buf, - right_buf = right_buf, - left_win = left_win, - right_win = right_win, - }, - nil -end - ----@param source table ----@return diffs.SplitEndpointSource -function normalize_source(source) - return { - version = 1, - kind = 'split_endpoint', - repo_root = source.repo_root, - spec = diffspec.new(source.spec), - side = source.side, - path = source.path, - filetype = source.filetype, - quickfix = source.quickfix, - worktree_buf = source.worktree_buf, - } -end - ----@param bufnr integer ----@param source table ----@param opts? { quickfix?: boolean } ----@return boolean, string? -function M.read_buffer(bufnr, source, opts) - opts = opts or {} - source = normalize_source(source) - - local peer = split_peer(bufnr) - if not peer or peer == bufnr then - detach_split_endpoint(bufnr) - return false, 'cannot reload split endpoint without a valid peer' - end - - local peer_source = peer and buffer_source(peer) or nil - if peer and not peer_source then - detach_split_endpoint(bufnr) - return false, 'cannot reload split peer without source metadata' - end - - local current_lines, current_err = endpoint_lines(source) - if not current_lines then - return false, current_err - end - - local peer_lines - if peer_source then - local peer_err - peer_lines, peer_err = endpoint_lines(peer_source) - if not peer_lines then - return false, peer_err - end - end - - local split_hunks - if peer_source then - local diff_lines, diff_err = render.file(source.spec, source.repo_root, { - worktree_buf = source.worktree_buf, - }) - if not diff_lines then - return false, diff_err - end - split_hunks = split_hunks_for(diff_lines, source.spec) - end - - if not (peer_source and peer_lines) then - return false, 'cannot reload split pair without peer content' - end - - local left_buf = source.side == 'left' and bufnr or peer - local right_buf = source.side == 'right' and bufnr or peer - local left_source = source.side == 'left' and source or peer_source - local right_source = source.side == 'right' and source or peer_source - local old_content = source.side == 'left' and current_lines or peer_lines - local new_content = source.side == 'right' and current_lines or peer_lines - - local difft = difft_alignment(source.spec, old_content, new_content) - local alignment, left_info, right_info = - build_pane_infos(old_content, new_content, split_hunks or {}, difft) - apply_buffer_lines(left_buf, left_source, alignment.left_lines, left_info, split_hunks or {}) - apply_buffer_lines(right_buf, right_source, alignment.right_lines, right_info, split_hunks or {}) - set_peers(left_buf, right_buf) - refresh_visible_pair_options(left_buf) - lists.set_for_split_pair({ - title = 'diff: ' .. diffspec.label(source.spec), - left_buf = left_buf, - right_buf = right_buf, - hunks = split_hunks, - anchors = alignment.anchors, - quickfix = opts.quickfix ~= false and source.quickfix, - }) - return true, nil -end - ----@param bufnr? integer ----@param keep_win integer ----@return table? -function M.close_pair_into_window(bufnr, keep_win) - bufnr = bufnr or vim.api.nvim_get_current_buf() - if type(keep_win) ~= 'number' then - return nil - end - return close_pair_into_window(bufnr, keep_win) -end - ----@param buffers table -function M.delete_pair_buffers(buffers) - delete_pair_buffers(buffers) -end - ----@param bufnr? integer ----@return boolean -function M.close_pair(bufnr) - bufnr = bufnr or vim.api.nvim_get_current_buf() - - local buffers = pair_buffers_for(bufnr) - if vim.tbl_isempty(buffers) then - return false - end - - for buf in pairs(buffers) do - if closing_buffers[buf] then - return false - end - end - - local pair_wins, non_pair_wins = tab_windows_for_buffers(buffers) - - for _, win in ipairs(pair_wins) do - split_windows[win] = nil - end - for buf in pairs(buffers) do - closing_buffers[buf] = true - end - - local keep_win - if #non_pair_wins == 0 then - local current_win = vim.api.nvim_get_current_win() - for _, win in ipairs(pair_wins) do - if win == current_win then - keep_win = win - break - end - end - keep_win = keep_win or pair_wins[1] - if keep_win and vim.api.nvim_win_is_valid(keep_win) then - vim.api.nvim_set_current_win(keep_win) - clear_pair_window_options(keep_win) - vim.cmd.enew() - end - end - - for _, win in ipairs(pair_wins) do - if win ~= keep_win then - clear_pair_window_options(win) - end - if win ~= keep_win and vim.api.nvim_win_is_valid(win) then - pcall(vim.api.nvim_win_close, win, true) - end - end - - delete_pair_buffers(buffers) - - local focus_win = non_pair_wins[1] or keep_win - if focus_win and vim.api.nvim_win_is_valid(focus_win) then - vim.api.nvim_set_current_win(focus_win) - end - - return true -end - ----@param win integer ----@return boolean -function M.release_pair_window_options(win) - if not vim.api.nvim_win_is_valid(win) or not pair_window_options[win] then - return false - end - - clear_pair_window_options(win) - return true -end - ----@param rows diffs.SplitRow[] ----@param cursor_row integer ----@param lnum_field "old_lnum"|"new_lnum" ----@return diffs.SplitRow? -local function nearest_non_filler_row(rows, cursor_row, lnum_field) - for offset = 0, #rows do - local above = rows[cursor_row - offset] - if above and above.kind ~= 'filler' and above[lnum_field] then - return above - end - local below = rows[cursor_row + offset] - if below and below.kind ~= 'filler' and below[lnum_field] then - return below - end - end - return nil -end - ----@param bufnr integer ----@param side "left"|"right" ----@return integer? -local function pane_buffer_for_side(bufnr, side) - local source = buffer_source(bufnr) - if source and source.side == side then - return bufnr - end - - local peer = split_peer(bufnr) - local peer_source = peer and buffer_source(peer) or nil - if peer_source and peer_source.side == side then - return peer - end - return nil -end - ----@param bufnr integer ----@param row? integer ----@return integer? -local function resolve_pane_row(bufnr, row) - if row then - return row - end - - local win = current_or_first_window_for_buffer(bufnr) - if not win then - return nil - end - return vim.api.nvim_win_get_cursor(win)[1] -end - ----@param bufnr integer ----@param row? integer ----@return diffs.SourcePosition? -local function source_position(bufnr, row) - local source = buffer_source(bufnr) - local info = pane_info[bufnr] - local pane_row = resolve_pane_row(bufnr, row) - if not source or not info or not info.rows or not pane_row then - return nil - end - - local lnum_field = side_config[source.side].lnum - local matched = nearest_non_filler_row(info.rows, pane_row, lnum_field) - if not matched then - return nil - end - return { side = source.side, lnum = matched[lnum_field] } -end - ----@param bufnr integer ----@param side "left"|"right" ----@param row integer ----@return diffs.SourcePosition? -local function position_at_row(bufnr, side, row) - local pane = pane_buffer_for_side(bufnr, side) - local info = pane and pane_info[pane] - local entry = info and info.rows and info.rows[row] - if not entry or entry.kind == 'filler' then - return nil - end - - local lnum = entry[side_config[side].lnum] - if not lnum then - return nil - end - return { side = side, lnum = lnum } -end - ----@param bufnr integer ----@param row? integer ----@return diffs.SourcePosition? -function M.displayed_position(bufnr, row) - local source = buffer_source(bufnr) - local pane_row = resolve_pane_row(bufnr, row) - if not source or not pane_row then - return nil - end - - local peer_side = source.side == 'left' and 'right' or 'left' - return position_at_row(bufnr, source.side, pane_row) - or position_at_row(bufnr, peer_side, pane_row) -end - ----@param bufnr integer ----@param position diffs.SourcePosition? ----@return boolean -function M.move_pair_to_source(bufnr, position) - if type(position) ~= 'table' or not side_config[position.side] then - return false - end - - local pane = pane_buffer_for_side(bufnr, position.side) - local info = pane and pane_info[pane] - if not info or not info.rows then - return false - end - - local lnum_field = side_config[position.side].lnum - for row, entry in ipairs(info.rows) do - if entry.kind ~= 'filler' and entry[lnum_field] == position.lnum then - move_pair_to_row(bufnr, row) - return true - end - end - return false -end - ----@param bufnr integer ----@return boolean -function M.open_source(bufnr) - local source = buffer_source(bufnr) - if not source then - notify('missing split diff source metadata', vim.log.levels.WARN) - return false - end - - local endpoint = side_endpoint(source.spec, source.side) - if endpoint.kind ~= diffspec.endpoint_kind.worktree then - if endpoint.kind == diffspec.endpoint_kind.index then - notify('cannot open index-backed split endpoint as a worktree file', vim.log.levels.WARN) - else - notify( - 'cannot open read-only tree-backed split endpoint as a worktree file', - vim.log.levels.WARN - ) - end - return false - end - - local source_win = current_or_first_window_for_buffer(bufnr) - if not source_win then - notify('cannot open hidden split endpoint as a source file', vim.log.levels.WARN) - return false - end - - local filepath = resolve_worktree_path(source.repo_root, source.path) - local cursor_row = vim.api.nvim_win_get_cursor(source_win)[1] - local position = source_position(bufnr, cursor_row) - local target_lnum = (position and position.lnum) or cursor_row - local existing_win = find_window_for_file(filepath) - if existing_win then - vim.api.nvim_set_current_win(existing_win) - else - local buffers = close_pair_into_window(bufnr, source_win) - if not buffers then - notify('cannot close split pair before opening source file', vim.log.levels.WARN) - return false - end - vim.cmd.edit(vim.fn.fnameescape(filepath)) - delete_pair_buffers(buffers) - end - local lnum = math.max(1, math.min(target_lnum, vim.api.nvim_buf_line_count(0))) - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) - return true -end - ----@param bufnr integer -function M.goto_next(bufnr) - goto_anchor(bufnr, 1) -end - ----@param bufnr integer -function M.goto_prev(bufnr) - goto_anchor(bufnr, -1) -end - ----@param bufnr integer -function M.sync_cursor_to_hunk(bufnr) - local win = current_or_first_window_for_buffer(bufnr) - if not win then - return - end - move_pair_to_row(bufnr, vim.api.nvim_win_get_cursor(win)[1]) -end - -M._test = { - buffer_name = buffer_name, - endpoint_lines = endpoint_lines, - split_hunks_for = split_hunks_for, -} - -return M diff --git a/lua/diffs/split_align.lua b/lua/diffs/split_align.lua deleted file mode 100644 index 2dc1ff9b..00000000 --- a/lua/diffs/split_align.lua +++ /dev/null @@ -1,118 +0,0 @@ -local M = {} - -local FILLER = '' - ----@class diffs.SplitRow ----@field kind "context"|"delete"|"add"|"filler" ----@field old_lnum integer? ----@field new_lnum integer? ----@field hunk_index integer? - ----@class diffs.SplitAlignment ----@field left_lines string[] ----@field right_lines string[] ----@field left_rows diffs.SplitRow[] ----@field right_rows diffs.SplitRow[] ----@field anchors integer[] buffer row (1-indexed) of each hunk's first changed line - ----@param old_lines string[] ----@param new_lines string[] ----@param hunks diffs.DiffHunk[] ----@return diffs.SplitAlignment -function M.align(old_lines, new_lines, hunks) - local left_lines, right_lines = {}, {} - local left_rows, right_rows = {}, {} - local anchors = {} - - local function push(lt, lr, rt, rr) - left_lines[#left_lines + 1] = lt - left_rows[#left_rows + 1] = lr - right_lines[#right_lines + 1] = rt - right_rows[#right_rows + 1] = rr - end - - local function push_context(old_lnum, new_lnum, hunk_index) - push( - old_lines[old_lnum] or '', - { - kind = 'context', - old_lnum = old_lnum, - new_lnum = new_lnum, - hunk_index = hunk_index, - }, - new_lines[new_lnum] or '', - { - kind = 'context', - old_lnum = old_lnum, - new_lnum = new_lnum, - hunk_index = hunk_index, - } - ) - end - - local function flush_change(dels, adds, hunk_index) - if hunk_index and not anchors[hunk_index] and (#dels > 0 or #adds > 0) then - anchors[hunk_index] = #left_lines + 1 - end - for i = 1, math.max(#dels, #adds) do - local d, a = dels[i], adds[i] - push( - d and (old_lines[d] or '') or FILLER, - d and { kind = 'delete', old_lnum = d, hunk_index = hunk_index } - or { kind = 'filler', hunk_index = hunk_index }, - a and (new_lines[a] or '') or FILLER, - a and { kind = 'add', new_lnum = a, hunk_index = hunk_index } - or { kind = 'filler', hunk_index = hunk_index } - ) - end - end - - local old_cursor, new_cursor = 1, 1 - for _, hunk in ipairs(hunks) do - while old_cursor < hunk.old_range.start do - push_context(old_cursor, new_cursor) - old_cursor = old_cursor + 1 - new_cursor = new_cursor + 1 - end - - local dels, adds = {}, {} - local function flush() - if #dels > 0 or #adds > 0 then - flush_change(dels, adds, hunk.index) - dels, adds = {}, {} - end - end - - for _, line in ipairs(hunk.lines) do - if line.kind == 'context' then - flush() - push_context(line.old_lnum, line.new_lnum, hunk.index) - old_cursor = (line.old_lnum or old_cursor) + 1 - new_cursor = (line.new_lnum or new_cursor) + 1 - elseif line.kind == 'delete' then - dels[#dels + 1] = line.old_lnum - old_cursor = (line.old_lnum or old_cursor) + 1 - elseif line.kind == 'add' then - adds[#adds + 1] = line.new_lnum - new_cursor = (line.new_lnum or new_cursor) + 1 - end - end - flush() - end - - while old_cursor <= #old_lines do - push_context(old_cursor, new_cursor) - old_cursor = old_cursor + 1 - new_cursor = new_cursor + 1 - end - - return { - left_lines = left_lines, - right_lines = right_lines, - left_rows = left_rows, - right_rows = right_rows, - anchors = anchors, - } -end - -return M diff --git a/plugin/diffs.lua b/plugin/diffs.lua index e9d907e5..8f586d89 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -14,30 +14,6 @@ local function get_runtime() return runtime end -local function get_commands() - return require('diffs.commands') -end - -local function has_conflict_marker(bufnr) - if vim.api.nvim_get_option_value('buftype', { buf = bufnr }) ~= '' then - return false - end - return vim.api.nvim_buf_call(bufnr, function() - return vim.fn.search('^<<<<<<<', 'cnw') > 0 - end) -end - -vim.api.nvim_create_user_command('Diff', function(opts) - get_commands().diff_command(opts.args ~= '' and opts.args or nil, opts.smods.vertical) -end, { - nargs = '*', - bar = true, - complete = function(...) - return get_commands().complete_diff_command(...) - end, - desc = 'Show a current-file diff, a repository review with :Diff review, or two files with :Diff files', -}) - local integration_config = user_config.integrations or {} if integrations.is_enabled(integration_config.gitsigns) then @@ -65,38 +41,6 @@ vim.api.nvim_create_autocmd('FileType', { callback = function(args) local runtime = get_runtime() runtime.attach(args.buf) - - if args.match == 'fugitive' then - local fugitive_config = runtime.get_fugitive_config() - if fugitive_config == true then - require('diffs.fugitive').setup_keymaps(args.buf) - end - end - end, -}) - -vim.api.nvim_create_autocmd('BufReadCmd', { - pattern = 'diffs://*', - callback = function(args) - vim.cmd( - ('lockmarks lua require("diffs.commands").read_buffer(%d, { quickfix = %s })'):format( - args.buf, - tostring(vim.b[args.buf].diffs_source ~= nil) - ) - ) - end, -}) - -vim.api.nvim_create_autocmd('BufReadPost', { - callback = function(args) - if not user_config.conflict or not has_conflict_marker(args.buf) then - return - end - local runtime = get_runtime() - local conflict_config = runtime.get_conflict_config() - if conflict_config then - require('diffs.conflict').attach(args.buf) - end end, }) @@ -123,85 +67,7 @@ vim.api.nvim_create_autocmd('VimEnter', { vim.api.nvim_create_autocmd('OptionSet', { pattern = 'diffopt', callback = function() - require('diffs.commands').on_diffopt_changed() + get_runtime().invalidate_attached() + vim.cmd('redraw!') end, }) - -vim.keymap.set('n', '(diffs-diff)', function() - get_commands().diff(nil, false) -end, { desc = 'Unified diff (horizontal)' }) -vim.keymap.set('n', '(diffs-diff-vertical)', function() - get_commands().diff(nil, true) -end, { desc = 'Unified diff (vertical)' }) -vim.keymap.set('n', '(diffs-diff-open-source)', function() - require('diffs.hunks').open_source(vim.api.nvim_get_current_buf()) -end, { desc = 'Open source file from generated diff' }) -vim.keymap.set('n', '(diffs-review-open-split)', function() - get_commands().review_split() -end, { desc = 'Open review file in split diff' }) -vim.keymap.set('n', '(diffs-review-toggle-layout)', function() - get_commands().review_toggle_layout() -end, { desc = 'Toggle review layout' }) -vim.keymap.set('n', '(diffs-review-next-file)', function() - for _ = 1, vim.v.count1 do - get_commands().review_next_file() - end -end, { desc = 'Next file in review split' }) -vim.keymap.set('n', '(diffs-review-prev-file)', function() - for _ = 1, vim.v.count1 do - get_commands().review_prev_file() - end -end, { desc = 'Previous file in review split' }) -vim.keymap.set('n', '(diffs-review-select-file)', function() - get_commands().select_review_file() -end, { desc = 'Pick a file in the review split' }) -vim.keymap.set('n', '(diffs-refresh)', function() - get_commands().refresh() -end, { desc = 'Re-render the current diff buffer' }) - -local function conflict_action(fn) - local bufnr = vim.api.nvim_get_current_buf() - local runtime = get_runtime() - local conflict_config = runtime.get_conflict_config() - if conflict_config then - fn(bufnr) - end -end - -vim.keymap.set('n', '(diffs-conflict-ours)', function() - conflict_action(require('diffs.conflict').resolve_ours) -end, { desc = 'Accept current (ours) change' }) -vim.keymap.set('n', '(diffs-conflict-theirs)', function() - conflict_action(require('diffs.conflict').resolve_theirs) -end, { desc = 'Accept incoming (theirs) change' }) -vim.keymap.set('n', '(diffs-conflict-both)', function() - conflict_action(require('diffs.conflict').resolve_both) -end, { desc = 'Accept both changes' }) -vim.keymap.set('n', '(diffs-conflict-none)', function() - conflict_action(require('diffs.conflict').resolve_none) -end, { desc = 'Reject both changes' }) -vim.keymap.set('n', '(diffs-conflict-next)', function() - conflict_action(require('diffs.conflict').goto_next) -end, { desc = 'Jump to next conflict' }) -vim.keymap.set('n', '(diffs-conflict-prev)', function() - conflict_action(require('diffs.conflict').goto_prev) -end, { desc = 'Jump to previous conflict' }) - -vim.keymap.set('n', '(diffs-merge-ours)', function() - conflict_action(require('diffs.merge').resolve_ours) -end, { desc = 'Accept ours in merge diff' }) -vim.keymap.set('n', '(diffs-merge-theirs)', function() - conflict_action(require('diffs.merge').resolve_theirs) -end, { desc = 'Accept theirs in merge diff' }) -vim.keymap.set('n', '(diffs-merge-both)', function() - conflict_action(require('diffs.merge').resolve_both) -end, { desc = 'Accept both in merge diff' }) -vim.keymap.set('n', '(diffs-merge-none)', function() - conflict_action(require('diffs.merge').resolve_none) -end, { desc = 'Reject both in merge diff' }) -vim.keymap.set('n', '(diffs-merge-next)', function() - conflict_action(require('diffs.merge').goto_next) -end, { desc = 'Jump to next conflict hunk' }) -vim.keymap.set('n', '(diffs-merge-prev)', function() - conflict_action(require('diffs.merge').goto_prev) -end, { desc = 'Jump to previous conflict hunk' }) diff --git a/spec/actions_spec.lua b/spec/actions_spec.lua deleted file mode 100644 index 9a5b6eb8..00000000 --- a/spec/actions_spec.lua +++ /dev/null @@ -1,735 +0,0 @@ -require('spec.helpers') - -local actions = require('diffs.actions') -local commands = require('diffs.commands') -local diffspec = require('diffs.spec') -local generated = require('diffs.generated') -local git = require('diffs.git') -local hunk_model = require('diffs.hunks') -local render = require('diffs.render') - -local test_buffers = {} -local test_repos = {} -local saved_notify -local saved_git_apply_patch -local buffer_counter = 0 - -local function git_cmd(repo_root, args) - local cmd = { 'git', '-C', repo_root } - for _, arg in ipairs(args) do - cmd[#cmd + 1] = arg - end - local output = vim.fn.systemlist(cmd) - assert.are.equal(0, vim.v.shell_error, table.concat(output, '\n')) - return output -end - -local function git_text(repo_root, args) - return table.concat(git_cmd(repo_root, args), '\n') -end - -local function write_repo_file(repo_root, lines) - vim.fn.writefile(lines, repo_root .. '/file.txt') -end - -local function file_lines(overrides) - local lines = {} - for i = 1, 30 do - lines[i] = 'line ' .. i - end - for lnum, line in pairs(overrides or {}) do - lines[lnum] = line - end - return lines -end - -local function file_lines_with_insertions(after, inserted) - local lines = file_lines() - for offset, line in ipairs(inserted) do - table.insert(lines, after + offset, line) - end - return lines -end - -local function file_lines_without(start, count) - local lines = file_lines() - for _ = 1, count do - table.remove(lines, start) - end - return lines -end - -local function create_repo() - local repo_root = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(repo_root, 'p') - test_repos[#test_repos + 1] = repo_root - - vim.fn.systemlist({ 'git', 'init', '-q', repo_root }) - assert.are.equal(0, vim.v.shell_error) - git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' }) - git_cmd(repo_root, { 'config', 'user.name', 'Test' }) - write_repo_file(repo_root, file_lines()) - git_cmd(repo_root, { 'add', 'file.txt' }) - git_cmd(repo_root, { 'commit', '-qm', 'initial' }) - - return repo_root -end - -local function create_diff_buffer(repo_root, diff_spec) - local diff_lines = assert(render.file(diff_spec, repo_root)) - buffer_counter = buffer_counter + 1 - local bufnr = vim.api.nvim_create_buf(false, true) - test_buffers[#test_buffers + 1] = bufnr - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, diff_lines) - vim.api.nvim_buf_set_name(bufnr, 'diffs://actions-' .. buffer_counter .. ':file.txt') - generated.set_source(bufnr, generated.file_source(repo_root, diff_spec)) - vim.api.nvim_buf_set_var(bufnr, 'diffs_hunks', hunk_model.parse(diff_lines, diff_spec)) - vim.api.nvim_set_current_buf(bufnr) - commands.setup_diff_buf(bufnr) - return bufnr -end - -local function keymap_callback(bufnr, lhs, mode) - for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, mode or 'n')) do - if keymap.lhs == lhs then - return keymap.callback - end - end - return nil -end - -local function find_hunk_line(bufnr, kind, text) - for _, hunk in ipairs(vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks')) do - for _, line in ipairs(hunk.lines) do - if line.kind == kind and line.text == text then - return line, hunk - end - end - end - error('could not find hunk line ' .. kind .. ' ' .. text) -end - -local function find_hunks(bufnr) - return vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks') -end - -local function set_visual_range(bufnr, range_start, range_finish) - vim.api.nvim_buf_set_mark(bufnr, '<', range_start, 0, {}) - vim.api.nvim_buf_set_mark(bufnr, '>', range_finish, 0, {}) -end - -local function move_to_hunk(bufnr, index) - local hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks') - vim.api.nvim_win_set_cursor(0, { hunks[index].buffer_range.start, 0 }) -end - -local function buffer_text(bufnr) - return table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n') -end - -local function capture_notifications() - local notifications = {} - saved_notify = vim.notify - vim.notify = function(message, level) - notifications[#notifications + 1] = { message = message, level = level } - end - return notifications -end - -local function cleanup() - if saved_notify then - vim.notify = saved_notify - saved_notify = nil - end - if saved_git_apply_patch then - git.apply_patch = saved_git_apply_patch - saved_git_apply_patch = nil - end - for _, bufnr in ipairs(test_buffers) do - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end - end - test_buffers = {} - for _, repo_root in ipairs(test_repos) do - vim.fn.delete(repo_root, 'rf') - end - test_repos = {} -end - -describe('diffs.actions', function() - after_each(cleanup) - - it('builds a one-hunk patch from stored file headers and hunk lines', function() - local diff_lines = { - 'diff --git a/file.txt b/file.txt', - '--- a/file.txt', - '+++ b/file.txt', - '@@ -1 +1 @@', - '-one', - '+two', - '@@ -10 +10 @@', - '-ten', - '+eleven', - } - local hunks = hunk_model.parse(diff_lines, diffspec.index_to_worktree('file.txt')) - - local patch = assert(actions.patch_for_hunk(hunks[2])) - - assert.are.equal( - table.concat({ - 'diff --git a/file.txt b/file.txt', - '--- a/file.txt', - '+++ b/file.txt', - '@@ -10 +10 @@', - '-ten', - '+eleven', - '', - }, '\n'), - patch - ) - end) - - it('builds a selected-add range patch for the side receiving the patch', function() - local diff_lines = { - 'diff --git a/file.txt b/file.txt', - '--- a/file.txt', - '+++ b/file.txt', - '@@ -1 +1,3 @@', - ' line 1', - '+insert alpha', - '+insert beta', - } - local hunk = hunk_model.parse(diff_lines, diffspec.index_to_worktree('file.txt'))[1] - - local patch = assert(actions.patch_for_range(hunk, 7, 7, { target = 'left' })) - local reverse_patch = assert(actions.patch_for_range(hunk, 7, 7, { target = 'right' })) - - assert.are.equal( - table.concat({ - 'diff --git a/file.txt b/file.txt', - '--- a/file.txt', - '+++ b/file.txt', - '@@ -1 +1,3 @@', - ' line 1', - '+insert beta', - '', - }, '\n'), - patch - ) - assert.are.equal( - table.concat({ - 'diff --git a/file.txt b/file.txt', - '--- a/file.txt', - '+++ b/file.txt', - '@@ -1 +1,3 @@', - ' line 1', - ' insert alpha', - '+insert beta', - '', - }, '\n'), - reverse_patch - ) - end) - - it('builds a selected-delete range patch for the side receiving the patch', function() - local diff_lines = { - 'diff --git a/file.txt b/file.txt', - '--- a/file.txt', - '+++ b/file.txt', - '@@ -1,3 +1 @@', - ' line 1', - '-delete alpha', - '-delete beta', - } - local hunk = hunk_model.parse(diff_lines, diffspec.index_to_worktree('file.txt'))[1] - - local patch = assert(actions.patch_for_range(hunk, 7, 7, { target = 'left' })) - local reverse_patch = assert(actions.patch_for_range(hunk, 7, 7, { target = 'right' })) - - assert.are.equal( - table.concat({ - 'diff --git a/file.txt b/file.txt', - '--- a/file.txt', - '+++ b/file.txt', - '@@ -1,3 +1 @@', - ' line 1', - ' delete alpha', - '-delete beta', - '', - }, '\n'), - patch - ) - assert.are.equal( - table.concat({ - 'diff --git a/file.txt b/file.txt', - '--- a/file.txt', - '+++ b/file.txt', - '@@ -1,3 +1 @@', - ' line 1', - '-delete beta', - '', - }, '\n'), - reverse_patch - ) - end) - - it('rejects partial replacement-group ranges before building a patch', function() - local diff_lines = { - 'diff --git a/file.txt b/file.txt', - '--- a/file.txt', - '+++ b/file.txt', - '@@ -1,2 +1,2 @@', - ' line 1', - '-old', - '+new', - } - local hunk = hunk_model.parse(diff_lines, diffspec.index_to_worktree('file.txt'))[1] - - local patch, err = actions.patch_for_range(hunk, 7, 7) - - assert.is_nil(patch) - assert.are.equal('select the complete replacement group before applying it', err) - end) - - it('stages only the current unstaged hunk and refreshes the buffer', function() - local repo_root = create_repo() - write_repo_file( - repo_root, - file_lines({ - [2] = 'line 2 changed', - [22] = 'line 22 changed', - }) - ) - - local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt')) - move_to_hunk(bufnr, 1) - - assert.is_not_nil(keymap_callback(bufnr, 'dp')) - keymap_callback(bufnr, 'dp')() - - local cached = git_text( - repo_root, - { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' } - ) - local worktree = - git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }) - - assert.is_true(cached:find('+line 2 changed', 1, true) ~= nil) - assert.is_nil(cached:find('+line 22 changed', 1, true)) - assert.is_nil(worktree:find('+line 2 changed', 1, true)) - assert.is_true(worktree:find('+line 22 changed', 1, true) ~= nil) - assert.is_nil(buffer_text(bufnr):find('+line 2 changed', 1, true)) - assert.is_true(buffer_text(bufnr):find('+line 22 changed', 1, true) ~= nil) - end) - - it('stages an untracked file hunk as a new file', function() - local repo_root = create_repo() - vim.fn.writefile({ 'new file line' }, repo_root .. '/new.txt') - - local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('new.txt')) - move_to_hunk(bufnr, 1) - - assert.is_true(actions.put_hunk(bufnr)) - - local cached = - git_text(repo_root, { 'diff', '--cached', '--no-ext-diff', '--no-color', '--', 'new.txt' }) - local worktree = git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '--', 'new.txt' }) - - assert.is_true(cached:find('new file mode 100644', 1, true) ~= nil) - assert.is_true(cached:find('--- /dev/null', 1, true) ~= nil) - assert.is_true(cached:find('+new file line', 1, true) ~= nil) - assert.are.equal('', worktree) - end) - - it('stages only selected added lines from a visual range', function() - local repo_root = create_repo() - write_repo_file( - repo_root, - file_lines_with_insertions(2, { - 'insert alpha', - 'insert beta', - }) - ) - - local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt')) - local selected = find_hunk_line(bufnr, 'add', '+insert beta') - - assert.is_true(actions.put_range(bufnr, selected.lnum, selected.lnum)) - - local cached = git_text( - repo_root, - { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' } - ) - local worktree = - git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }) - - assert.is_true(cached:find('+insert beta', 1, true) ~= nil) - assert.is_nil(cached:find('+insert alpha', 1, true)) - assert.is_true(worktree:find('+insert alpha', 1, true) ~= nil) - assert.is_nil(worktree:find('+insert beta', 1, true)) - end) - - it('stages a visual selection through the buffer-local dp map and refreshes', function() - local repo_root = create_repo() - write_repo_file( - repo_root, - file_lines_with_insertions(2, { - 'insert alpha', - 'insert beta', - }) - ) - - local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt')) - local selected = find_hunk_line(bufnr, 'add', '+insert beta') - local callback = assert(keymap_callback(bufnr, 'dp', 'x')) - - set_visual_range(bufnr, selected.lnum, selected.lnum) - callback() - - local cached = git_text( - repo_root, - { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' } - ) - - assert.is_true(cached:find('+insert beta', 1, true) ~= nil) - assert.is_nil(cached:find('+insert alpha', 1, true)) - assert.is_nil(buffer_text(bufnr):find('+insert beta', 1, true)) - assert.is_true(buffer_text(bufnr):find('+insert alpha', 1, true) ~= nil) - end) - - it('stages only selected deleted lines from a visual range', function() - local repo_root = create_repo() - write_repo_file(repo_root, file_lines_without(3, 2)) - - local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt')) - local selected = find_hunk_line(bufnr, 'delete', '-line 4') - - assert.is_true(actions.put_range(bufnr, selected.lnum, selected.lnum)) - - local cached = git_text( - repo_root, - { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' } - ) - local worktree = - git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }) - - assert.is_true(cached:find('-line 4', 1, true) ~= nil) - assert.is_nil(cached:find('-line 3', 1, true)) - assert.is_true(worktree:find('-line 3', 1, true) ~= nil) - assert.is_nil(worktree:find('-line 4', 1, true)) - end) - - it('unstages only the current staged hunk and refreshes the buffer', function() - local repo_root = create_repo() - write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' })) - git_cmd(repo_root, { 'add', 'file.txt' }) - write_repo_file( - repo_root, - file_lines({ - [2] = 'line 2 changed', - [22] = 'line 22 changed', - }) - ) - - local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt')) - move_to_hunk(bufnr, 1) - - assert.is_not_nil(keymap_callback(bufnr, 'do')) - keymap_callback(bufnr, 'do')() - - local cached = git_text( - repo_root, - { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' } - ) - local worktree = - git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }) - - assert.are.equal('', cached) - assert.is_true(worktree:find('+line 2 changed', 1, true) ~= nil) - assert.is_true(worktree:find('+line 22 changed', 1, true) ~= nil) - assert.are.equal(0, #vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks')) - end) - - it('unstages a staged added-file hunk back to an untracked file', function() - local repo_root = create_repo() - vim.fn.writefile({ 'new file line' }, repo_root .. '/new.txt') - git_cmd(repo_root, { 'add', 'new.txt' }) - - local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('new.txt')) - move_to_hunk(bufnr, 1) - - assert.is_true(actions.obtain_hunk(bufnr)) - - local cached = - git_text(repo_root, { 'diff', '--cached', '--no-ext-diff', '--no-color', '--', 'new.txt' }) - vim.fn.systemlist({ 'git', '-C', repo_root, 'ls-files', '--error-unmatch', 'new.txt' }) - - assert.are.equal('', cached) - assert.is_not.equal(0, vim.v.shell_error) - end) - - it('stages an unstaged deletion hunk', function() - local repo_root = create_repo() - vim.fn.delete(repo_root .. '/file.txt') - - local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt')) - move_to_hunk(bufnr, 1) - - assert.is_true(actions.put_hunk(bufnr)) - - local cached = - git_text(repo_root, { 'diff', '--cached', '--no-ext-diff', '--no-color', '--', 'file.txt' }) - local worktree = - git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '--', 'file.txt' }) - - assert.is_true(cached:find('deleted file mode 100644', 1, true) ~= nil) - assert.is_true(cached:find('+++ /dev/null', 1, true) ~= nil) - assert.is_true(cached:find('-line 1', 1, true) ~= nil) - assert.are.equal('', worktree) - end) - - it('unstages a staged deletion hunk back to an unstaged deletion', function() - local repo_root = create_repo() - vim.fn.delete(repo_root .. '/file.txt') - git_cmd(repo_root, { 'add', '-u', 'file.txt' }) - - local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt')) - move_to_hunk(bufnr, 1) - - assert.is_true(actions.obtain_hunk(bufnr)) - - local cached = - git_text(repo_root, { 'diff', '--cached', '--no-ext-diff', '--no-color', '--', 'file.txt' }) - local worktree = - git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '--', 'file.txt' }) - - assert.are.equal('', cached) - assert.is_true(worktree:find('deleted file mode 100644', 1, true) ~= nil) - assert.is_true(worktree:find('+++ /dev/null', 1, true) ~= nil) - assert.is_true(worktree:find('-line 1', 1, true) ~= nil) - end) - - it('unstages only selected added lines from a visual range', function() - local repo_root = create_repo() - write_repo_file( - repo_root, - file_lines_with_insertions(2, { - 'insert alpha', - 'insert beta', - }) - ) - git_cmd(repo_root, { 'add', 'file.txt' }) - - local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt')) - local selected = find_hunk_line(bufnr, 'add', '+insert beta') - - assert.is_true(actions.obtain_range(bufnr, selected.lnum, selected.lnum)) - - local cached = git_text( - repo_root, - { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' } - ) - local worktree = - git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }) - - assert.is_true(cached:find('+insert alpha', 1, true) ~= nil) - assert.is_nil(cached:find('+insert beta', 1, true)) - assert.is_true(worktree:find('+insert beta', 1, true) ~= nil) - assert.is_nil(worktree:find('+insert alpha', 1, true)) - end) - - it('unstages a visual selection through the buffer-local do map and refreshes', function() - local repo_root = create_repo() - write_repo_file( - repo_root, - file_lines_with_insertions(2, { - 'insert alpha', - 'insert beta', - }) - ) - git_cmd(repo_root, { 'add', 'file.txt' }) - - local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt')) - local selected = find_hunk_line(bufnr, 'add', '+insert beta') - local callback = assert(keymap_callback(bufnr, 'do', 'x')) - - set_visual_range(bufnr, selected.lnum, selected.lnum) - callback() - - local cached = git_text( - repo_root, - { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' } - ) - - assert.is_true(cached:find('+insert alpha', 1, true) ~= nil) - assert.is_nil(cached:find('+insert beta', 1, true)) - assert.is_nil(buffer_text(bufnr):find('+insert beta', 1, true)) - assert.is_true(buffer_text(bufnr):find('+insert alpha', 1, true) ~= nil) - end) - - it('unstages only selected deleted lines from a visual range', function() - local repo_root = create_repo() - write_repo_file(repo_root, file_lines_without(3, 2)) - git_cmd(repo_root, { 'add', 'file.txt' }) - - local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt')) - local selected = find_hunk_line(bufnr, 'delete', '-line 4') - - assert.is_true(actions.obtain_range(bufnr, selected.lnum, selected.lnum)) - - local cached = git_text( - repo_root, - { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' } - ) - local worktree = - git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }) - - assert.is_true(cached:find('-line 3', 1, true) ~= nil) - assert.is_nil(cached:find('-line 4', 1, true)) - assert.is_true(worktree:find('-line 4', 1, true) ~= nil) - assert.is_nil(worktree:find('-line 3', 1, true)) - end) - - it('rejects visual ranges that cross diff hunks without touching the index', function() - local notifications = capture_notifications() - local repo_root = create_repo() - write_repo_file( - repo_root, - file_lines({ - [2] = 'line 2 changed', - [22] = 'line 22 changed', - }) - ) - - local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt')) - local hunks = find_hunks(bufnr) - - assert.is_false( - actions.put_range(bufnr, hunks[1].buffer_range.finish, hunks[2].buffer_range.start) - ) - - local cached = git_text( - repo_root, - { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' } - ) - assert.are.equal('', cached) - assert.is_true( - notifications[#notifications].message:find( - 'visual selection must stay within one diff hunk', - 1, - true - ) ~= nil - ) - end) - - it('rejects partial replacement visual ranges without touching the index', function() - local notifications = capture_notifications() - local repo_root = create_repo() - write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' })) - - local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt')) - local selected = find_hunk_line(bufnr, 'add', '+line 2 changed') - - assert.is_false(actions.put_range(bufnr, selected.lnum, selected.lnum)) - - local cached = git_text( - repo_root, - { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' } - ) - assert.are.equal('', cached) - assert.is_true( - notifications[#notifications].message:find('complete replacement group', 1, true) ~= nil - ) - end) - - it('leaves the index untouched when the patch is stale', function() - local notifications = capture_notifications() - local repo_root = create_repo() - write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' })) - local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt')) - - write_repo_file(repo_root, file_lines({ [2] = 'line 2 other' })) - git_cmd(repo_root, { 'add', 'file.txt' }) - write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' })) - move_to_hunk(bufnr, 1) - local index_tree = git_text(repo_root, { 'write-tree' }) - - assert.is_false(actions.put_hunk(bufnr)) - assert.are.equal(index_tree, git_text(repo_root, { 'write-tree' })) - - local cached = git_text( - repo_root, - { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' } - ) - assert.is_true(cached:find('+line 2 other', 1, true) ~= nil) - assert.is_nil(cached:find('+line 2 changed', 1, true)) - assert.is_true( - notifications[#notifications].message:find('patch does not apply', 1, true) ~= nil - ) - end) - - it('leaves the index untouched when a selected replacement is stale', function() - local notifications = capture_notifications() - local repo_root = create_repo() - local changed = file_lines({ [2] = 'line 2 changed', [5] = 'line 5 changed' }) - write_repo_file(repo_root, changed) - local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt')) - local first = find_hunk_line(bufnr, 'delete', '-line 2') - local last = find_hunk_line(bufnr, 'add', '+line 2 changed') - - write_repo_file(repo_root, file_lines({ [2] = 'line 2 other' })) - git_cmd(repo_root, { 'add', 'file.txt' }) - write_repo_file(repo_root, changed) - local index_tree = git_text(repo_root, { 'write-tree' }) - - assert.is_false(actions.put_range(bufnr, first.lnum, last.lnum)) - assert.are.equal(index_tree, git_text(repo_root, { 'write-tree' })) - assert.are.same(changed, vim.fn.readfile(repo_root .. '/file.txt')) - assert.is_true( - notifications[#notifications].message:find('patch does not apply', 1, true) ~= nil - ) - end) - - it('rejects read-only and unsupported hunk edges without running git apply', function() - local notifications = capture_notifications() - local calls = 0 - saved_git_apply_patch = git.apply_patch - git.apply_patch = function() - calls = calls + 1 - return true, {} - end - - local repo_root = create_repo() - write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' })) - local bufnr = create_diff_buffer(repo_root, diffspec.rev_to_worktree('HEAD', 'file.txt')) - move_to_hunk(bufnr, 1) - - assert.is_false(actions.put_hunk(bufnr)) - assert.is_false(actions.obtain_hunk(bufnr)) - assert.are.equal(0, calls) - assert.is_true(notifications[1].message:find('read-only diff hunk', 1, true) ~= nil) - assert.is_true(notifications[2].message:find('read-only diff hunk', 1, true) ~= nil) - end) - - it('rejects destructive worktree restore and redundant index put operations', function() - local notifications = capture_notifications() - local repo_root = create_repo() - write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' })) - local unstaged_buf = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt')) - move_to_hunk(unstaged_buf, 1) - - assert.is_false(actions.obtain_hunk(unstaged_buf)) - - write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' })) - git_cmd(repo_root, { 'add', 'file.txt' }) - local staged_buf = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt')) - move_to_hunk(staged_buf, 1) - - assert.is_false(actions.put_hunk(staged_buf)) - assert.is_true( - notifications[1].message:find('restoring worktree hunks is not supported', 1, true) ~= nil - ) - assert.is_true(notifications[2].message:find('already in the index', 1, true) ~= nil) - end) -end) diff --git a/spec/api_spec.lua b/spec/api_spec.lua new file mode 100644 index 00000000..f4427637 --- /dev/null +++ b/spec/api_spec.lua @@ -0,0 +1,113 @@ +require('spec.helpers') +local api = require('diffs') +local helpers = require('spec.helpers') +local highlight = require('diffs.highlight') +local runtime = require('diffs.runtime') + +describe('highlighting API', function() + local bufnr + local ns = vim.api.nvim_create_namespace('diffs') + + after_each(function() + helpers.delete_buffer(bufnr) + end) + + it('replaces the parser and refreshes supplied hunks without a text edit', function() + bufnr = helpers.create_buffer({ 'row -local value = 1', 'row +local value = 2' }) + vim.api.nvim_set_current_buf(bufnr) + api.attach(0) + local supplied = { + { + filename = 'test.lua', + lang = 'lua', + start_line = 0, + lines = { '-local value = 1', '+local value = 2' }, + prefix_width = 1, + quote_width = 0, + column_offset = 4, + }, + } + local calls = 0 + api.attach(0, { + hunks = function(buffer) + assert.are.equal(bufnr, buffer) + calls = calls + 1 + return supplied + end, + }) + assert.are.equal(1, calls) + local entry = runtime._test.hunk_cache[bufnr] + highlight.highlight_hunk(bufnr, ns, entry.hunks[1], api.highlight_options()) + local marks = helpers.get_extmarks(bufnr, ns) + assert.is_true(#marks > 0) + for _, mark in ipairs(marks) do + assert.is_true(mark[3] >= 4) + end + assert.is_nil(supplied[1]._hl_line_count) + + supplied = {} + api.refresh(0) + runtime._test.ensure_cache(bufnr) + runtime._test.process_pending_clear(bufnr) + assert.are.equal(2, calls) + assert.are.same({}, runtime._test.hunk_cache[bufnr].hunks) + assert.are.same({}, helpers.get_extmarks(bufnr, ns)) + end) + + it('keeps no-newline marker rows outside changed text spans', function() + bufnr = helpers.create_buffer({ + 'M test.lua', + '@@ -1 +1 @@', + '-local value = 1', + '\\ No newline at end of file', + '+local value = 2', + '\\ No newline at end of file', + }) + api.attach(bufnr) + local hunk = runtime._test.hunk_cache[bufnr].hunks[1] + assert.are.equal(4, #hunk.lines) + local spans = api.compute_intra_hunks(hunk.lines) + assert.are.equal(1, spans.del_spans[1].line) + assert.are.equal(3, spans.add_spans[1].line) + local options = api.highlight_options() + options.hide_prefix = true + highlight.highlight_hunk(bufnr, ns, hunk, options) + for _, mark in ipairs(helpers.get_extmarks(bufnr, ns)) do + assert.is_not_equal(3, mark[2]) + assert.is_not_equal(5, mark[2]) + end + end) + + it('rejects deferred syntax from a detached attachment after reattachment', function() + bufnr = helpers.create_buffer({ + 'M test.lua', + '@@ -1 +1 @@', + '-local value = 1', + '+local value = 2', + }) + api.attach(bufnr) + local entry = runtime._test.hunk_cache[bufnr] + local changedtick = vim.api.nvim_buf_get_changedtick(bufnr) + local old_job = runtime._test.next_syntax_job(bufnr) + vim.api.nvim_buf_set_extmark(bufnr, ns, 2, 1, { end_col = 6, hl_group = 'DiffsDelete' }) + api.detach(bufnr) + assert.is_nil(runtime._test.hunk_cache[bufnr]) + assert.are.same({}, helpers.get_extmarks(bufnr, ns)) + api.attach(bufnr) + local new_job = runtime._test.next_syntax_job(bufnr) + assert.is_false( + runtime._test.run_deferred_syntax(bufnr, entry.tick, changedtick, old_job, entry.hunks) + ) + assert.are.same({}, helpers.get_extmarks(bufnr, ns)) + assert.is_true( + runtime._test.run_deferred_syntax( + bufnr, + entry.tick, + changedtick, + new_job, + runtime._test.hunk_cache[bufnr].hunks + ) + ) + assert.is_true(#helpers.get_extmarks(bufnr, ns) > 0) + end) +end) diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua deleted file mode 100644 index d86890ea..00000000 --- a/spec/commands_spec.lua +++ /dev/null @@ -1,4498 +0,0 @@ -local helpers = require('spec.helpers') - -local commands = require('diffs.commands') -local diffspec = require('diffs.spec') -local generated = require('diffs.generated') -local git = require('diffs.git') -local rails = require('diffs.rails') -local runtime = require('diffs.runtime') -local split = require('diffs.split') - -local saved_git = {} -local saved_runtime_attach -local saved_runtime_get_conflict_config -local saved_runtime_get_view_config -local saved_schedule -local saved_systemlist -local saved_jobstart -local saved_jobwait -local saved_notify -local test_buffers = {} -local test_repos = {} - -local function mock_repo_root(fn) - saved_git.get_repo_root = git.get_repo_root - git.get_repo_root = fn -end - -local function mock_git_method(name, fn) - saved_git[name] = git[name] - git[name] = fn -end - -local function mock_systemlist(fn) - saved_systemlist = vim.fn.systemlist - saved_jobstart = vim.fn.jobstart - saved_jobwait = vim.fn.jobwait - local exit_code - vim.fn.jobstart = function(cmd, opts) - local result, code, stderr = fn(cmd) - exit_code = code or 0 - local stdout = exit_code == 0 and vim.deepcopy(result or {}) or {} - stderr = stderr or (exit_code ~= 0 and vim.deepcopy(result or {}) or {}) - stdout[#stdout + 1] = '' - stderr[#stderr + 1] = '' - opts.on_stdout(1, stdout) - opts.on_stderr(1, stderr) - return 1 - end - vim.fn.jobwait = function() - return { exit_code } - end - vim.fn.systemlist = function(cmd) - local result, shell_error = fn(cmd) - if shell_error and shell_error ~= 0 then - saved_systemlist({ 'false' }) - else - saved_systemlist({ 'true' }) - end - return result - end -end - -local function mock_runtime_attach(fn) - saved_runtime_attach = saved_runtime_attach or runtime.attach - runtime.attach = fn - saved_schedule = saved_schedule or vim.schedule - vim.schedule = function(callback) - callback() - end -end - -local function mock_conflict_config(config) - saved_runtime_get_conflict_config = runtime.get_conflict_config - runtime.get_conflict_config = function() - return config - end -end - -local function mock_view_config(config) - saved_runtime_get_view_config = runtime.get_view_config - runtime.get_view_config = function() - return config - end -end - -local function capture_notifications() - local notifications = {} - saved_notify = vim.notify - vim.notify = function(message, level) - notifications[#notifications + 1] = { message = message, level = level } - end - return notifications -end - -local function git_cmd(repo_root, args) - local cmd = { 'git', '-C', repo_root } - for _, arg in ipairs(args) do - cmd[#cmd + 1] = arg - end - local output = vim.fn.systemlist(cmd) - assert.are.equal(0, vim.v.shell_error, table.concat(output, '\n')) - return output -end - -local function create_repo() - local repo_root = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(repo_root, 'p') - test_repos[#test_repos + 1] = repo_root - - vim.fn.systemlist({ 'git', 'init', '-q', repo_root }) - assert.are.equal(0, vim.v.shell_error) - git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' }) - git_cmd(repo_root, { 'config', 'user.name', 'Test' }) - git_cmd(repo_root, { 'config', 'core.filemode', 'true' }) - vim.fn.writefile({ 'line 1', 'line 2' }, repo_root .. '/file.txt') - git_cmd(repo_root, { 'add', 'file.txt' }) - git_cmd(repo_root, { 'commit', '-qm', 'initial' }) - - return repo_root -end - -local function create_conflicted_repo() - local repo_root = create_repo() - local filepath = repo_root .. '/file.txt' - local base = git_cmd(repo_root, { 'rev-parse', 'HEAD' })[1] - - git_cmd(repo_root, { 'checkout', '-qb', 'ours' }) - vim.fn.writefile({ 'line 1', 'line 2 ours' }, filepath) - git_cmd(repo_root, { 'add', 'file.txt' }) - git_cmd(repo_root, { 'commit', '-qm', 'ours' }) - - git_cmd(repo_root, { 'checkout', '-qb', 'theirs', base }) - vim.fn.writefile({ 'line 1', 'line 2 theirs' }, filepath) - git_cmd(repo_root, { 'add', 'file.txt' }) - git_cmd(repo_root, { 'commit', '-qm', 'theirs' }) - - local output = vim.fn.systemlist({ 'git', '-C', repo_root, 'merge', 'ours' }) - assert.are_not.equal(0, vim.v.shell_error, table.concat(output, '\n')) - - return repo_root, filepath -end - -local function write_repo_file(repo_root, path, lines) - local filepath = repo_root .. '/' .. path - vim.fn.mkdir(vim.fn.fnamemodify(filepath, ':h'), 'p') - vim.fn.writefile(lines, filepath) - return filepath -end - -local function create_review_repo(opts) - opts = opts or {} - local repo_root = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(repo_root, 'p') - test_repos[#test_repos + 1] = repo_root - - vim.fn.systemlist({ 'git', 'init', '-q', repo_root }) - assert.are.equal(0, vim.v.shell_error) - git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' }) - git_cmd(repo_root, { 'config', 'user.name', 'Test' }) - - write_repo_file(repo_root, 'lua/one.lua', { 'old one' }) - write_repo_file(repo_root, 'lua/two.lua', { - 'line 1', - 'line 2', - 'line 3', - 'line 4', - 'line 5', - 'line 6', - 'line 7', - 'line 8', - 'line 9', - 'line 10', - 'line 11', - 'line 12', - }) - git_cmd(repo_root, { 'add', 'lua' }) - git_cmd(repo_root, { 'commit', '-qm', 'base' }) - local base_sha = git_cmd(repo_root, { 'rev-parse', 'HEAD' })[1] - - local base_ref = base_sha - local target_ref - if opts.named_refs then - git_cmd(repo_root, { 'branch', 'review-base' }) - git_cmd(repo_root, { 'checkout', '-qb', 'review-topic' }) - base_ref = 'review-base' - target_ref = 'review-topic' - end - - write_repo_file(repo_root, 'lua/one.lua', { 'new one' }) - write_repo_file(repo_root, 'lua/two.lua', { - 'line 1', - 'line 2 changed', - 'line 3', - 'line 4', - 'line 5', - 'line 6', - 'line 7', - 'line 8', - 'line 9', - 'line 10', - 'line 11 changed', - 'line 12', - }) - - local target_sha - if opts.commit_target ~= false then - git_cmd(repo_root, { 'add', 'lua' }) - git_cmd(repo_root, { 'commit', '-qm', 'target' }) - target_sha = git_cmd(repo_root, { 'rev-parse', 'HEAD' })[1] - target_ref = target_ref or target_sha - end - - return { - repo_root = repo_root, - base = base_ref, - target = target_ref, - base_sha = base_sha, - target_sha = target_sha, - } -end - -local function create_current_state_review_repo(opts) - opts = opts or {} - local repo_root = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(repo_root, 'p') - test_repos[#test_repos + 1] = repo_root - - vim.fn.systemlist({ 'git', 'init', '-q', repo_root }) - assert.are.equal(0, vim.v.shell_error) - git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' }) - git_cmd(repo_root, { 'config', 'user.name', 'Test' }) - if opts.mnemonic_prefix then - git_cmd(repo_root, { 'config', 'diff.mnemonicPrefix', 'true' }) - end - if opts.noprefix then - git_cmd(repo_root, { 'config', 'diff.noprefix', 'true' }) - end - - write_repo_file(repo_root, 'lua/branch.lua', { 'branch base' }) - write_repo_file(repo_root, 'lua/dup.lua', { 'dup base' }) - write_repo_file(repo_root, 'lua/staged.lua', { 'staged base' }) - write_repo_file(repo_root, 'lua/unstaged.lua', { 'unstaged base' }) - git_cmd(repo_root, { 'add', 'lua' }) - git_cmd(repo_root, { 'commit', '-qm', 'base' }) - local base = git_cmd(repo_root, { 'rev-parse', 'HEAD' })[1] - - git_cmd(repo_root, { 'checkout', '-qb', 'topic' }) - write_repo_file(repo_root, 'lua/branch.lua', { 'branch head' }) - write_repo_file(repo_root, 'lua/dup.lua', { 'dup head' }) - git_cmd(repo_root, { 'add', 'lua/branch.lua', 'lua/dup.lua' }) - git_cmd(repo_root, { 'commit', '-qm', 'branch changes' }) - - write_repo_file(repo_root, 'lua/dup.lua', { 'dup staged' }) - write_repo_file(repo_root, 'lua/staged.lua', { 'staged changed' }) - git_cmd(repo_root, { 'add', 'lua/dup.lua', 'lua/staged.lua' }) - - write_repo_file(repo_root, 'lua/dup.lua', { 'dup unstaged' }) - write_repo_file(repo_root, 'lua/unstaged.lua', { 'unstaged changed' }) - write_repo_file(repo_root, 'lua/new.lua', { 'new file' }) - - return { - repo_root = repo_root, - base = base, - } -end - -local function create_mode_only_review_repo() - local repo_root = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(repo_root, 'p') - test_repos[#test_repos + 1] = repo_root - - vim.fn.systemlist({ 'git', 'init', '-q', repo_root }) - assert.are.equal(0, vim.v.shell_error) - git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' }) - git_cmd(repo_root, { 'config', 'user.name', 'Test' }) - git_cmd(repo_root, { 'config', 'core.filemode', 'true' }) - - write_repo_file(repo_root, 'scripts/tool.sh', { '#!/bin/sh', 'echo tool' }) - git_cmd(repo_root, { 'add', 'scripts/tool.sh' }) - git_cmd(repo_root, { 'commit', '-qm', 'base' }) - git_cmd(repo_root, { 'branch', 'mode-base' }) - - git_cmd(repo_root, { 'checkout', '-qb', 'mode-topic' }) - git_cmd(repo_root, { 'update-index', '--chmod=+x', 'scripts/tool.sh' }) - git_cmd(repo_root, { 'commit', '-qm', 'mode' }) - - return { - repo_root = repo_root, - base = 'mode-base', - target = 'mode-topic', - } -end - -local function edit_file(path) - vim.cmd('edit ' .. vim.fn.fnameescape(path)) - local bufnr = vim.api.nvim_get_current_buf() - test_buffers[#test_buffers + 1] = bufnr - return bufnr -end - -local function write_binary_file(path, text) - vim.fn.system({ 'sh', '-c', 'printf "$1" > "$2"', 'sh', text, path }) - assert.are.equal(0, vim.v.shell_error) -end - -local function buffer_lines(bufnr) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local ok, rail_width = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_rail_width') - if ok then - return vim.tbl_map(function(line) - return rails.strip(line, rail_width) - end, lines) - end - return lines -end - -local function buffer_text(bufnr) - return table.concat(buffer_lines(bufnr), '\n') -end - -local function quickfix_items() - return vim.fn.getqflist({ items = 0 }).items -end - -local function loclist_items(win) - local nr = win and vim.fn.win_id2win(win) or 0 - return vim.fn.getloclist(nr, { items = 0 }).items -end - -local function find_window_for_buffer(bufnr) - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.api.nvim_win_get_buf(win) == bufnr then - return win - end - end - return nil -end - ----@param win integer ----@return integer? -local function find_loclist_window_for_window(win) - local winnr = vim.fn.win_id2win(win) - if winnr == 0 then - return nil - end - - local loclist = vim.fn.getloclist(winnr, { winid = 0 }) - if type(loclist) ~= 'table' or type(loclist.winid) ~= 'number' or loclist.winid == 0 then - return nil - end - return loclist.winid -end - -local function find_buffer_line(bufnr, text) - for lnum, line in ipairs(buffer_lines(bufnr)) do - if line:find(text, 1, true) then - return lnum - end - end - return nil -end - -local function find_buffer_line_after(bufnr, after_text, text) - local start = find_buffer_line(bufnr, after_text) - assert.is_not_nil(start) - local lines = buffer_lines(bufnr) - for lnum = start + 1, #lines do - if lines[lnum]:find(text, 1, true) then - return lnum - end - end - return nil -end - -local function has_buf_var(bufnr, name) - local ok = pcall(vim.api.nvim_buf_get_var, bufnr, name) - return ok -end - -local function restore_mocks() - for k, v in pairs(saved_git) do - git[k] = v - end - saved_git = {} - if saved_runtime_attach then - runtime.attach = saved_runtime_attach - saved_runtime_attach = nil - end - if saved_runtime_get_conflict_config then - runtime.get_conflict_config = saved_runtime_get_conflict_config - saved_runtime_get_conflict_config = nil - end - if saved_runtime_get_view_config then - runtime.get_view_config = saved_runtime_get_view_config - saved_runtime_get_view_config = nil - end - if saved_schedule then - vim.schedule = saved_schedule - saved_schedule = nil - end - if saved_systemlist then - vim.fn.systemlist = saved_systemlist - saved_systemlist = nil - end - if saved_jobstart then - vim.fn.jobstart = saved_jobstart - saved_jobstart = nil - end - if saved_jobwait then - vim.fn.jobwait = saved_jobwait - saved_jobwait = nil - end - if saved_notify then - vim.notify = saved_notify - saved_notify = nil - end -end - -local function cleanup_buffers() - for _, bufnr in ipairs(test_buffers) do - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end - end - test_buffers = {} -end - -local function cleanup_repos() - for _, repo_root in ipairs(test_repos) do - vim.fn.delete(repo_root, 'rf') - end - test_repos = {} -end - -describe('commands', function() - after_each(function() - restore_mocks() - cleanup_buffers() - cleanup_repos() - pcall(vim.fn.setqflist, {}, 'f') - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - pcall(vim.api.nvim_set_option_value, 'winhighlight', '', { win = win }) - end - end) - - describe('Diff command dispatch', function() - local function capture_dispatch(args, vertical) - local saved_diff = commands.diff - local saved_review = commands.review_command - local captured = {} - commands.diff = function(a, v, o) - captured.diff = { args = a, vertical = v, opts = o } - end - commands.review_command = function(a, v, o) - captured.review = { args = a, vertical = v, opts = o } - end - commands.diff_command(args, vertical) - commands.diff = saved_diff - commands.review_command = saved_review - return captured - end - - it('routes plain arguments to the current-file diff', function() - local captured = capture_dispatch('HEAD~3', false) - assert.is_nil(captured.review) - assert.are.same( - { args = 'HEAD~3', vertical = false, opts = { warn_vertical_split = false } }, - captured.diff - ) - end) - - it('threads the :vertical modifier and the split warning into the diff path', function() - local captured = capture_dispatch(nil, true) - assert.is_nil(captured.review) - assert.are.same( - { args = nil, vertical = true, opts = { warn_vertical_split = true } }, - captured.diff - ) - end) - - it('routes a leading review subcommand to the review surface', function() - local captured = capture_dispatch('review origin/main', true) - assert.is_nil(captured.diff) - assert.are.same( - { args = 'origin/main', vertical = true, opts = { warn_vertical_split = true } }, - captured.review - ) - end) - - it('treats a bare review subcommand as a review with no spec', function() - local captured = capture_dispatch('review', false) - assert.is_nil(captured.diff) - assert.are.same( - { args = nil, vertical = false, opts = { warn_vertical_split = false } }, - captured.review - ) - end) - - it('only treats review as the subcommand when it is the first token', function() - local captured = capture_dispatch('++layout=split review', false) - assert.is_nil(captured.review) - assert.are.same( - { args = '++layout=split review', vertical = false, opts = { warn_vertical_split = false } }, - captured.diff - ) - end) - end) - - describe('generated rail style metadata', function() - local function diff_lines(change) - return { - 'diff --git a/lua/foo.lua b/lua/foo.lua', - '--- a/lua/foo.lua', - '+++ b/lua/foo.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+' .. change, - ' return M', - } - end - - local function assert_single_rail_display(bufnr, change) - local display_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - assert.are.equal(8, vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_width')) - assert.are.equal('single', vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_style')) - assert.are.equal('single', rails.style_for_buffer(bufnr)) - assert.are.equal(' │ diff --git a/lua/foo.lua b/lua/foo.lua', display_lines[1]) - assert.is_true(table.concat(display_lines, '\n'):find(' 2 │ +' .. change, 1, true) ~= nil) - end - - it('creates single-rail generated buffers and keeps hunk metadata raw', function() - mock_view_config({ prefix = true }) - - local diff_spec = diffspec.index_to_worktree('lua/foo.lua') - local bufnr = commands._test.create_generated_diff_buffer({ - name = 'diffs://unstaged:lua/foo.lua', - lines = diff_lines('local x = 1'), - repo_root = '/tmp/repo', - diff_spec = diff_spec, - source = generated.file_source('/tmp/repo', diff_spec), - rail_style = 'single', - }) - table.insert(test_buffers, bufnr) - - assert_single_rail_display(bufnr, 'local x = 1') - - local diff_hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal('diff --git a/lua/foo.lua b/lua/foo.lua', diff_hunks[1].file_header_lines[1]) - assert.are.equal('@@ -1,2 +1,3 @@', diff_hunks[1].header) - assert.are.equal('@@ -1,2 +1,3 @@', diff_hunks[1].lines[1].text) - assert.are.equal(' local M = {}', diff_hunks[1].lines[2].text) - assert.are.equal('+local x = 1', diff_hunks[1].lines[3].text) - assert.are.equal(' return M', diff_hunks[1].lines[4].text) - end) - - it('preserves single rails when generated buffers reload without an explicit style', function() - mock_view_config({ prefix = true }) - mock_runtime_attach(function() end) - mock_systemlist(function(cmd) - assert.are.same({ - 'git', - '-C', - '/tmp/repo', - 'diff', - '--no-ext-diff', - '--no-color', - '--src-prefix=a/', - '--dst-prefix=b/', - }, cmd) - return diff_lines('local reloaded = true') - end) - - local bufnr = commands._test.create_generated_diff_buffer({ - name = 'diffs://unstaged:all', - lines = diff_lines('local stale = true'), - repo_root = '/tmp/repo', - source = generated.section_source('/tmp/repo', 'unstaged'), - rail_style = 'single', - }) - table.insert(test_buffers, bufnr) - - commands.read_buffer(bufnr) - - assert_single_rail_display(bufnr, 'local reloaded = true') - assert.is_true(buffer_text(bufnr):find('+local reloaded = true', 1, true) ~= nil) - assert.is_false(buffer_text(bufnr):find('+local stale = true', 1, true) ~= nil) - end) - - it('lets generated buffer replacement override an existing rail style', function() - mock_view_config({ prefix = true }) - - local bufnr = commands._test.create_generated_diff_buffer({ - name = 'diffs://unstaged:all', - lines = diff_lines('local single = true'), - repo_root = '/tmp/repo', - rail_style = 'single', - }) - table.insert(test_buffers, bufnr) - - commands._test.replace_generated_diff_buffer_lines( - bufnr, - diff_lines('local dual = true'), - nil, - { rail_style = 'dual' } - ) - - local display_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - assert.are.equal(10, vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_width')) - assert.are.equal('dual', vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_style')) - assert.are.equal('dual', rails.style_for_buffer(bufnr)) - assert.are.equal(' │ diff --git a/lua/foo.lua b/lua/foo.lua', display_lines[1]) - assert.is_true( - table.concat(display_lines, '\n'):find(' 2 │ +local dual = true', 1, true) ~= nil - ) - end) - end) - - describe('command completion', function() - it('completes the Diff subcommands, layout options, objects, and refs', function() - mock_repo_root(function() - return '/tmp/repo' - end) - mock_systemlist(function() - return { 'origin/main', 'feature/topic' } - end) - - assert.are.same({ - 'review', - 'files', - '++layout=unified', - '++layout=stacked', - '++layout=split', - ':', - ':%', - ':0:%', - '@:%', - 'origin/main', - 'feature/topic', - }, commands._test.complete_diff('', 'Diff ', #'Diff ')) - - assert.are.same({ 'review' }, commands._test.complete_diff('rev', 'Diff rev', #'Diff rev')) - - assert.are.same({ 'files' }, commands._test.complete_diff('fil', 'Diff fil', #'Diff fil')) - - assert.are.same({ - '++layout=unified', - '++layout=stacked', - }, commands._test.complete_diff('++l', 'Diff files ++l', #'Diff files ++l')) - - assert.are.same({ - '++layout=unified', - '++layout=stacked', - '++layout=split', - }, commands._test.complete_diff('++l', 'Diff ++l', #'Diff ++l')) - - assert.are.same({ - '++layout=unified', - '++layout=stacked', - '++layout=split', - '++nountracked', - 'origin/main', - 'feature/topic', - }, commands._test.complete_diff('', 'Diff review ', #'Diff review ')) - - assert.are.same( - { - '++nountracked', - 'origin/main', - 'feature/topic', - }, - commands._test.complete_diff( - '', - 'Diff review ++layout=split ', - #'Diff review ++layout=split ' - ) - ) - - assert.are.same( - { 'origin/main', 'feature/topic' }, - commands._test.complete_diff('', 'Diff review origin/main ', #'Diff review origin/main ') - ) - assert.are.same( - { 'origin/main', 'feature/topic' }, - commands._test.complete_diff( - '', - 'Diff review ++layout=split origin/main ', - #'Diff review ++layout=split origin/main ' - ) - ) - assert.are.same({}, commands._test.complete_diff('', 'Diff HEAD ', #'Diff HEAD ')) - 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('diff DiffSpec rendering', function() - local function create_split_source(opts) - opts = opts or {} - local filepath = opts.filepath or '/tmp/repo/lua/foo.lua' - local relpath = opts.relpath or 'lua/foo.lua' - local worktree_lines = opts.worktree_lines - or { - 'local M = {}', - 'local x = 1', - 'return M', - } - local index_lines = opts.index_lines or { 'local M = {}', 'return M' } - - local source_buf = vim.api.nvim_create_buf(false, true) - table.insert(test_buffers, source_buf) - vim.api.nvim_buf_set_name(source_buf, filepath) - vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, worktree_lines) - vim.api.nvim_set_option_value('filetype', 'lua', { buf = source_buf }) - vim.api.nvim_set_current_buf(source_buf) - - mock_git_method('get_relative_path', function(actual_filepath) - assert.are.equal(filepath, actual_filepath) - return relpath - end) - mock_git_method('get_index_content', function(actual_filepath) - assert.are.equal(filepath, actual_filepath) - return index_lines - end) - mock_repo_root(function(actual_filepath) - assert.are.equal(filepath, actual_filepath) - return '/tmp/repo' - end) - - return source_buf - end - - local function find_split_windows(left_buf, right_buf) - local left_win - local right_win - local left_index - local right_index - for i, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - local win_buf = vim.api.nvim_win_get_buf(win) - if win_buf == left_buf then - left_win = win - left_index = i - elseif win_buf == right_buf then - right_win = win - right_index = i - end - end - - return left_win, right_win, left_index, right_index - end - - it('opens default :Diff as an unstaged index to worktree diff', function() - local source_buf = vim.api.nvim_create_buf(false, true) - table.insert(test_buffers, source_buf) - vim.api.nvim_buf_set_name(source_buf, '/tmp/repo/lua/foo.lua') - vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, { - 'local M = {}', - 'local x = 1', - 'return M', - }) - vim.api.nvim_set_current_buf(source_buf) - - local called_index = false - local called_head = false - mock_git_method('get_relative_path', function(filepath) - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return 'lua/foo.lua' - end) - mock_git_method('get_index_content', function(filepath) - called_index = true - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return { 'local M = {}', 'return M' } - end) - mock_git_method('get_file_content', function() - called_head = true - return { 'should not be used' } - end) - mock_repo_root(function(filepath) - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return '/tmp/repo' - end) - mock_runtime_attach(function() end) - mock_view_config({ prefix = true }) - - commands.diff(nil, false) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - local lines = buffer_lines(diff_buf) - - assert.is_true(called_index) - assert.is_false(called_head) - assert.are.equal('diffs://unstaged:lua/foo.lua', vim.api.nvim_buf_get_name(diff_buf)) - assert.are.same( - diffspec.index_to_worktree('lua/foo.lua'), - vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec - ) - local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal('lua/foo.lua', diff_hunks[1].file) - assert.is_true(diff_hunks[1].can_put) - assert.is_false(diff_hunks[1].can_obtain) - assert.is_true(diff_hunks[1].actionable) - assert.are.equal('worktree', diff_hunks[1].mutation_target) - assert.is_true(helpers.has_keymap(diff_buf, ']c')) - assert.is_true(helpers.has_keymap(diff_buf, '[c')) - assert.is_true(helpers.has_keymap(diff_buf, '')) - assert.is_false(helpers.has_keymap(diff_buf, 'o')) - assert.is_false(helpers.has_keymap(diff_buf, 'do')) - assert.is_true(helpers.has_keymap(diff_buf, 'dp')) - assert.are.equal('diff --git a/lua/foo.lua b/lua/foo.lua', lines[1]) - assert.is_true(table.concat(lines, '\n'):find('+local x = 1', 1, true) ~= nil) - - local display_lines = vim.api.nvim_buf_get_lines(diff_buf, 0, -1, false) - assert.are.equal(10, vim.api.nvim_buf_get_var(diff_buf, 'diffs_rail_width')) - assert.are.equal('dual', vim.api.nvim_buf_get_var(diff_buf, 'diffs_rail_style')) - assert.are.equal(' │ diff --git a/lua/foo.lua b/lua/foo.lua', display_lines[1]) - assert.is_true( - table.concat(display_lines, '\n'):find(' 2 │ +local x = 1', 1, true) ~= nil - ) - - local qf = quickfix_items() - assert.are.equal(0, #qf) - - local loc = loclist_items() - assert.are.equal(1, #loc) - assert.are.equal(diff_buf, loc[1].bufnr) - assert.are.equal(diff_hunks[1].buffer_range.start, loc[1].lnum) - assert.is_true(loc[1].text:find('lua/foo.lua', 1, true) ~= nil) - end) - - it('leaves an existing user quickfix list untouched for a single-file diff', function() - local source_buf = vim.api.nvim_create_buf(false, true) - table.insert(test_buffers, source_buf) - vim.api.nvim_buf_set_name(source_buf, '/tmp/repo/lua/foo.lua') - vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, { - 'local M = {}', - 'local x = 1', - 'return M', - }) - vim.api.nvim_set_current_buf(source_buf) - - mock_git_method('get_relative_path', function() - return 'lua/foo.lua' - end) - mock_git_method('get_index_content', function() - return { 'local M = {}', 'return M' } - end) - mock_repo_root(function() - return '/tmp/repo' - end) - mock_runtime_attach(function() end) - mock_view_config({ prefix = true }) - - vim.fn.setqflist({}, ' ', { - title = 'user list', - items = { - { filename = '/tmp/repo/other.lua', lnum = 3, text = 'grep hit' }, - { filename = '/tmp/repo/more.lua', lnum = 7, text = 'grep hit two' }, - }, - }) - - commands.diff(nil, false) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - - local qf = vim.fn.getqflist({ title = 0, items = 0 }) - assert.are.equal('user list', qf.title) - assert.are.equal(2, #qf.items) - - local loc = loclist_items() - assert.is_true(#loc >= 1) - assert.are.equal(diff_buf, loc[1].bufnr) - end) - - it('opens stacked :Diff as a generated buffer with single rails', function() - mock_runtime_attach(function() end) - mock_view_config({ prefix = true }) - create_split_source() - - commands.diff('++layout=stacked', false) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - local display_lines = vim.api.nvim_buf_get_lines(diff_buf, 0, -1, false) - - assert.are.equal('diffs://unstaged:lua/foo.lua', vim.api.nvim_buf_get_name(diff_buf)) - assert.are.same( - diffspec.index_to_worktree('lua/foo.lua'), - vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec - ) - assert.are.equal(8, vim.api.nvim_buf_get_var(diff_buf, 'diffs_rail_width')) - assert.are.equal('single', vim.api.nvim_buf_get_var(diff_buf, 'diffs_rail_style')) - assert.are.equal('single', rails.style_for_buffer(diff_buf)) - assert.are.equal(' │ diff --git a/lua/foo.lua b/lua/foo.lua', display_lines[1]) - assert.is_true(table.concat(display_lines, '\n'):find(' 2 │ +local x = 1', 1, true) ~= nil) - assert.is_false(has_buf_var(diff_buf, 'diffs_split_peer')) - - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - local name = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win)) - assert.is_false(name:match('^diffs://split:') ~= nil) - end - end) - - it('opens default :Diff for untracked files as index to worktree additions', function() - local source_buf = vim.api.nvim_create_buf(false, true) - table.insert(test_buffers, source_buf) - vim.api.nvim_buf_set_name(source_buf, '/tmp/repo/lua/new.lua') - vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, { - 'local M = {}', - 'return M', - }) - vim.api.nvim_set_current_buf(source_buf) - - mock_git_method('get_relative_path', function(filepath) - assert.are.equal('/tmp/repo/lua/new.lua', filepath) - return 'lua/new.lua' - end) - mock_git_method('get_index_content', function(filepath) - assert.are.equal('/tmp/repo/lua/new.lua', filepath) - return nil, 'file not in index' - end) - mock_repo_root(function(filepath) - assert.are.equal('/tmp/repo/lua/new.lua', filepath) - return '/tmp/repo' - end) - mock_runtime_attach(function() end) - - commands.diff(nil, false) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - local lines = buffer_lines(diff_buf) - - assert.are.equal('diffs://unstaged:lua/new.lua', vim.api.nvim_buf_get_name(diff_buf)) - assert.are.same( - diffspec.index_to_worktree('lua/new.lua'), - vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec - ) - local text = table.concat(lines, '\n') - assert.is_true(text:find('new file mode 100644', 1, true) ~= nil) - assert.is_true(text:find('--- /dev/null', 1, true) ~= nil) - assert.is_true(text:find('+local M = {}', 1, true) ~= nil) - end) - - it('opens configured default :Diff as paired endpoint windows', function() - local saved_splitright = vim.o.splitright - vim.o.splitright = false - local ok, err = pcall(function() - mock_view_config({ - default_layout = 'split', - prefix = true, - }) - create_split_source() - commands.diff(nil, false) - end) - vim.o.splitright = saved_splitright - assert.is_true(ok, err) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - test_buffers[#test_buffers + 1] = left_buf - test_buffers[#test_buffers + 1] = right_buf - - assert.are.equal('diffs://split:left:index:lua/foo.lua', vim.api.nvim_buf_get_name(left_buf)) - assert.are.equal( - 'diffs://split:right:worktree:lua/foo.lua', - vim.api.nvim_buf_get_name(right_buf) - ) - end) - - it('opens opt-in split :Diff as paired endpoint windows', function() - local saved_splitright = vim.o.splitright - vim.o.splitright = false - local ok, err = pcall(function() - mock_view_config({ prefix = true }) - create_split_source() - commands.diff('++layout=split', false) - end) - vim.o.splitright = saved_splitright - assert.is_true(ok, err) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local left_win, right_win, left_index, right_index = find_split_windows(left_buf, right_buf) - - assert.is_not_nil(left_win) - assert.is_not_nil(right_win) - assert.is_true(left_index < right_index) - assert.are.equal('diffs://split:left:index:lua/foo.lua', vim.api.nvim_buf_get_name(left_buf)) - assert.are.equal( - 'diffs://split:right:worktree:lua/foo.lua', - vim.api.nvim_buf_get_name(right_buf) - ) - local left_lines = vim.api.nvim_buf_get_lines(left_buf, 0, -1, false) - local right_lines = vim.api.nvim_buf_get_lines(right_buf, 0, -1, false) - assert.are.equal(#left_lines, #right_lines) - assert.are.equal('local M = {}', left_lines[1]) - assert.are.equal('local M = {}', right_lines[1]) - assert.are.equal('local x = 1', right_lines[2]) - assert.is_true(vim.tbl_contains(left_lines, '')) - assert.are.same( - diffspec.index_to_worktree('lua/foo.lua'), - vim.api.nvim_buf_get_var(left_buf, 'diffs_source').spec - ) - assert.are.equal('left', vim.api.nvim_buf_get_var(left_buf, 'diffs_source').side) - assert.are.equal('right', vim.api.nvim_buf_get_var(right_buf, 'diffs_source').side) - assert.are.equal('lua', vim.api.nvim_get_option_value('filetype', { buf = left_buf })) - assert.are.equal('lua', vim.api.nvim_get_option_value('filetype', { buf = right_buf })) - assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = left_buf })) - assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = right_buf })) - assert.is_false(vim.api.nvim_get_option_value('diff', { win = left_win })) - assert.is_false(vim.api.nvim_get_option_value('diff', { win = right_win })) - assert.is_false(vim.api.nvim_get_option_value('number', { win = left_win })) - assert.is_false(vim.api.nvim_get_option_value('number', { win = right_win })) - assert.is_true(vim.api.nvim_get_option_value('statuscolumn', { win = left_win }) ~= '') - assert.is_true(vim.api.nvim_get_option_value('statuscolumn', { win = right_win }) ~= '') - assert.are.equal('manual', vim.api.nvim_get_option_value('foldmethod', { win = left_win })) - assert.are.equal('manual', vim.api.nvim_get_option_value('foldmethod', { win = right_win })) - assert.is_false(vim.api.nvim_get_option_value('foldenable', { win = left_win })) - assert.is_false(vim.api.nvim_get_option_value('foldenable', { win = right_win })) - assert.are.equal('0', vim.api.nvim_get_option_value('foldcolumn', { win = left_win })) - assert.are.equal('0', vim.api.nvim_get_option_value('foldcolumn', { win = right_win })) - assert.is_true(vim.api.nvim_get_option_value('scrollbind', { win = left_win })) - assert.is_true(vim.api.nvim_get_option_value('scrollbind', { win = right_win })) - assert.is_true(vim.api.nvim_get_option_value('cursorbind', { win = left_win })) - assert.is_true(vim.api.nvim_get_option_value('cursorbind', { win = right_win })) - assert.is_true(helpers.has_keymap(left_buf, 'q')) - assert.is_true(helpers.has_keymap(right_buf, 'q')) - assert.is_true(helpers.has_keymap(left_buf, '')) - assert.is_true(helpers.has_keymap(right_buf, '')) - assert.is_true(helpers.has_keymap(left_buf, ']c')) - assert.is_true(helpers.has_keymap(right_buf, ']c')) - assert.is_true(helpers.has_keymap(left_buf, '[c')) - assert.is_true(helpers.has_keymap(right_buf, '[c')) - assert.is_false(helpers.has_keymap(right_buf, 'dp')) - assert.is_false(helpers.has_keymap(right_buf, 'do')) - - local line_ns = vim.api.nvim_get_namespaces().diffs_split_line - local right_bg = vim.api.nvim_buf_get_extmarks(right_buf, line_ns, 0, -1, { details = true }) - local has_added_line_bg = false - for _, mark in ipairs(right_bg) do - if mark[2] == 1 and mark[4].hl_group == 'DiffsAdd' and mark[4].hl_eol then - has_added_line_bg = true - end - end - assert.is_true(has_added_line_bg) - - vim.g.statusline_winid = right_win - vim.v.lnum = 2 - local rail = require('diffs.split').statuscolumn() - assert.is_true(rail:find('DiffsAddBar', 1, true) ~= nil) - assert.is_true(rail:find('▏', 1, true) ~= nil) - - local qf = quickfix_items() - assert.are.equal(0, #qf) - - local left_loc = loclist_items(left_win) - local right_loc = loclist_items(right_win) - assert.are.equal(1, #left_loc) - assert.are.equal(1, #right_loc) - assert.are.equal(left_buf, left_loc[1].bufnr) - assert.are.equal(right_buf, right_loc[1].bufnr) - end) - - it('paints the configured intra word-diff overlay on both split panes', function() - mock_view_config({ prefix = true }) - create_split_source({ - index_lines = { 'function f()', ' return 111', ' log()', 'end' }, - worktree_lines = { 'function f()', ' return 222', ' log()', 'end' }, - }) - commands.diff('++layout=split', false) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - - local intra_ns = vim.api.nvim_get_namespaces().diffs_split_intra - assert.is_not_nil(intra_ns) - - local function intra_mark(bufnr, group) - for _, mark in - ipairs(vim.api.nvim_buf_get_extmarks(bufnr, intra_ns, 0, -1, { details = true })) - do - if mark[4].hl_group == group then - return mark - end - end - return nil - end - - local left_mark = intra_mark(left_buf, 'DiffsDeleteText') - local right_mark = intra_mark(right_buf, 'DiffsAddText') - assert.is_not_nil(left_mark) - assert.is_not_nil(right_mark) - assert.are.equal(1, left_mark[2]) - assert.are.equal(9, left_mark[3]) - assert.are.equal(12, left_mark[4].end_col) - assert.are.equal(1, right_mark[2]) - assert.are.equal(9, right_mark[3]) - assert.are.equal(12, right_mark[4].end_col) - - local line_ns = vim.api.nvim_get_namespaces().diffs_split_line - assert.is_true(#vim.api.nvim_buf_get_extmarks(left_buf, line_ns, { 1, 0 }, { 1, -1 }, {}) > 0) - assert.is_true( - #vim.api.nvim_buf_get_extmarks(right_buf, line_ns, { 1, 0 }, { 1, -1 }, {}) > 0 - ) - end) - - it('leaves split panes untouched when attach_diff re-runs', function() - mock_view_config({ prefix = true }) - create_split_source() - commands.diff('++layout=split', false) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local left_win, right_win = find_split_windows(left_buf, right_buf) - - runtime.attach_diff() - - assert.is_false(vim.api.nvim_get_option_value('diff', { win = left_win })) - assert.is_false(vim.api.nvim_get_option_value('diff', { win = right_win })) - assert.is_true(vim.api.nvim_get_option_value('statuscolumn', { win = left_win }) ~= '') - assert.is_true(vim.api.nvim_get_option_value('statuscolumn', { win = right_win }) ~= '') - end) - - it('renders real per-side line numbers through the statuscolumn rail', function() - mock_view_config({ prefix = true }) - create_split_source({ - index_lines = { 'a', 'b', 'c', 'd' }, - worktree_lines = { 'X', 'a', 'b', 'c', 'd' }, - }) - commands.diff('++layout=split', false) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local left_win, right_win = find_split_windows(left_buf, right_buf) - - vim.wo[left_win].number = true - vim.wo[right_win].number = true - - local function rail(win, lnum) - local sc = vim.api.nvim_get_option_value('statuscolumn', { win = win }) - return vim.api.nvim_eval_statusline( - sc, - { winid = win, use_statuscol_lnum = lnum, highlights = false } - ).str - end - - assert.are.equal( - vim.api.nvim_buf_line_count(left_buf), - vim.api.nvim_buf_line_count(right_buf) - ) - assert.is_true(#rail(right_win, 1) > 0) - assert.is_true(rail(right_win, 1):find('1', 1, true) ~= nil) - assert.is_true(rail(left_win, 2):find('1', 1, true) ~= nil) - assert.is_true(rail(right_win, 2):find('2', 1, true) ~= nil) - assert.is_true(rail(right_win, 2):find('1', 1, true) == nil) - - vim.wo[right_win].relativenumber = true - assert.is_true(rail(right_win, 2):find('2', 1, true) ~= nil) - - vim.wo[right_win].number = false - assert.is_nil(rail(right_win, 2):find('%d')) - end) - - for _, exit in ipairs({ 'replace', 'close' }) do - it('preserves window defaults when leaving a split via ' .. exit, function() - vim.cmd.tabnew() - local tab = vim.api.nvim_get_current_tabpage() - local ok, err = pcall(function() - create_split_source() - local defaults = { - statuscolumn = 'ordinary:%l', - number = true, - scrollbind = false, - cursorbind = false, - wrap = true, - foldmethod = 'marker', - foldenable = true, - } - for name, value in pairs(defaults) do - vim.api.nvim_set_option_value(name, value, { win = 0 }) - end - vim.api.nvim_set_option_value('statuscolumn', 'source:%l', { - win = 0, - scope = 'local', - }) - - commands.diff('++layout=split', false) - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.b[right_buf].diffs_split_peer - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local left_win, right_win = find_split_windows(left_buf, right_buf) - for _, win in ipairs({ left_win, right_win }) do - for name, value in pairs(defaults) do - assert.are.equal( - value, - vim.api.nvim_get_option_value(name, { win = win, scope = 'global' }), - name - ) - end - end - - if exit == 'replace' then - vim.cmd.enew() - else - assert.is_true(split.close_pair(right_buf)) - end - for name, value in pairs(defaults) do - assert.are.equal(value, vim.api.nvim_get_option_value(name, { win = 0 }), name) - assert.are.equal( - value, - vim.api.nvim_get_option_value(name, { win = 0, scope = 'global' }), - name - ) - end - assert.are.equal( - 'ordinary:1', - vim.api.nvim_eval_statusline(vim.wo.statuscolumn, { use_statuscol_lnum = 1 }).str - ) - end) - if vim.api.nvim_tabpage_is_valid(tab) then - vim.api.nvim_set_current_tabpage(tab) - vim.cmd.tabclose({ bang = true }) - end - assert.is_true(ok, err) - end) - end - - it('warns and still opens the split when :vertical Diff ++layout=split is used', function() - local notifications = capture_notifications() - local saved_splitright = vim.o.splitright - vim.o.splitright = false - local ok, err = pcall(function() - mock_view_config({ prefix = true }) - create_split_source() - commands.diff_command('++layout=split', true) - end) - vim.o.splitright = saved_splitright - assert.is_true(ok, err) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local left_win, right_win = find_split_windows(left_buf, right_buf) - assert.is_not_nil(left_win) - assert.is_not_nil(right_win) - - local warned = false - for _, n in ipairs(notifications) do - if tostring(n.message):find('++layout=split ignores the :vertical modifier', 1, true) then - warned = true - end - end - assert.is_true(warned) - end) - - it('moves split hunk navigation through both endpoint windows', function() - create_split_source({ - index_lines = { - 'line 1', - 'line 2', - 'line 3', - 'line 4', - 'line 5', - 'line 6', - 'line 7', - 'line 8', - 'line 9', - 'line 10', - 'line 11', - 'line 12', - }, - worktree_lines = { - 'line 1', - 'line 2 changed', - 'line 3', - 'line 4', - 'line 5', - 'line 6', - 'line 7', - 'line 8', - 'line 9', - 'line 10', - 'line 11 changed', - 'line 12', - }, - }) - commands.diff('++layout=split', false) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local left_win, right_win = find_split_windows(left_buf, right_buf) - - local right_loc = loclist_items(right_win) - assert.are.equal(2, #right_loc) - assert.are.equal(right_buf, right_loc[2].bufnr) - - local function synced_row() - local lrow = vim.api.nvim_win_get_cursor(left_win)[1] - local rrow = vim.api.nvim_win_get_cursor(right_win)[1] - assert.are.equal(lrow, rrow) - return rrow - end - local function in_hunk(row, item) - return row == item.lnum - end - - vim.api.nvim_set_current_win(right_win) - vim.api.nvim_win_set_cursor(right_win, { 1, 0 }) - - split.goto_next(right_buf) - assert.is_true(in_hunk(synced_row(), right_loc[1])) - - split.goto_next(right_buf) - assert.is_true(in_hunk(synced_row(), right_loc[2])) - - split.goto_prev(right_buf) - assert.is_true(in_hunk(synced_row(), right_loc[1])) - - vim.api.nvim_set_current_win(right_win) - vim.cmd('lopen') - local found_qf = false - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - local buf = vim.api.nvim_win_get_buf(win) - if vim.api.nvim_get_option_value('buftype', { buf = buf }) == 'quickfix' then - vim.api.nvim_set_current_win(win) - found_qf = true - break - end - end - assert.is_true(found_qf) - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - vim.cmd('normal \r') - vim.wait(100, function() - return vim.api.nvim_win_get_cursor(left_win)[1] == right_loc[2].lnum - end) - - assert.are.equal(right_loc[2].lnum, vim.api.nvim_win_get_cursor(left_win)[1]) - assert.are.equal( - vim.api.nvim_win_get_cursor(left_win)[1], - vim.api.nvim_win_get_cursor(right_win)[1] - ) - end) - - it('targets the old endpoint for split deleted hunks in the loclist', function() - create_split_source({ - index_lines = { - 'line 1', - 'line 2', - 'line 3', - }, - worktree_lines = {}, - }) - commands.diff('++layout=split', false) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local left_win, right_win = find_split_windows(left_buf, right_buf) - - local qf = quickfix_items() - assert.are.equal(0, #qf) - - local right_loc = loclist_items(right_win) - assert.are.equal(1, #right_loc) - assert.are.equal(left_buf, right_loc[1].bufnr) - assert.are.equal(1, right_loc[1].lnum) - - vim.api.nvim_set_current_win(right_win) - vim.cmd('lopen') - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - local buf = vim.api.nvim_win_get_buf(win) - if vim.api.nvim_get_option_value('buftype', { buf = buf }) == 'quickfix' then - vim.api.nvim_set_current_win(win) - break - end - end - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - vim.cmd('normal \r') - vim.wait(100, function() - return vim.api.nvim_get_current_buf() == left_buf - and vim.api.nvim_win_get_cursor(left_win)[1] == right_loc[1].lnum - end) - - assert.are.equal(left_buf, vim.api.nvim_get_current_buf()) - assert.are.same({ right_loc[1].lnum, 0 }, vim.api.nvim_win_get_cursor(left_win)) - end) - - it('opens the worktree split endpoint in an existing source window', function() - local notifications = capture_notifications() - local source_buf = create_split_source() - commands.diff('++layout=split', false) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local left_win, right_win = find_split_windows(left_buf, right_buf) - - vim.api.nvim_set_current_win(right_win) - vim.cmd('belowright split') - local source_win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(source_win, source_buf) - - vim.api.nvim_set_current_win(right_win) - vim.api.nvim_win_set_cursor(right_win, { 2, 0 }) - assert.is_true(split.open_source(right_buf)) - - assert.are.equal(source_win, vim.api.nvim_get_current_win()) - assert.are.same({ 2, 0 }, vim.api.nvim_win_get_cursor(source_win)) - - vim.api.nvim_set_current_win(left_win) - assert.is_false(split.open_source(left_buf)) - assert.is_true( - notifications[#notifications].message:find('index-backed split endpoint', 1, true) ~= nil - ) - end) - - it( - 'opens the worktree split endpoint in the invoking window when no source window exists', - function() - local source_buf = create_split_source() - vim.api.nvim_set_option_value('diff', false, { win = 0 }) - vim.api.nvim_set_option_value('scrollbind', false, { win = 0 }) - vim.api.nvim_set_option_value('cursorbind', false, { win = 0 }) - vim.api.nvim_set_option_value('foldenable', true, { win = 0 }) - - commands.diff('++layout=split', false) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local left_win, right_win = find_split_windows(left_buf, right_buf) - - vim.api.nvim_set_current_win(right_win) - vim.api.nvim_win_set_cursor(right_win, { 2, 0 }) - assert.is_true(split.open_source(right_buf)) - - assert.are.equal(right_win, vim.api.nvim_get_current_win()) - assert.are.equal(source_buf, vim.api.nvim_get_current_buf()) - assert.are.same({ 2, 0 }, vim.api.nvim_win_get_cursor(right_win)) - assert.is_false(vim.api.nvim_buf_is_valid(left_buf)) - assert.is_false(vim.api.nvim_buf_is_valid(right_buf)) - assert.is_false(vim.api.nvim_win_is_valid(left_win)) - assert.is_true(vim.api.nvim_win_is_valid(right_win)) - assert.is_false(vim.api.nvim_get_option_value('diff', { win = right_win })) - assert.is_false(vim.api.nvim_get_option_value('scrollbind', { win = right_win })) - assert.is_false(vim.api.nvim_get_option_value('cursorbind', { win = right_win })) - assert.is_true(vim.api.nvim_get_option_value('foldenable', { win = right_win })) - end - ) - - it('cleans up the peer endpoint when one split buffer is wiped', function() - create_split_source() - commands.diff('++layout=split', false) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - - vim.api.nvim_buf_delete(left_buf, { force = true }) - - vim.wait(100, function() - return not vim.api.nvim_buf_is_valid(right_buf) - end) - - assert.is_false(vim.api.nvim_buf_is_valid(left_buf)) - assert.is_false(vim.api.nvim_buf_is_valid(right_buf)) - end) - - it('tears down the pair when a pane window is closed', function() - create_split_source() - vim.cmd('split') - commands.diff('++layout=split', false) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local _, right_win = find_split_windows(left_buf, right_buf) - - vim.api.nvim_win_close(right_win, false) - vim.wait(200, function() - return not vim.api.nvim_buf_is_valid(left_buf) - end) - - assert.is_false(vim.api.nvim_buf_is_valid(left_buf)) - assert.is_false(vim.api.nvim_buf_is_valid(right_buf)) - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(buf) then - assert.is_nil(vim.api.nvim_buf_get_name(buf):match('diffs://split:')) - end - end - end) - - it('tears down the pair when :only is run on a pane', function() - create_split_source() - commands.diff('++layout=split', false) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local left_win = find_split_windows(left_buf, right_buf) - - vim.api.nvim_set_current_win(left_win) - vim.cmd('only') - vim.wait(200, function() - return not vim.api.nvim_buf_is_valid(right_buf) - end) - - assert.is_false(vim.api.nvim_buf_is_valid(left_buf)) - assert.is_false(vim.api.nvim_buf_is_valid(right_buf)) - assert.is_true(#vim.api.nvim_tabpage_list_wins(0) >= 1) - end) - - it('closes paired split endpoint buffers with q and can reopen the same split', function() - local source_buf = create_split_source() - - commands.diff('++layout=split', false) - - local right_buf = vim.api.nvim_get_current_buf() - local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer') - table.insert(test_buffers, left_buf) - table.insert(test_buffers, right_buf) - local _, right_win = find_split_windows(left_buf, right_buf) - - vim.api.nvim_set_current_win(right_win) - vim.cmd('normal q') - - assert.is_false(vim.api.nvim_buf_is_valid(left_buf)) - assert.is_false(vim.api.nvim_buf_is_valid(right_buf)) - - vim.api.nvim_set_current_buf(source_buf) - assert.has_no.errors(function() - commands.diff('++layout=split', false) - end) - - local reopened_right_buf = vim.api.nvim_get_current_buf() - local reopened_left_buf = vim.api.nvim_buf_get_var(reopened_right_buf, 'diffs_split_peer') - table.insert(test_buffers, reopened_left_buf) - table.insert(test_buffers, reopened_right_buf) - - assert.are.equal( - 'diffs://split:left:index:lua/foo.lua', - vim.api.nvim_buf_get_name(reopened_left_buf) - ) - assert.are.equal( - 'diffs://split:right:worktree:lua/foo.lua', - vim.api.nvim_buf_get_name(reopened_right_buf) - ) - end) - - it('reopens stale split endpoint buffers from the invoking source window', function() - local source_buf = create_split_source() - commands.diff('++layout=split', false) - - local old_right_buf = vim.api.nvim_get_current_buf() - local old_left_buf = vim.api.nvim_buf_get_var(old_right_buf, 'diffs_split_peer') - table.insert(test_buffers, old_left_buf) - table.insert(test_buffers, old_right_buf) - - local scratch_buf = vim.api.nvim_create_buf(false, true) - table.insert(test_buffers, scratch_buf) - vim.cmd('topleft split') - local scratch_win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(scratch_win, scratch_buf) - - vim.cmd('botright split') - local invoking_win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(invoking_win, source_buf) - - commands.diff('++layout=split', false) - - local reopened_right_buf = vim.api.nvim_get_current_buf() - local reopened_left_buf = vim.api.nvim_buf_get_var(reopened_right_buf, 'diffs_split_peer') - table.insert(test_buffers, reopened_left_buf) - table.insert(test_buffers, reopened_right_buf) - local reopened_left_win, reopened_right_win = - find_split_windows(reopened_left_buf, reopened_right_buf) - - assert.is_false(vim.api.nvim_buf_is_valid(old_left_buf)) - assert.is_false(vim.api.nvim_buf_is_valid(old_right_buf)) - assert.are.equal(invoking_win, reopened_left_win) - assert.are.equal(reopened_right_win, vim.api.nvim_get_current_win()) - assert.are.equal(scratch_buf, vim.api.nvim_win_get_buf(scratch_win)) - end) - - it('preserves the explicit revision generated buffer surface', function() - local source_buf = vim.api.nvim_create_buf(false, true) - table.insert(test_buffers, source_buf) - vim.api.nvim_buf_set_name(source_buf, '/tmp/repo/lua/foo.lua') - vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, { - 'local M = {}', - 'local x = 1', - 'return M', - }) - vim.api.nvim_set_current_buf(source_buf) - - local captured_revision - mock_git_method('get_relative_path', function(filepath) - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return 'lua/foo.lua' - end) - mock_git_method('get_file_content', function(revision, filepath) - captured_revision = revision - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return { 'local M = {}', 'return M' } - end) - mock_repo_root(function(filepath) - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return '/tmp/repo' - end) - mock_runtime_attach(function() end) - - commands.diff('HEAD~3', false) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - local lines = buffer_lines(diff_buf) - - assert.are.equal('HEAD~3', captured_revision) - assert.are.equal('diffs://HEAD~3:lua/foo.lua', vim.api.nvim_buf_get_name(diff_buf)) - assert.are.equal('/tmp/repo', vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').repo_root) - assert.are.same( - diffspec.rev_to_worktree('HEAD~3', 'lua/foo.lua'), - vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec - ) - local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal('lua/foo.lua', diff_hunks[1].file) - assert.is_false(diff_hunks[1].can_put) - assert.is_false(diff_hunks[1].can_obtain) - assert.is_false(diff_hunks[1].actionable) - assert.are.equal('worktree', diff_hunks[1].mutation_target) - assert.are.equal('diff --git a/lua/foo.lua b/lua/foo.lua', lines[1]) - assert.are.equal('--- a/lua/foo.lua', lines[2]) - assert.are.equal('+++ b/lua/foo.lua', lines[3]) - assert.is_true(table.concat(lines, '\n'):find('+local x = 1', 1, true) ~= nil) - end) - end) - - describe('diff real repository rendering', function() - for _, layout in ipairs({ 'unified', 'stacked', 'split' }) do - it('reloads live worktree content in ' .. layout .. ' views', function() - local repo_root = create_repo() - local source_buf = edit_file(repo_root .. '/file.txt') - vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, { 'unsaved first' }) - mock_runtime_attach(function() end) - commands.diff('++layout=' .. layout, false) - local diff_buf = vim.api.nvim_get_current_buf() - test_buffers[#test_buffers + 1] = diff_buf - if layout == 'split' then - local peer = vim.b[diff_buf].diffs_split_peer - test_buffers[#test_buffers + 1] = peer - if vim.b[diff_buf].diffs_source.side == 'left' then - diff_buf = peer - end - end - assert.is_true(buffer_text(diff_buf):find('unsaved first', 1, true) ~= nil) - - vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, { 'unsaved second' }) - commands.refresh(diff_buf) - assert.is_true(buffer_text(diff_buf):find('unsaved second', 1, true) ~= nil) - assert.are.same({ 'line 1', 'line 2' }, vim.fn.readfile(repo_root .. '/file.txt')) - - vim.api.nvim_buf_delete(source_buf, { force = true }) - commands.refresh(diff_buf) - assert.is_nil(buffer_text(diff_buf):find('unsaved second', 1, true)) - end) - end - - it('reports no unstaged changes for staged-only tracked files', function() - local repo_root = create_repo() - local filepath = repo_root .. '/file.txt' - vim.fn.writefile({ 'line 1', 'line 2 staged' }, filepath) - git_cmd(repo_root, { 'add', 'file.txt' }) - local source_buf = edit_file(filepath) - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - commands.diff(nil, false) - - assert.are.equal(source_buf, vim.api.nvim_get_current_buf()) - assert.is_true( - notifications[#notifications].message:find('no changes for index -> worktree', 1, true) - ~= nil - ) - end) - - it('reports no unstaged changes for staged additions matching the worktree', function() - local repo_root = create_repo() - local filepath = repo_root .. '/new.txt' - vim.fn.writefile({ 'new line' }, filepath) - git_cmd(repo_root, { 'add', 'new.txt' }) - local source_buf = edit_file(filepath) - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - commands.diff(nil, false) - - assert.are.equal(source_buf, vim.api.nvim_get_current_buf()) - assert.is_true( - notifications[#notifications].message:find('no changes for index -> worktree', 1, true) - ~= nil - ) - end) - - it('reports no unstaged changes for staged deletions matching the worktree', function() - local repo_root = create_repo() - local filepath = repo_root .. '/file.txt' - git_cmd(repo_root, { 'rm', '-q', 'file.txt' }) - local source_buf = edit_file(filepath) - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - commands.diff(nil, false) - - assert.are.equal(source_buf, vim.api.nvim_get_current_buf()) - assert.is_true( - notifications[#notifications].message:find('no changes for index -> worktree', 1, true) - ~= nil - ) - end) - - it('shows only the unstaged edge when a file has staged and unstaged changes', function() - local repo_root = create_repo() - local filepath = repo_root .. '/file.txt' - vim.fn.writefile({ 'line 1', 'line 2 staged' }, filepath) - git_cmd(repo_root, { 'add', 'file.txt' }) - vim.fn.writefile({ 'line 1', 'line 2 unstaged' }, filepath) - edit_file(filepath) - mock_runtime_attach(function() end) - - commands.diff(nil, false) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - local text = buffer_text(diff_buf) - assert.is_true(text:find('-line 2 staged', 1, true) ~= nil) - assert.is_true(text:find('+line 2 unstaged', 1, true) ~= nil) - assert.is_false(text:find('-line 2\n', 1, true) ~= nil) - end) - - it('uses buffer EOF state for final-newline-only direct diffs', function() - local repo_root = create_repo() - local filepath = repo_root .. '/file.txt' - vim.fn.writefile({ 'line 1', 'line 2' }, filepath, 'b') - local source_buf = edit_file(filepath) - mock_runtime_attach(function() end) - - assert.is_false(vim.bo[source_buf].endofline) - - commands.diff(nil, false) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - local text = buffer_text(diff_buf) - assert.is_true(text:find('-line 2', 1, true) ~= nil) - assert.is_true(text:find('+line 2', 1, true) ~= nil) - assert.is_true(text:find('\\ No newline at end of file', 1, true) ~= nil) - end) - - it('reports mode-only direct changes instead of rendering fake text hunks', function() - local repo_root = create_repo() - local filepath = repo_root .. '/file.txt' - vim.fn.setfperm(filepath, 'rwxr-xr-x') - local source_buf = edit_file(filepath) - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - commands.diff(nil, false) - - assert.are.equal(source_buf, vim.api.nvim_get_current_buf()) - assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level) - assert.is_true( - notifications[#notifications].message:find( - 'diff does not support mode-only changes', - 1, - true - ) ~= nil - ) - end) - - it('reports mode-only status-row changes without rendering text hunks', function() - local repo_root = create_repo() - local filepath = repo_root .. '/file.txt' - vim.fn.setfperm(filepath, 'rwxr-xr-x') - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - commands.diff_file(filepath) - - assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level) - assert.is_true( - notifications[#notifications].message:find( - 'diff does not support mode-only changes', - 1, - true - ) ~= nil - ) - end) - - it('reports binary status-row changes without rendering text hunks', function() - local repo_root = create_repo() - local filepath = repo_root .. '/bin.dat' - write_binary_file(filepath, 'binary\\000old') - git_cmd(repo_root, { 'add', 'bin.dat' }) - git_cmd(repo_root, { 'commit', '-qm', 'binary' }) - write_binary_file(filepath, 'binary\\000new') - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - commands.diff_file(filepath) - - assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level) - assert.is_true( - notifications[#notifications].message:find('diff does not support binary files', 1, true) - ~= nil - ) - end) - - it('reports submodule status-row changes without rendering pseudo text hunks', function() - local repo_root = create_repo() - local submodule_path = repo_root .. '/submodule' - git_cmd(repo_root, { - 'update-index', - '--add', - '--cacheinfo', - '160000,0123456789012345678901234567890123456789,submodule', - }) - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - commands.diff_file(submodule_path, { staged = true }) - - assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level) - assert.is_true( - notifications[#notifications].message:find( - 'diff does not support submodule changes', - 1, - true - ) ~= nil - ) - end) - - it('reports pure status-row renames as no text changes with actions unavailable', function() - local repo_root = create_repo() - local old_filepath = repo_root .. '/file.txt' - local new_filepath = repo_root .. '/renamed.txt' - git_cmd(repo_root, { 'mv', 'file.txt', 'renamed.txt' }) - local source_buf = edit_file(new_filepath) - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - commands.diff_file(new_filepath, { staged = true, old_filepath = old_filepath }) - - assert.are.equal(source_buf, vim.api.nvim_get_current_buf()) - assert.are.equal(vim.log.levels.INFO, notifications[#notifications].level) - assert.is_true( - notifications[#notifications].message:find( - 'no text changes for staged rename/copy file.txt -> renamed.txt', - 1, - true - ) ~= nil - ) - assert.is_true( - notifications[#notifications].message:find( - 'generated hunk actions are unavailable', - 1, - true - ) ~= nil - ) - end) - - it('renders status-row rename content changes without hunk actions', function() - local repo_root = create_repo() - local old_filepath = repo_root .. '/file.txt' - local new_filepath = repo_root .. '/renamed.txt' - git_cmd(repo_root, { 'mv', 'file.txt', 'renamed.txt' }) - vim.fn.writefile({ 'line 1', 'line 2 renamed' }, new_filepath) - git_cmd(repo_root, { 'add', 'renamed.txt' }) - mock_runtime_attach(function() end) - - commands.diff_file(new_filepath, { staged = true, old_filepath = old_filepath }) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - local text = buffer_text(diff_buf) - assert.are.equal('diffs://staged:renamed.txt', vim.api.nvim_buf_get_name(diff_buf)) - assert.is_true(text:find('diff --git a/file.txt b/renamed.txt', 1, true) ~= nil) - assert.is_true(text:find('-line 2', 1, true) ~= nil) - assert.is_true(text:find('+line 2 renamed', 1, true) ~= nil) - assert.is_false(has_buf_var(diff_buf, 'diffs_hunks')) - assert.is_false(helpers.has_keymap(diff_buf, 'do')) - assert.is_false(helpers.has_keymap(diff_buf, 'dp')) - assert.are.same({ - version = 1, - kind = 'file_pair', - repo_root = repo_root, - edge = 'staged', - path = 'renamed.txt', - old_path = 'file.txt', - }, vim.api.nvim_buf_get_var(diff_buf, 'diffs_source')) - - local qf = quickfix_items() - assert.are.equal(0, #qf) - - local loc = loclist_items() - assert.are.equal(1, #loc) - assert.are.equal(diff_buf, loc[1].bufnr) - assert.is_true(loc[1].lnum > 1) - assert.is_true(loc[1].text:find('renamed.txt', 1, true) ~= nil) - end) - - it('opens staged and unstaged section headers as explicit section buffers', function() - local repo_root = create_repo() - vim.fn.writefile({ 'line 1', 'line 2 staged' }, repo_root .. '/file.txt') - git_cmd(repo_root, { 'add', 'file.txt' }) - vim.fn.writefile({ 'line 1', 'line 2 staged', 'line 3 unstaged' }, repo_root .. '/file.txt') - mock_runtime_attach(function() end) - - commands.diff_section(repo_root, { staged = true }) - local staged_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, staged_buf) - assert.are.equal('diffs://staged:all', vim.api.nvim_buf_get_name(staged_buf)) - assert.is_true(buffer_text(staged_buf):find('+line 2 staged', 1, true) ~= nil) - assert.is_false(has_buf_var(staged_buf, 'diffs_hunks')) - assert.are.same({ - version = 1, - kind = 'section', - repo_root = repo_root, - section = 'staged', - }, vim.api.nvim_buf_get_var(staged_buf, 'diffs_source')) - - commands.diff_section(repo_root) - local unstaged_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, unstaged_buf) - assert.are.equal('diffs://unstaged:all', vim.api.nvim_buf_get_name(unstaged_buf)) - assert.is_true(buffer_text(unstaged_buf):find('+line 3 unstaged', 1, true) ~= nil) - assert.is_false(has_buf_var(unstaged_buf, 'diffs_hunks')) - assert.are.same({ - version = 1, - kind = 'section', - repo_root = repo_root, - section = 'unstaged', - }, vim.api.nvim_buf_get_var(unstaged_buf, 'diffs_source')) - end) - - it('pins a/ b/ for staged section diffs under diff.noprefix', function() - local repo_root = create_repo() - git_cmd(repo_root, { 'config', 'diff.noprefix', 'true' }) - vim.fn.writefile({ 'line 1', 'line 2 staged' }, repo_root .. '/file.txt') - git_cmd(repo_root, { 'add', 'file.txt' }) - mock_runtime_attach(function() end) - - commands.diff_section(repo_root, { staged = true }) - local staged_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, staged_buf) - assert.is_true( - buffer_text(staged_buf):find('diff --git a/file.txt b/file.txt', 1, true) ~= nil - ) - end) - - it('pins a/ b/ for unstaged section diffs under diff.mnemonicPrefix', function() - local repo_root = create_repo() - git_cmd(repo_root, { 'config', 'diff.mnemonicPrefix', 'true' }) - vim.fn.writefile({ 'line 1', 'line 2 unstaged' }, repo_root .. '/file.txt') - mock_runtime_attach(function() end) - - commands.diff_section(repo_root) - local unstaged_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, unstaged_buf) - assert.is_true( - buffer_text(unstaged_buf):find('diff --git a/file.txt b/file.txt', 1, true) ~= nil - ) - end) - - it('renders an explicit-path object against that path worktree counterpart', function() - local repo_root = create_repo() - write_repo_file(repo_root, 'other.lua', { 'local a = 1', 'return a' }) - git_cmd(repo_root, { 'add', 'other.lua' }) - git_cmd(repo_root, { 'commit', '-qm', 'add other' }) - vim.fn.writefile({ 'local a = 1', 'local b = 2', 'return a' }, repo_root .. '/other.lua') - edit_file(repo_root .. '/file.txt') - mock_runtime_attach(function() end) - - commands.diff_command('HEAD:other.lua', false) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - assert.are.equal('diffs://HEAD:other.lua', vim.api.nvim_buf_get_name(diff_buf)) - assert.are.same( - diffspec.rev_to_worktree('HEAD', 'other.lua'), - vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec - ) - local text = table.concat(buffer_lines(diff_buf), '\n') - assert.is_true(text:find('+local b = 2', 1, true) ~= nil) - end) - - it('renders a read-only merge-stage object against the worktree', function() - local _, filepath = create_conflicted_repo() - edit_file(filepath) - mock_runtime_attach(function() end) - assert.is_true(git.is_unmerged(filepath)) - - commands.diff_command(':2:%', false) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - assert.are.equal('diffs://stage2:file.txt', vim.api.nvim_buf_get_name(diff_buf)) - assert.are.same( - diffspec.file(diffspec.stage(2), diffspec.worktree(), 'file.txt'), - vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec - ) - assert.is_false(helpers.has_keymap(diff_buf, 'dp')) - assert.is_false(helpers.has_keymap(diff_buf, 'do')) - end) - - it('rejects a merge-stage object when the file is not in a conflict', function() - local repo_root = create_repo() - edit_file(repo_root .. '/file.txt') - mock_runtime_attach(function() end) - local notifications = capture_notifications() - - commands.diff_command(':2:%', false) - - local rejected = false - for _, n in ipairs(notifications) do - if tostring(n.message):find('is not in a merge conflict', 1, true) then - rejected = true - end - end - assert.is_true(rejected) - end) - - it('opens unmerged files with conflict handling disabled', function() - local _, filepath = create_conflicted_repo() - edit_file(filepath) - mock_runtime_attach(function() end) - mock_conflict_config(false) - - commands.diff(nil, false) - - local bufnr = vim.api.nvim_get_current_buf() - table.insert(test_buffers, bufnr) - assert.is_false(helpers.has_keymap(bufnr, 'go')) - end) - - it('routes direct :Diff on unmerged files to the generated unmerged view', function() - local repo_root, filepath = create_conflicted_repo() - local source_buf = edit_file(filepath) - local source_win = vim.api.nvim_get_current_win() - mock_runtime_attach(function() end) - mock_conflict_config(true) - - assert.is_true(git.is_unmerged(filepath)) - - commands.diff(nil, false) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - - local function assert_unmerged_view(bufnr, expected_rail_style) - local lines = buffer_lines(bufnr) - local text = table.concat(lines, '\n') - - assert.are.equal('diffs://unmerged:file.txt', vim.api.nvim_buf_get_name(bufnr)) - assert.are.equal(repo_root, vim.api.nvim_buf_get_var(bufnr, 'diffs_source').repo_root) - assert.are.same({ - version = 1, - kind = 'unmerged', - repo_root = repo_root, - path = 'file.txt', - working_path = filepath, - }, vim.api.nvim_buf_get_var(bufnr, 'diffs_source')) - assert.are.equal(filepath, vim.api.nvim_buf_get_var(bufnr, 'diffs_source').working_path) - assert.is_nil(vim.api.nvim_buf_get_var(bufnr, 'diffs_source').spec) - assert.is_false(has_buf_var(bufnr, 'diffs_hunks')) - assert.are.equal('diff --git a/file.txt b/file.txt', lines[1]) - assert.is_true(text:find('-line 2 theirs', 1, true) ~= nil) - assert.is_true(text:find('+line 2 ours', 1, true) ~= nil) - assert.is_true(text:find('--- /dev/null', 1, true) == nil) - assert.is_true(text:find('new file mode', 1, true) == nil) - assert.is_true(text:find('<<<<<<<', 1, true) == nil) - assert.is_false(helpers.has_keymap(bufnr, 'go')) - assert.is_false(helpers.has_keymap(bufnr, 'gt')) - if expected_rail_style then - assert.are.equal(expected_rail_style, vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_style')) - end - end - - assert_unmerged_view(diff_buf) - - vim.api.nvim_set_option_value('modifiable', true, { buf = diff_buf }) - vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, { 'stale unmerged content' }) - vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf }) - - commands.read_buffer(diff_buf) - - assert_unmerged_view(diff_buf) - helpers.delete_buffer(diff_buf) - - vim.api.nvim_set_current_win(source_win) - commands.diff('++layout=stacked', false) - - local stacked_unmerged_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, stacked_unmerged_buf) - assert_unmerged_view(stacked_unmerged_buf, 'single') - - vim.api.nvim_set_option_value('modifiable', true, { buf = stacked_unmerged_buf }) - vim.api.nvim_buf_set_lines(stacked_unmerged_buf, 0, -1, false, { 'stale unmerged content' }) - vim.api.nvim_set_option_value('modifiable', false, { buf = stacked_unmerged_buf }) - - commands.read_buffer(stacked_unmerged_buf) - - assert_unmerged_view(stacked_unmerged_buf, 'single') - helpers.delete_buffer(stacked_unmerged_buf) - - for _, object in ipairs({ ':%', ':0:%' }) do - vim.api.nvim_set_current_win(source_win) - commands.diff(object, false) - - local explicit_index_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, explicit_index_buf) - assert_unmerged_view(explicit_index_buf) - helpers.delete_buffer(explicit_index_buf) - end - - vim.api.nvim_set_current_win(source_win) - commands.diff('HEAD', false) - - local revision_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, revision_buf) - local revision_text = buffer_text(revision_buf) - - assert.are.equal('diffs://HEAD:file.txt', vim.api.nvim_buf_get_name(revision_buf)) - assert.are.same( - diffspec.rev_to_worktree('HEAD', 'file.txt'), - vim.api.nvim_buf_get_var(revision_buf, 'diffs_source').spec - ) - assert.are.same({ - version = 1, - kind = 'file', - repo_root = repo_root, - spec = diffspec.rev_to_worktree('HEAD', 'file.txt'), - worktree_buf = source_buf, - }, vim.api.nvim_buf_get_var(revision_buf, 'diffs_source')) - assert.is_true(revision_text:find('+<<<<<<<', 1, true) ~= nil) - end) - end) - - describe('diff_file DiffSpec metadata', function() - it('marks fugitive-style staged file diffs as HEAD to index buffers', function() - mock_git_method('get_relative_path', function(filepath) - if filepath == '/tmp/repo/lua/foo.lua' then - return 'lua/foo.lua' - end - return nil - end) - mock_git_method('get_file_content', function(revision, filepath) - assert.are.equal('HEAD', revision) - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return { 'local M = {}', 'return M' } - end) - mock_git_method('get_index_content', function(filepath) - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return { 'local M = {}', 'local x = 1', 'return M' } - end) - mock_repo_root(function(filepath) - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return '/tmp/repo' - end) - mock_runtime_attach(function() end) - - commands.diff_file('/tmp/repo/lua/foo.lua', { staged = true }) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - - assert.are.equal('diffs://staged:lua/foo.lua', vim.api.nvim_buf_get_name(diff_buf)) - assert.are.same( - diffspec.head_to_index('lua/foo.lua'), - vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec - ) - local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal('index', diff_hunks[1].mutation_target) - assert.is_false(diff_hunks[1].can_put) - assert.is_true(diff_hunks[1].can_obtain) - assert.is_true(helpers.has_keymap(diff_buf, 'do')) - assert.is_false(helpers.has_keymap(diff_buf, 'dp')) - end) - - it('marks fugitive-style unstaged file diffs as index to worktree buffers', function() - mock_git_method('get_relative_path', function(filepath) - if filepath == '/tmp/repo/lua/foo.lua' then - return 'lua/foo.lua' - end - return nil - end) - mock_git_method('get_index_content', function(filepath) - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return { 'local M = {}', 'return M' } - end) - mock_git_method('get_working_content', function(filepath) - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return { 'local M = {}', 'local x = 1', 'return M' } - end) - mock_repo_root(function(filepath) - assert.are.equal('/tmp/repo/lua/foo.lua', filepath) - return '/tmp/repo' - end) - mock_runtime_attach(function() end) - - commands.diff_file('/tmp/repo/lua/foo.lua') - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - - assert.are.equal('diffs://unstaged:lua/foo.lua', vim.api.nvim_buf_get_name(diff_buf)) - assert.are.same( - diffspec.index_to_worktree('lua/foo.lua'), - vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec - ) - local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal('worktree', diff_hunks[1].mutation_target) - assert.is_true(diff_hunks[1].can_put) - assert.is_false(diff_hunks[1].can_obtain) - assert.is_false(helpers.has_keymap(diff_buf, 'do')) - assert.is_true(helpers.has_keymap(diff_buf, 'dp')) - end) - - it('marks fugitive-style untracked file diffs as index to worktree buffers', function() - mock_git_method('get_relative_path', function(filepath) - if filepath == '/tmp/repo/lua/new.lua' then - return 'lua/new.lua' - end - return nil - end) - mock_git_method('get_index_content', function(filepath) - assert.are.equal('/tmp/repo/lua/new.lua', filepath) - return nil, 'file not in index' - end) - mock_git_method('get_working_content', function(filepath) - assert.are.equal('/tmp/repo/lua/new.lua', filepath) - return { 'local M = {}', 'return M' } - end) - mock_repo_root(function(filepath) - assert.are.equal('/tmp/repo/lua/new.lua', filepath) - return '/tmp/repo' - end) - mock_runtime_attach(function() end) - - commands.diff_file('/tmp/repo/lua/new.lua', { untracked = true }) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - - assert.are.equal('diffs://untracked:lua/new.lua', vim.api.nvim_buf_get_name(diff_buf)) - assert.are.same( - diffspec.index_to_worktree('lua/new.lua'), - vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec - ) - local text = buffer_text(diff_buf) - assert.is_true(text:find('new file mode 100644', 1, true) ~= nil) - assert.is_true(text:find('--- /dev/null', 1, true) ~= nil) - local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal('worktree', diff_hunks[1].mutation_target) - assert.is_true(diff_hunks[1].can_put) - assert.is_false(diff_hunks[1].can_obtain) - assert.is_true(diff_hunks[1].actionable) - assert.is_false(helpers.has_keymap(diff_buf, 'do')) - assert.is_true(helpers.has_keymap(diff_buf, 'dp')) - end) - - it('marks fugitive-style staged additions as HEAD to index buffers', function() - mock_git_method('get_relative_path', function(filepath) - if filepath == '/tmp/repo/lua/new.lua' then - return 'lua/new.lua' - end - return nil - end) - mock_git_method('get_file_content', function(revision, filepath) - assert.are.equal('HEAD', revision) - assert.are.equal('/tmp/repo/lua/new.lua', filepath) - return nil, 'file not in revision: HEAD' - end) - mock_git_method('get_index_content', function(filepath) - assert.are.equal('/tmp/repo/lua/new.lua', filepath) - return { 'local M = {}', 'return M' } - end) - mock_repo_root(function(filepath) - assert.are.equal('/tmp/repo/lua/new.lua', filepath) - return '/tmp/repo' - end) - mock_runtime_attach(function() end) - - commands.diff_file('/tmp/repo/lua/new.lua', { staged = true }) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - - assert.are.equal('diffs://staged:lua/new.lua', vim.api.nvim_buf_get_name(diff_buf)) - assert.are.same( - diffspec.head_to_index('lua/new.lua'), - vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec - ) - local text = buffer_text(diff_buf) - assert.is_true(text:find('new file mode 100644', 1, true) ~= nil) - assert.is_true(text:find('--- /dev/null', 1, true) ~= nil) - local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal('index', diff_hunks[1].mutation_target) - assert.is_false(diff_hunks[1].can_put) - assert.is_true(diff_hunks[1].can_obtain) - assert.is_true(diff_hunks[1].actionable) - assert.is_true(helpers.has_keymap(diff_buf, 'do')) - assert.is_false(helpers.has_keymap(diff_buf, 'dp')) - end) - - it('marks fugitive-style staged deletions as HEAD to index buffers', function() - mock_git_method('get_relative_path', function(filepath) - if filepath == '/tmp/repo/lua/deleted.lua' then - return 'lua/deleted.lua' - end - return nil - end) - mock_git_method('get_file_content', function(revision, filepath) - assert.are.equal('HEAD', revision) - assert.are.equal('/tmp/repo/lua/deleted.lua', filepath) - return { 'local M = {}', 'return M' } - end) - mock_git_method('get_index_content', function(filepath) - assert.are.equal('/tmp/repo/lua/deleted.lua', filepath) - return nil, 'file not in index' - end) - mock_repo_root(function(filepath) - assert.are.equal('/tmp/repo/lua/deleted.lua', filepath) - return '/tmp/repo' - end) - mock_runtime_attach(function() end) - - commands.diff_file('/tmp/repo/lua/deleted.lua', { staged = true }) - - local diff_buf = vim.api.nvim_get_current_buf() - table.insert(test_buffers, diff_buf) - - assert.are.equal('diffs://staged:lua/deleted.lua', vim.api.nvim_buf_get_name(diff_buf)) - assert.are.same( - diffspec.head_to_index('lua/deleted.lua'), - vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec - ) - local text = buffer_text(diff_buf) - assert.is_true(text:find('deleted file mode 100644', 1, true) ~= nil) - assert.is_true(text:find('+++ /dev/null', 1, true) ~= nil) - local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal('index', diff_hunks[1].mutation_target) - assert.is_false(diff_hunks[1].can_put) - assert.is_true(diff_hunks[1].can_obtain) - assert.is_true(diff_hunks[1].actionable) - assert.is_true(helpers.has_keymap(diff_buf, 'do')) - assert.is_false(helpers.has_keymap(diff_buf, 'dp')) - end) - end) - - describe('generated buffer lifecycle matrix', function() - local section_lines = { - 'diff --git a/lua/section.lua b/lua/section.lua', - '--- a/lua/section.lua', - '+++ b/lua/section.lua', - '@@ -1 +1 @@', - '-old section', - '+new section', - } - - local review_lines = { - 'diff --git a/lua/review.lua b/lua/review.lua', - '--- a/lua/review.lua', - '+++ b/lua/review.lua', - '@@ -1 +1 @@', - '-old review', - '+new review', - } - - local file_content = { - ['/tmp/repo/lua/unstaged.lua'] = { - index = { 'local M = {}', 'return M' }, - worktree = { 'local M = {}', 'local unstaged = true', 'return M' }, - }, - ['/tmp/repo/lua/staged.lua'] = { - head = { 'local M = {}', 'return M' }, - index = { 'local M = {}', 'local staged = true', 'return M' }, - }, - ['/tmp/repo/lua/new.lua'] = { - worktree = { 'local M = {}', 'local new_file = true', 'return M' }, - }, - } - - local function contains_cmd_arg(cmd, arg) - for _, value in ipairs(cmd) do - if value == arg then - return true - end - end - return false - end - - local function install_lifecycle_mocks() - mock_git_method('get_relative_path', function(filepath) - return filepath:match('^/tmp/repo/(.+)$') - end) - mock_repo_root(function() - return '/tmp/repo' - end) - mock_git_method('get_file_content', function(revision, filepath) - local entry = file_content[filepath] - if not entry or not entry.head then - return nil, 'file not in revision: ' .. revision - end - return entry.head - end) - mock_git_method('get_index_content', function(filepath) - local entry = file_content[filepath] - if not entry or not entry.index then - return nil, 'file not in index' - end - return entry.index - end) - mock_git_method('get_working_content', function(filepath) - local entry = file_content[filepath] - if not entry or not entry.worktree then - return nil, 'file not readable' - end - return entry.worktree - end) - mock_git_method('get_tree_mode', function() - return '100644' - end) - mock_git_method('get_index_mode', function(filepath) - local entry = file_content[filepath] - return entry and entry.index and '100644' or nil - end) - mock_git_method('get_working_mode', function(filepath) - local entry = file_content[filepath] - return entry and entry.worktree and '100644' or nil - end) - mock_systemlist(function(cmd) - if cmd[4] == 'rev-parse' then - return { 'commit' } - end - if cmd[4] == 'merge-base' then - return { 'merge-base-commit' } - end - if - contains_cmd_arg(cmd, '--name-status') - or contains_cmd_arg(cmd, '--numstat') - or contains_cmd_arg(cmd, '--raw') - then - return {} - end - if contains_cmd_arg(cmd, 'origin/main...refs/forge/pr/42') then - return review_lines - end - return section_lines - end) - mock_runtime_attach(function() end) - end - - local function set_stale_content(bufnr) - vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr }) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale lifecycle content' }) - vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) - end - - local function assert_generated_options(bufnr) - assert.are.equal('nowrite', vim.api.nvim_get_option_value('buftype', { buf = bufnr })) - assert.are.equal('delete', vim.api.nvim_get_option_value('bufhidden', { buf = bufnr })) - assert.is_false(vim.api.nvim_get_option_value('swapfile', { buf = bufnr })) - assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = bufnr })) - assert.are.equal('diff', vim.api.nvim_get_option_value('filetype', { buf = bufnr })) - end - - local function assert_no_hunk_maps(bufnr) - assert.is_false(helpers.has_keymap(bufnr, ']c')) - assert.is_false(helpers.has_keymap(bufnr, '[c')) - assert.is_false(helpers.has_keymap(bufnr, '')) - assert.is_false(helpers.has_keymap(bufnr, 'o')) - assert.is_false(helpers.has_keymap(bufnr, 'do')) - assert.is_false(helpers.has_keymap(bufnr, 'dp')) - assert.is_false(helpers.has_keymap(bufnr, 'do', 'x')) - assert.is_false(helpers.has_keymap(bufnr, 'dp', 'x')) - end - - local function assert_hunk_maps(bufnr, hunk) - assert.is_true(helpers.has_keymap(bufnr, ']c')) - assert.is_true(helpers.has_keymap(bufnr, '[c')) - assert.is_true(helpers.has_keymap(bufnr, '')) - assert.is_false(helpers.has_keymap(bufnr, 'o')) - assert.are.equal(hunk.can_obtain, helpers.has_keymap(bufnr, 'do')) - assert.are.equal(hunk.can_put, helpers.has_keymap(bufnr, 'dp')) - assert.are.equal(hunk.can_obtain, helpers.has_keymap(bufnr, 'do', 'x')) - assert.are.equal(hunk.can_put, helpers.has_keymap(bufnr, 'dp', 'x')) - end - - local function assert_lifecycle_state(bufnr, case) - assert.are.equal(case.buffer_name, vim.api.nvim_buf_get_name(bufnr)) - assert.are.equal('/tmp/repo', vim.api.nvim_buf_get_var(bufnr, 'diffs_source').repo_root) - assert_generated_options(bufnr) - - if case.diff_spec then - assert.are.same(case.diff_spec, vim.api.nvim_buf_get_var(bufnr, 'diffs_source').spec) - else - assert.is_nil(vim.api.nvim_buf_get_var(bufnr, 'diffs_source').spec) - end - - assert.are.same(case.source, vim.api.nvim_buf_get_var(bufnr, 'diffs_source')) - - if case.hunk then - local diff_hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal(case.hunk.file, diff_hunks[1].file) - assert.are.equal(case.hunk.mutation_target, diff_hunks[1].mutation_target) - assert.are.equal(case.hunk.can_put, diff_hunks[1].can_put) - assert.are.equal(case.hunk.can_obtain, diff_hunks[1].can_obtain) - assert.are.equal(case.hunk.can_put or case.hunk.can_obtain, diff_hunks[1].actionable) - assert_hunk_maps(bufnr, case.hunk) - else - assert.is_false(has_buf_var(bufnr, 'diffs_hunks')) - assert_no_hunk_maps(bufnr) - end - - local lines = buffer_lines(bufnr) - assert.are.equal(case.first_line, lines[1]) - assert.is_false(vim.tbl_contains(lines, 'stale lifecycle content')) - - local qf = quickfix_items() - if case.review then - assert.is_true(#qf >= 1, case.label) - assert.are.equal(bufnr, qf[1].bufnr, case.label) - assert.are.equal(1, qf[1].lnum, case.label) - else - assert.are.equal(0, #qf, case.label) - end - - local loc = loclist_items() - assert.is_true(#loc >= 1, case.label) - assert.are.equal(bufnr, loc[1].bufnr, case.label) - assert.is_true(loc[1].lnum > 1, case.label) - end - - it('preserves generated buffer metadata and action keymaps across reloads', function() - install_lifecycle_mocks() - - local cases = { - { - label = 'unstaged file', - open = function() - commands.diff_file('/tmp/repo/lua/unstaged.lua') - end, - buffer_name = 'diffs://unstaged:lua/unstaged.lua', - diff_spec = diffspec.index_to_worktree('lua/unstaged.lua'), - source = { - version = 1, - kind = 'file', - repo_root = '/tmp/repo', - spec = diffspec.index_to_worktree('lua/unstaged.lua'), - }, - hunk = { - file = 'lua/unstaged.lua', - mutation_target = 'worktree', - can_put = true, - can_obtain = false, - }, - first_line = 'diff --git a/lua/unstaged.lua b/lua/unstaged.lua', - }, - { - label = 'staged file', - open = function() - commands.diff_file('/tmp/repo/lua/staged.lua', { staged = true }) - end, - buffer_name = 'diffs://staged:lua/staged.lua', - diff_spec = diffspec.head_to_index('lua/staged.lua'), - source = { - version = 1, - kind = 'file', - repo_root = '/tmp/repo', - spec = diffspec.head_to_index('lua/staged.lua'), - }, - hunk = { - file = 'lua/staged.lua', - mutation_target = 'index', - can_put = false, - can_obtain = true, - }, - first_line = 'diff --git a/lua/staged.lua b/lua/staged.lua', - }, - { - label = 'untracked file', - open = function() - commands.diff_file('/tmp/repo/lua/new.lua', { untracked = true }) - end, - buffer_name = 'diffs://untracked:lua/new.lua', - diff_spec = diffspec.index_to_worktree('lua/new.lua'), - source = { - version = 1, - kind = 'file', - repo_root = '/tmp/repo', - spec = diffspec.index_to_worktree('lua/new.lua'), - }, - hunk = { - file = 'lua/new.lua', - mutation_target = 'worktree', - can_put = true, - can_obtain = false, - }, - first_line = 'diff --git a/lua/new.lua b/lua/new.lua', - }, - { - label = 'unstaged section', - open = function() - commands.diff_section('/tmp/repo') - end, - buffer_name = 'diffs://unstaged:all', - source = { - version = 1, - kind = 'section', - repo_root = '/tmp/repo', - section = 'unstaged', - }, - first_line = 'diff --git a/lua/section.lua b/lua/section.lua', - }, - { - label = 'review', - open = function() - commands.review({ - base = 'origin/main', - target = 'refs/forge/pr/42', - mode = 'merge-base', - repo = '/tmp/repo', - }) - end, - buffer_name = 'diffs://review:origin/main...refs/forge/pr/42', - source = { - version = 1, - kind = 'review', - repo_root = '/tmp/repo', - review = { - base = 'origin/main', - target = 'refs/forge/pr/42', - mode = 'merge-base', - untracked = true, - }, - }, - review = { - base = 'origin/main', - target = 'refs/forge/pr/42', - mode = 'merge-base', - }, - first_line = 'diff --git a/lua/review.lua b/lua/review.lua', - }, - } - - for _, case in ipairs(cases) do - case.open() - local bufnr = vim.api.nvim_get_current_buf() - table.insert(test_buffers, bufnr) - - assert_lifecycle_state(bufnr, case) - set_stale_content(bufnr) - - assert.has_no.errors(function() - commands.read_buffer(bufnr) - end, case.label) - assert_lifecycle_state(bufnr, case) - end - end) - end) - - describe('review helpers', function() - it('normalizes default base inside the resolved repo', function() - local captured_cmds = {} - mock_repo_root(function(path) - assert.are.equal('/tmp/repo/.', path) - return '/tmp/repo' - end) - mock_systemlist(function(cmd) - captured_cmds[#captured_cmds + 1] = cmd - if cmd[4] == 'symbolic-ref' then - return { 'refs/remotes/origin/main' } - end - if cmd[4] == 'rev-parse' then - return { 'commit' } - end - return {} - end) - - local review = commands._test.normalize_review({ repo = '/tmp/repo' }) - - assert.are.equal('/tmp/repo', review.repo_root) - assert.are.equal('origin/main', review.base) - assert.are.equal('origin/main', review.display) - assert.are.same( - { 'git', '-C', '/tmp/repo', 'symbolic-ref', 'refs/remotes/origin/HEAD' }, - captured_cmds[1] - ) - assert.are.equal(1, #captured_cmds) - end) - - it('completes target refs after merge-base separator', function() - mock_repo_root(function() - return '/tmp/repo' - end) - mock_systemlist(function(cmd) - assert.are.same({ - 'git', - '-C', - '/tmp/repo', - 'for-each-ref', - '--format=%(refname:short)', - 'refs/heads/', - 'refs/remotes/', - 'refs/tags/', - }, cmd) - return { 'origin/main', 'refs/forge/pr/42', 'refs/forge/pr/43' } - end) - - local matches = commands._test.complete_review('origin/main...refs/forge/pr/4') - - assert.are.same({ - 'origin/main...refs/forge/pr/42', - 'origin/main...refs/forge/pr/43', - }, matches) - end) - - it('completes target refs after direct separator', function() - mock_repo_root(function() - return '/tmp/repo' - end) - mock_systemlist(function() - return { 'origin/main', 'feature/a', 'feature/b' } - end) - - local matches = commands._test.complete_review('origin/main..feature/') - - assert.are.same({ 'origin/main..feature/a', 'origin/main..feature/b' }, matches) - end) - - it('completes :Diff review layout options only before a review spec', function() - mock_repo_root(function() - return '/tmp/repo' - end) - mock_systemlist(function() - return { 'origin/main', 'feature/topic', '++topic' } - end) - - assert.are.same({ - '++layout=unified', - '++layout=stacked', - '++layout=split', - '++nountracked', - 'origin/main', - 'feature/topic', - '++topic', - }, commands._test.complete_diff('', 'Diff review ', #'Diff review ')) - assert.are.same( - { - '++nountracked', - 'origin/main', - 'feature/topic', - '++topic', - }, - commands._test.complete_diff( - '', - 'Diff review ++layout=split ', - #'Diff review ++layout=split ' - ) - ) - assert.are.same( - { 'origin/main', 'feature/topic', '++topic' }, - commands._test.complete_diff('', 'Diff review origin/main ', #'Diff review origin/main ') - ) - assert.are.same( - { 'origin/main' }, - commands._test.complete_diff('origin/', 'Diff review origin/', #'Diff review origin/') - ) - assert.are.same( - { 'origin/main' }, - commands._test.complete_diff( - 'origin/', - 'belowright Diff review origin/', - #'belowright Diff review origin/' - ) - ) - assert.are.same({ - '++layout=unified', - '++layout=stacked', - '++layout=split', - '++nountracked', - '++topic', - }, commands._test.complete_diff('++', 'Diff review ++', #'Diff review ++')) - assert.are.same( - { '++nountracked', '++topic' }, - commands._test.complete_diff( - '++', - 'Diff review ++layout=split ++', - #'Diff review ++layout=split ++' - ) - ) - end) - end) - - describe('review', function() - it('reports Git errors from the operation without preflight ref checks', function() - for _, case in ipairs({ - { - base = 'missing/ref', - operation = 'merge-base', - message = 'fatal: Not a valid object name missing/ref', - }, - { - base = 'HEAD', - target = 'missing/target', - operation = 'diff', - message = "fatal: bad revision 'missing/target'", - }, - { - base = 'HEAD', - target = 'other', - operation = 'diff', - message = 'fatal: no merge base found', - }, - }) do - local called = {} - local notifications = capture_notifications() - mock_repo_root(function() - return '/tmp/repo' - end) - mock_systemlist(function(cmd) - called[#called + 1] = cmd[4] - return { case.message }, 128 - end) - - local bufnr = commands.review({ - base = case.base, - target = case.target, - repo = '/tmp/repo', - }) - - assert.is_nil(bufnr) - assert.are.same({ case.operation }, called) - assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level) - assert.is_true(notifications[#notifications].message:find(case.message, 1, true) ~= nil) - restore_mocks() - end - end) - - it( - 'passes invalid revision arguments to Git without treating them as paths or flags', - function() - local repo_root = create_repo() - local notifications = capture_notifications() - for _, target in ipairs({ 'file.txt', '--stat' }) do - local bufnr = commands.review({ - base = 'HEAD', - target = target, - mode = 'direct', - repo = repo_root, - }) - assert.is_nil(bufnr) - assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level) - assert.is_true(notifications[#notifications].message:find(target, 1, true) ~= nil) - end - end - ) - - it('reports successful Git warnings without inserting them into review buffers', function() - mock_repo_root(function() - return '/tmp/repo' - end) - mock_runtime_attach(function() end) - local notifications = capture_notifications() - mock_systemlist(function() - return { - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1 +1 @@', - '-old', - '+new', - }, - 0, - { 'warning: multiple merge bases' } - end) - local bufnr = commands.review({ base = 'main', target = 'topic', repo = '/tmp/repo' }) - assert.is_not_nil(bufnr) - test_buffers[#test_buffers + 1] = bufnr - assert.is_nil(buffer_text(bufnr):find('warning:', 1, true)) - assert.is_true(buffer_text(bufnr):find('+new', 1, true) ~= nil) - assert.are.equal(vim.log.levels.WARN, notifications[1].level) - assert.is_true(notifications[1].message:find('warning: multiple merge bases', 1, true) ~= nil) - end) - - it('opens a review buffer for an explicit merge-base target', function() - local captured_cmd - mock_repo_root(function(path) - assert.are.equal('/tmp/repo/.', path) - return '/tmp/repo' - end) - mock_systemlist(function(cmd) - if cmd[4] == 'rev-parse' then - return { 'commit' } - end - if cmd[4] == 'merge-base' then - return { 'merge-base-commit' } - end - if cmd[4] ~= 'diff' then - return {} - end - captured_cmd = cmd - return { - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1 +1 @@', - '-old', - '+new', - } - end) - mock_runtime_attach(function() end) - - local bufnr = commands.review({ - base = 'origin/main', - target = 'refs/forge/pr/42', - mode = 'merge-base', - repo = '/tmp/repo', - }) - table.insert(test_buffers, bufnr) - - assert.are.same({ - 'git', - '-C', - '/tmp/repo', - 'diff', - '--no-ext-diff', - '--no-color', - '--src-prefix=a/', - '--dst-prefix=b/', - '--end-of-options', - 'origin/main...refs/forge/pr/42', - '--', - }, captured_cmd) - assert.are.equal( - 'diffs://review:origin/main...refs/forge/pr/42', - vim.api.nvim_buf_get_name(bufnr) - ) - end) - - it('initializes generated review metadata before diff FileType attach', function() - local attach_records = {} - saved_schedule = vim.schedule - vim.schedule = function(callback) - callback() - end - - local group = vim.api.nvim_create_augroup('DiffsGeneratedReviewFileTypeRegression', { - clear = true, - }) - vim.api.nvim_create_autocmd('FileType', { - pattern = 'diff', - group = group, - callback = function(args) - runtime.attach(args.buf) - local rail_ok, rail_width = pcall(vim.api.nvim_buf_get_var, args.buf, 'diffs_rail_width') - local entry = runtime._test.hunk_cache[args.buf] - attach_records[#attach_records + 1] = { - name = vim.api.nvim_buf_get_name(args.buf), - rail_width = rail_ok and rail_width or nil, - hunk_count = entry and #entry.hunks or nil, - } - end, - }) - - local repo = create_review_repo({ named_refs = true }) - local ok, bufnr = pcall(function() - return commands.review({ - base = repo.base, - target = repo.target, - mode = 'merge-base', - repo = repo.repo_root, - }) - end) - pcall(vim.api.nvim_del_augroup_by_id, group) - - assert.is_true(ok) - assert.is_not_nil(bufnr) - table.insert(test_buffers, bufnr) - - local first_attach = attach_records[1] - assert.is_not_nil(first_attach) - assert.are.equal('diffs://review:review-base...review-topic', first_attach.name) - assert.is_number(first_attach.rail_width) - assert.is_true(first_attach.rail_width > 0) - assert.is_number(first_attach.hunk_count) - assert.is_true(first_attach.hunk_count > 0) - end) - - it('renders current-state reviews as stacked sections', function() - local repo = create_current_state_review_repo() - mock_runtime_attach(function() end) - - local bufnr = commands.review({ - base = repo.base, - repo = repo.repo_root, - }) - assert.is_not_nil(bufnr) - table.insert(test_buffers, bufnr) - - local text = buffer_text(bufnr) - assert.is_true(text:find('# Branch:', 1, true) ~= nil) - assert.is_true(text:find('# Staged:', 1, true) ~= nil) - assert.is_true(text:find('# Unstaged:', 1, true) ~= nil) - assert.is_true(text:find('# Untracked:', 1, true) ~= nil) - assert.is_true(text:find('+branch head', 1, true) ~= nil) - assert.is_true(text:find('+dup staged', 1, true) ~= nil) - assert.is_true(text:find('+dup unstaged', 1, true) ~= nil) - assert.is_true(text:find('+new file', 1, true) ~= nil) - - local qf = quickfix_items() - assert.are.equal(7, #qf) - assert.are.equal('lua/branch.lua', vim.trim(qf[1].module)) - assert.are.equal('lua/dup.lua', vim.trim(qf[2].module)) - assert.are.equal('lua/dup.lua', vim.trim(qf[3].module)) - assert.are.equal('lua/staged.lua', vim.trim(qf[4].module)) - assert.are.equal('lua/dup.lua', vim.trim(qf[5].module)) - assert.are.equal('lua/unstaged.lua', vim.trim(qf[6].module)) - assert.are.equal('lua/new.lua', vim.trim(qf[7].module)) - - assert.are.equal('branch:lua/dup.lua', qf[2].user_data.diffs.key) - assert.are.equal('staged:lua/dup.lua', qf[3].user_data.diffs.key) - assert.are.equal('unstaged:lua/dup.lua', qf[5].user_data.diffs.key) - end) - - it('omits the untracked section when spec.untracked is false', function() - local repo = create_current_state_review_repo() - mock_runtime_attach(function() end) - - local bufnr = commands.review({ - base = repo.base, - repo = repo.repo_root, - untracked = false, - }) - assert.is_not_nil(bufnr) - table.insert(test_buffers, bufnr) - - local text = buffer_text(bufnr) - assert.is_true(text:find('# Branch:', 1, true) ~= nil) - assert.is_true(text:find('# Staged:', 1, true) ~= nil) - assert.is_true(text:find('# Unstaged:', 1, true) ~= nil) - assert.is_nil(text:find('# Untracked:', 1, true)) - assert.is_nil(text:find('+new file', 1, true)) - - local qf = quickfix_items() - assert.are.equal(6, #qf) - for _, item in ipairs(qf) do - assert.is_nil(item.text:find('[Untracked]', 1, true)) - end - end) - - it('preserves untracked=false across reload', function() - local repo = create_current_state_review_repo() - mock_runtime_attach(function() end) - - local bufnr = commands.review({ - base = repo.base, - repo = repo.repo_root, - untracked = false, - }) - assert.is_not_nil(bufnr) - table.insert(test_buffers, bufnr) - - commands.read_buffer(bufnr) - - local text = buffer_text(bufnr) - assert.is_nil(text:find('# Untracked:', 1, true)) - end) - - it('renders worktree sections when diff.mnemonicPrefix is enabled', function() - local repo = create_current_state_review_repo({ mnemonic_prefix = true }) - mock_runtime_attach(function() end) - - local bufnr = commands.review({ - base = repo.base, - repo = repo.repo_root, - }) - assert.is_not_nil(bufnr) - table.insert(test_buffers, bufnr) - - local text = buffer_text(bufnr) - assert.is_true(text:find('# Staged:', 1, true) ~= nil) - assert.is_true(text:find('# Unstaged:', 1, true) ~= nil) - assert.is_true(text:find('+staged changed', 1, true) ~= nil) - assert.is_true(text:find('+unstaged changed', 1, true) ~= nil) - - local qf = quickfix_items() - assert.are.equal(7, #qf) - assert.are.equal('lua/dup.lua', vim.trim(qf[3].module)) - assert.are.equal('lua/staged.lua', vim.trim(qf[4].module)) - assert.are.equal('lua/dup.lua', vim.trim(qf[5].module)) - assert.are.equal('lua/unstaged.lua', vim.trim(qf[6].module)) - end) - - it('renders worktree sections when diff.noprefix is enabled', function() - local repo = create_current_state_review_repo({ noprefix = true }) - mock_runtime_attach(function() end) - - local bufnr = commands.review({ - base = repo.base, - repo = repo.repo_root, - }) - assert.is_not_nil(bufnr) - table.insert(test_buffers, bufnr) - - local text = buffer_text(bufnr) - assert.is_true(text:find('# Staged:', 1, true) ~= nil) - assert.is_true(text:find('# Unstaged:', 1, true) ~= nil) - assert.is_true(text:find('+staged changed', 1, true) ~= nil) - assert.is_true(text:find('+unstaged changed', 1, true) ~= nil) - - local qf = quickfix_items() - assert.are.equal(7, #qf) - assert.are.equal('lua/dup.lua', vim.trim(qf[3].module)) - assert.are.equal('lua/staged.lua', vim.trim(qf[4].module)) - assert.are.equal('lua/dup.lua', vim.trim(qf[5].module)) - assert.are.equal('lua/unstaged.lua', vim.trim(qf[6].module)) - end) - - it('opens review stacked layout as a single generated review map with single rails', function() - local repo = create_current_state_review_repo() - edit_file(repo.repo_root .. '/lua/branch.lua') - mock_runtime_attach(function() end) - mock_view_config({ prefix = true }) - - local bufnr = commands.review_command(('++layout=stacked %s'):format(repo.base)) - assert.is_not_nil(bufnr) - table.insert(test_buffers, bufnr) - - local display_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local text = buffer_text(bufnr) - - assert.are.equal('diffs://review:' .. repo.base, vim.api.nvim_buf_get_name(bufnr)) - assert.are.equal('single', vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_style')) - assert.are.equal('single', rails.style_for_buffer(bufnr)) - assert.are.same({ display = repo.base, layout = 'stacked' }, vim.b[bufnr].diffs_review) - assert.is_true(helpers.has_keymap(bufnr, 'gs')) - assert.is_true(display_lines[1]:find(' │ # Branch:', 1, true) ~= nil) - assert.is_true(text:find('# Branch:', 1, true) ~= nil) - assert.is_true(text:find('# Staged:', 1, true) ~= nil) - assert.is_true(text:find('# Unstaged:', 1, true) ~= nil) - assert.is_true(text:find('# Untracked:', 1, true) ~= nil) - assert.is_true(text:find('+branch head', 1, true) ~= nil) - assert.is_nil(commands._test.review_split_state(bufnr)) - - local qf = quickfix_items() - assert.are.equal(7, #qf) - assert.are.equal(bufnr, qf[1].bufnr) - assert.are.equal('branch:lua/branch.lua', qf[1].user_data.diffs.key) - assert.are.equal('staged:lua/dup.lua', qf[3].user_data.diffs.key) - assert.are.equal('unstaged:lua/dup.lua', qf[5].user_data.diffs.key) - - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - local name = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win)) - assert.is_false(name:match('^diffs://review%-split:') ~= nil) - assert.is_false(name:match('^diffs://review%-target:') ~= nil) - end - end) - - local function main_windows() - local wins = {} - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - local buf = vim.api.nvim_win_get_buf(win) - if vim.api.nvim_get_option_value('buftype', { buf = buf }) ~= 'quickfix' then - wins[#wins + 1] = win - end - end - return wins - end - - local function visible_review_map() - for _, win in ipairs(main_windows()) do - local name = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win)) - if name:match('^diffs://review:') then - return win - end - end - return nil - end - - local function track_panes(left_buf) - assert.is_not_nil(left_buf) - table.insert(test_buffers, left_buf) - local right_buf = vim.api.nvim_buf_get_var(left_buf, 'diffs_split_peer') - table.insert(test_buffers, right_buf) - local state = commands._test.review_split_state(left_buf) - assert.is_not_nil(state) - assert.are.equal(left_buf, state.left_buf) - assert.are.equal(right_buf, state.right_buf) - return { - state = state, - left_buf = left_buf, - right_buf = right_buf, - left_win = state.left_win, - right_win = state.right_win, - } - end - - local function run_buf_keymap(bufnr, lhs) - for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, 'n')) do - if keymap.lhs == lhs and keymap.callback then - keymap.callback() - return - end - end - error('missing buffer keymap: ' .. lhs) - end - - local function assert_target_at_hunk(panes, hunk_index) - local hunk = loclist_items(panes.right_win)[hunk_index] - assert.is_not_nil(hunk) - local left_row = vim.api.nvim_win_get_cursor(panes.left_win)[1] - local right_row = vim.api.nvim_win_get_cursor(panes.right_win)[1] - assert.are.equal(left_row, right_row) - assert.is_true(right_row >= 1) - end - - local function create_binary_first_review_repo() - local repo_root = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(repo_root, 'p') - test_repos[#test_repos + 1] = repo_root - - vim.fn.systemlist({ 'git', 'init', '-q', repo_root }) - assert.are.equal(0, vim.v.shell_error) - git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' }) - git_cmd(repo_root, { 'config', 'user.name', 'Test' }) - - write_binary_file(repo_root .. '/aaa-bin.dat', 'binary\\000old') - write_repo_file(repo_root, 'zzz-changed.lua', { 'old' }) - git_cmd(repo_root, { 'add', 'aaa-bin.dat', 'zzz-changed.lua' }) - git_cmd(repo_root, { 'commit', '-qm', 'base' }) - git_cmd(repo_root, { 'branch', 'binary-first-base' }) - - git_cmd(repo_root, { 'checkout', '-qb', 'binary-first-topic' }) - write_binary_file(repo_root .. '/aaa-bin.dat', 'binary\\000new') - write_repo_file(repo_root, 'zzz-changed.lua', { 'new' }) - git_cmd(repo_root, { 'add', 'aaa-bin.dat', 'zzz-changed.lua' }) - git_cmd(repo_root, { 'commit', '-qm', 'binary and content' }) - - return repo_root - end - - local function create_binary_review_repo(opts) - opts = opts or {} - local repo_root = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(repo_root, 'p') - test_repos[#test_repos + 1] = repo_root - - vim.fn.systemlist({ 'git', 'init', '-q', repo_root }) - assert.are.equal(0, vim.v.shell_error) - git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' }) - git_cmd(repo_root, { 'config', 'user.name', 'Test' }) - - write_repo_file(repo_root, 'aaa-one.lua', { 'old one' }) - write_binary_file(repo_root .. '/bbb-bin.dat', 'binary\\000old') - if opts.trailing ~= false then - write_repo_file(repo_root, 'ccc-two.lua', { 'old two' }) - git_cmd(repo_root, { 'add', 'aaa-one.lua', 'bbb-bin.dat', 'ccc-two.lua' }) - else - git_cmd(repo_root, { 'add', 'aaa-one.lua', 'bbb-bin.dat' }) - end - git_cmd(repo_root, { 'commit', '-qm', 'base' }) - git_cmd(repo_root, { 'branch', 'binary-base' }) - - git_cmd(repo_root, { 'checkout', '-qb', 'binary-topic' }) - write_repo_file(repo_root, 'aaa-one.lua', { 'new one' }) - write_binary_file(repo_root .. '/bbb-bin.dat', 'binary\\000new') - if opts.trailing ~= false then - write_repo_file(repo_root, 'ccc-two.lua', { 'new two' }) - git_cmd(repo_root, { 'add', 'aaa-one.lua', 'bbb-bin.dat', 'ccc-two.lua' }) - else - git_cmd(repo_root, { 'add', 'aaa-one.lua', 'bbb-bin.dat' }) - end - git_cmd(repo_root, { 'commit', '-qm', 'target' }) - - return repo_root - end - - it('opens review layout split as exactly two visible surfaces', function() - local repo_root = create_repo() - vim.fn.writefile({ 'line 1', 'line 2 changed' }, repo_root .. '/file.txt') - edit_file(repo_root .. '/file.txt') - mock_runtime_attach(function() end) - - local left_buf = commands.review_command('++layout=split HEAD') - local panes = track_panes(left_buf) - - assert.are.equal( - 'diffs://split:left:index:file.txt', - vim.api.nvim_buf_get_name(panes.left_buf) - ) - assert.are.equal( - 'diffs://split:right:worktree:file.txt', - vim.api.nvim_buf_get_name(panes.right_buf) - ) - assert.are.same( - diffspec.index_to_worktree('file.txt'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.same( - diffspec.index_to_worktree('file.txt'), - vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec - ) - assert.are.equal('left', vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').side) - assert.are.equal('right', vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').side) - assert.are.same( - { 'line 1', 'line 2' }, - vim.api.nvim_buf_get_lines(panes.left_buf, 0, -1, false) - ) - assert.are.same( - { 'line 1', 'line 2 changed' }, - vim.api.nvim_buf_get_lines(panes.right_buf, 0, -1, false) - ) - assert.are.equal(2, #main_windows()) - assert.is_nil(visible_review_map()) - assert.is_true(helpers.has_keymap(panes.left_buf, 'gs')) - assert.is_true(helpers.has_keymap(panes.right_buf, 'gs')) - assert.is_false(vim.api.nvim_get_option_value('diff', { win = panes.left_win })) - assert.is_false(vim.api.nvim_get_option_value('diff', { win = panes.right_win })) - assert.is_true(vim.api.nvim_get_option_value('scrollbind', { win = panes.left_win })) - assert.is_true(vim.api.nvim_get_option_value('scrollbind', { win = panes.right_win })) - - assert.are.equal(0, #quickfix_items()) - - local loc = loclist_items(panes.left_win) - assert.are.equal(1, #loc) - assert.are.equal(panes.left_buf, loc[1].bufnr) - assert.is_true(loc[1].text:find('file.txt', 1, true) ~= nil) - end) - - it('excludes untracked files from the split file list with ++nountracked', function() - local repo = create_current_state_review_repo() - edit_file(repo.repo_root .. '/lua/branch.lua') - mock_runtime_attach(function() end) - - local left_buf = commands.review_command('++layout=split ++nountracked ' .. repo.base) - assert.is_not_nil(left_buf) - local panes = track_panes(left_buf) - - local files = commands.review_files(panes.left_buf) - local paths = {} - for _, file in ipairs(files) do - paths[file.path] = true - end - assert.is_nil(paths['lua/new.lua']) - assert.is_true(paths['lua/staged.lua']) - assert.is_true(paths['lua/unstaged.lua']) - end) - - it('warns and still opens the workspace for :vertical Diff review ++layout=split', function() - local repo_root = create_repo() - vim.fn.writefile({ 'line 1', 'line 2 changed' }, repo_root .. '/file.txt') - edit_file(repo_root .. '/file.txt') - mock_runtime_attach(function() end) - local notifications = capture_notifications() - - local left_buf = - commands.review_command('++layout=split HEAD', true, { warn_vertical_split = true }) - track_panes(left_buf) - - local warned = false - for _, n in ipairs(notifications) do - if tostring(n.message):find('++layout=split ignores the :vertical modifier', 1, true) then - warned = true - end - end - assert.is_true(warned) - assert.are.equal(2, #main_windows()) - end) - - it('replaces the visible review buffer even when another window is current', function() - local repo = create_review_repo() - mock_runtime_attach(function() end) - - local review_buf = commands.review({ - base = repo.base, - target = repo.target, - mode = 'direct', - repo = repo.repo_root, - }) - assert.is_not_nil(review_buf) - table.insert(test_buffers, review_buf) - local review_win = find_window_for_buffer(review_buf) - assert.is_not_nil(review_win) - - vim.cmd('rightbelow vsplit') - local source_buf = edit_file(repo.repo_root .. '/lua/one.lua') - local source_win = vim.api.nvim_get_current_win() - - local left_buf = commands.review_split({ bufnr = review_buf }) - local panes = track_panes(left_buf) - - assert.are.equal(review_win, panes.left_win) - assert.are.equal(source_buf, vim.api.nvim_win_get_buf(source_win)) - assert.is_nil(find_window_for_buffer(review_buf)) - assert.is_not_nil(find_window_for_buffer(panes.left_buf)) - assert.is_not_nil(find_window_for_buffer(panes.right_buf)) - end) - - it('warns instead of splitting when the selected review buffer is hidden', function() - local repo = create_review_repo() - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - local review_buf = commands.review({ - base = repo.base, - target = repo.target, - mode = 'direct', - repo = repo.repo_root, - }) - assert.is_not_nil(review_buf) - table.insert(test_buffers, review_buf) - vim.api.nvim_set_option_value('bufhidden', 'hide', { buf = review_buf }) - - local source_buf = edit_file(repo.repo_root .. '/lua/one.lua') - local source_win = vim.api.nvim_get_current_win() - - local opened = commands.review_split({ bufnr = review_buf }) - - assert.is_nil(opened) - assert.are.equal(source_buf, vim.api.nvim_win_get_buf(source_win)) - assert.is_true( - notifications[#notifications].message:find('selected review buffer is not visible', 1, true) - ~= nil - ) - end) - - it('toggles a selected stacked review file to split and back to the review map', function() - local repo = create_review_repo() - edit_file(repo.repo_root .. '/lua/one.lua') - mock_runtime_attach(function() end) - - local review_buf = commands.review({ - base = repo.base, - target = repo.target, - mode = 'direct', - repo = repo.repo_root, - }, { - rail_style = 'single', - review_layout = 'stacked', - }) - assert.is_not_nil(review_buf) - table.insert(test_buffers, review_buf) - local review_win = find_window_for_buffer(review_buf) - assert.is_not_nil(review_win) - local line = find_buffer_line(review_buf, 'diff --git a/lua/two.lua b/lua/two.lua') - assert.is_not_nil(line) - vim.api.nvim_set_current_win(review_win) - vim.api.nvim_win_set_cursor(review_win, { line, 0 }) - - local left_buf = commands.review_toggle_layout() - local panes = track_panes(left_buf) - - assert.are.equal('lua/two.lua', panes.state.selected_file) - assert.are.equal('stacked', panes.state.map_layout) - assert.is_nil(visible_review_map()) - assert.is_true(helpers.has_keymap(panes.left_buf, 'gs')) - assert.is_true(helpers.has_keymap(panes.right_buf, 'gs')) - - vim.api.nvim_set_current_win(panes.right_win) - local map_buf = commands.review_toggle_layout() - assert.is_not_nil(map_buf) - table.insert(test_buffers, map_buf) - local map_win = find_window_for_buffer(map_buf) - assert.is_not_nil(map_win) - local cursor_line = vim.api.nvim_win_get_cursor(map_win)[1] - local cursor_text = buffer_lines(map_buf)[cursor_line] - local loc = loclist_items(map_win) - - assert.is_false(vim.api.nvim_buf_is_valid(panes.left_buf)) - assert.is_false(vim.api.nvim_buf_is_valid(panes.right_buf)) - assert.are.equal('single', vim.api.nvim_buf_get_var(map_buf, 'diffs_rail_style')) - assert.are.same( - { display = repo.base .. '..' .. repo.target, layout = 'stacked' }, - vim.b[map_buf].diffs_review - ) - assert.is_true(helpers.has_keymap(map_buf, 'gs')) - assert.is_true(cursor_text:find('+line 2 changed', 1, true) ~= nil) - assert.is_true(#loc >= 1) - assert.are.equal(map_buf, loc[1].bufnr) - assert.is_true(loc[1].text:find('lua/two.lua', 1, true) ~= nil) - end) - - it('toggles a direct split review back to a unified review map', function() - local repo_root = create_repo() - vim.fn.writefile({ 'line 1', 'line 2 changed' }, repo_root .. '/file.txt') - edit_file(repo_root .. '/file.txt') - mock_runtime_attach(function() end) - - local left_buf = commands.review_command('++layout=split HEAD') - local panes = track_panes(left_buf) - - vim.api.nvim_set_current_win(panes.right_win) - local map_buf = commands.review_toggle_layout() - assert.is_not_nil(map_buf) - table.insert(test_buffers, map_buf) - - assert.is_false(vim.api.nvim_buf_is_valid(panes.left_buf)) - assert.is_false(vim.api.nvim_buf_is_valid(panes.right_buf)) - assert.are.equal('dual', vim.api.nvim_buf_get_var(map_buf, 'diffs_rail_style')) - assert.are.same({ display = 'HEAD', layout = 'unified' }, vim.b[map_buf].diffs_review) - assert.is_true(buffer_text(map_buf):find('diff --git a/file.txt b/file.txt', 1, true) ~= nil) - end) - - local function create_block_review_repo() - local repo_root = create_repo() - write_repo_file(repo_root, 'lua/block.lua', { - 'one', - 'two', - 'three', - 'four', - 'five', - 'six', - }) - git_cmd(repo_root, { 'add', 'lua/block.lua' }) - git_cmd(repo_root, { 'commit', '-qm', 'block' }) - write_repo_file(repo_root, 'lua/block.lua', { - 'one', - 'two changed', - 'three changed', - 'five', - 'six', - }) - return repo_root - end - - ---@param repo_root string - ---@param text string - ---@return integer, integer, integer - local function toggle_from_review_line(repo_root, text) - edit_file(repo_root .. '/lua/block.lua') - mock_runtime_attach(function() end) - - local review_buf = commands.review({ base = 'HEAD', repo = repo_root }) - assert.is_not_nil(review_buf) - table.insert(test_buffers, review_buf) - local review_win = find_window_for_buffer(review_buf) - assert.is_not_nil(review_win) - local review_line = find_buffer_line(review_buf, text) - assert.is_not_nil(review_line) - vim.api.nvim_set_current_win(review_win) - vim.api.nvim_win_set_cursor(review_win, { review_line, 0 }) - - local panes = track_panes(commands.review_toggle_layout()) - vim.api.nvim_set_current_win(panes.right_win) - local split_row = vim.api.nvim_win_get_cursor(panes.right_win)[1] - - local map_buf = commands.review_toggle_layout() - assert.is_not_nil(map_buf) - table.insert(test_buffers, map_buf) - local map_win = find_window_for_buffer(map_buf) - assert.is_not_nil(map_win) - - return review_line, split_row, vim.api.nvim_win_get_cursor(map_win)[1] - end - - it('opens the split at the selected review line and returns to it', function() - local repo_root = create_block_review_repo() - - local review_line, split_row, returned_line = - toggle_from_review_line(repo_root, '+three changed') - - assert.are.equal(3, split_row) - assert.are.equal(review_line, returned_line) - end) - - it('returns to a deleted review line from the filler row facing it', function() - local repo_root = create_block_review_repo() - - local review_line, _, returned_line = toggle_from_review_line(repo_root, '-four') - - assert.are.equal(review_line, returned_line) - end) - - it('settles on the replacing line when a removed line selected the split', function() - local repo_root = create_block_review_repo() - - local first_line, _, second_line = toggle_from_review_line(repo_root, '-two') - local third_line = select(3, toggle_from_review_line(repo_root, '+two changed')) - - assert.are_not.equal(first_line, second_line) - assert.are.equal(second_line, third_line) - end) - - it('returns to the review line the split cursor moved to', function() - local repo_root = create_block_review_repo() - edit_file(repo_root .. '/lua/block.lua') - mock_runtime_attach(function() end) - - local review_buf = commands.review({ base = 'HEAD', repo = repo_root }) - assert.is_not_nil(review_buf) - table.insert(test_buffers, review_buf) - local review_win = find_window_for_buffer(review_buf) - vim.api.nvim_set_current_win(review_win) - vim.api.nvim_win_set_cursor(review_win, { find_buffer_line(review_buf, '+two changed'), 0 }) - - local panes = track_panes(commands.review_toggle_layout()) - vim.api.nvim_set_current_win(panes.right_win) - local moved_row = nil - for lnum, line in ipairs(vim.api.nvim_buf_get_lines(panes.right_buf, 0, -1, false)) do - if line == 'six' then - moved_row = lnum - end - end - assert.is_not_nil(moved_row) - vim.api.nvim_win_set_cursor(panes.right_win, { moved_row, 0 }) - - local map_buf = commands.review_toggle_layout() - assert.is_not_nil(map_buf) - table.insert(test_buffers, map_buf) - local map_win = find_window_for_buffer(map_buf) - local returned_line = vim.api.nvim_win_get_cursor(map_win)[1] - - assert.are.equal(find_buffer_line(map_buf, ' six'), returned_line) - end) - - it('preserves review keys through quickfix jumps and layout toggles', function() - local repo = create_current_state_review_repo() - edit_file(repo.repo_root .. '/lua/dup.lua') - mock_runtime_attach(function() end) - - local review_buf = commands.review({ - base = repo.base, - repo = repo.repo_root, - }) - assert.is_not_nil(review_buf) - table.insert(test_buffers, review_buf) - local review_win = find_window_for_buffer(review_buf) - assert.is_not_nil(review_win) - vim.api.nvim_set_current_win(review_win) - vim.cmd('copen') - local qf_win = vim.api.nvim_get_current_win() - local qf_buf = vim.api.nvim_get_current_buf() - vim.api.nvim_win_set_cursor(qf_win, { 5, 0 }) - run_buf_keymap(qf_buf, '') - assert.are.equal(review_win, vim.api.nvim_get_current_win()) - - local left_buf = commands.review_toggle_layout() - local panes = track_panes(left_buf) - assert.are.equal('unstaged:lua/dup.lua', panes.state.selected_key) - assert.are.equal(5, vim.fn.getqflist({ idx = 0 }).idx) - local wins = main_windows() - - for _, row in ipairs({ 3, 5, 5 }) do - vim.api.nvim_set_current_win(qf_win) - vim.api.nvim_win_set_cursor(qf_win, { row, 0 }) - run_buf_keymap(qf_buf, '') - panes = track_panes(panes.state.left_buf) - local item = quickfix_items()[row] - assert.are.equal(item.user_data.diffs.key, panes.state.selected_key) - assert.are.same(item.user_data.diffs.diff_spec, vim.b[panes.right_buf].diffs_source.spec) - assert.are.equal(panes.right_win, vim.api.nvim_get_current_win()) - assert.are.equal(row, vim.fn.getqflist({ idx = 0 }).idx) - assert.are.same(wins, main_windows()) - assert.is_nil(visible_review_map()) - end - - commands.review_prev_file() - panes = track_panes(panes.state.left_buf) - assert.are.equal(4, vim.fn.getqflist({ idx = 0 }).idx) - assert.are.equal(4, vim.api.nvim_win_get_cursor(qf_win)[1]) - commands.review_next_file() - panes = track_panes(panes.state.left_buf) - assert.are.equal(5, vim.fn.getqflist({ idx = 0 }).idx) - assert.are.equal(5, vim.api.nvim_win_get_cursor(qf_win)[1]) - - vim.api.nvim_set_current_win(panes.left_win) - local map_buf = commands.review_toggle_layout() - assert.is_not_nil(map_buf) - table.insert(test_buffers, map_buf) - local map_win = find_window_for_buffer(map_buf) - assert.is_not_nil(map_win) - local expected_line = find_buffer_line_after(map_buf, '# Unstaged:', '-dup staged') - - assert.are.equal(expected_line, vim.api.nvim_win_get_cursor(map_win)[1]) - end) - - it('reports nil when toggling outside a review surface', function() - local repo_root = create_repo() - local bufnr = edit_file(repo_root .. '/file.txt') - local notifications = capture_notifications() - - local opened = commands.review_toggle_layout() - - assert.is_nil(opened) - assert.are.equal(bufnr, vim.api.nvim_get_current_buf()) - assert.is_true( - notifications[#notifications].message:find('generated diff file index', 1, true) ~= nil - ) - end) - - it('does not fall back to another diffs window for an invalid replacement window', function() - local repo = create_review_repo() - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - local opened = commands.review({ - base = repo.base, - target = repo.target, - mode = 'direct', - repo = repo.repo_root, - }, { - replace_win = 999999, - }) - - assert.is_nil(opened) - assert.is_true( - notifications[#notifications].message:find('replacement diff window', 1, true) ~= nil - ) - end) - - it('closes an existing review split workspace before opening another spec', function() - local repo = create_review_repo() - mock_repo_root(function() - return repo.repo_root - end) - mock_runtime_attach(function() end) - - local first = - commands.review_command(('++layout=split %s..%s'):format(repo.base, repo.target)) - local first_panes = track_panes(first) - local first_left = first_panes.left_buf - local first_right = first_panes.right_buf - - local second = - commands.review_command(('++layout=split %s...%s'):format(repo.base, repo.target)) - local second_panes = track_panes(second) - - assert.is_false(vim.api.nvim_buf_is_valid(first_left)) - assert.is_false(vim.api.nvim_buf_is_valid(first_right)) - assert.is_nil(commands._test.review_split_state(first_left)) - assert.is_not_nil(commands._test.review_split_state(second)) - assert.are.equal(2, #main_windows()) - assert.is_not_nil(find_window_for_buffer(second_panes.left_buf)) - assert.is_not_nil(find_window_for_buffer(second_panes.right_buf)) - end) - - it('skips unsupported default review entries when opening direct split workspaces', function() - local repo_root = create_binary_first_review_repo() - edit_file(repo_root .. '/zzz-changed.lua') - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - local left_buf = - commands.review_command('++layout=split binary-first-base..binary-first-topic') - local panes = track_panes(left_buf) - - assert.are.same( - diffspec.rev_to_rev('binary-first-base', 'binary-first-topic', 'zzz-changed.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.same( - diffspec.rev_to_rev('binary-first-base', 'binary-first-topic', 'zzz-changed.lua'), - vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec - ) - assert.are.same({ 'old' }, vim.api.nvim_buf_get_lines(panes.left_buf, 0, -1, false)) - assert.are.same({ 'new' }, vim.api.nvim_buf_get_lines(panes.right_buf, 0, -1, false)) - assert.are.equal( - '[diffs]: review skipped 1 file(s): aaa-bin.dat\n[diffs]: (2 of 2): zzz-changed.lua', - notifications[1].message - ) - end) - - it('marks skipped review files in the quickfix file index', function() - local repo_root = create_binary_review_repo() - mock_runtime_attach(function() end) - - local bufnr = commands.review({ - base = 'binary-base', - target = 'binary-topic', - mode = 'direct', - repo = repo_root, - }) - assert.is_not_nil(bufnr) - table.insert(test_buffers, bufnr) - - local qf = quickfix_items() - assert.are.equal(3, #qf) - assert.are.equal('aaa-one.lua', vim.trim(qf[1].module)) - assert.are.equal('+1 -1', qf[1].text) - assert.are.equal('bbb-bin.dat (skipped)', vim.trim(qf[2].module)) - assert.are.equal('ccc-two.lua', vim.trim(qf[3].module)) - assert.are.equal('+1 -1', qf[3].text) - assert.is_false(qf[1].user_data.diffs.skipped) - assert.is_true(qf[2].user_data.diffs.skipped) - assert.is_false(qf[3].user_data.diffs.skipped) - end) - - it('routes current-state duplicate paths by section in the two-surface workspace', function() - local repo = create_current_state_review_repo() - mock_runtime_attach(function() end) - - local function open_section(header, expected_spec) - local review_buf = commands.review({ - base = repo.base, - repo = repo.repo_root, - }) - assert.is_not_nil(review_buf) - table.insert(test_buffers, review_buf) - - local line = - find_buffer_line_after(review_buf, header, 'diff --git a/lua/dup.lua b/lua/dup.lua') - assert.is_not_nil(line) - vim.api.nvim_win_set_cursor(0, { line, 0 }) - - local left_buf = commands.review_split({ bufnr = review_buf }) - local panes = track_panes(left_buf) - assert.are.same( - expected_spec, - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.same( - expected_spec, - vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec - ) - assert.is_not_nil(find_window_for_buffer(panes.left_buf)) - assert.is_not_nil(find_window_for_buffer(panes.right_buf)) - assert.is_nil(find_window_for_buffer(review_buf)) - assert.is_nil(visible_review_map()) - split.close_pair(panes.left_buf) - end - - open_section('# Branch:', diffspec.rev_to_rev(repo.base, 'HEAD', 'lua/dup.lua')) - open_section('# Staged:', diffspec.head_to_index('lua/dup.lua')) - open_section('# Unstaged:', diffspec.index_to_worktree('lua/dup.lua')) - end) - - it('uses ]q/[q and ]f/[f to switch review files and loclist for active-file hunks', function() - local repo = create_review_repo() - edit_file(repo.repo_root .. '/lua/one.lua') - mock_runtime_attach(function() end) - - local left_buf = - commands.review_command(('++layout=split %s..%s'):format(repo.base, repo.target)) - local panes = track_panes(left_buf) - assert.are.same( - diffspec.rev_to_rev(repo.base, repo.target, 'lua/one.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - - assert.are.equal(0, #quickfix_items()) - assert.is_true(helpers.has_keymap(panes.left_buf, ']f')) - assert.is_true(helpers.has_keymap(panes.right_buf, ']f')) - assert.is_true(helpers.has_keymap(panes.left_buf, '[f')) - assert.is_true(helpers.has_keymap(panes.right_buf, '[f')) - assert.is_true(helpers.has_keymap(panes.left_buf, ']q')) - assert.is_true(helpers.has_keymap(panes.right_buf, ']q')) - assert.is_true(helpers.has_keymap(panes.left_buf, '[q')) - assert.is_true(helpers.has_keymap(panes.right_buf, '[q')) - - vim.api.nvim_set_current_win(panes.right_win) - commands.review_next_file() - panes = track_panes(panes.state.left_buf) - assert.are.same( - diffspec.rev_to_rev(repo.base, repo.target, 'lua/two.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.same( - diffspec.rev_to_rev(repo.base, repo.target, 'lua/two.lua'), - vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec - ) - assert.is_true(buffer_text(panes.right_buf):find('line 11 changed', 1, true) ~= nil) - assert.are.equal(0, #quickfix_items()) - - vim.api.nvim_set_current_win(panes.right_win) - commands.review_next_file() - panes = track_panes(panes.state.left_buf) - assert.are.same( - diffspec.rev_to_rev(repo.base, repo.target, 'lua/one.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - - vim.api.nvim_set_current_win(panes.right_win) - commands.review_prev_file() - panes = track_panes(panes.state.left_buf) - assert.are.same( - diffspec.rev_to_rev(repo.base, repo.target, 'lua/two.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - - assert_target_at_hunk(panes, 1) - - vim.api.nvim_set_current_win(panes.left_win) - vim.cmd('lopen') - local loc_win = find_loclist_window_for_window(panes.left_win) - assert.is_not_nil(loc_win) - local loc = loclist_items(panes.left_win) - assert.are.equal(2, #loc) - assert.are.equal(panes.left_buf, loc[1].bufnr) - local rloc = loclist_items(panes.right_win) - assert.are.equal(2, #rloc) - assert.are.equal(panes.right_buf, rloc[1].bufnr) - vim.cmd('lclose') - - vim.api.nvim_set_current_win(panes.right_win) - run_buf_keymap(panes.right_buf, ']c') - assert_target_at_hunk(panes, 2) - run_buf_keymap(panes.right_buf, '[c') - assert_target_at_hunk(panes, 1) - end) - - it('skips unsupported files when switching review files', function() - local repo_root = create_binary_review_repo() - edit_file(repo_root .. '/aaa-one.lua') - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - local left_buf = commands.review_command('++layout=split binary-base..binary-topic') - local panes = track_panes(left_buf) - assert.are.same( - diffspec.rev_to_rev('binary-base', 'binary-topic', 'aaa-one.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - - vim.api.nvim_set_current_win(panes.right_win) - commands.review_next_file() - panes = track_panes(panes.state.left_buf) - assert.are.same( - diffspec.rev_to_rev('binary-base', 'binary-topic', 'ccc-two.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.equal(vim.log.levels.INFO, notifications[#notifications].level) - assert.are.equal( - '[diffs]: review skipped 1 file(s): bbb-bin.dat\n[diffs]: (3 of 3): ccc-two.lua', - notifications[#notifications].message - ) - - vim.api.nvim_set_current_win(panes.right_win) - commands.review_prev_file() - panes = track_panes(panes.state.left_buf) - assert.are.same( - diffspec.rev_to_rev('binary-base', 'binary-topic', 'aaa-one.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.equal(vim.log.levels.INFO, notifications[#notifications].level) - assert.are.equal( - '[diffs]: review skipped 1 file(s): bbb-bin.dat\n[diffs]: (1 of 3): aaa-one.lua', - notifications[#notifications].message - ) - end) - - it('keeps the skipped-file message when no candidate switches', function() - local repo_root = create_binary_review_repo({ trailing = false }) - edit_file(repo_root .. '/aaa-one.lua') - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - local left_buf = commands.review_command('++layout=split binary-base..binary-topic') - local panes = track_panes(left_buf) - assert.are.same( - diffspec.rev_to_rev('binary-base', 'binary-topic', 'aaa-one.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - - vim.api.nvim_set_current_win(panes.right_win) - commands.review_next_file() - panes = track_panes(panes.state.left_buf) - - assert.are.same( - diffspec.rev_to_rev('binary-base', 'binary-topic', 'aaa-one.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.equal(1, #notifications) - assert.are.equal(vim.log.levels.INFO, notifications[1].level) - assert.are.equal('[diffs]: review skipped 1 file(s): bbb-bin.dat', notifications[1].message) - end) - - it('marks skipped review files for the API and picker', function() - local repo_root = create_binary_review_repo() - edit_file(repo_root .. '/aaa-one.lua') - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - local left_buf = commands.review_command('++layout=split binary-base..binary-topic') - local panes = track_panes(left_buf) - local files = commands.review_files(panes.left_buf) - assert.are.equal(3, #files) - assert.is_false(files[1].skipped) - assert.is_true(files[2].skipped) - assert.is_false(files[3].skipped) - - local formatted = {} - local original = vim.ui.select - vim.ui.select = function(items, opts, on_choice) - formatted[1] = opts.format_item(items[1]) - formatted[2] = opts.format_item(items[2]) - formatted[3] = opts.format_item(items[3]) - on_choice(items[2]) - end - local ok, err = pcall(commands.select_review_file, panes.left_buf) - vim.ui.select = original - assert.is_true(ok, err) - - assert.is_true(formatted[1]:match('^aaa%-one%.lua%s+%+1%s+%-1$') ~= nil) - assert.are.equal('bbb-bin.dat (skipped)', formatted[2]) - assert.is_true(formatted[3]:match('^ccc%-two%.lua%s+%+1%s+%-1$') ~= nil) - assert.are.same( - diffspec.rev_to_rev('binary-base', 'binary-topic', 'aaa-one.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.equal(1, #notifications) - assert.are.equal('[diffs]: review skipped 1 file(s): bbb-bin.dat', notifications[1].message) - end) - - it('exposes review_files/current/goto, the gO map, and the b:diffs_review marker', function() - local repo = create_review_repo() - edit_file(repo.repo_root .. '/lua/one.lua') - mock_runtime_attach(function() end) - - local left_buf = - commands.review_command(('++layout=split %s..%s'):format(repo.base, repo.target)) - local panes = track_panes(left_buf) - - for _, buf in ipairs({ panes.left_buf, panes.right_buf }) do - local marker = vim.b[buf].diffs_review - assert.is_table(marker) - assert.are.equal('split', marker.layout) - assert.is_true(helpers.has_keymap(buf, 'gO')) - assert.is_true(helpers.has_keymap(buf, 'gs')) - end - - local files = commands.review_files(panes.left_buf) - assert.are.equal(2, #files) - assert.are.equal('lua/one.lua', files[1].path) - assert.are.equal('lua/two.lua', files[2].path) - assert.is_string(files[1].key) - assert.is_number(files[1].added) - assert.is_number(files[1].removed) - - local current = commands.review_current(panes.left_buf) - assert.are.equal(1, current.index) - assert.are.equal(2, current.count) - assert.are.equal('lua/one.lua', current.file.path) - - assert.is_true(commands.review_goto(files[2].key, panes.left_buf)) - panes = track_panes(panes.state.left_buf) - assert.are.same( - diffspec.rev_to_rev(repo.base, repo.target, 'lua/two.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.equal(2, commands.review_current(panes.left_buf).index) - - assert.is_false(commands.review_goto('does/not/exist', panes.left_buf)) - local scratch = vim.api.nvim_create_buf(false, true) - table.insert(test_buffers, scratch) - assert.is_nil(commands.review_files(scratch)) - assert.is_false(commands.review_goto(files[1].key, scratch)) - end) - - it('navigates repeated paths by their review section', function() - local repo = create_current_state_review_repo() - edit_file(repo.repo_root .. '/lua/dup.lua') - mock_runtime_attach(function() end) - - local panes = track_panes(commands.review_command('++layout=split ' .. repo.base)) - local files = commands.review_files(panes.right_buf) - for index, file in ipairs(files) do - assert.are.equal(index, commands.review_current(panes.right_buf).index) - assert.are.equal(file.key, panes.state.selected_key) - vim.api.nvim_set_current_win(panes.right_win) - commands.review_next_file() - panes = track_panes(panes.state.left_buf) - end - assert.are.equal(1, commands.review_current(panes.right_buf).index) - - for _, key in ipairs({ 'branch:lua/dup.lua', 'staged:lua/dup.lua', 'unstaged:lua/dup.lua' }) do - assert.is_true(commands.review_goto(key, panes.right_buf)) - panes = track_panes(panes.state.left_buf) - assert.are.equal(key, commands.review_current(panes.right_buf).file.key) - end - end) - - it('select_review_file jumps to the chosen file via vim.ui.select', function() - local repo = create_review_repo() - edit_file(repo.repo_root .. '/lua/one.lua') - mock_runtime_attach(function() end) - - local left_buf = - commands.review_command(('++layout=split %s..%s'):format(repo.base, repo.target)) - local panes = track_panes(left_buf) - - local original = vim.ui.select - vim.ui.select = function(items, _, on_choice) - on_choice(items[2]) - end - local ok, err = pcall(commands.select_review_file, panes.left_buf) - vim.ui.select = original - assert.is_true(ok, err) - - panes = track_panes(panes.state.left_buf) - assert.are.same( - diffspec.rev_to_rev(repo.base, repo.target, 'lua/two.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - end) - - it('navigates loclist hunks without switching the active review file', function() - local repo = create_review_repo() - edit_file(repo.repo_root .. '/lua/one.lua') - mock_runtime_attach(function() end) - - local left_buf = - commands.review_command(('++layout=split %s..%s'):format(repo.base, repo.target)) - local panes = track_panes(left_buf) - assert.are.equal('lua/one.lua', panes.state.selected_file) - - vim.api.nvim_set_current_win(panes.left_win) - vim.cmd('lopen') - local loc_win = find_loclist_window_for_window(panes.left_win) - assert.is_not_nil(loc_win) - local loc = loclist_items(panes.left_win) - assert.is_true(#loc >= 1) - - vim.api.nvim_set_current_win(loc_win) - vim.api.nvim_win_set_cursor(loc_win, { #loc, 0 }) - local ok = pcall(function() - vim.cmd('normal \r') - end) - vim.wait(50) - - assert.is_true(ok) - assert.are.equal('lua/one.lua', panes.state.selected_file) - assert.is_true(vim.api.nvim_buf_is_valid(panes.left_buf)) - assert.is_true(vim.api.nvim_buf_is_valid(panes.right_buf)) - end) - - it('closes both review split surfaces from the generated diff buffer', function() - local repo_root = create_repo() - vim.fn.writefile({ 'line 1', 'line 2 changed' }, repo_root .. '/file.txt') - edit_file(repo_root .. '/file.txt') - mock_runtime_attach(function() end) - - local left_buf = commands.review_command('++layout=split HEAD') - local panes = track_panes(left_buf) - - vim.api.nvim_set_current_win(panes.right_win) - run_buf_keymap(panes.right_buf, 'q') - - assert.is_false(vim.api.nvim_buf_is_valid(panes.left_buf)) - assert.is_false(vim.api.nvim_buf_is_valid(panes.right_buf)) - assert.is_nil(commands._test.review_split_state(panes.left_buf)) - assert.is_nil(commands._test.review_split_state(panes.right_buf)) - end) - - it('skips binary untracked files in current-state reviews', function() - local repo_root = create_repo() - write_repo_file(repo_root, 'lua/new.lua', { 'new text' }) - write_binary_file(repo_root .. '/bin.dat', 'binary\\000new') - mock_runtime_attach(function() end) - - local bufnr = commands.review({ - base = 'HEAD', - repo = repo_root, - }) - assert.is_not_nil(bufnr) - table.insert(test_buffers, bufnr) - - local text = buffer_text(bufnr) - assert.is_true(text:find('# Untracked:', 1, true) ~= nil) - assert.is_true(text:find('diff --git a/lua/new.lua b/lua/new.lua', 1, true) ~= nil) - assert.is_true(text:find('+new text', 1, true) ~= nil) - assert.is_false(text:find('bin.dat', 1, true) ~= nil) - - local qf = quickfix_items() - assert.are.equal(1, #qf) - assert.are.equal('lua/new.lua', vim.trim(qf[1].module)) - end) - - it('routes current-state conflict reviews to read-only unmerged target views', function() - local repo_root = create_conflicted_repo() - mock_runtime_attach(function() end) - - local bufnr = commands.review({ - base = 'HEAD', - repo = repo_root, - }) - assert.is_not_nil(bufnr) - table.insert(test_buffers, bufnr) - - local text = buffer_text(bufnr) - assert.is_false(text:find('# Staged:', 1, true) ~= nil) - assert.is_true(text:find('# Unstaged:', 1, true) ~= nil) - assert.is_true(text:find('-line 2 theirs', 1, true) ~= nil) - assert.is_true(text:find('+line 2 ours', 1, true) ~= nil) - - local qf = quickfix_items() - assert.are.equal(1, #qf) - assert.are.equal('unstaged', qf[1].user_data.diffs.section) - assert.are.same(diffspec.rev_to_rev(':2', ':3', 'file.txt'), qf[1].user_data.diffs.diff_spec) - - local header_line = find_buffer_line(bufnr, '# Unstaged:') - assert.is_not_nil(header_line) - vim.api.nvim_win_set_cursor(0, { header_line, 0 }) - local left_buf = commands.review_split({ bufnr = bufnr }) - local panes = track_panes(left_buf) - - assert.are.same( - diffspec.rev_to_rev(':2', ':3', 'file.txt'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.same( - diffspec.rev_to_rev(':2', ':3', 'file.txt'), - vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec - ) - assert.is_true(buffer_text(panes.left_buf):find('line 2 theirs', 1, true) ~= nil) - assert.is_true(buffer_text(panes.right_buf):find('line 2 ours', 1, true) ~= nil) - end) - - it('opens a base-only current-state review file as the selected section edge', function() - local repo = create_review_repo({ commit_target = false }) - edit_file(repo.repo_root .. '/lua/two.lua') - mock_runtime_attach(function() end) - - local left_buf = commands.review_command(('++layout=split %s'):format(repo.base)) - local panes = track_panes(left_buf) - - assert.are.same( - diffspec.index_to_worktree('lua/one.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.same( - diffspec.index_to_worktree('lua/one.lua'), - vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec - ) - end) - - it('opens a merge-base review file from the resolved merge base to the target tree', function() - local repo = create_review_repo({ named_refs = true }) - edit_file(repo.repo_root .. '/lua/one.lua') - mock_runtime_attach(function() end) - - local left_buf = - commands.review_command(('++layout=split %s...%s'):format(repo.base, repo.target)) - local panes = track_panes(left_buf) - - assert.are.same( - diffspec.rev_to_rev(repo.base_sha, repo.target, 'lua/one.lua'), - vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec - ) - assert.are.same( - diffspec.rev_to_rev(repo.base_sha, repo.target, 'lua/one.lua'), - vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec - ) - end) - - it('reports skipped selected review files without opening a workspace', function() - local repo = create_mode_only_review_repo() - local notifications = capture_notifications() - mock_runtime_attach(function() end) - - local bufnr = commands.review({ - base = repo.base, - target = repo.target, - mode = 'direct', - repo = repo.repo_root, - }) - assert.is_not_nil(bufnr) - table.insert(test_buffers, bufnr) - - local mode_line = find_buffer_line(bufnr, 'diff --git a/scripts/tool.sh b/scripts/tool.sh') - assert.is_not_nil(mode_line) - vim.api.nvim_win_set_cursor(0, { mode_line, 0 }) - - local opened = commands.review_split({ bufnr = bufnr }) - - assert.is_nil(opened) - assert.are.equal( - '[diffs]: review skipped 1 file(s): scripts/tool.sh', - notifications[1].message - ) - assert.is_nil(commands._test.review_split_state(bufnr)) - end) - - it('keeps no-hunk review file records in quickfix with an empty active loclist', function() - mock_repo_root(function(path) - assert.are.equal('/tmp/repo/.', path) - return '/tmp/repo' - end) - mock_systemlist(function(cmd) - if cmd[4] == 'rev-parse' then - return { 'commit' } - end - if cmd[4] == 'merge-base' then - return { 'merge-base-commit' } - end - if cmd[4] ~= 'diff' then - return {} - end - return { - 'diff --git a/lua/mode.lua b/lua/mode.lua', - 'old mode 100644', - 'new mode 100755', - 'diff --git a/lua/changed.lua b/lua/changed.lua', - '--- a/lua/changed.lua', - '+++ b/lua/changed.lua', - '@@ -1 +1 @@', - '-old', - '+new', - } - end) - mock_runtime_attach(function() end) - - local bufnr = commands.review({ - base = 'origin/main', - target = 'refs/forge/pr/42', - mode = 'merge-base', - repo = '/tmp/repo', - }) - table.insert(test_buffers, bufnr) - - local qf = quickfix_items() - assert.are.equal(2, #qf) - assert.are.equal(bufnr, qf[1].bufnr) - assert.are.equal(1, qf[1].lnum) - assert.are.equal('lua/mode.lua (skipped)', vim.trim(qf[1].module)) - assert.is_true(qf[1].user_data.diffs.skipped) - assert.are.equal(bufnr, qf[2].bufnr) - assert.are.equal(4, qf[2].lnum) - assert.are.equal('lua/changed.lua', vim.trim(qf[2].module)) - assert.is_false(qf[2].user_data.diffs.skipped) - - assert.are.equal(0, #loclist_items()) - - vim.api.nvim_win_set_cursor(0, { 4, 0 }) - vim.api.nvim_exec_autocmds('CursorMoved', { buffer = bufnr }) - - local loc = loclist_items() - assert.are.equal(1, #loc) - assert.are.equal(7, loc[1].lnum) - assert.is_true(loc[1].text:find('lua/changed.lua', 1, true) ~= nil) - end) - end) - - describe('find_hunk_line', function() - it('finds matching @@ header and returns target line', function() - local diff_lines = { - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - '+local new = true', - ' return M', - } - local hunk_position = { - hunk_header = '@@ -1,3 +1,4 @@', - offset = 2, - } - local target_line = commands.find_hunk_line(diff_lines, hunk_position) - assert.equals(6, target_line) - end) - - it('returns nil when hunk header not found', function() - local diff_lines = { - 'diff --git a/file.lua b/file.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - } - local hunk_position = { - hunk_header = '@@ -99,3 +99,4 @@', - offset = 1, - } - local target_line = commands.find_hunk_line(diff_lines, hunk_position) - assert.is_nil(target_line) - end) - - it('handles multiple hunks and finds correct one', function() - local diff_lines = { - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - '+local x = 1', - ' ', - '@@ -10,3 +11,4 @@', - ' function M.foo()', - '+ print("hello")', - ' end', - } - local hunk_position = { - hunk_header = '@@ -10,3 +11,4 @@', - offset = 2, - } - local target_line = commands.find_hunk_line(diff_lines, hunk_position) - assert.equals(10, target_line) - end) - end) -end) diff --git a/spec/config_spec.lua b/spec/config_spec.lua index 9512118d..5a73686f 100644 --- a/spec/config_spec.lua +++ b/spec/config_spec.lua @@ -39,7 +39,6 @@ describe('diffs.config', function() gitsigns = true, telescope = true, }, - conflict = false, }) end) end) @@ -79,12 +78,6 @@ describe('diffs.config', function() assert.is_nil(opts.hide_prefix) end) - it('accepts boolean conflict settings', function() - assert.is_true(config.new().conflict) - assert.is_true(config.new({ conflict = true }).conflict) - assert.is_false(config.new({ conflict = false }).conflict) - end) - it('accepts integration lists and boolean maps', function() for _, key in ipairs(integration_keys) do for _, integrations in ipairs({ { key }, { [key] = true }, { [key] = false } }) do diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua deleted file mode 100644 index 2a2733d2..00000000 --- a/spec/conflict_spec.lua +++ /dev/null @@ -1,721 +0,0 @@ -local conflict = require('diffs.conflict') -local helpers = require('spec.helpers') - -local function create_file_buffer(lines) - local bufnr = vim.api.nvim_create_buf(false, false) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) - return bufnr -end - -local function get_extmarks(bufnr) - return vim.api.nvim_buf_get_extmarks( - bufnr, - vim.api.nvim_get_namespaces()['diffs-conflict'], - 0, - -1, - { details = true } - ) -end - -describe('conflict', function() - describe('parse', function() - it('parses a single conflict', function() - local lines = { - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - } - local regions = conflict.parse(lines) - assert.are.equal(1, #regions) - assert.are.equal(0, regions[1].marker_ours) - assert.are.equal(1, regions[1].ours_start) - assert.are.equal(2, regions[1].ours_end) - assert.are.equal(2, regions[1].marker_sep) - assert.are.equal(3, regions[1].theirs_start) - assert.are.equal(4, regions[1].theirs_end) - assert.are.equal(4, regions[1].marker_theirs) - end) - - it('parses multiple conflicts', function() - local lines = { - '<<<<<<< HEAD', - 'a', - '=======', - 'b', - '>>>>>>> feat', - 'normal line', - '<<<<<<< HEAD', - 'c', - '=======', - 'd', - '>>>>>>> feat', - } - local regions = conflict.parse(lines) - assert.are.equal(2, #regions) - assert.are.equal(0, regions[1].marker_ours) - assert.are.equal(6, regions[2].marker_ours) - end) - - it('parses diff3 format', function() - local lines = { - '<<<<<<< HEAD', - 'local x = 1', - '||||||| base', - 'local x = 0', - '=======', - 'local x = 2', - '>>>>>>> feature', - } - local regions = conflict.parse(lines) - assert.are.equal(1, #regions) - assert.are.equal(2, regions[1].marker_base) - assert.are.equal(3, regions[1].base_start) - assert.are.equal(4, regions[1].base_end) - end) - - it('handles empty ours section', function() - local lines = { - '<<<<<<< HEAD', - '=======', - 'local x = 2', - '>>>>>>> feature', - } - local regions = conflict.parse(lines) - assert.are.equal(1, #regions) - assert.are.equal(1, regions[1].ours_start) - assert.are.equal(1, regions[1].ours_end) - end) - - it('handles empty theirs section', function() - local lines = { - '<<<<<<< HEAD', - 'local x = 1', - '=======', - '>>>>>>> feature', - } - local regions = conflict.parse(lines) - assert.are.equal(1, #regions) - assert.are.equal(3, regions[1].theirs_start) - assert.are.equal(3, regions[1].theirs_end) - end) - - it('returns empty for no markers', function() - local lines = { 'local x = 1', 'local y = 2' } - local regions = conflict.parse(lines) - assert.are.equal(0, #regions) - end) - - it('discards malformed markers (no separator)', function() - local lines = { - '<<<<<<< HEAD', - 'local x = 1', - '>>>>>>> feature', - } - local regions = conflict.parse(lines) - assert.are.equal(0, #regions) - end) - - it('discards malformed markers (no end)', function() - local lines = { - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - } - local regions = conflict.parse(lines) - assert.are.equal(0, #regions) - end) - - it('handles trailing text on marker lines', function() - local lines = { - '<<<<<<< HEAD (some text)', - 'local x = 1', - '======= extra', - 'local x = 2', - '>>>>>>> feature-branch/some-thing', - } - local regions = conflict.parse(lines) - assert.are.equal(1, #regions) - end) - - it('handles empty base in diff3', function() - local lines = { - '<<<<<<< HEAD', - 'local x = 1', - '||||||| base', - '=======', - 'local x = 2', - '>>>>>>> feature', - } - local regions = conflict.parse(lines) - assert.are.equal(1, #regions) - assert.are.equal(3, regions[1].base_start) - assert.are.equal(3, regions[1].base_end) - end) - end) - - describe('highlighting', function() - after_each(function() - conflict.detach(vim.api.nvim_get_current_buf()) - end) - - it('applies extmarks for conflict regions', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach(bufnr) - - local extmarks = get_extmarks(bufnr) - assert.is_true(#extmarks > 0) - - local has_ours = false - local has_theirs = false - local has_marker = false - for _, mark in ipairs(extmarks) do - local hl = mark[4] and mark[4].hl_group - if hl == 'DiffsConflictOurs' then - has_ours = true - end - if hl == 'DiffsConflictTheirs' then - has_theirs = true - end - if hl == 'DiffsConflictMarker' then - has_marker = true - end - end - assert.is_true(has_ours) - assert.is_true(has_theirs) - assert.is_true(has_marker) - - helpers.delete_buffer(bufnr) - end) - - it('uses fixed extmark priority for conflict regions', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach(bufnr) - - local checked = 0 - for _, mark in ipairs(get_extmarks(bufnr)) do - local details = mark[4] - if details and (details.hl_group or details.number_hl_group) then - assert.are.equal(200, details.priority) - checked = checked + 1 - end - end - assert.is_true(checked > 0) - - helpers.delete_buffer(bufnr) - end) - - it('applies number_hl_group to content lines', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach(bufnr) - - local extmarks = get_extmarks(bufnr) - local has_ours_nr = false - local has_theirs_nr = false - for _, mark in ipairs(extmarks) do - local nr = mark[4] and mark[4].number_hl_group - if nr == 'DiffsConflictOursNr' then - has_ours_nr = true - end - if nr == 'DiffsConflictTheirsNr' then - has_theirs_nr = true - end - end - assert.is_true(has_ours_nr) - assert.is_true(has_theirs_nr) - - helpers.delete_buffer(bufnr) - end) - - it('highlights base region in diff3', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '||||||| base', - 'local x = 0', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach(bufnr) - - local extmarks = get_extmarks(bufnr) - local has_base = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsConflictBase' then - has_base = true - break - end - end - assert.is_true(has_base) - - helpers.delete_buffer(bufnr) - end) - - it('clears extmarks on detach', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach(bufnr) - assert.is_true(#get_extmarks(bufnr) > 0) - - conflict.detach(bufnr) - assert.are.equal(0, #get_extmarks(bufnr)) - - helpers.delete_buffer(bufnr) - end) - end) - - describe('resolution', function() - local function make_conflict_buffer() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - vim.api.nvim_set_current_buf(bufnr) - return bufnr - end - - it('resolve_ours keeps ours content', function() - local bufnr = make_conflict_buffer() - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - - conflict.resolve_ours(bufnr) - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - assert.are.equal(1, #lines) - assert.are.equal('local x = 1', lines[1]) - - helpers.delete_buffer(bufnr) - end) - - it('resolve_theirs keeps theirs content', function() - local bufnr = make_conflict_buffer() - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - - conflict.resolve_theirs(bufnr) - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - assert.are.equal(1, #lines) - assert.are.equal('local x = 2', lines[1]) - - helpers.delete_buffer(bufnr) - end) - - it('resolve_both keeps ours then theirs', function() - local bufnr = make_conflict_buffer() - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - - conflict.resolve_both(bufnr) - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - assert.are.equal(2, #lines) - assert.are.equal('local x = 1', lines[1]) - assert.are.equal('local x = 2', lines[2]) - - helpers.delete_buffer(bufnr) - end) - - it('resolve_none removes entire block', function() - local bufnr = make_conflict_buffer() - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - - conflict.resolve_none(bufnr) - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - assert.are.equal(1, #lines) - assert.are.equal('', lines[1]) - - helpers.delete_buffer(bufnr) - end) - - it('does nothing when cursor is outside conflict', function() - local bufnr = create_file_buffer({ - 'normal line', - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - conflict.resolve_ours(bufnr) - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - assert.are.equal(6, #lines) - - helpers.delete_buffer(bufnr) - end) - - it('resolves one conflict among multiple', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'a', - '=======', - 'b', - '>>>>>>> feat', - 'middle', - '<<<<<<< HEAD', - 'c', - '=======', - 'd', - '>>>>>>> feat', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - - conflict.resolve_ours(bufnr) - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - assert.are.equal('a', lines[1]) - assert.are.equal('middle', lines[2]) - assert.are.equal('<<<<<<< HEAD', lines[3]) - - helpers.delete_buffer(bufnr) - end) - - it('resolve_ours with empty ours section', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - conflict.resolve_ours(bufnr) - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - assert.are.equal(1, #lines) - assert.are.equal('', lines[1]) - - helpers.delete_buffer(bufnr) - end) - - it('handles diff3 resolution (ignores base)', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '||||||| base', - 'local x = 0', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - - conflict.resolve_theirs(bufnr) - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - assert.are.equal(1, #lines) - assert.are.equal('local x = 2', lines[1]) - - helpers.delete_buffer(bufnr) - end) - end) - - describe('navigation', function() - it('goto_next jumps to next conflict', function() - local bufnr = create_file_buffer({ - 'normal', - '<<<<<<< HEAD', - 'a', - '=======', - 'b', - '>>>>>>> feat', - 'middle', - '<<<<<<< HEAD', - 'c', - '=======', - 'd', - '>>>>>>> feat', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - conflict.goto_next(bufnr) - assert.are.equal(2, vim.api.nvim_win_get_cursor(0)[1]) - - conflict.goto_next(bufnr) - assert.are.equal(8, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(bufnr) - end) - - it('goto_next wraps to first conflict', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'a', - '=======', - 'b', - '>>>>>>> feat', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - conflict.goto_next(bufnr) - assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(bufnr) - end) - - it('goto_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', - 'a', - '=======', - 'b', - '>>>>>>> feat', - 'middle', - '<<<<<<< HEAD', - 'c', - '=======', - 'd', - '>>>>>>> feat', - 'end', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 12, 0 }) - - conflict.goto_prev(bufnr) - assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) - - conflict.goto_prev(bufnr) - assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(bufnr) - end) - - it('goto_prev wraps to last conflict', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'a', - '=======', - 'b', - '>>>>>>> feat', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - conflict.goto_prev(bufnr) - assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(bufnr) - end) - - it('goto_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) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - conflict.goto_next(bufnr) - assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(bufnr) - end) - end) - - describe('lifecycle', function() - it('attach is idempotent', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'a', - '=======', - 'b', - '>>>>>>> feat', - }) - conflict.attach(bufnr) - local count1 = #get_extmarks(bufnr) - conflict.attach(bufnr) - local count2 = #get_extmarks(bufnr) - assert.are.equal(count1, count2) - conflict.detach(bufnr) - helpers.delete_buffer(bufnr) - end) - - it('skips non-file buffers', function() - local bufnr = helpers.create_buffer({ - '<<<<<<< HEAD', - 'a', - '=======', - 'b', - '>>>>>>> feat', - }) - vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr }) - - conflict.attach(bufnr) - assert.are.equal(0, #get_extmarks(bufnr)) - - helpers.delete_buffer(bufnr) - end) - - it('skips buffers without conflict markers', function() - local bufnr = create_file_buffer({ 'local x = 1', 'local y = 2' }) - - conflict.attach(bufnr) - assert.are.equal(0, #get_extmarks(bufnr)) - - helpers.delete_buffer(bufnr) - end) - - it('re-highlights when markers return after resolution', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - local events = {} - local autocmd = vim.api.nvim_create_autocmd('User', { - pattern = { 'DiffsConflictDetected', 'DiffsConflictResolved', 'DiffsConflictDetached' }, - callback = function(event) - assert.are.equal(bufnr, event.data.bufnr) - events[#events + 1] = event.match - end, - }) - vim.api.nvim_set_current_buf(bufnr) - conflict.attach(bufnr) - - assert.is_true(#get_extmarks(bufnr) > 0) - - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - conflict.resolve_ours(bufnr) - assert.are.equal(0, #get_extmarks(bufnr)) - conflict.refresh(bufnr) - - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) - - assert.is_true(#get_extmarks(bufnr) > 0) - - conflict.refresh(bufnr) - conflict.detach(bufnr) - assert.are.same({ - 'DiffsConflictDetected', - 'DiffsConflictResolved', - 'DiffsConflictDetected', - 'DiffsConflictDetached', - }, events) - vim.api.nvim_del_autocmd(autocmd) - helpers.delete_buffer(bufnr) - end) - - it('detaches after last conflict resolved', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - vim.api.nvim_set_current_buf(bufnr) - conflict.attach(bufnr) - - assert.is_true(#get_extmarks(bufnr) > 0) - - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - conflict.resolve_ours(bufnr) - - assert.are.equal(0, #get_extmarks(bufnr)) - - helpers.delete_buffer(bufnr) - end) - end) -end) diff --git a/spec/content_spec.lua b/spec/content_spec.lua deleted file mode 100644 index 6cb5705c..00000000 --- a/spec/content_spec.lua +++ /dev/null @@ -1,71 +0,0 @@ -require('spec.helpers') - -local content = require('diffs.content') - -local test_buffers = {} -local test_files = {} - -local function edit_temp_file(lines, flags) - local path = vim.fn.resolve(vim.fn.tempname()) - test_files[#test_files + 1] = path - vim.fn.writefile(lines, path, flags or '') - vim.cmd('edit ' .. vim.fn.fnameescape(path)) - local bufnr = vim.api.nvim_get_current_buf() - test_buffers[#test_buffers + 1] = bufnr - return content.from_buffer(bufnr) -end - -describe('diffs.content', function() - after_each(function() - for _, bufnr in ipairs(test_buffers) do - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end - end - test_buffers = {} - for _, path in ipairs(test_files) do - vim.fn.delete(path) - end - test_files = {} - end) - - it('normalizes one final newline sentinel', function() - local lines = content.from_raw_lines({ 'a', '' }) - - assert.are.equal(1, #lines) - assert.are.equal('a', lines[1]) - assert.are.equal('a\n', content.to_string(lines)) - end) - - it('preserves an intentional trailing blank line', function() - local lines = content.from_raw_lines({ 'a', '', '' }) - - assert.are.equal(2, #lines) - assert.are.equal('a', lines[1]) - assert.are.equal('', lines[2]) - assert.are.equal('a\n\n', content.to_string(lines)) - end) - - it('preserves content without a final newline', function() - local lines = content.from_raw_lines({ 'a' }) - - assert.are.equal(1, #lines) - assert.are.equal('a', lines[1]) - assert.are.equal('a', content.to_string(lines)) - end) - - it('normalizes empty file reads separately from one-newline files', function() - local empty = content.from_raw_lines({ '' }, { empty_is_empty = true }) - local newline = content.from_raw_lines({ '', '' }) - - assert.are.equal('', content.to_string(empty)) - assert.are.equal('\n', content.to_string(newline)) - end) - - it('preserves buffer final-newline state', function() - assert.are.equal('', content.to_string(edit_temp_file({}, 'b'))) - assert.are.equal('\n', content.to_string(edit_temp_file({ '' }))) - assert.are.equal('a', content.to_string(edit_temp_file({ 'a' }, 'b'))) - assert.are.equal('a\n', content.to_string(edit_temp_file({ 'a' }))) - end) -end) diff --git a/spec/diff_files_spec.lua b/spec/diff_files_spec.lua deleted file mode 100644 index 21b72b52..00000000 --- a/spec/diff_files_spec.lua +++ /dev/null @@ -1,242 +0,0 @@ -require('spec.helpers') - -local commands = require('diffs.commands') -local generated = require('diffs.generated') - -local saved_notify -local notifications = {} - -local function capture_notify() - saved_notify = vim.notify - notifications = {} - vim.notify = function(msg, level) - table.insert(notifications, { msg = msg, level = level }) - end -end - -local function restore_notify() - if saved_notify then - vim.notify = saved_notify - saved_notify = nil - end -end - ----@return string? -local function last_message() - local entry = notifications[#notifications] - return entry and entry.msg -end - ----@param needle string ----@return boolean -local function last_message_has(needle) - local msg = last_message() - return msg ~= nil and msg:find(needle, 1, true) ~= nil -end - -local function diff_lines(bufnr) - return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) -end - -local function has_line(bufnr, pattern) - for _, line in ipairs(diff_lines(bufnr)) do - if line:match(pattern) then - return true - end - end - return false -end - -describe('diffs.commands.diff_files', function() - local dir - local a - local b - - before_each(function() - dir = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(dir, 'p') - dir = vim.fn.resolve(dir) - a = dir .. '/a.txt' - b = dir .. '/b.txt' - vim.fn.writefile({ 'alpha', 'beta', 'gamma' }, a) - vim.fn.writefile({ 'alpha', 'BETA', 'gamma', 'delta' }, b) - capture_notify() - end) - - after_each(function() - restore_notify() - -- Let the scheduled runtime.attach run while the buffers are still valid. - vim.wait(20, function() - return false - end) - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - local name = vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_get_name(buf) or '' - if name:match('^diffs://') or name:find(dir, 1, true) then - pcall(vim.api.nvim_buf_delete, buf, { force = true }) - end - end - vim.fn.delete(dir, 'rf') - end) - - it('renders a read-only unified diff of two files outside any repo', function() - local bufnr = commands.diff_files(a, b, { layout = 'unified' }) - - assert.is_number(bufnr) - assert.are.equal('diff', vim.bo[bufnr].filetype) - assert.is_false(vim.bo[bufnr].modifiable) - assert.is_true(has_line(bufnr, 'diff %-%-git a/.*a%.txt b/.*b%.txt')) - assert.is_true(has_line(bufnr, '^.*%+BETA')) - - local source = generated.source(bufnr) - assert.are.equal('files', source.kind) - assert.are.equal(a, source.left_path) - assert.are.equal(b, source.right_path) - end) - - it('defaults the new side to live buffer content with a single path', function() - vim.cmd('edit ' .. vim.fn.fnameescape(a)) - local src = vim.api.nvim_get_current_buf() - vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'UNSAVED' }) - - local bufnr = commands.diff_files(b, nil, { layout = 'unified' }) - - assert.is_number(bufnr) - assert.is_true(has_line(bufnr, '^.*%+UNSAVED')) - - local source = generated.source(bufnr) - assert.are.equal(b, source.left_path) - assert.are.equal(a, source.right_path) - assert.are.equal(src, source.right_buf) - end) - - it('compares the current buffer against its last save with no paths', function() - vim.cmd('edit ' .. vim.fn.fnameescape(a)) - local src = vim.api.nvim_get_current_buf() - vim.api.nvim_buf_set_lines(src, 2, 3, false, { 'GAMMA' }) - - local bufnr = commands.diff_files(nil, nil, { layout = 'unified' }) - - assert.is_number(bufnr) - assert.is_true(has_line(bufnr, '^.*%-gamma')) - assert.is_true(has_line(bufnr, '^.*%+GAMMA')) - - local source = generated.source(bufnr) - assert.are.equal(a, source.left_path) - assert.are.equal(a, source.right_path) - assert.are.equal(src, source.right_buf) - assert.is_truthy(vim.api.nvim_buf_get_name(bufnr):find('-> buffer:', 1, true)) - end) - - it('reports no changes for an unmodified buffer with no paths', function() - vim.cmd('edit ' .. vim.fn.fnameescape(a)) - - local bufnr = commands.diff_files(nil, nil, { layout = 'unified' }) - - assert.is_nil(bufnr) - local name = vim.fn.fnamemodify(a, ':~:.') - assert.is_true(last_message_has('no changes between ' .. name .. ' and buffer:' .. name)) - end) - - it('reads both sides from disk when the new side is named', function() - vim.cmd('edit ' .. vim.fn.fnameescape(b)) - vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'UNSAVED' }) - - local bufnr = commands.diff_files(a, b, { layout = 'unified' }) - - assert.is_number(bufnr) - assert.is_false(has_line(bufnr, 'UNSAVED')) - assert.is_nil(generated.source(bufnr).right_buf) - end) - - it('allows a buffer-backed side that is not on disk', function() - vim.cmd('edit ' .. vim.fn.fnameescape(dir .. '/new.txt')) - vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'alpha', 'beta', 'gamma', 'delta' }) - - local bufnr = commands.diff_files(a, nil, { layout = 'unified' }) - - assert.is_number(bufnr) - assert.is_true(has_line(bufnr, '^.*%+delta')) - end) - - it('refuses a defaulted side in an unnamed buffer', function() - vim.cmd('enew') - - assert.is_nil(commands.diff_files(a, nil, { layout = 'unified' })) - assert.is_true(last_message_has('cannot diff unnamed buffer')) - end) - - it('re-reads the buffer when the view reloads', function() - vim.cmd('edit ' .. vim.fn.fnameescape(a)) - local src = vim.api.nvim_get_current_buf() - vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'first' }) - - local bufnr = commands.diff_files(b, nil, { layout = 'unified' }) - assert.is_number(bufnr) - - vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'second' }) - commands.read_buffer(bufnr) - - assert.is_true(has_line(bufnr, '^.*%+second')) - assert.is_false(has_line(bufnr, '^.*%+first')) - end) - - it('falls back to disk when the buffer is gone at reload', function() - vim.cmd('edit ' .. vim.fn.fnameescape(a)) - local src = vim.api.nvim_get_current_buf() - vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'UNSAVED' }) - - local bufnr = commands.diff_files(b, nil, { layout = 'unified' }) - assert.is_number(bufnr) - assert.is_true(has_line(bufnr, '^.*%+UNSAVED')) - - vim.api.nvim_buf_delete(src, { force = true }) - commands.read_buffer(bufnr) - - assert.is_false(has_line(bufnr, 'UNSAVED')) - assert.is_true(has_line(bufnr, '^.*%+beta')) - end) - - it('reloads from disk through the files source', function() - local bufnr = commands.diff_files(a, b, { layout = 'unified' }) - assert.is_number(bufnr) - - vim.fn.writefile({ 'alpha', 'beta', 'gamma', 'delta', 'epsilon' }, b) - commands.read_buffer(bufnr) - - assert.is_true(has_line(bufnr, '^.*%+epsilon')) - end) - - it('reports no changes for identical files without opening a buffer', function() - local bufnr = commands.diff_files(a, a, { layout = 'unified' }) - - assert.is_nil(bufnr) - local name = vim.fn.fnamemodify(a, ':~:.') - assert.is_true(last_message_has('no changes between ' .. name .. ' and ' .. name)) - end) - - it('refuses a directory', function() - local bufnr = commands.diff_files(dir, b, { layout = 'unified' }) - - assert.is_nil(bufnr) - assert.is_true(last_message_has('is a directory; :Diff files compares two files')) - end) - - it('refuses an unreadable path', function() - local bufnr = commands.diff_files(dir .. '/missing.txt', b, { layout = 'unified' }) - - assert.is_nil(bufnr) - assert.is_true(last_message_has('missing.txt: file not readable')) - end) -end) - -describe('diffs.commands.diff_files_command', function() - before_each(capture_notify) - after_each(restore_notify) - - it('surfaces parser errors', function() - local result = commands.diff_files_command('a b c', false) - - assert.is_nil(result) - assert.is_true(last_message_has('expected at most two file paths')) - end) -end) diff --git a/spec/diffargs_spec.lua b/spec/diffargs_spec.lua deleted file mode 100644 index 77eb3ab6..00000000 --- a/spec/diffargs_spec.lua +++ /dev/null @@ -1,184 +0,0 @@ -require('spec.helpers') - -local diffargs = require('diffs.diffargs') -local diffspec = require('diffs.spec') - -describe('diffs.diffargs', function() - local path = 'lua/foo.lua' - - local function parse(args, current) - local result, err = diffargs.parse(args, { - path = path, - current = current or diffspec.worktree(), - }) - assert.is_nil(err) - return result - end - - it('maps default worktree :Diff to index -> worktree', function() - local result = parse(nil) - - assert.are.same(diffspec.index_to_worktree(path), result.spec) - assert.are.equal('unified', result.layout) - end) - - it('maps explicit revisions to revision -> worktree', function() - local result = parse('HEAD~3') - - assert.are.same(diffspec.rev_to_worktree('HEAD~3', path), result.spec) - end) - - it('normalizes @ as HEAD', function() - local result = parse('@') - - assert.are.same(diffspec.rev_to_worktree('HEAD', path), result.spec) - end) - - it('maps current-file index objects to index -> worktree', function() - for _, object in ipairs({ ':', ':%', ':0:%' }) do - local result = parse(object) - - assert.are.same(diffspec.index_to_worktree(path), result.spec) - end - end) - - it('maps revision current-file objects to tree -> worktree', function() - local result = parse('@:%') - - assert.are.same(diffspec.rev_to_worktree('HEAD', path), result.spec) - end) - - it('maps staged/index context default to HEAD -> index', function() - local result = parse(nil, diffspec.index()) - - assert.are.same(diffspec.head_to_index(path), result.spec) - end) - - it('maps staged/index context @:% to HEAD -> index', function() - local result = parse('@:%', diffspec.index()) - - assert.are.same(diffspec.head_to_index(path), result.spec) - end) - - it('rejects --staged as an unsupported flag', function() - local result, err = diffargs.parse('--staged', { path = path }) - - assert.is_nil(result) - assert.are.equal('unsupported option --staged', err) - end) - - it('rejects multiple objects', function() - local result, err = diffargs.parse('HEAD :0:%', { path = path }) - - assert.is_nil(result) - assert.are.equal('expected at most one Fugitive object', err) - end) - - it('maps revision explicit-path objects to revision -> worktree of that path', function() - local result = parse('HEAD:lua/bar.lua') - - assert.are.same(diffspec.rev_to_worktree('HEAD', 'lua/bar.lua'), result.spec) - end) - - it('normalizes @ in revision explicit-path objects', function() - local result = parse('@:lua/bar.lua') - - assert.are.same(diffspec.rev_to_worktree('HEAD', 'lua/bar.lua'), result.spec) - end) - - it('maps index explicit-path objects to index -> worktree of that path', function() - for _, object in ipairs({ ':lua/bar.lua', ':0:lua/bar.lua' }) do - local result = parse(object) - - assert.are.same(diffspec.index_to_worktree('lua/bar.lua'), result.spec) - end - end) - - it('maps merge-stage current-file objects to stage -> worktree', function() - for _, stage in ipairs({ 1, 2, 3 }) do - local result = parse((':%d:%%'):format(stage)) - - assert.are.same(diffspec.file(diffspec.stage(stage), diffspec.worktree(), path), result.spec) - end - end) - - it('maps merge-stage explicit-path objects to stage -> worktree of that path', function() - local result = parse(':3:lua/bar.lua') - - assert.are.same( - diffspec.file(diffspec.stage(3), diffspec.worktree(), 'lua/bar.lua'), - result.spec - ) - end) - - it('pins explicit-path objects to the worktree even in a staged context', function() - local result = parse('HEAD:lua/bar.lua', diffspec.index()) - - assert.are.same(diffspec.rev_to_worktree('HEAD', 'lua/bar.lua'), result.spec) - end) - - local rejected = { - { object = '#', err = 'alternate-buffer objects (#) are not supported' }, - { object = '#2', err = 'alternate-buffer objects (#) are not supported' }, - { object = '!', err = 'owner-commit objects (!) are not supported' }, - { object = '!:lua/bar.lua', err = 'owner-commit objects (!) are not supported' }, - { object = '', err = ' objects are not supported' }, - { object = '-', err = 'the previous-object form (-) is not supported' }, - { object = './lua/bar.lua', err = 'worktree-relative path objects (./) are not supported' }, - { object = '../lua/bar.lua', err = 'worktree-relative path objects (./) are not supported' }, - { - object = 'main..other', - err = 'range objects are not supported; use :Diff review for ranges', - }, - { - object = 'main...other', - err = 'range objects are not supported; use :Diff review for ranges', - }, - { object = ':2', err = 'merge stage :2 needs a path; use :2:% for the current file' }, - { object = ':/fix', err = 'commit-message search objects (:/) are not supported' }, - { object = ':(top)lua/bar.lua', err = 'pathspec-magic objects (:(...)) are not supported' }, - { object = 'master:', err = 'tree objects (trailing :) are not supported' }, - } - - for _, case in ipairs(rejected) do - it('rejects ' .. case.object, function() - local result, err = diffargs.parse(case.object, { path = path }) - - assert.is_nil(result) - assert.are.equal(case.err, err) - end) - end -end) - -describe('diffs.diffargs.parse_files', function() - it('parses a single path as the old side with the current buffer as new', function() - local result = diffargs.parse_files('old.lua') - - assert.are.equal('old.lua', result.left) - assert.is_nil(result.right) - assert.are.equal('unified', result.layout) - end) - - it('parses two paths as old and new', function() - local result = diffargs.parse_files('old.lua new.lua') - - assert.are.equal('old.lua', result.left) - assert.are.equal('new.lua', result.right) - assert.are.equal('unified', result.layout) - end) - - it('defaults both sides to the current buffer with no paths', function() - local bare, bare_err = diffargs.parse_files(nil) - assert.is_nil(bare_err) - assert.is_nil(bare.left) - assert.is_nil(bare.right) - assert.are.equal('unified', bare.layout) - end) - - it('rejects more than two paths', function() - local result, err = diffargs.parse_files('a.lua b.lua c.lua') - - assert.is_nil(result) - assert.are.equal('expected at most two file paths', err) - end) -end) diff --git a/spec/diffopt_spec.lua b/spec/diffopt_spec.lua index 3df49963..0c8b06a8 100644 --- a/spec/diffopt_spec.lua +++ b/spec/diffopt_spec.lua @@ -2,7 +2,6 @@ require('spec.helpers') local diff = require('diffs.diff') local diffopt = require('diffs.diffopt') -local render = require('diffs.render') describe('diffopt resolver', function() local saved @@ -50,63 +49,10 @@ describe('diffopt resolver', function() assert.are.equal('simple', diffopt.inline()) end) - it('omits inline from git flags and vim.diff() options', function() + it('omits inline from vim.text.diff options', function() vim.o.diffopt = 'internal,inline:word,iwhiteall' - assert.are.same({ '--ignore-all-space' }, diffopt.git_flags()) assert.are.same({ ignore_whitespace = true }, diffopt.vim_diff_opts()) end) - - it('maps flags onto the equivalent git diff flags', function() - vim.o.diffopt = 'internal,iwhiteall' - assert.are.same({ '--ignore-all-space' }, diffopt.git_flags()) - - vim.o.diffopt = 'internal,iwhite' - assert.are.same({ '--ignore-space-change' }, diffopt.git_flags()) - - vim.o.diffopt = 'internal,iwhiteeol' - assert.are.same({ '--ignore-space-at-eol' }, diffopt.git_flags()) - - vim.o.diffopt = 'internal,iblank' - assert.are.same({ '--ignore-blank-lines' }, diffopt.git_flags()) - - vim.o.diffopt = 'internal,algorithm:histogram' - assert.are.same({ '--diff-algorithm=histogram' }, diffopt.git_flags()) - end) - - it('omits linematch from git flags (no git equivalent)', function() - vim.o.diffopt = 'internal,linematch:60' - assert.are.same({}, diffopt.git_flags()) - end) - - it('combines whitespace and algorithm git flags', function() - vim.o.diffopt = 'internal,iwhiteall,algorithm:patience' - local flags = diffopt.git_flags() - assert.is_true(vim.tbl_contains(flags, '--ignore-all-space')) - assert.is_true(vim.tbl_contains(flags, '--diff-algorithm=patience')) - end) -end) - -describe('git-backed diffs honor diffopt whitespace', function() - local review = require('diffs.review') - local saved - - before_each(function() - saved = vim.o.diffopt - end) - - after_each(function() - vim.o.diffopt = saved - end) - - it('splices whitespace flags into the review diff command', function() - vim.o.diffopt = 'internal' - local plain = review.build_cmd({ repo_root = '/repo', display = 'HEAD' }) - assert.is_false(vim.tbl_contains(plain, '--ignore-all-space')) - - vim.o.diffopt = 'internal,iwhiteall' - local ignored = review.build_cmd({ repo_root = '/repo', display = 'HEAD' }) - assert.is_true(vim.tbl_contains(ignored, '--ignore-all-space')) - end) end) describe('whitespace-only line classification', function() @@ -202,37 +148,6 @@ describe('whitespace-only line classification', function() end) end) -describe('line-level whitespace handling', function() - local saved - - before_each(function() - saved = vim.o.diffopt - end) - - after_each(function() - vim.o.diffopt = saved - end) - - it('drops a whitespace-only change when iwhiteall is set', function() - local old = { 'local x = 1', 'keep' } - local new = { 'local x = 1 ', 'keep' } - - vim.o.diffopt = 'internal,filler' - assert.is_true(#render.unified_lines(old, new, 'f', 'f') > 0) - - vim.o.diffopt = 'internal,filler,iwhiteall' - assert.are.same({}, render.unified_lines(old, new, 'f', 'f')) - end) - - it('keeps a content change that also touches whitespace', function() - local old = { 'local x = 1', 'keep' } - local new = { 'local y = 1 ', 'keep' } - - vim.o.diffopt = 'internal,filler,iwhiteall' - assert.is_true(#render.unified_lines(old, new, 'f', 'f') > 0) - end) -end) - describe('intra-line whitespace handling', function() local saved diff --git a/spec/diffspec_spec.lua b/spec/diffspec_spec.lua deleted file mode 100644 index dc3a4033..00000000 --- a/spec/diffspec_spec.lua +++ /dev/null @@ -1,112 +0,0 @@ -require('spec.helpers') - -local diffspec = require('diffs.spec') - -describe('diffs.spec', function() - local path = 'lua/foo.lua' - - it('represents index to worktree file diffs', function() - local spec = diffspec.index_to_worktree(path) - - assert.are.same({ - left = { kind = 'index' }, - right = { kind = 'worktree' }, - scope = { kind = 'file', path = path }, - mode = 'unified', - }, spec) - assert.are.same({ can_put = true, can_obtain = false }, diffspec.patch_actions(spec)) - assert.are.equal('index -> worktree file:lua/foo.lua', diffspec.label(spec)) - end) - - it('represents HEAD to index file diffs', function() - local spec = diffspec.head_to_index(path) - - assert.are.same({ - left = { kind = 'tree', rev = 'HEAD' }, - right = { kind = 'index' }, - scope = { kind = 'file', path = path }, - mode = 'unified', - }, spec) - assert.are.same({ can_put = false, can_obtain = true }, diffspec.patch_actions(spec)) - assert.are.equal('tree:HEAD -> index file:lua/foo.lua', diffspec.label(spec)) - end) - - it('represents revision to worktree file diffs', function() - local spec = diffspec.rev_to_worktree('feature~2', path) - - assert.are.same({ - left = { kind = 'tree', rev = 'feature~2' }, - right = { kind = 'worktree' }, - scope = { kind = 'file', path = path }, - mode = 'unified', - }, spec) - assert.are.same({ can_put = false, can_obtain = false }, diffspec.patch_actions(spec)) - assert.are.equal('tree:feature~2 -> worktree file:lua/foo.lua', diffspec.label(spec)) - end) - - it('represents read-only revision to revision file diffs', function() - local spec = diffspec.rev_to_rev('origin/main', 'HEAD', path) - - assert.are.same({ - left = { kind = 'tree', rev = 'origin/main' }, - right = { kind = 'tree', rev = 'HEAD' }, - scope = { kind = 'file', path = path }, - mode = 'unified', - }, spec) - assert.are.same({ can_put = false, can_obtain = false }, diffspec.patch_actions(spec)) - assert.are.equal('tree:origin/main -> tree:HEAD file:lua/foo.lua', diffspec.label(spec)) - end) - - it('represents read-only merge-stage to worktree file diffs', function() - local spec = diffspec.file(diffspec.stage(2), diffspec.worktree(), path) - - assert.are.same({ - left = { kind = 'stage', stage = 2 }, - right = { kind = 'worktree' }, - scope = { kind = 'file', path = path }, - mode = 'unified', - }, spec) - assert.are.same({ can_put = false, can_obtain = false }, diffspec.patch_actions(spec)) - assert.are.equal('stage:2 -> worktree file:lua/foo.lua', diffspec.label(spec)) - end) - - it('rejects invalid merge-stage numbers', function() - assert.has_error(function() - diffspec.stage(0) - end, 'diffs: stage endpoint must be 1, 2, or 3') - assert.has_error(function() - diffspec.stage(4) - end, 'diffs: stage endpoint must be 1, 2, or 3') - end) - - it('normalizes table-shaped specs without keeping caller-owned tables', function() - local input = { - left = { kind = 'tree', rev = 'HEAD' }, - right = { kind = 'index' }, - scope = { kind = 'file', path = path }, - } - - local spec = diffspec.new(input) - - input.left.rev = 'changed' - input.scope.path = 'changed.lua' - - assert.are.equal('HEAD', spec.left.rev) - assert.are.equal(path, spec.scope.path) - assert.are.equal('unified', spec.mode) - end) - - it('rejects unsupported endpoint and scope kinds', function() - assert.has_error(function() - diffspec.file({ kind = 'blob', oid = 'abc123' }, diffspec.worktree(), path) - end, 'diffs: unsupported endpoint kind: blob') - - assert.has_error(function() - diffspec.new({ - left = diffspec.index(), - right = diffspec.worktree(), - scope = { kind = 'repo' }, - }) - end, 'diffs: unsupported scope kind: repo') - end) -end) diff --git a/spec/difftastic_spec.lua b/spec/difftastic_spec.lua deleted file mode 100644 index 1390a28f..00000000 --- a/spec/difftastic_spec.lua +++ /dev/null @@ -1,201 +0,0 @@ -require('spec.helpers') - -local config = require('diffs.config') -local difftastic = require('diffs.difftastic') -local runtime = require('diffs.runtime') - -describe('diffs.difftastic', function() - describe('config', function() - it('accepts difftastic in integration lists and boolean maps', function() - for _, integrations in ipairs({ { 'difftastic' }, { difftastic = true } }) do - assert.is_true(config.new({ integrations = integrations }).integrations.difftastic) - end - end) - - it('accepts a table with args', function() - assert.has_no.errors(function() - config.new({ integrations = { difftastic = { args = { '--ignore-comments' } } } }) - end) - end) - - it('defaults to disabled when unset', function() - local cfg = config.new({}) - assert.is_nil(cfg.integrations.difftastic) - end) - end) - - describe('resolve', function() - local original - before_each(function() - original = runtime.get_difftastic_config - end) - after_each(function() - runtime.get_difftastic_config = original - end) - - it('is disabled for nil/false', function() - runtime.get_difftastic_config = function() - return nil - end - assert.is_false(difftastic.resolve().enabled) - runtime.get_difftastic_config = function() - return false - end - assert.is_false(difftastic.resolve().enabled) - end) - - it('is enabled with empty args for true', function() - runtime.get_difftastic_config = function() - return true - end - local cfg = difftastic.resolve() - assert.is_true(cfg.enabled) - assert.same({}, cfg.args) - end) - - it('forwards args from a table', function() - runtime.get_difftastic_config = function() - return { args = { '--ignore-comments' } } - end - assert.same({ '--ignore-comments' }, difftastic.resolve().args) - end) - end) - - describe('span_maps', function() - it('keys changes by line_number and is order-independent', function() - local json = { - chunks = { - { - { lhs = vim.NIL, rhs = { line_number = 5, changes = { { start = 2, ['end'] = 6 } } } }, - { lhs = { line_number = 1, changes = { { start = 0, ['end'] = 3 } } }, rhs = vim.NIL }, - }, - }, - } - local lhs, rhs = difftastic.span_maps(json) - assert.same({ { col_start = 0, col_end = 3 } }, lhs[1]) - assert.same({ { col_start = 2, col_end = 6 } }, rhs[5]) - end) - - it('drops empty/zero-width spans and vim.NIL sides', function() - local json = { - chunks = { - { - { lhs = { line_number = 0, changes = { { start = 1, ['end'] = 1 } } }, rhs = vim.NIL }, - }, - }, - } - local lhs = difftastic.span_maps(json) - assert.same({}, lhs[0]) - end) - end) - - describe('has_changes', function() - it('is true only when a side has spans', function() - assert.is_false(difftastic.has_changes({}, {})) - assert.is_true(difftastic.has_changes({ [1] = { { col_start = 0, col_end = 1 } } }, {})) - end) - end) - - describe('build_alignment', function() - -- old = {a,b,c}; new = {a,C}: a unchanged, b deleted, c rewritten to C - local json = { - language = 'Lua', - status = 'changed', - aligned_lines = { { 0, 0 }, { 1, vim.NIL }, { 2, 1 } }, - chunks = { - { - { lhs = { line_number = 1, changes = { { start = 0, ['end'] = 1 } } }, rhs = vim.NIL }, - { - lhs = { line_number = 2, changes = { { start = 0, ['end'] = 1 } } }, - rhs = { line_number = 1, changes = { { start = 0, ['end'] = 1 } } }, - }, - }, - }, - } - - it('produces aligned rows with structural kinds', function() - local a = difftastic.build_alignment(json, { 'a', 'b', 'c' }, { 'a', 'C' }) - assert.same({ 'a', 'b', 'c' }, a.left_lines) - assert.same({ 'a', '', 'C' }, a.right_lines) - assert.are.equal('context', a.left_rows[1].kind) - assert.are.equal('delete', a.left_rows[2].kind) - assert.are.equal('filler', a.right_rows[2].kind) - assert.are.equal('delete', a.left_rows[3].kind) - assert.are.equal('add', a.right_rows[3].kind) - end) - - it('reports changed and anchors at the first changed run', function() - local a = difftastic.build_alignment(json, { 'a', 'b', 'c' }, { 'a', 'C' }) - assert.is_true(a.changed) - assert.same({ 2 }, a.anchors) - end) - - it('carries per-row intra spans', function() - local a = difftastic.build_alignment(json, { 'a', 'b', 'c' }, { 'a', 'C' }) - assert.same({ { col_start = 0, col_end = 1 } }, a.left_intra[3]) - assert.same({ { col_start = 0, col_end = 1 } }, a.right_intra[3]) - assert.is_nil(a.right_intra[1]) - end) - - it('reports not changed for a structurally identical alignment', function() - local a = difftastic.build_alignment({ - aligned_lines = { { 0, 0 }, { 1, 1 } }, - chunks = {}, - }, { 'a', 'b' }, { 'a', 'b' }) - assert.is_false(a.changed) - assert.same({}, a.anchors) - end) - end) - - describe('integration (difft-gated)', function() - if vim.fn.executable('difft') ~= 1 then - return - end - - local original - before_each(function() - original = runtime.get_difftastic_config - runtime.get_difftastic_config = function() - return true - end - end) - after_each(function() - runtime.get_difftastic_config = original - end) - - it('aligns a rename via real difft', function() - local old_lines = { 'local function clamp(value, low, high)', ' return low', 'end' } - local new_lines = - { 'local function clamp(value, low, high)', ' return math.max(low, value)', 'end' } - local a = difftastic.align(old_lines, new_lines, 'rate.lua') - assert.is_truthy(a) - assert.is_true(a.changed) - assert.are.equal(#a.left_lines, #a.right_lines) - end) - - it('reports not changed for a reindentation-only change', function() - local old_lines = { 'local function f()', 'return 1', 'end' } - local new_lines = { 'local function f()', ' return 1', 'end' } - local a = difftastic.align(old_lines, new_lines, 'x.lua') - assert.is_truthy(a) - assert.is_false(a.changed) - end) - - it('falls back cleanly on all-added and all-removed files', function() - assert.is_nil(difftastic.align({}, { 'local x = 1', 'return x' }, 'x.lua')) - assert.is_nil(difftastic.align({ 'local x = 1', 'return x' }, {}, 'x.lua')) - end) - - it('keeps byte offsets aligned with tabs and multibyte chars', function() - local _, rhs = difftastic.span_maps_for_content( - { '\tlocal café_count = 1' }, - { '\tlocal café_count = 2' }, - 'x.lua' - ) - assert.is_truthy(rhs and rhs[0]) - local line = '\tlocal café_count = 2' - local span = rhs[0][1] - assert.are.equal('2', line:sub(span.col_start + 1, span.col_end)) - end) - end) -end) diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua deleted file mode 100644 index b62121d3..00000000 --- a/spec/fugitive_spec.lua +++ /dev/null @@ -1,391 +0,0 @@ -local helpers = require('spec.helpers') - -local fugitive = require('diffs.fugitive') - -describe('fugitive', function() - local test_buffers = {} - - local function create_buffer(lines) - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines or {}) - test_buffers[#test_buffers + 1] = buf - return buf - end - - after_each(function() - for _, buf in ipairs(test_buffers) do - if vim.api.nvim_buf_is_valid(buf) then - vim.api.nvim_buf_delete(buf, { force = true }) - end - end - test_buffers = {} - end) - - describe('get_section_at_line', function() - local function create_status_buffer(lines) - return create_buffer(lines) - end - - it('returns staged for lines in Staged section', function() - local buf = create_status_buffer({ - 'Head: main', - '', - 'Staged (2)', - 'M file1.lua', - 'A file2.lua', - '', - 'Unstaged (1)', - 'M file3.lua', - }) - assert.equals('staged', fugitive.get_section_at_line(buf, 4)) - assert.equals('staged', fugitive.get_section_at_line(buf, 5)) - end) - - it('returns unstaged for lines in Unstaged section', function() - local buf = create_status_buffer({ - 'Head: main', - '', - 'Staged (1)', - 'M file1.lua', - '', - 'Unstaged (2)', - 'M file2.lua', - 'M file3.lua', - }) - assert.equals('unstaged', fugitive.get_section_at_line(buf, 7)) - assert.equals('unstaged', fugitive.get_section_at_line(buf, 8)) - end) - - it('returns untracked for lines in Untracked section', function() - local buf = create_status_buffer({ - 'Head: main', - '', - 'Untracked (2)', - '? newfile.lua', - '? another.lua', - }) - assert.equals('untracked', fugitive.get_section_at_line(buf, 4)) - assert.equals('untracked', fugitive.get_section_at_line(buf, 5)) - end) - - it('returns nil for lines before any section', function() - local buf = create_status_buffer({ - 'Head: main', - 'Push: origin/main', - '', - 'Staged (1)', - 'M file1.lua', - }) - assert.is_nil(fugitive.get_section_at_line(buf, 1)) - assert.is_nil(fugitive.get_section_at_line(buf, 2)) - end) - end) - - describe('get_file_at_line', function() - local function create_status_buffer(lines) - return create_buffer(lines) - end - - it('parses simple modified file', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M src/foo.lua', - }) - local filename, section = fugitive.get_file_at_line(buf, 2) - assert.equals('src/foo.lua', filename) - assert.equals('unstaged', section) - end) - - it('parses renamed file and returns both names', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'R oldname.lua -> newname.lua', - }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('newname.lua', filename) - assert.equals('staged', section) - assert.is_false(is_header) - assert.equals('oldname.lua', old_filename) - end) - - it('parses renamed file with similarity index', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'R100 old.lua -> new.lua', - }) - local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('new.lua', filename) - assert.equals('staged', section) - assert.equals('old.lua', old_filename) - end) - - it('parses copied file with similarity index and returns both names', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'C100 old.lua -> copy.lua', - }) - local filename, section, _, old_filename, status = fugitive.get_file_at_line(buf, 2) - assert.equals('copy.lua', filename) - assert.equals('staged', section) - assert.equals('old.lua', old_filename) - assert.equals('C', status) - end) - - it('returns nil old_filename for non-renames', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'M modified.lua', - }) - local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('modified.lua', filename) - assert.equals('staged', section) - assert.is_nil(old_filename) - end) - - it('handles renamed file with spaces in name', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'R old file.lua -> new file.lua', - }) - local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('new file.lua', filename) - assert.equals('old file.lua', old_filename) - end) - - 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) - 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) - 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) - 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) - end) - - it('unquotes renamed files with quotes', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'R100 "old name.lua" -> "new name.lua"', - }) - local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) - assert.equals('new name.lua', filename) - assert.equals('old name.lua', old_filename) - end) - - it('returns nil for section header', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M file.lua', - }) - local filename = fugitive.get_file_at_line(buf, 1) - assert.is_nil(filename) - end) - - it('walks back from hunk line to find file', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M file.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - '+local new = true', - ' return M', - }) - local filename, section = fugitive.get_file_at_line(buf, 5) - assert.equals('file.lua', filename) - assert.equals('unstaged', section) - end) - - it('handles file with both staged and unstaged indicator', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'M both.lua', - '', - 'Unstaged (1)', - 'M both.lua', - }) - local filename1, section1 = fugitive.get_file_at_line(buf, 2) - assert.equals('both.lua', filename1) - assert.equals('staged', section1) - - local filename2, section2 = fugitive.get_file_at_line(buf, 5) - assert.equals('both.lua', filename2) - assert.equals('unstaged', section2) - end) - - it('detects section header for Staged', function() - local buf = create_status_buffer({ - 'Head: main', - '', - 'Staged (2)', - 'M file1.lua', - }) - local filename, section, is_header = fugitive.get_file_at_line(buf, 3) - assert.is_nil(filename) - assert.equals('staged', section) - assert.is_true(is_header) - end) - - it('returns is_header=false for file lines', function() - local buf = create_status_buffer({ - 'Staged (1)', - 'M file.lua', - }) - local filename, section, is_header = fugitive.get_file_at_line(buf, 2) - assert.equals('file.lua', filename) - assert.equals('staged', section) - assert.is_false(is_header) - end) - end) - - describe('get_hunk_position', function() - local function create_status_buffer(lines) - return create_buffer(lines) - end - - it('returns nil when on file header line', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M file.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - '+local new = true', - }) - local pos = fugitive.get_hunk_position(buf, 2) - assert.is_nil(pos) - end) - - it('returns nil when on @@ header line', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M file.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - }) - local pos = fugitive.get_hunk_position(buf, 3) - assert.is_nil(pos) - end) - - it('returns hunk header and offset for + line', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M file.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - '+local new = true', - ' return M', - }) - local pos = fugitive.get_hunk_position(buf, 5) - assert.is_not_nil(pos) - assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header) - assert.equals(2, pos.offset) - end) - - it('returns hunk header and offset for context line', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M file.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - '+local new = true', - ' return M', - }) - local pos = fugitive.get_hunk_position(buf, 6) - assert.is_not_nil(pos) - assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header) - assert.equals(3, pos.offset) - end) - - it('returns correct offset for first line after @@', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M file.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - }) - local pos = fugitive.get_hunk_position(buf, 4) - assert.is_not_nil(pos) - assert.equals(1, pos.offset) - end) - - it('handles @@ header with context text', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M file.lua', - '@@ -10,3 +10,4 @@ function M.hello()', - ' print("hi")', - '+ print("world")', - }) - local pos = fugitive.get_hunk_position(buf, 5) - assert.is_not_nil(pos) - assert.equals('@@ -10,3 +10,4 @@ function M.hello()', pos.hunk_header) - assert.equals(2, pos.offset) - end) - - it('returns nil when section header interrupts search', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M file.lua', - ' some orphan line', - }) - local pos = fugitive.get_hunk_position(buf, 3) - assert.is_nil(pos) - end) - end) - - describe('setup_keymaps', function() - it('installs fixed horizontal and vertical maps', function() - local buf = create_buffer() - - fugitive.setup_keymaps(buf) - - assert.are.equal('Unified diff (horizontal)', helpers.get_keymap(buf, 'du').desc) - assert.are.equal('Unified diff (vertical)', helpers.get_keymap(buf, 'dU').desc) - end) - - it('preserves pre-existing horizontal map and installs vertical map', function() - local buf = create_buffer() - vim.keymap.set('n', 'du', '', { buffer = buf, desc = 'user horizontal' }) - - fugitive.setup_keymaps(buf) - - assert.are.equal('user horizontal', helpers.get_keymap(buf, 'du').desc) - assert.are.equal('Unified diff (vertical)', helpers.get_keymap(buf, 'dU').desc) - end) - - it('preserves pre-existing vertical map and installs horizontal map', function() - local buf = create_buffer() - vim.keymap.set('n', 'dU', '', { buffer = buf, desc = 'user vertical' }) - - fugitive.setup_keymaps(buf) - - assert.are.equal('Unified diff (horizontal)', helpers.get_keymap(buf, 'du').desc) - assert.are.equal('user vertical', helpers.get_keymap(buf, 'dU').desc) - end) - end) -end) diff --git a/spec/git_spec.lua b/spec/git_spec.lua deleted file mode 100644 index c47cba05..00000000 --- a/spec/git_spec.lua +++ /dev/null @@ -1,167 +0,0 @@ -require('spec.helpers') -local git = require('diffs.git') - -local test_repos = {} - -local function git_cmd(repo_root, args) - local cmd = { 'git', '-C', repo_root } - for _, arg in ipairs(args) do - cmd[#cmd + 1] = arg - end - local output = vim.fn.systemlist(cmd) - assert.are.equal(0, vim.v.shell_error, table.concat(output, '\n')) - return output -end - -local function create_repo() - local repo_root = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(repo_root, 'p') - test_repos[#test_repos + 1] = repo_root - - vim.fn.systemlist({ 'git', 'init', '-q', repo_root }) - assert.are.equal(0, vim.v.shell_error) - git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' }) - git_cmd(repo_root, { 'config', 'user.name', 'Test' }) - vim.fn.writefile({ 'line 1', 'line 2' }, repo_root .. '/file.txt') - git_cmd(repo_root, { 'add', 'file.txt' }) - git_cmd(repo_root, { 'commit', '-qm', 'initial' }) - - return repo_root -end - -describe('git', function() - after_each(function() - for _, repo_root in ipairs(test_repos) do - vim.fn.delete(repo_root, 'rf') - end - test_repos = {} - end) - - describe('get_repo_root', function() - it('uses the current directory when the filepath is absent', function() - assert.are.equal(vim.fn.getcwd(), git.get_repo_root()) - end) - - it('returns repo root for current repo', function() - local cwd = vim.fn.getcwd() - local root = git.get_repo_root(cwd .. '/lua/diffs/init.lua') - assert.is_not_nil(root) - assert.are.equal(cwd, root) - end) - - it('returns nil for non-git directory', function() - local root = git.get_repo_root('/tmp') - assert.is_nil(root) - end) - end) - - describe('get_file_content', function() - it('returns file content at HEAD', function() - local cwd = vim.fn.getcwd() - local content, err = git.get_file_content('HEAD', cwd .. '/lua/diffs/init.lua') - assert.is_nil(err) - assert.is_not_nil(content) - assert.is_true(#content > 0) - end) - - it('returns error for non-existent file', function() - local cwd = vim.fn.getcwd() - local content, err = git.get_file_content('HEAD', cwd .. '/does_not_exist.lua') - assert.is_nil(content) - assert.is_not_nil(err) - end) - - it('distinguishes a missing path from an invalid revision', function() - local repo_root = create_repo() - local lines, err = git.get_file_content('HEAD', repo_root .. '/missing.txt') - assert.is_nil(lines) - assert.are.equal('file not in revision: HEAD', err) - - lines, err = git.get_file_content('missing-ref', repo_root .. '/file.txt') - assert.is_nil(lines) - assert.are.equal('failed to resolve revision: missing-ref', err) - end) - - it('reads a file from a tree expression', function() - local repo_root = create_repo() - local lines, err = git.get_file_content('HEAD^{tree}', repo_root .. '/file.txt') - assert.is_nil(err) - assert.are.same({ 'line 1', 'line 2' }, { unpack(lines) }) - end) - - it('reads subtree paths without confusing colon filenames', function() - local repo_root = create_repo() - vim.fn.mkdir(repo_root .. '/sub', 'p') - vim.fn.writefile({ 'nested content' }, repo_root .. '/sub/file.txt') - vim.fn.writefile({ 'different file' }, repo_root .. '/sub:file.txt') - git_cmd(repo_root, { 'add', 'sub/file.txt', 'sub:file.txt' }) - git_cmd(repo_root, { 'commit', '-qm', 'subtree' }) - - local lines, err = git.get_file_content('HEAD:sub', repo_root .. '/file.txt') - assert.is_nil(err) - assert.are.same({ 'nested content' }, { unpack(lines) }) - - lines, err = git.get_file_content('HEAD:sub', repo_root .. '/missing.txt') - assert.is_nil(lines) - assert.are.equal('file not in revision: HEAD:sub', err) - - vim.fn.writefile({ 'updated content' }, repo_root .. '/sub/file.txt') - git_cmd(repo_root, { 'commit', '-qam', 'update subtree' }) - lines, err = git.get_file_content('HEAD:sub', repo_root .. '/file.txt') - assert.is_nil(err) - assert.are.same({ 'updated content' }, { unpack(lines) }) - end) - - it('rejects a blob expression as a file revision', function() - local repo_root = create_repo() - local lines, err = git.get_file_content('HEAD:file.txt', repo_root .. '/file.txt') - assert.is_nil(lines) - assert.are.equal('failed to resolve revision: HEAD:file.txt', err) - end) - - it('returns error for non-git directory', function() - local content, err = git.get_file_content('HEAD', '/tmp/some_file.txt') - assert.is_nil(content) - assert.is_not_nil(err) - end) - end) - - describe('get_relative_path', function() - it('returns relative path within repo', function() - local cwd = vim.fn.getcwd() - local rel = git.get_relative_path(cwd .. '/lua/diffs/init.lua') - assert.are.equal('lua/diffs/init.lua', rel) - end) - - it('returns nil for non-git directory', function() - local rel = git.get_relative_path('/tmp/some_file.txt') - assert.is_nil(rel) - end) - end) - - describe('is_unmerged', function() - it('detects paths with unmerged index stages', function() - local repo_root = create_repo() - local filepath = repo_root .. '/file.txt' - - git_cmd(repo_root, { 'checkout', '-qb', 'ours' }) - vim.fn.writefile({ 'line 1', 'line 2 ours' }, filepath) - git_cmd(repo_root, { 'commit', '-am', 'ours' }) - git_cmd(repo_root, { 'checkout', '-qb', 'theirs', 'HEAD~1' }) - vim.fn.writefile({ 'line 1', 'line 2 theirs' }, filepath) - git_cmd(repo_root, { 'commit', '-am', 'theirs' }) - - local merge_output = vim.fn.systemlist({ 'git', '-C', repo_root, 'merge', 'ours' }) - assert.are_not.equal(0, vim.v.shell_error, table.concat(merge_output, '\n')) - - assert.is_true(git.is_unmerged(filepath)) - end) - - it('returns false outside an unmerged path', function() - local repo_root = create_repo() - - assert.is_false(git.is_unmerged(repo_root .. '/file.txt')) - assert.is_false(git.is_unmerged('/tmp/not-a-diffs-repo-file.txt')) - end) - end) -end) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 863262ce..e133b77e 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -1,7 +1,6 @@ require('spec.helpers') local config = require('diffs.config') local highlight = require('diffs.highlight') -local rails = require('diffs.rails') describe('highlight', function() describe('hunk_opts', function() @@ -49,7 +48,6 @@ 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) after_each(function() @@ -92,6 +90,7 @@ describe('highlight', function() if overrides.highlights then opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights) end + opts.intra = overrides.intra if overrides.hide_prefix ~= nil then opts.hide_prefix = overrides.hide_prefix end @@ -231,7 +230,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('keeps whitespace-only highlighting on difftastic-active buffers', function() + it('keeps line backgrounds when intra highlighting is disabled', function() local saved = vim.o.diffopt vim.o.diffopt = 'internal,iwhiteall' @@ -240,7 +239,6 @@ describe('highlight', function() '- return a + b', '+ return a + b', }) - vim.b[bufnr].diffs_difft_active = true local hunk = { filename = 'test.lua', start_line = 1, @@ -253,7 +251,7 @@ describe('highlight', function() bufnr, ns, hunk, - default_opts({ highlights = { background = true } }) + default_opts({ intra = false, highlights = { background = true } }) ) vim.o.diffopt = saved @@ -785,380 +783,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('draws generated rail change bars before line-number columns', function() - local bufnr = create_buffer({ - ' │ @@ -1,1 +1,1 @@', - ' 1 │ -old', - ' 1 │ +new', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-old', '+new' }, - prefix_width = 1, - quote_width = 10, - rail_width = 10, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - hide_prefix = true, - highlights = { - background = true, - treesitter = false, - }, - }) - ) - - local bars = {} - for _, mark in ipairs(get_extmarks(bufnr)) do - local d = mark[4] - if d and d.virt_text and d.virt_text[1] then - local group = d.virt_text[1][2] - if group == 'DiffsAddBar' or group == 'DiffsDeleteBar' then - bars[mark[2]] = group - assert.are.equal('▏', d.virt_text[1][1]) - assert.are.equal(0, mark[3]) - end - end - end - - assert.are.equal('DiffsDeleteBar', bars[1]) - assert.are.equal('DiffsAddBar', bars[2]) - delete_buffer(bufnr) - end) - - it('highlights generated rail numbers', function() - local bufnr = create_buffer({ - ' │ @@ -1,1 +1,1 @@', - ' 1 │ -old', - ' 1 │ +new', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-old', '+new' }, - prefix_width = 1, - quote_width = 10, - rail_width = 10, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - hide_prefix = true, - highlights = { - background = true, - treesitter = false, - }, - }) - ) - - local rail_marks = {} - local rail_numbers = {} - for _, mark in ipairs(get_extmarks(bufnr)) do - local d = mark[4] - if d and d.hl_group == 'DiffsRail' then - rail_marks[#rail_marks + 1] = { row = mark[2], col = mark[3], end_col = d.end_col } - end - if d and d.hl_group == 'DiffsRailNr' then - rail_numbers[#rail_numbers + 1] = { row = mark[2], col = mark[3], end_col = d.end_col } - end - end - - assert.are.same({ - { row = 1, col = 0, end_col = 10 }, - { row = 2, col = 0, end_col = 10 }, - }, rail_marks) - assert.are.same({ - { row = 1, col = 2, end_col = 3 }, - { row = 1, col = 4, end_col = 5 }, - { row = 2, col = 2, end_col = 3 }, - { row = 2, col = 4, end_col = 5 }, - }, rail_numbers) - delete_buffer(bufnr) - end) - - it('keeps hide_prefix overlay off generated line-number rails', function() - local bufnr = create_buffer({ - ' │ @@ -1,1 +1,1 @@', - ' 1 │ -old', - ' 1 │ +new', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-old', '+new' }, - prefix_width = 1, - quote_width = 10, - rail_width = 10, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - hide_prefix = true, - highlights = { - background = true, - treesitter = false, - }, - }) - ) - - local overlays = {} - local rail_marks = {} - local base_rail_numbers = {} - local rail_numbers = {} - for _, mark in ipairs(get_extmarks(bufnr)) do - local d = mark[4] - if d and d.virt_text and d.virt_text[1] then - local text, group = d.virt_text[1][1], d.virt_text[1][2] - if group == 'DiffsAdd' or group == 'DiffsDelete' then - overlays[#overlays + 1] = { row = mark[2], col = mark[3], text = text, group = group } - end - end - if d and d.hl_group == 'DiffsRail' then - rail_marks[#rail_marks + 1] = { row = mark[2], col = mark[3], end_col = d.end_col } - end - if d and d.hl_group == 'DiffsRailNr' then - base_rail_numbers[#base_rail_numbers + 1] = - { row = mark[2], col = mark[3], end_col = d.end_col } - end - if d and (d.hl_group == 'DiffsAddRailNr' or d.hl_group == 'DiffsDeleteRailNr') then - rail_numbers[#rail_numbers + 1] = - { row = mark[2], col = mark[3], end_col = d.end_col, group = d.hl_group } - end - end - - assert.are.same({ - { row = 1, col = 10, text = ' ', group = 'DiffsDelete' }, - { row = 2, col = 10, text = ' ', group = 'DiffsAdd' }, - }, overlays) - assert.are.same({ - { row = 1, col = 0, end_col = 10 }, - { row = 2, col = 0, end_col = 10 }, - }, rail_marks) - assert.are.same({ - { row = 1, col = 2, end_col = 3 }, - { row = 1, col = 4, end_col = 5 }, - { row = 2, col = 2, end_col = 3 }, - { row = 2, col = 4, end_col = 5 }, - }, base_rail_numbers) - assert.are.same({ - { row = 1, col = 2, end_col = 5, group = 'DiffsDeleteRailNr' }, - { row = 2, col = 2, end_col = 5, group = 'DiffsAddRailNr' }, - }, rail_numbers) - delete_buffer(bufnr) - end) - - it('highlights single generated rail numbers for context and changed lines', function() - local annotated, info = rails.annotate({ - '@@ -1,2 +1,2 @@', - ' local M = {}', - '-local old = false', - '+local new = true', - }, { rail_style = 'single' }) - local bufnr = create_buffer(annotated) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { - ' local M = {}', - '-local old = false', - '+local new = true', - }, - prefix_width = 1, - quote_width = info.prefix_width, - rail_width = info.prefix_width, - rail_style = info.style, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - highlights = { - treesitter = false, - }, - }) - ) - - local rail_marks = {} - local base_numbers = {} - local changed_numbers = {} - for _, mark in ipairs(get_extmarks(bufnr)) do - local d = mark[4] - if d and d.hl_group == 'DiffsRail' then - rail_marks[#rail_marks + 1] = { row = mark[2], col = mark[3], end_col = d.end_col } - end - if d and d.hl_group == 'DiffsRailNr' then - base_numbers[#base_numbers + 1] = { row = mark[2], col = mark[3], end_col = d.end_col } - end - if d and (d.hl_group == 'DiffsAddRailNr' or d.hl_group == 'DiffsDeleteRailNr') then - changed_numbers[#changed_numbers + 1] = - { row = mark[2], col = mark[3], end_col = d.end_col, group = d.hl_group } - end - end - - assert.are.same({ - { row = 1, col = 0, end_col = info.prefix_width }, - { row = 2, col = 0, end_col = info.prefix_width }, - { row = 3, col = 0, end_col = info.prefix_width }, - }, rail_marks) - assert.are.same({ - { row = 1, col = 2, end_col = 3 }, - { row = 2, col = 2, end_col = 3 }, - { row = 3, col = 2, end_col = 3 }, - }, base_numbers) - assert.are.same({ - { row = 2, col = 2, end_col = 3, group = 'DiffsDeleteRailNr' }, - { row = 3, col = 2, end_col = 3, group = 'DiffsAddRailNr' }, - }, changed_numbers) - delete_buffer(bufnr) - end) - - it('keeps hide_prefix overlay off single generated line-number rails', function() - local annotated, info = rails.annotate({ - '@@ -1,1 +1,1 @@', - '-old', - '+new', - }, { rail_style = 'single' }) - local bufnr = create_buffer(annotated) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-old', '+new' }, - prefix_width = 1, - quote_width = info.prefix_width, - rail_width = info.prefix_width, - rail_style = info.style, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - hide_prefix = true, - highlights = { - background = true, - treesitter = false, - }, - }) - ) - - local overlays = {} - for _, mark in ipairs(get_extmarks(bufnr)) do - local d = mark[4] - if d and d.virt_text and d.virt_text[1] then - local text, group = d.virt_text[1][1], d.virt_text[1][2] - if group == 'DiffsAdd' or group == 'DiffsDelete' then - overlays[#overlays + 1] = { row = mark[2], col = mark[3], text = text, group = group } - assert.is_true(mark[3] >= info.prefix_width) - end - end - end - - assert.are.same({ - { row = 1, col = info.prefix_width, text = ' ', group = 'DiffsDelete' }, - { row = 2, col = info.prefix_width, text = ' ', group = 'DiffsAdd' }, - }, overlays) - delete_buffer(bufnr) - end) - - it('leaves generated context prefix cells unpainted for cursorline', function() - local bufnr = create_buffer({ - ' 1 1 │ local M = {}', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 0, - lines = { ' local M = {}' }, - prefix_width = 1, - quote_width = 10, - rail_width = 10, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - hide_prefix = true, - highlights = { - background = true, - treesitter = false, - }, - }) - ) - - for _, mark in ipairs(get_extmarks(bufnr)) do - local d = mark[4] - if d and d.virt_text_pos == 'overlay' then - error('unexpected hide_prefix overlay on generated context line') - end - if d and d.hl_group == 'DiffsClear' then - assert.are.equal(0, mark[3]) - assert.are.equal(10, d.end_col) - end - end - - delete_buffer(bufnr) - end) - - it('does not apply DiffsClear over generated rail source content', function() - local bufnr = create_buffer({ - ' │ @@ -0,0 +1,1 @@', - ' 1 │ +local untracked = {}', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '+local untracked = {}' }, - prefix_width = 1, - quote_width = 10, - rail_width = 10, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local has_keyword = false - for _, mark in ipairs(get_extmarks(bufnr)) do - local d = mark[4] - if mark[2] == 1 and d and d.hl_group == '@keyword.lua' then - has_keyword = true - end - if mark[2] == 1 and d and d.hl_group == 'DiffsClear' then - assert.is_true(mark[3] < 11, 'DiffsClear starts on generated content at col ' .. mark[3]) - end - end - - assert.is_true(has_keyword, 'treesitter keyword highlight was applied') - delete_buffer(bufnr) - end) - it('does not apply prefix extmark on context line', function() local bufnr = create_buffer({ '@@ -1,2 +1,2 @@', @@ -1808,105 +1432,6 @@ describe('highlight', function() 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 } }) - ) - - local extmarks = get_extmarks(bufnr) - local line_bgs = {} - local marker_text = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then - line_bgs[mark[2]] = d.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_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 @@', diff --git a/spec/hunks_spec.lua b/spec/hunks_spec.lua deleted file mode 100644 index 7005ed80..00000000 --- a/spec/hunks_spec.lua +++ /dev/null @@ -1,383 +0,0 @@ -local helpers = require('spec.helpers') - -local diffspec = require('diffs.spec') -local hunks = require('diffs.hunks') - -local function sample_lines() - return { - 'diff --git a/lua/foo.lua b/lua/foo.lua', - '--- a/lua/foo.lua', - '+++ b/lua/foo.lua', - '@@ -1,3 +1,4 @@', - ' local a = 1', - '-local b = 2', - '+local b = 3', - ' local c = 4', - '+local d = 5', - } -end - -describe('diffs.hunks', function() - describe('parse', function() - it('builds hunk and line metadata for added, deleted, and context lines', function() - local lines = sample_lines() - lines[6] = '--- removed' - lines[7] = '+++ added' - local parsed = hunks.parse(lines, diffspec.index_to_worktree('lua/foo.lua')) - - assert.are.equal(1, #parsed) - assert.are.equal('lua/foo.lua', parsed[1].file) - assert.are.same({ start = 1, count = 3, finish = 3 }, parsed[1].old_range) - assert.are.same({ start = 1, count = 4, finish = 4 }, parsed[1].new_range) - assert.are.same({ start = 4, count = 6, finish = 9 }, parsed[1].buffer_range) - assert.are.same({ start = 1, count = 3, finish = 3 }, parsed[1].file_header_range) - assert.are.same({ - 'diff --git a/lua/foo.lua b/lua/foo.lua', - '--- a/lua/foo.lua', - '+++ b/lua/foo.lua', - }, parsed[1].file_header_lines) - assert.are.equal('@@ -1,3 +1,4 @@', parsed[1].header) - assert.is_true(parsed[1].can_put) - assert.is_false(parsed[1].can_obtain) - assert.is_true(parsed[1].actionable) - assert.are.equal('worktree', parsed[1].mutation_target) - assert.are.same({ kind = 'index' }, parsed[1].edge.left) - assert.are.same({ kind = 'worktree' }, parsed[1].edge.right) - - assert.are.equal('header', parsed[1].lines[1].kind) - assert.are.equal(4, parsed[1].lines[1].lnum) - assert.are.equal(1, parsed[1].lines[1].old_lnum) - assert.are.equal(1, parsed[1].lines[1].new_lnum) - - assert.are.equal('context', parsed[1].lines[2].kind) - assert.are.equal(1, parsed[1].lines[2].old_lnum) - assert.are.equal(1, parsed[1].lines[2].new_lnum) - - assert.are.equal('delete', parsed[1].lines[3].kind) - assert.are.equal(2, parsed[1].lines[3].old_lnum) - assert.is_nil(parsed[1].lines[3].new_lnum) - assert.are.equal(2, parsed[1].lines[3].source_lnum) - - assert.are.equal('add', parsed[1].lines[4].kind) - assert.is_nil(parsed[1].lines[4].old_lnum) - assert.are.equal(2, parsed[1].lines[4].new_lnum) - - assert.are.equal('add', parsed[1].lines[6].kind) - assert.are.equal(4, parsed[1].lines[6].new_lnum) - end) - - it('keeps no-newline markers inside the hunk as metadata lines', function() - local parsed = hunks.parse({ - 'diff --git a/foo.lua b/foo.lua', - '--- a/foo.lua', - '+++ b/foo.lua', - '@@ -1 +1 @@', - '-return 1', - '\\ No newline at end of file', - '+return 2', - '\\ No newline at end of file', - }, diffspec.index_to_worktree('foo.lua')) - - assert.are.same({ start = 4, count = 5, finish = 8 }, parsed[1].buffer_range) - assert.are.equal('meta', parsed[1].lines[3].kind) - assert.are.equal(6, parsed[1].lines[3].lnum) - assert.are.equal('meta', parsed[1].lines[5].kind) - assert.are.equal(8, parsed[1].lines[5].lnum) - end) - - it('detects multiple hunks in one file', function() - local parsed = hunks.parse({ - 'diff --git a/foo.lua b/foo.lua', - '--- a/foo.lua', - '+++ b/foo.lua', - '@@ -1 +1 @@', - '-one', - '+two', - '@@ -10 +10 @@', - '-ten', - '+eleven', - }, diffspec.index_to_worktree('foo.lua')) - - assert.are.equal(2, #parsed) - assert.are.equal('foo.lua', parsed[1].file) - assert.are.equal('foo.lua', parsed[2].file) - assert.are.same({ start = 4, count = 3, finish = 6 }, parsed[1].buffer_range) - assert.are.same({ start = 7, count = 3, finish = 9 }, parsed[2].buffer_range) - assert.are.same({ start = 10, count = 1, finish = 10 }, parsed[2].old_range) - assert.are.same({ start = 10, count = 1, finish = 10 }, parsed[2].new_range) - assert.are.same(parsed[1].file_header_lines, parsed[2].file_header_lines) - end) - - it('detects hunks across multiple files', function() - local parsed = hunks.parse({ - 'diff --git a/foo.lua b/foo.lua', - '--- a/foo.lua', - '+++ b/foo.lua', - '@@ -1 +1 @@', - '-one', - '+two', - 'diff --git a/bar.py b/bar.py', - '--- a/bar.py', - '+++ b/bar.py', - '@@ -3 +3 @@', - '-old', - '+new', - }) - - assert.are.equal(2, #parsed) - assert.are.equal('foo.lua', parsed[1].file) - assert.are.equal('bar.py', parsed[2].file) - assert.are.same({ start = 4, count = 3, finish = 6 }, parsed[1].buffer_range) - assert.are.same({ start = 10, count = 3, finish = 12 }, parsed[2].buffer_range) - end) - - it('preserves real top-level a and b directory names', function() - local parsed = hunks.parse({ - 'diff --git a/a/foo.lua b/a/foo.lua', - '--- a/a/foo.lua', - '+++ b/a/foo.lua', - '@@ -1 +1 @@', - '-old', - '+new', - 'diff --git a/b/bar.lua b/b/bar.lua', - '--- a/b/bar.lua', - '+++ b/b/bar.lua', - '@@ -1 +1 @@', - '-old', - '+new', - }) - - assert.are.equal('a/foo.lua', parsed[1].file) - assert.are.equal('b/bar.lua', parsed[2].file) - end) - - it('marks tree to tree hunks as read-only', function() - local parsed = - hunks.parse(sample_lines(), diffspec.rev_to_rev('HEAD~1', 'HEAD', 'lua/foo.lua')) - - assert.is_false(parsed[1].can_put) - assert.is_false(parsed[1].can_obtain) - assert.is_false(parsed[1].actionable) - assert.is_nil(parsed[1].mutation_target) - assert.are.same({ kind = 'tree', rev = 'HEAD~1' }, parsed[1].edge.left) - assert.are.same({ kind = 'tree', rev = 'HEAD' }, parsed[1].edge.right) - end) - - it('keeps right-endpoint mutation metadata separate from patch actionability', function() - local worktree_parsed = - hunks.parse(sample_lines(), diffspec.rev_to_worktree('HEAD', 'lua/foo.lua')) - local index_parsed = hunks.parse(sample_lines(), diffspec.head_to_index('lua/foo.lua')) - - assert.are.equal('worktree', worktree_parsed[1].mutation_target) - assert.is_false(worktree_parsed[1].can_put) - assert.is_false(worktree_parsed[1].can_obtain) - assert.is_false(worktree_parsed[1].actionable) - - assert.are.equal('index', index_parsed[1].mutation_target) - assert.is_false(index_parsed[1].can_put) - assert.is_true(index_parsed[1].can_obtain) - assert.is_true(index_parsed[1].actionable) - end) - end) - - describe('lookup helpers', function() - it('resolves cursor lines to hunks and hunk lines', function() - local parsed = hunks.parse(sample_lines(), diffspec.index_to_worktree('lua/foo.lua')) - - assert.is_nil(hunks.hunk_at_line(parsed, 1)) - assert.are.equal(parsed[1], hunks.hunk_at_line(parsed, 4)) - assert.are.equal(parsed[1], hunks.hunk_at_line(parsed, 6)) - assert.are.equal('header', hunks.line_at(parsed, 4).kind) - assert.are.equal('delete', hunks.line_at(parsed, 6).kind) - assert.are.equal('context', hunks.line_at(parsed, 8).kind) - assert.is_nil(hunks.line_at(parsed, 2)) - end) - - it('finds next and previous hunk boundaries', function() - local parsed = hunks.parse({ - 'diff --git a/foo.lua b/foo.lua', - '--- a/foo.lua', - '+++ b/foo.lua', - '@@ -1 +1 @@', - '-one', - '+two', - '@@ -10 +10 @@', - '-ten', - '+eleven', - }, diffspec.index_to_worktree('foo.lua')) - - assert.are.equal(parsed[1], hunks.next_hunk(parsed, 1)) - assert.are.equal(parsed[2], hunks.next_hunk(parsed, 4)) - assert.is_nil(hunks.next_hunk(parsed, 7)) - assert.are.equal(parsed[1], hunks.prev_hunk(parsed, 6)) - assert.are.equal(parsed[1], hunks.prev_hunk(parsed, 7)) - assert.is_nil(hunks.prev_hunk(parsed, 4)) - end) - - it('maps hunks and lines to source file positions', function() - local parsed = hunks.parse(sample_lines(), diffspec.index_to_worktree('lua/foo.lua')) - - assert.are.same({ path = 'lua/foo.lua', lnum = 1 }, hunks.source_line_for(parsed[1])) - assert.are.same({ path = 'lua/foo.lua', lnum = 2 }, hunks.source_line_for(parsed[1].lines[3])) - assert.are.same({ path = 'lua/foo.lua', lnum = 4 }, hunks.source_line_for(parsed[1].lines[6])) - end) - end) - - describe('navigation', function() - local bufnr - - after_each(function() - if bufnr and vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end - bufnr = nil - end) - - it('jumps between stored hunk boundaries with wrapping', function() - local lines = { - 'diff --git a/foo.lua b/foo.lua', - '--- a/foo.lua', - '+++ b/foo.lua', - '@@ -1 +1 @@', - '-one', - '+two', - '@@ -10 +10 @@', - '-ten', - '+eleven', - } - local parsed = hunks.parse(lines, diffspec.index_to_worktree('foo.lua')) - bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - vim.api.nvim_buf_set_var(bufnr, 'diffs_hunks', parsed) - vim.api.nvim_set_current_buf(bufnr) - - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - hunks.goto_next(bufnr) - assert.are.same({ 4, 0 }, vim.api.nvim_win_get_cursor(0)) - - hunks.goto_next(bufnr) - assert.are.same({ 7, 0 }, vim.api.nvim_win_get_cursor(0)) - - hunks.goto_prev(bufnr) - assert.are.same({ 4, 0 }, vim.api.nvim_win_get_cursor(0)) - end) - end) - - describe('source opening', function() - local source_buf - local diff_buf - local source_win - local diff_win - local saved_notify - - local function cleanup() - if saved_notify then - vim.notify = saved_notify - saved_notify = nil - end - pcall(vim.cmd, 'only') - helpers.delete_buffer(diff_buf) - helpers.delete_buffer(source_buf) - source_buf = nil - diff_buf = nil - source_win = nil - diff_win = nil - end - - local function create_source_window() - source_buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_name(source_buf, '/tmp/repo/lua/foo.lua') - vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, { - 'local a = 1', - 'local b = 3', - 'local c = 4', - 'local d = 5', - }) - vim.api.nvim_set_current_buf(source_buf) - source_win = vim.api.nvim_get_current_win() - end - - local function create_diff_window(diff_spec) - local lines = sample_lines() - diff_buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, lines) - vim.api.nvim_buf_set_var(diff_buf, 'diffs_hunks', hunks.parse(lines, diff_spec)) - vim.api.nvim_buf_set_var(diff_buf, 'diffs_source', { repo_root = '/tmp/repo' }) - vim.cmd('split') - diff_win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(diff_win, diff_buf) - end - - after_each(cleanup) - - it('opens an existing worktree source window from an added line', function() - create_source_window() - create_diff_window(diffspec.index_to_worktree('lua/foo.lua')) - - vim.api.nvim_win_set_cursor(diff_win, { 7, 0 }) - - assert.is_true(hunks.open_source(diff_buf)) - assert.are.equal(source_win, vim.api.nvim_get_current_win()) - assert.are.equal(source_buf, vim.api.nvim_get_current_buf()) - assert.are.same({ 2, 0 }, vim.api.nvim_win_get_cursor(0)) - end) - - it('maps deleted lines to the best worktree source line', function() - create_source_window() - create_diff_window(diffspec.rev_to_worktree('HEAD', 'lua/foo.lua')) - - vim.api.nvim_win_set_cursor(diff_win, { 6, 0 }) - - assert.is_true(hunks.open_source(diff_buf)) - assert.are.equal(source_win, vim.api.nvim_get_current_win()) - assert.are.same({ 2, 0 }, vim.api.nvim_win_get_cursor(0)) - end) - - it('refuses index-backed hunks with a clear message', function() - local notification - saved_notify = vim.notify - vim.notify = function(message, level) - notification = { message = message, level = level } - end - create_source_window() - create_diff_window(diffspec.head_to_index('lua/foo.lua')) - - vim.api.nvim_win_set_cursor(diff_win, { 7, 0 }) - - assert.is_false(hunks.open_source(diff_buf)) - assert.are.equal(diff_win, vim.api.nvim_get_current_win()) - assert.is_not_nil(notification) - assert.are.equal(vim.log.levels.WARN, notification.level) - assert.is_true(notification.message:find('index%-backed diff hunk') ~= nil) - end) - - it('refuses read-only tree-backed hunks with a clear message', function() - local notification - saved_notify = vim.notify - vim.notify = function(message, level) - notification = { message = message, level = level } - end - create_source_window() - create_diff_window(diffspec.rev_to_rev('HEAD~1', 'HEAD', 'lua/foo.lua')) - - vim.api.nvim_win_set_cursor(diff_win, { 7, 0 }) - - assert.is_false(hunks.open_source(diff_buf)) - assert.is_not_nil(notification) - assert.are.equal(vim.log.levels.WARN, notification.level) - assert.is_true(notification.message:find('read%-only tree%-backed diff hunk') ~= nil) - end) - - it('returns the resolved source location without opening it', function() - create_source_window() - create_diff_window(diffspec.index_to_worktree('lua/foo.lua')) - - vim.api.nvim_win_set_cursor(diff_win, { 9, 0 }) - - assert.are.same( - { path = '/tmp/repo/lua/foo.lua', lnum = 4 }, - hunks.source_at_cursor(diff_buf) - ) - end) - end) -end) diff --git a/spec/integration_spec.lua b/spec/integration_spec.lua index 62d1fd01..673ac78b 100644 --- a/spec/integration_spec.lua +++ b/spec/integration_spec.lua @@ -185,6 +185,29 @@ describe('integration', function() delete_buffer(bufnr) end) + it('does not refresh a new attachment from an old filetype retry', function() + local bufnr = create_buffer({ + 'diff --git a/app.conf b/app.conf', + '@@ -1 +1 @@', + '-old', + '+new', + }) + runtime.attach(bufnr) + assert.is_not_nil(runtime._test.ft_retry_pending[bufnr]) + runtime.detach(bufnr) + local calls = 0 + runtime.attach(bufnr, { + hunks = function() + calls = calls + 1 + return {} + end, + }) + run_scheduled_callbacks() + assert.are.equal(1, calls) + assert.is_nil(runtime._test.ft_retry_pending[bufnr]) + delete_buffer(bufnr) + end) + it('refreshes cache after scheduled callback fires', function() local bufnr = create_buffer({ 'diff --git a/app.conf b/app.conf', diff --git a/spec/jj_conflict_spec.lua b/spec/jj_conflict_spec.lua deleted file mode 100644 index d6ca7cdc..00000000 --- a/spec/jj_conflict_spec.lua +++ /dev/null @@ -1,175 +0,0 @@ -local conflict = require('diffs.conflict') - -local function create_file_buffer(lines) - local bufnr = vim.api.nvim_create_buf(false, false) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) - return bufnr -end - -local function snapshot_lines() - return { - 'line1', - '<<<<<<< conflict 1 of 1', - '+++++++ tzmtwuuv 321864a5 "sidea" (rebase destination)', - 'AAA', - '------- sozvolrq 24563c2b "base" (parents of rebased revision)', - 'line2', - '+++++++ vooloppp 8bb449c1 "sideb" (rebased revision)', - 'BBB', - '>>>>>>> conflict 1 of 1 ends', - 'line3', - } -end - -local function diff_lines() - return { - 'line1', - '<<<<<<< conflict 1 of 1', - '%%%%%%% diff from: sozvolrq 24563c2b "base" (parents of rebased revision)', - '\\\\\\\\\\\\\\ to: tzmtwuuv 321864a5 "sidea" (rebase destination)', - '-line2', - '+AAA', - '+++++++ vooloppp 8bb449c1 "sideb" (rebased revision)', - 'BBB', - '>>>>>>> conflict 1 of 1 ends', - 'line3', - } -end - -local function three_sided_lines() - return { - 'line1', - '<<<<<<< conflict 1 of 1', - '+++++++ sideA', - 'AAA', - '------- base', - 'line2', - '+++++++ sideB', - 'BBB', - '------- base', - 'line2', - '+++++++ sideC', - 'CCC', - '>>>>>>> conflict 1 of 1 ends', - 'line3', - } -end - -describe('jj conflict markers', function() - describe('snapshot style', function() - it('parses a two sided conflict', function() - local regions = conflict.parse(snapshot_lines()) - assert.are.equal(1, #regions) - local region = regions[1] - assert.are.equal(1, region.marker_open) - assert.are.equal(2, region.marker_ours) - assert.are.equal(3, region.ours_start) - assert.are.equal(4, region.ours_end) - assert.are.equal(4, region.marker_base) - assert.are.equal(5, region.base_start) - assert.are.equal(6, region.base_end) - assert.are.equal(6, region.marker_sep) - assert.are.equal(7, region.theirs_start) - assert.are.equal(8, region.theirs_end) - assert.are.equal(8, region.marker_theirs) - end) - - it('resolves to the first side', function() - local bufnr = create_file_buffer(snapshot_lines()) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 4, 0 }) - conflict.resolve_ours(bufnr) - assert.are.same({ 'line1', 'AAA', 'line3' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - - it('resolves to the last side', function() - local bufnr = create_file_buffer(snapshot_lines()) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 4, 0 }) - conflict.resolve_theirs(bufnr) - assert.are.same({ 'line1', 'BBB', 'line3' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - - it('removes the opening marker when resolving', function() - local bufnr = create_file_buffer(snapshot_lines()) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - conflict.resolve_none(bufnr) - assert.are.same({ 'line1', 'line3' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - - it('highlights the opening marker', function() - local bufnr = create_file_buffer(snapshot_lines()) - conflict.attach(bufnr) - local rows = {} - for _, mark in - ipairs( - vim.api.nvim_buf_get_extmarks( - bufnr, - vim.api.nvim_get_namespaces()['diffs-conflict'], - 0, - -1, - { details = true } - ) - ) - do - if mark[4] and mark[4].hl_group == 'DiffsConflictMarker' then - rows[mark[2]] = true - end - end - assert.is_true(rows[1]) - assert.is_true(rows[2]) - assert.is_true(rows[4]) - assert.is_true(rows[6]) - assert.is_true(rows[8]) - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - end) - - describe('unsupported styles', function() - it('does not parse diff style', function() - assert.are.equal(0, #conflict.parse(diff_lines())) - end) - - it('does not parse conflicts with more than two sides', function() - assert.are.equal(0, #conflict.parse(three_sided_lines())) - end) - - it('does not attach when nothing parses', function() - local bufnr = create_file_buffer(diff_lines()) - conflict.attach(bufnr) - assert.are.equal( - 0, - #vim.api.nvim_buf_get_extmarks( - bufnr, - vim.api.nvim_get_namespaces()['diffs-conflict'], - 0, - -1, - {} - ) - ) - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - end) - - describe('git style regression', function() - it('still parses without an opening marker field', function() - local regions = conflict.parse({ - '<<<<<<< tzmtwuuv 321864a5 "sidea" (rebase destination)', - 'AAA', - '||||||| sozvolrq 24563c2b "base" (parents of rebased revision)', - 'line2', - '=======', - 'BBB', - '>>>>>>> vooloppp 8bb449c1 "sideb" (rebased revision)', - }) - assert.are.equal(1, #regions) - assert.is_nil(regions[1].marker_open) - assert.are.equal(0, regions[1].marker_ours) - assert.are.equal(6, regions[1].marker_theirs) - end) - end) -end) diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua deleted file mode 100644 index a302ce3c..00000000 --- a/spec/merge_spec.lua +++ /dev/null @@ -1,792 +0,0 @@ -local helpers = require('spec.helpers') -local merge = require('diffs.merge') -local rails = require('diffs.rails') - -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_source', { 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 generated hunks with line-number rails', function() - local annotated, info = rails.annotate({ - '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 bufnr = helpers.create_buffer(annotated) - vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_width', info.prefix_width) - - 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) - - 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) - - 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) - - 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) - - 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) - assert.is_true(merge.is_resolved(diff_bufnr, 1)) - - cleanup() - end) - - it('matches and resolves conflicts from generated single-rail hunks', function() - local working_path = '/tmp/diffs_test_single_rail_resolve.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - local annotated, info = rails.annotate({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, { rail_style = 'single' }) - local d_bufnr = create_diff_buffer(annotated, working_path) - vim.api.nvim_buf_set_var(d_bufnr, 'diffs_rail_width', info.prefix_width) - vim.api.nvim_buf_set_var(d_bufnr, 'diffs_rail_style', info.style) - vim.api.nvim_set_current_buf(d_bufnr) - - local hunks = merge.parse_hunks(d_bufnr) - assert.are.equal(1, #hunks) - assert.are.same({ 'local x = 1' }, hunks[1].del_lines) - assert.are.same({ 'local x = 2' }, hunks[1].add_lines) - assert.is_not_nil(merge.match_hunk_to_conflict(hunks[1], w_bufnr)) - - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - merge.resolve_theirs(d_bufnr) - - local lines = vim.api.nvim_buf_get_lines(w_bufnr, 0, -1, false) - assert.are.same({ 'local x = 2' }, lines) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - 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) - - 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) - 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) - 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) - - 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('attach', 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 }) - - merge.resolve_ours(d_bufnr) - assert.is_true(merge.is_resolved(d_bufnr, 1)) - - local ready_bufnr - vim.api.nvim_create_autocmd('User', { - pattern = 'DiffsMergeReady', - once = true, - callback = function(event) - ready_bufnr = event.data.bufnr - end, - }) - merge.attach(d_bufnr) - assert.are.equal(d_bufnr, ready_bufnr) - - assert.is_false(merge.is_resolved(d_bufnr, 1)) - 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/parser_spec.lua b/spec/parser_spec.lua index 16b11e26..fe482055 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -1,6 +1,5 @@ require('spec.helpers') local parser = require('diffs.parser') -local rails = require('diffs.rails') describe('parser', function() describe('parse_buffer', function() @@ -56,61 +55,6 @@ describe('parser', function() delete_buffer(bufnr) end) - it('strips generated line-number rails before parsing hunks', function() - local annotated, info = rails.annotate({ - 'diff --git a/lua/test.lua b/lua/test.lua', - '--- a/lua/test.lua', - '+++ b/lua/test.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local new = true', - ' return M', - }) - local bufnr = create_buffer(annotated) - vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_width', info.prefix_width) - - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('lua/test.lua', hunks[1].filename) - assert.are.equal(info.prefix_width, hunks[1].quote_width) - assert.are.equal(info.prefix_width, hunks[1].rail_width) - assert.are.equal(4, hunks[1].start_line) - assert.are.same({ - ' local M = {}', - '+local new = true', - ' return M', - }, hunks[1].lines) - delete_buffer(bufnr) - end) - - it('records single generated rail style and keeps parsed hunk lines rail-free', function() - local annotated, info = rails.annotate({ - 'diff --git a/lua/test.lua b/lua/test.lua', - '--- a/lua/test.lua', - '+++ b/lua/test.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '-local old = false', - '+local new = true', - }, { rail_style = 'single' }) - local bufnr = create_buffer(annotated) - vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_width', info.prefix_width) - vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_style', info.style) - - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('single', hunks[1].rail_style) - assert.are.equal(info.prefix_width, hunks[1].rail_width) - assert.are.same({ - ' local M = {}', - '-local old = false', - '+local new = true', - }, hunks[1].lines) - delete_buffer(bufnr) - end) - it('detects multiple hunks in same file', function() local bufnr = create_buffer({ 'M lua/test.lua', @@ -432,33 +376,6 @@ describe('parser', function() delete_buffer(bufnr) end) - it('uses filetype from existing buffer when available', function() - local repo_root = '/tmp/test-repo' - local file_path = repo_root .. '/build' - - local file_buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_name(file_buf, file_path) - vim.api.nvim_set_option_value('filetype', 'bash', { buf = file_buf }) - - local diff_buf = create_buffer({ - 'M build', - '@@ -1,2 +1,3 @@', - ' echo "hello"', - '+set -e', - ' echo "done"', - }) - vim.api.nvim_buf_set_var(diff_buf, 'diffs_source', { repo_root = repo_root }) - - local hunks = parser.parse_buffer(diff_buf) - - assert.are.equal(1, #hunks) - assert.are.equal('build', hunks[1].filename) - assert.are.equal('bash', hunks[1].ft) - - delete_buffer(file_buf) - delete_buffer(diff_buf) - end) - it('uses filetype from existing buffer via git_dir', function() local git_dir = '/tmp/test-repo/.git' local repo_root = '/tmp/test-repo' @@ -505,7 +422,7 @@ describe('parser', function() '+set -e', ' echo "hello"', }) - vim.api.nvim_buf_set_var(diff_buf, 'diffs_source', { repo_root = repo_root }) + vim.api.nvim_buf_set_var(diff_buf, 'git_dir', repo_root .. '/.git') local hunks = parser.parse_buffer(diff_buf) diff --git a/spec/plugin_spec.lua b/spec/plugin_spec.lua index 2a3855bf..721d389c 100644 --- a/spec/plugin_spec.lua +++ b/spec/plugin_spec.lua @@ -29,28 +29,6 @@ describe('plugin bootstrap', function() return output end - it('registers entry points without loading implementation modules', function() - local init_lines = { - ('vim.opt.runtimepath:prepend(%s)'):format(vim.inspect(vim.fn.getcwd())), - } - local after_lines = { - 'local commands = vim.api.nvim_get_commands({})', - "print('has_diff_command=' .. tostring(commands.Diff ~= nil))", - "print('diff_bar=' .. tostring(commands.Diff and commands.Diff.bar == true))", - "print('commands_loaded=' .. tostring(package.loaded['diffs.commands'] ~= nil))", - "print('runtime_loaded=' .. tostring(package.loaded['diffs.runtime'] ~= nil))", - "print('highlight_loaded=' .. tostring(package.loaded['diffs.highlight'] ~= nil))", - } - - local output = run_child(init_lines, after_lines) - - assert.matches('has_diff_command=true', output, 1, true) - assert.matches('diff_bar=true', output, 1, true) - assert.matches('commands_loaded=false', output, 1, true) - assert.matches('runtime_loaded=false', output, 1, true) - assert.matches('highlight_loaded=false', output, 1, true) - end) - it('does not load runtime for an ordinary file', function() local init_lines = { ('vim.opt.runtimepath:prepend(%s)'):format(vim.inspect(vim.fn.getcwd())), @@ -60,67 +38,12 @@ describe('plugin bootstrap', function() "vim.fn.writefile({ 'ordinary source line' }, path)", 'vim.cmd.edit(path)', "print('runtime_loaded=' .. tostring(package.loaded['diffs.runtime'] ~= nil))", - "print('conflict_loaded=' .. tostring(package.loaded['diffs.conflict'] ~= nil))", 'vim.fn.delete(path)', } local output = run_child(init_lines, after_lines) assert.matches('runtime_loaded=false', output, 1, true) - assert.matches('conflict_loaded=false', output, 1, true) - end) - - it('respects the conflict setting', function() - for _, enabled in ipairs({ true, false }) do - local init_lines = { - ('vim.g.diffs = { conflict = %s }'):format(tostring(enabled)), - ('vim.opt.runtimepath:prepend(%s)'):format(vim.inspect(vim.fn.getcwd())), - } - local after_lines = vim.split( - [[ -local path = vim.fn.tempname() -vim.fn.writefile({ '<<<<<<< ours', 'old', '=======', 'new', '>>>>>>> theirs' }, path) -vim.cmd.edit(path) -print('runtime_loaded=' .. tostring(package.loaded['diffs.runtime'] ~= nil)) -print('conflict_loaded=' .. tostring(package.loaded['diffs.conflict'] ~= nil)) - -if not vim.g.diffs.conflict then - local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) - vim.fn.maparg('(diffs-conflict-ours)', 'n', false, true).callback() - print('unchanged=' .. tostring(vim.deep_equal(lines, vim.api.nvim_buf_get_lines(0, 0, -1, false)))) -end - -for _, kind in ipairs({ 'conflict', 'merge' }) do - if kind == 'merge' then - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { '@@ -1 +1 @@', '-old', '+new' }) - vim.b[buf].diffs_source = { working_path = path } - vim.api.nvim_set_current_buf(buf) - end - for _, direction in ipairs({ 'next', 'prev' }) do - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - vim.fn.maparg('(diffs-' .. kind .. '-' .. direction .. ')', 'n', false, true).callback() - print(kind .. '_' .. direction .. '=' .. vim.api.nvim_win_get_cursor(0)[1]) - end -end -vim.fn.delete(path) -]], - '\n' - ) - - local output = run_child(init_lines, after_lines) - - assert.matches('runtime_loaded=' .. tostring(enabled), output, 1, true) - assert.matches('conflict_loaded=' .. tostring(enabled), output, 1, true) - for _, kind in ipairs({ 'conflict', 'merge' }) do - for _, direction in ipairs({ 'next', 'prev' }) do - assert.matches(kind .. '_' .. direction .. '=' .. (enabled and 1 or 2), output, 1, true) - end - end - if not enabled then - assert.matches('unchanged=true', output, 1, true) - end - end end) it('loads supported config during plugin startup', function() @@ -129,7 +52,7 @@ vim.fn.delete(path) 'vim.notify = function(message, level)', '_G.diffs_notifications[#_G.diffs_notifications + 1] = { message = message, level = level }', 'end', - 'vim.g.diffs = { view = { prefix = false }, highlights = { background = false }, conflict = false, integrations = { "fugitive", "neogit", "neojj", "gitsigns", "telescope" } }', + 'vim.g.diffs = { view = { prefix = false }, highlights = { background = false }, integrations = { "fugitive", "neogit", "neojj", "gitsigns", "telescope" } }', ('vim.opt.runtimepath:prepend(%s)'):format(vim.inspect(vim.fn.getcwd())), } @@ -150,7 +73,6 @@ vim.fn.delete(path) "print('runtime_gitsigns=' .. tostring(runtime_config.integrations.gitsigns))", "print('runtime_telescope=' .. tostring(runtime_config.integrations.telescope))", "print('runtime_background=' .. tostring(runtime_config.highlights.background))", - "print('runtime_conflict=' .. tostring(runtime_config.conflict))", "print('runtime_config_priorities=' .. tostring(runtime_config.highlights.priorities))", "print('highlight_priority_clear=' .. tostring(highlight_opts.highlights.priorities.clear))", "print('highlight_priority_syntax=' .. tostring(highlight_opts.highlights.priorities.syntax))", @@ -186,7 +108,6 @@ vim.fn.delete(path) assert.matches('runtime_gitsigns=true', output, 1, true) assert.matches('runtime_telescope=true', output, 1, true) assert.matches('runtime_background=false', output, 1, true) - assert.matches('runtime_conflict=false', output, 1, true) assert.matches('runtime_config_priorities=nil', output, 1, true) assert.matches('highlight_priority_clear=198', output, 1, true) assert.matches('highlight_priority_syntax=199', output, 1, true) @@ -199,113 +120,6 @@ vim.fn.delete(path) assert.matches('has_telescope_autocmd=true', output, 1, true) end) - it('navigates both review panes and preserves quickfix entries across reloads', function() - local init_lines = { - ('vim.opt.runtimepath:prepend(%s)'):format(vim.inspect(vim.fn.getcwd())), - 'vim.g.diffs = { integrations = { difftastic = false } }', - } - local after_lines = vim.split( - [[ -local repo = vim.fn.resolve(vim.fn.tempname()) -vim.fn.mkdir(repo, 'p') -local function git(...) - local output = vim.fn.systemlist({ 'git', '-C', repo, ... }) - assert(vim.v.shell_error == 0, table.concat(output, '\n')) -end -local ok, err = pcall(function() - git('init', '-q') - git('config', 'user.name', 'Test') - git('config', 'user.email', 'test@example.com') - for _, file in ipairs({ 'one.lua', 'three.lua', 'two.lua' }) do - vim.fn.writefile({ 'old' }, repo .. '/' .. file) - end - git('add', '.') - git('commit', '-qm', 'base') - for _, file in ipairs({ 'one.lua', 'three.lua', 'two.lua' }) do - vim.fn.writefile({ 'new' }, repo .. '/' .. file) - end - git('commit', '-qam', 'target') - vim.cmd.cd(repo) - vim.cmd('Diff review HEAD~1') - local buf = vim.api.nvim_get_current_buf() - local window_count = #vim.api.nvim_tabpage_list_wins(0) - local statuscolumn = vim.wo.statuscolumn - local before = vim.fn.getqflist({ id = 0, items = 0 }) - assert(#before.items == 3) - vim.keymap.set('n', ']q', 'cnext') - vim.keymap.set('n', '[q', 'cprevious') - vim.cmd('normal gs') - assert(not vim.api.nvim_buf_is_loaded(buf)) - local pair_wins = vim.tbl_filter(function(win) - return require('diffs').review_current(vim.api.nvim_win_get_buf(win)) ~= nil - end, vim.api.nvim_tabpage_list_wins(0)) - assert(#pair_wins == 2) - local reads = 0 - local autocmd = vim.api.nvim_create_autocmd('BufReadCmd', { - pattern = 'diffs://review:*', - callback = function() reads = reads + 1 end, - }) - local function check_pair(index) - assert(#vim.api.nvim_tabpage_list_wins(0) == window_count + 1) - for _, win in ipairs(pair_wins) do - local pane = vim.api.nvim_win_get_buf(win) - local current = require('diffs').review_current(pane) - assert(current and current.index == index, vim.inspect(current)) - assert(vim.bo[pane].modifiable == false) - assert(vim.wo[win].scrollbind and vim.wo[win].cursorbind) - end - assert(reads == 0) - assert(vim.fn.getqflist({ id = 0 }).id == before.id) - end - check_pair(1) - vim.cmd('normal ]q') - check_pair(2) - vim.api.nvim_set_current_win(pair_wins[1]) - vim.cmd('normal ]q') - check_pair(3) - vim.cmd('normal ]q') - check_pair(1) - vim.cmd('normal [q') - check_pair(3) - vim.cmd('normal 2[q') - check_pair(1) - vim.cmd('normal 2]q') - check_pair(3) - vim.api.nvim_del_autocmd(autocmd) - vim.cmd('normal gs') - assert(#vim.api.nvim_tabpage_list_wins(0) == window_count) - buf = vim.api.nvim_get_current_buf() - assert(vim.b[buf].diffs_review.layout == 'unified') - before = vim.fn.getqflist({ id = 0, items = 0 }) - vim.cmd('normal gs') - assert(require('diffs').review_current().index == 3) - vim.cmd('normal q') - local remaining_windows = #vim.api.nvim_tabpage_list_wins(0) - vim.cmd('cc 2') - local after = vim.fn.getqflist({ id = 0, idx = 0 }) - assert(after.id == before.id) - assert(after.idx == 2) - assert(vim.api.nvim_get_current_buf() == buf) - assert(vim.api.nvim_win_get_cursor(0)[1] == before.items[2].lnum) - assert(#vim.api.nvim_tabpage_list_wins(0) == remaining_windows) - assert(not vim.wo.scrollbind) - assert(not vim.wo.cursorbind) - assert(vim.wo.statuscolumn == statuscolumn) - vim.fn.writefile({ 'new file' }, repo .. '/four.lua') - vim.cmd('edit') - assert(#vim.fn.getqflist() == 4) -end) -vim.fn.delete(repo, 'rf') -assert(ok, err) -print('quickfix_reloaded=true') -]], - '\n' - ) - - local output = run_child(init_lines, after_lines) - assert.matches('quickfix_reloaded=true', output, 1, true) - end) - it('enhances native diff windows opened at startup with nvim -d', function() local tmpdir = vim.fn.resolve(vim.fn.tempname()) assert.are.equal(1, vim.fn.mkdir(tmpdir, 'p')) diff --git a/spec/rails_spec.lua b/spec/rails_spec.lua deleted file mode 100644 index 463e539e..00000000 --- a/spec/rails_spec.lua +++ /dev/null @@ -1,159 +0,0 @@ -local helpers = require('spec.helpers') - -local rails = require('diffs.rails') - -describe('diffs.rails', function() - it('adds old and new line-number rails to unified diff lines', function() - local annotated, info = rails.annotate({ - 'diff --git a/file.txt b/file.txt', - '--- a/file.txt', - '+++ b/file.txt', - '@@ -9,3 +10,4 @@', - ' alpha', - '-beta', - '+beta changed', - ' gamma', - '+delta', - '\\ No newline at end of file', - }) - - assert.are.same({ - width = 2, - prefix_width = 12, - style = 'dual', - }, info) - assert.are.equal(' │ diff --git a/file.txt b/file.txt', annotated[1]) - assert.are.equal(' │ @@ -9,3 +10,4 @@', annotated[4]) - assert.are.equal(' 9 10 │ alpha', annotated[5]) - assert.are.equal(' 10 │ -beta', annotated[6]) - assert.are.equal(' 11 │ +beta changed', annotated[7]) - assert.are.equal(' 11 12 │ gamma', annotated[8]) - assert.are.equal(' 13 │ +delta', annotated[9]) - assert.are.equal(' │ \\ No newline at end of file', annotated[10]) - end) - - it('leaves non-diff content unchanged', function() - local lines = { 'not a diff' } - local annotated, info = rails.annotate(lines) - - assert.are.equal(lines, annotated) - assert.is_nil(info) - end) - - it('adds a single side-aware line-number rail to unified diff lines', function() - local annotated, info = rails.annotate({ - 'diff --git a/file.txt b/file.txt', - '--- a/file.txt', - '+++ b/file.txt', - '@@ -9,3 +10,4 @@', - ' alpha', - '-beta', - '+beta changed', - ' gamma', - '+delta', - '\\ No newline at end of file', - }, { rail_style = 'single' }) - - assert.are.same({ - width = 2, - prefix_width = 9, - style = 'single', - }, info) - assert.are.equal(' │ diff --git a/file.txt b/file.txt', annotated[1]) - assert.are.equal(' │ @@ -9,3 +10,4 @@', annotated[4]) - assert.are.equal(' 10 │ alpha', annotated[5]) - assert.are.equal(' 10 │ -beta', annotated[6]) - assert.are.equal(' 11 │ +beta changed', annotated[7]) - assert.are.equal(' 12 │ gamma', annotated[8]) - assert.are.equal(' 13 │ +delta', annotated[9]) - assert.are.equal(' │ \\ No newline at end of file', annotated[10]) - end) - - it('does not render trailing spaces for empty context lines', function() - local annotated, info = rails.annotate({ - 'diff --git a/file.txt b/file.txt', - '@@ -1,2 +1,2 @@', - ' line', - ' ', - }) - - assert.are.same({ - width = 1, - prefix_width = 10, - style = 'dual', - }, info) - assert.are.equal(' 2 2 │', annotated[4]) - assert.is_nil(annotated[4]:match('%s$')) - assert.are.equal(' ', rails.strip(annotated[4], info.prefix_width)) - end) - - it('strips single rail display lines back to raw unified diff lines', function() - local lines = { - 'diff --git a/file.txt b/file.txt', - '@@ -1,3 +1,3 @@', - ' first', - ' ', - '-old', - '+new', - } - local annotated, info = rails.annotate(lines, { rail_style = 'single' }) - - assert.are.equal(' 2 │', annotated[4]) - assert.is_nil(annotated[4]:match('%s$')) - assert.are.equal(' ', rails.strip(annotated[4], info.prefix_width)) - assert.are.same( - lines, - vim.tbl_map(function(line) - return rails.strip(line, info.prefix_width) - end, annotated) - ) - end) - - it('returns byte ranges for old and new rail number columns', function() - assert.are.same({ - old_start = 2, - old_end = 4, - new_start = 5, - new_end = 7, - }, rails.ranges(12)) - end) - - it('returns style-aware byte ranges for rail number columns', function() - assert.are.same({ - { - start = 2, - finish = 4, - }, - { - start = 5, - finish = 7, - }, - }, rails.number_ranges(12, 'dual')) - assert.are.same({ - { - start = 2, - finish = 4, - }, - }, rails.number_ranges(9, 'single')) - end) - - it('reads buffer rail style with a dual compatibility default', function() - local bufnr = helpers.create_buffer({}) - - assert.are.equal('dual', rails.style_for_buffer(bufnr)) - - vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_style', 'single') - assert.are.equal('single', rails.style_for_buffer(bufnr)) - - vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_style', 'dual') - assert.are.equal('dual', rails.style_for_buffer(bufnr)) - - vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_style', 'stacked') - assert.are.equal('dual', rails.style_for_buffer(bufnr)) - - pcall(vim.api.nvim_buf_del_var, bufnr, 'diffs_rail_style') - assert.are.equal('dual', rails.style_for_buffer(bufnr)) - - helpers.delete_buffer(bufnr) - end) -end) diff --git a/spec/read_buffer_spec.lua b/spec/read_buffer_spec.lua deleted file mode 100644 index edcc7df5..00000000 --- a/spec/read_buffer_spec.lua +++ /dev/null @@ -1,662 +0,0 @@ -local helpers = require('spec.helpers') - -local commands = require('diffs.commands') -local diffspec = require('diffs.spec') -local generated = require('diffs.generated') -local git = require('diffs.git') -local rails = require('diffs.rails') -local runtime = require('diffs.runtime') - -local saved_git = {} -local saved_notify -local saved_systemlist -local saved_jobstart -local saved_jobwait -local test_buffers = {} - -local function mock_git(overrides) - overrides = overrides or {} - saved_git.get_file_content = git.get_file_content - saved_git.get_index_content = git.get_index_content - saved_git.get_working_content = git.get_working_content - - git.get_file_content = overrides.get_file_content - or function() - return { 'local M = {}', 'return M' } - end - git.get_index_content = overrides.get_index_content - or function() - return { 'local M = {}', 'return M' } - end - git.get_working_content = overrides.get_working_content - or function() - return { 'local M = {}', 'local x = 1', 'return M' } - end -end - -local function mock_systemlist(fn) - saved_systemlist = vim.fn.systemlist - saved_jobstart = vim.fn.jobstart - saved_jobwait = vim.fn.jobwait - local exit_code - vim.fn.jobstart = function(cmd, opts) - local result, code, stderr = fn(cmd) - exit_code = code or 0 - local stdout = exit_code == 0 and vim.deepcopy(result or {}) or {} - stderr = stderr or (exit_code ~= 0 and vim.deepcopy(result or {}) or {}) - stdout[#stdout + 1] = '' - stderr[#stderr + 1] = '' - opts.on_stdout(1, stdout) - opts.on_stderr(1, stderr) - return 1 - end - vim.fn.jobwait = function() - return { exit_code } - end - vim.fn.systemlist = function(cmd) - local result, shell_error = fn(cmd) - if shell_error and shell_error ~= 0 then - saved_systemlist({ 'false' }) - else - saved_systemlist({ 'true' }) - end - return result - end -end - -local function mock_notify(fn) - saved_notify = vim.notify - vim.notify = fn -end - -local function restore_mocks() - for k, v in pairs(saved_git) do - git[k] = v - end - saved_git = {} - if saved_notify then - vim.notify = saved_notify - saved_notify = nil - end - if saved_jobstart then - vim.fn.jobstart = saved_jobstart - saved_jobstart = nil - end - if saved_jobwait then - vim.fn.jobwait = saved_jobwait - saved_jobwait = nil - end - if saved_systemlist then - vim.fn.systemlist = saved_systemlist - saved_systemlist = nil - end -end - ----@param name string ----@param vars? table ----@return integer -local function create_diffs_buffer(name, vars) - local existing = vim.fn.bufnr(name) - if existing ~= -1 then - vim.api.nvim_buf_delete(existing, { force = true }) - end - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_name(bufnr, name) - vars = vars or {} - for k, v in pairs(vars) do - vim.api.nvim_buf_set_var(bufnr, k, v) - end - table.insert(test_buffers, bufnr) - return bufnr -end - -local function cleanup_buffers() - for _, bufnr in ipairs(test_buffers) do - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end - end - test_buffers = {} -end - -local function buffer_lines(bufnr) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - return vim.tbl_map(function(line) - return rails.strip(line, rails.width_for_buffer(bufnr)) - end, lines) -end - -local function review_diff_lines() - return { - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1 +1 @@', - '-old', - '+new', - } -end - -local function has_buf_var(bufnr, name) - local ok = pcall(vim.api.nvim_buf_get_var, bufnr, name) - return ok -end - -describe('read_buffer', function() - after_each(function() - restore_mocks() - cleanup_buffers() - end) - - describe('early returns', function() - it('does nothing on non-diffs:// buffer', function() - local bufnr = vim.api.nvim_create_buf(false, true) - table.insert(test_buffers, bufnr) - assert.has_no.errors(function() - commands.read_buffer(bufnr) - end) - assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) - end) - - it('leaves content unchanged when source metadata is missing', function() - local notification - mock_notify(function(message, level) - notification = { message = message, level = level } - end) - local bufnr = create_diffs_buffer('diffs://staged:missing_source.lua') - assert.has_no.errors(function() - commands.read_buffer(bufnr) - end) - assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) - assert.is_not_nil(notification) - assert.are.equal(vim.log.levels.WARN, notification.level) - assert.is_true(notification.message:find('without diffs_source metadata', 1, true) ~= nil) - end) - end) - - describe('unloaded buffers', function() - it('regenerates content after an unload cleared the buffer variables', function() - mock_git() - - local bufnr = commands._test.create_generated_diff_buffer({ - name = 'diffs://unstaged:unloaded.lua', - lines = review_diff_lines(), - repo_root = '/tmp', - source = { - version = 1, - kind = 'file', - repo_root = '/tmp', - spec = diffspec.index_to_worktree('unloaded.lua'), - }, - rail_style = 'single', - }) - table.insert(test_buffers, bufnr) - - vim.api.nvim_win_set_buf(0, bufnr) - vim.cmd('enew') - - assert.is_false(vim.api.nvim_buf_is_loaded(bufnr)) - assert.is_false(has_buf_var(bufnr, 'diffs_source')) - assert.is_false(has_buf_var(bufnr, 'diffs_repo_root')) - - commands.read_buffer(bufnr) - - local lines = buffer_lines(bufnr) - assert.are.equal('diff --git a/unloaded.lua b/unloaded.lua', lines[1]) - assert.are.equal('+local x = 1', lines[6]) - assert.are.equal('/tmp', vim.api.nvim_buf_get_var(bufnr, 'diffs_source').repo_root) - assert.are.equal('single', vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_style')) - end) - - it('restores review metadata and keymaps for an unloaded review map', function() - mock_systemlist(function(cmd) - if cmd[4] == 'rev-parse' then - return { 'commit' } - end - if cmd[4] ~= 'diff' then - return {} - end - return review_diff_lines() - end) - - local bufnr = commands._test.create_generated_diff_buffer({ - name = 'diffs://review:origin/main...feature/topic', - lines = review_diff_lines(), - repo_root = '/tmp/repo', - source = { - version = 1, - kind = 'review', - repo_root = '/tmp/repo', - review = { - base = 'origin/main', - target = 'feature/topic', - mode = 'merge-base', - }, - }, - vars = { - diffs_review = { display = 'origin/main...feature/topic', layout = 'stacked' }, - }, - }) - table.insert(test_buffers, bufnr) - - vim.api.nvim_win_set_buf(0, bufnr) - vim.cmd('enew') - - commands.read_buffer(bufnr) - - assert.are.same(review_diff_lines(), buffer_lines(bufnr)) - assert.are.same( - { display = 'origin/main...feature/topic', layout = 'stacked' }, - vim.api.nvim_buf_get_var(bufnr, 'diffs_review') - ) - assert.is_true(helpers.has_keymap(bufnr, 'gs')) - end) - end) - - describe('buffer options', function() - it('sets buftype, bufhidden, swapfile, modifiable, filetype', function() - mock_git() - local bufnr = create_diffs_buffer('diffs://staged:options_test.lua', { - diffs_source = generated.file_source('/tmp', diffspec.head_to_index('options_test.lua')), - }) - - commands.read_buffer(bufnr) - - assert.are.equal('nowrite', vim.api.nvim_get_option_value('buftype', { buf = bufnr })) - assert.are.equal('delete', vim.api.nvim_get_option_value('bufhidden', { buf = bufnr })) - assert.is_false(vim.api.nvim_get_option_value('swapfile', { buf = bufnr })) - assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = bufnr })) - assert.are.equal('diff', vim.api.nvim_get_option_value('filetype', { buf = bufnr })) - end) - end) - - describe('dispatch', function() - it('uses diffs_source metadata without parsing the display name', function() - local calls = {} - mock_git({ - get_file_content = function(rev, filepath) - table.insert(calls, { 'file', rev, filepath }) - return { 'tree' } - end, - get_index_content = function(filepath) - table.insert(calls, { 'index', filepath }) - return { 'index' } - end, - get_working_content = function(filepath) - table.insert(calls, { 'worktree', filepath }) - return { 'worktree' } - end, - }) - - local bufnr = create_diffs_buffer('diffs://metadata-only', { - diffs_source = { - version = 1, - kind = 'file', - repo_root = '/tmp', - spec = diffspec.index_to_worktree('source_meta.lua'), - }, - }) - commands.read_buffer(bufnr) - - assert.are.same({ - { 'index', '/tmp/source_meta.lua' }, - { 'worktree', '/tmp/source_meta.lua' }, - }, calls) - local diff_hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal('source_meta.lua', diff_hunks[1].file) - assert.is_true(diff_hunks[1].can_put) - assert.is_false(diff_hunks[1].can_obtain) - assert.is_true(diff_hunks[1].actionable) - end) - - it('rejects standalone split endpoint reloads without adding pair actions', function() - local called_index = false - local notification - mock_notify(function(message, level) - notification = { message = message, level = level } - end) - mock_git({ - get_index_content = function() - called_index = true - return { 'local M = {}', 'return M' } - end, - }) - - local bufnr = create_diffs_buffer('diffs://split:left:index:lua/foo.lua', { - diffs_source = { - version = 1, - kind = 'split_endpoint', - repo_root = '/tmp/repo', - spec = diffspec.index_to_worktree('lua/foo.lua'), - side = 'left', - path = 'lua/foo.lua', - filetype = 'lua', - }, - }) - - commands.read_buffer(bufnr) - - assert.is_false(called_index) - assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) - assert.is_false(has_buf_var(bufnr, 'diffs_split_side')) - assert.is_false(has_buf_var(bufnr, 'diffs_split_peer')) - assert.is_false(helpers.has_keymap(bufnr, 'q')) - assert.is_false(helpers.has_keymap(bufnr, '')) - assert.is_false(helpers.has_keymap(bufnr, ']c')) - assert.is_false(helpers.has_keymap(bufnr, '[c')) - assert.is_not_nil(notification) - assert.are.equal(vim.log.levels.WARN, notification.level) - assert.is_true(notification.message:find('without a valid peer', 1, true) ~= nil) - end) - - it('reloads both split endpoints from paired source metadata', function() - mock_git() - - local source = { - version = 1, - kind = 'split_endpoint', - repo_root = '/tmp/repo', - spec = diffspec.index_to_worktree('lua/foo.lua'), - path = 'lua/foo.lua', - filetype = 'lua', - } - local left_buf = create_diffs_buffer('diffs://split:left:index:lua/foo.lua', { - diffs_source = vim.tbl_extend('force', source, { side = 'left' }), - }) - local right_buf = create_diffs_buffer('diffs://split:right:worktree:lua/foo.lua', { - diffs_source = vim.tbl_extend('force', source, { side = 'right' }), - }) - vim.api.nvim_buf_set_var(left_buf, 'diffs_split_peer', right_buf) - vim.api.nvim_buf_set_var(right_buf, 'diffs_split_peer', left_buf) - vim.api.nvim_buf_set_lines(left_buf, 0, -1, false, { 'stale left' }) - vim.api.nvim_buf_set_lines(right_buf, 0, -1, false, { 'stale right' }) - - commands.read_buffer(left_buf) - - local left_lines = vim.api.nvim_buf_get_lines(left_buf, 0, -1, false) - local right_lines = vim.api.nvim_buf_get_lines(right_buf, 0, -1, false) - assert.are.equal(#left_lines, #right_lines) - assert.are.equal('local M = {}', left_lines[1]) - assert.are.equal('local M = {}', right_lines[1]) - assert.are.equal('local x = 1', right_lines[2]) - assert.is_true(vim.tbl_contains(left_lines, '')) - assert.are.equal(right_buf, vim.api.nvim_buf_get_var(left_buf, 'diffs_split_peer')) - assert.are.equal(left_buf, vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')) - end) - - it('does not partially reload paired split endpoints when the peer read fails', function() - mock_notify(function() end) - mock_git({ - get_index_content = function() - return { 'new left' } - end, - get_working_content = function() - return nil, 'read failed' - end, - }) - - local source = { - version = 1, - kind = 'split_endpoint', - repo_root = '/tmp/repo', - spec = diffspec.index_to_worktree('lua/foo.lua'), - path = 'lua/foo.lua', - filetype = 'lua', - } - local left_buf = create_diffs_buffer('diffs://split:left:index:lua/foo.lua', { - diffs_source = vim.tbl_extend('force', source, { side = 'left' }), - }) - local right_buf = create_diffs_buffer('diffs://split:right:worktree:lua/foo.lua', { - diffs_source = vim.tbl_extend('force', source, { side = 'right' }), - }) - vim.api.nvim_buf_set_var(left_buf, 'diffs_split_peer', right_buf) - vim.api.nvim_buf_set_var(right_buf, 'diffs_split_peer', left_buf) - vim.api.nvim_buf_set_lines(left_buf, 0, -1, false, { 'stale left' }) - vim.api.nvim_buf_set_lines(right_buf, 0, -1, false, { 'stale right' }) - - commands.read_buffer(left_buf) - - assert.are.same({ 'stale left' }, vim.api.nvim_buf_get_lines(left_buf, 0, -1, false)) - assert.are.same({ 'stale right' }, vim.api.nvim_buf_get_lines(right_buf, 0, -1, false)) - end) - - it('reloads untracked DiffSpec buffers with empty index content and hunk metadata', function() - mock_git({ - get_index_content = function(filepath) - assert.are.equal('/tmp/new.lua', filepath) - return nil, 'file not in index' - end, - get_working_content = function(filepath) - assert.are.equal('/tmp/new.lua', filepath) - return { 'local M = {}', 'return M' } - end, - }) - - local bufnr = create_diffs_buffer('diffs://untracked:new.lua', { - diffs_source = generated.file_source('/tmp', diffspec.index_to_worktree('new.lua')), - }) - commands.read_buffer(bufnr) - - local text = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n') - assert.is_true(text:find('new file mode 100644', 1, true) ~= nil) - assert.is_true(text:find('--- /dev/null', 1, true) ~= nil) - local diff_hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal('new.lua', diff_hunks[1].file) - assert.is_true(diff_hunks[1].actionable) - end) - - it( - 'reloads staged deletion DiffSpec buffers with empty index content and hunk metadata', - function() - mock_git({ - get_file_content = function(rev, filepath) - assert.are.equal('HEAD', rev) - assert.are.equal('/tmp/deleted.lua', filepath) - return { 'local M = {}', 'return M' } - end, - get_index_content = function(filepath) - assert.are.equal('/tmp/deleted.lua', filepath) - return nil, 'file not in index' - end, - }) - - local bufnr = create_diffs_buffer('diffs://staged:deleted.lua', { - diffs_source = generated.file_source('/tmp', diffspec.head_to_index('deleted.lua')), - }) - commands.read_buffer(bufnr) - - local text = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n') - assert.is_true(text:find('deleted file mode 100644', 1, true) ~= nil) - assert.is_true(text:find('+++ /dev/null', 1, true) ~= nil) - local diff_hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks') - assert.are.equal(1, #diff_hunks) - assert.are.equal('deleted.lua', diff_hunks[1].file) - assert.are.equal('index', diff_hunks[1].mutation_target) - end - ) - - it('warns and leaves content unchanged for invalid diffs_source metadata', function() - local notification - mock_notify(function(message, level) - notification = { message = message, level = level } - end) - - local bufnr = create_diffs_buffer('diffs://unstaged:invalid_source.lua', { - diffs_source = { - version = 1, - kind = 'file', - repo_root = '/tmp', - }, - }) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale content' }) - - commands.read_buffer(bufnr) - - assert.is_not_nil(notification) - assert.are.equal(vim.log.levels.WARN, notification.level) - assert.is_true(notification.message:find('invalid diffs_source metadata', 1, true) ~= nil) - assert.are.same({ 'stale content' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) - end) - - it('reports missing review refs from source metadata without replacing buffer lines', function() - local called_diff = false - local notification - local operation_repo - mock_notify(function(message, level) - notification = { message = message, level = level } - end) - mock_systemlist(function(cmd) - if cmd[4] == 'diff' then - called_diff = true - elseif cmd[4] == 'merge-base' then - operation_repo = cmd[3] - return { 'fatal: Not a valid object name missing/ref' }, 128 - end - return {} - end) - - local bufnr = create_diffs_buffer('diffs://review:missing/ref', { - diffs_source = { - version = 1, - kind = 'review', - repo_root = '/home/test/repo', - review = { - base = 'missing/ref', - }, - }, - }) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale review' }) - - commands.read_buffer(bufnr) - - assert.is_false(called_diff) - assert.are.equal('/home/test/repo', operation_repo) - assert.are.same({ 'stale review' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) - assert.is_not_nil(notification) - assert.are.equal(vim.log.levels.WARN, notification.level) - assert.is_true( - notification.message:find('fatal: Not a valid object name missing/ref', 1, true) ~= nil - ) - end) - end) - - describe('content', function() - it('generates valid unified diff header with correct paths', function() - mock_git({ - get_file_content = function() - return { 'old' } - end, - get_working_content = function() - return { 'new' } - end, - }) - - local bufnr = create_diffs_buffer('diffs://HEAD:lua/diffs/init.lua', { - diffs_source = generated.file_source( - '/tmp', - diffspec.rev_to_worktree('HEAD', 'lua/diffs/init.lua') - ), - }) - commands.read_buffer(bufnr) - - local lines = buffer_lines(bufnr) - assert.are.equal('diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua', lines[1]) - assert.are.equal('--- a/lua/diffs/init.lua', lines[2]) - assert.are.equal('+++ b/lua/diffs/init.lua', lines[3]) - end) - - it('uses both source paths for diff headers in renames', function() - mock_git({ - get_file_content = function(_, path) - assert.are.equal('/tmp/old_name.lua', path) - return { 'old content' } - end, - get_index_content = function() - return { 'new content' } - end, - }) - - local bufnr = create_diffs_buffer('diffs://staged:new_name.lua', { - diffs_source = generated.file_pair_source('/tmp', 'staged', 'new_name.lua', 'old_name.lua'), - }) - commands.read_buffer(bufnr) - - local lines = buffer_lines(bufnr) - assert.are.equal('diff --git a/old_name.lua b/new_name.lua', lines[1]) - assert.are.equal('--- a/old_name.lua', lines[2]) - assert.are.equal('+++ b/new_name.lua', lines[3]) - end) - - it('produces empty buffer when old and new are identical', function() - mock_git({ - get_file_content = function() - return { 'identical' } - end, - get_working_content = function() - return { 'identical' } - end, - }) - - local bufnr = create_diffs_buffer('diffs://HEAD:nodiff.lua', { - diffs_source = generated.file_source( - '/tmp', - diffspec.rev_to_worktree('HEAD', 'nodiff.lua') - ), - }) - commands.read_buffer(bufnr) - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - assert.are.same({ '' }, lines) - end) - - it('replaces existing buffer content on reload', function() - mock_git({ - get_file_content = function() - return { 'old' } - end, - get_working_content = function() - return { 'new' } - end, - }) - - local bufnr = create_diffs_buffer('diffs://HEAD:replace_test.lua', { - diffs_source = generated.file_source( - '/tmp', - diffspec.rev_to_worktree('HEAD', 'replace_test.lua') - ), - }) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale', 'content', 'from', 'before' }) - - commands.read_buffer(bufnr) - - local lines = buffer_lines(bufnr) - assert.are.equal('diff --git a/replace_test.lua b/replace_test.lua', lines[1]) - for _, line in ipairs(lines) do - assert.is_not_equal('stale', line) - end - end) - end) - - describe('attach integration', function() - it('calls attach on the buffer', function() - mock_git() - - local attach_called_with - local original_attach = runtime.attach - runtime.attach = function(bufnr) - attach_called_with = bufnr - end - - local bufnr = create_diffs_buffer('diffs://staged:attach_test.lua', { - diffs_source = generated.file_source('/tmp', diffspec.head_to_index('attach_test.lua')), - }) - commands.read_buffer(bufnr) - - assert.are.equal(bufnr, attach_called_with) - - runtime.attach = original_attach - end) - end) -end) diff --git a/spec/render_spec.lua b/spec/render_spec.lua deleted file mode 100644 index ad6c00e7..00000000 --- a/spec/render_spec.lua +++ /dev/null @@ -1,311 +0,0 @@ -require('spec.helpers') - -local diffspec = require('diffs.spec') -local git = require('diffs.git') -local render = require('diffs.render') - -local saved_git = {} -local test_repos = {} -local original_diffopt = vim.o.diffopt - -local function git_cmd(repo_root, args) - local cmd = { 'git', '-C', repo_root } - for _, arg in ipairs(args) do - cmd[#cmd + 1] = arg - end - local output = vim.fn.systemlist(cmd) - assert.are.equal(0, vim.v.shell_error, table.concat(output, '\n')) - return output -end - -local function create_repo() - local repo_root = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(repo_root, 'p') - test_repos[#test_repos + 1] = repo_root - - vim.fn.systemlist({ 'git', 'init', '-q', repo_root }) - assert.are.equal(0, vim.v.shell_error) - git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' }) - git_cmd(repo_root, { 'config', 'user.name', 'Test' }) - vim.fn.writefile({ 'line 1', 'line 2' }, repo_root .. '/file.txt') - git_cmd(repo_root, { 'add', 'file.txt' }) - git_cmd(repo_root, { 'commit', '-qm', 'initial' }) - - return repo_root -end - -local function write_binary_file(path, text) - vim.fn.system({ 'sh', '-c', 'printf "$1" > "$2"', 'sh', text, path }) - assert.are.equal(0, vim.v.shell_error) -end - -local function mock_git_content() - saved_git.get_file_content = git.get_file_content - saved_git.get_index_content = git.get_index_content - saved_git.get_working_content = git.get_working_content - - git.get_file_content = function(rev, filepath) - assert.are.equal('/tmp/repo/foo.lua', filepath) - if rev == 'HEAD' then - return { 'return "head"' } - end - if rev == 'HEAD~1' then - return { 'return "older"' } - end - if rev == 'feature' then - return { 'return "feature"' } - end - return nil, 'unknown rev' - end - - git.get_index_content = function(filepath) - assert.are.equal('/tmp/repo/foo.lua', filepath) - return { 'return "index"' } - end - - git.get_working_content = function(filepath) - assert.are.equal('/tmp/repo/foo.lua', filepath) - return { 'return "worktree"' } - end -end - -local function restore_mocks() - for k, v in pairs(saved_git) do - git[k] = v - end - saved_git = {} - for _, repo_root in ipairs(test_repos) do - vim.fn.delete(repo_root, 'rf') - end - test_repos = {} -end - -local function render_text(spec) - local lines, err = render.file(spec, '/tmp/repo') - assert.is_nil(err) - assert.is_not_nil(lines) - return table.concat(lines, '\n') -end - -describe('diffs.render', function() - after_each(function() - restore_mocks() - vim.o.diffopt = original_diffopt - end) - - it('renders index to worktree edges separately from staged changes', function() - mock_git_content() - - local text = render_text(diffspec.index_to_worktree('foo.lua')) - - assert.is_true(text:find('-return "index"', 1, true) ~= nil) - assert.is_true(text:find('+return "worktree"', 1, true) ~= nil) - assert.is_false(text:find('return "head"', 1, true) ~= nil) - end) - - it('renders HEAD to index edges separately from unstaged changes', function() - mock_git_content() - - local text = render_text(diffspec.head_to_index('foo.lua')) - - assert.is_true(text:find('-return "head"', 1, true) ~= nil) - assert.is_true(text:find('+return "index"', 1, true) ~= nil) - assert.is_false(text:find('return "worktree"', 1, true) ~= nil) - end) - - it('renders arbitrary tree to index edges', function() - mock_git_content() - - local text = render_text(diffspec.file(diffspec.tree('HEAD~1'), diffspec.index(), 'foo.lua')) - - assert.is_true(text:find('-return "older"', 1, true) ~= nil) - assert.is_true(text:find('+return "index"', 1, true) ~= nil) - assert.is_false(text:find('return "worktree"', 1, true) ~= nil) - end) - - it('renders revision to worktree edges', function() - mock_git_content() - - local text = render_text(diffspec.rev_to_worktree('HEAD~1', 'foo.lua')) - - assert.is_true(text:find('-return "older"', 1, true) ~= nil) - assert.is_true(text:find('+return "worktree"', 1, true) ~= nil) - end) - - it('renders read-only revision to revision edges', function() - local calls = {} - mock_git_content() - git.get_file_content = function(rev, filepath) - table.insert(calls, { rev, filepath }) - if rev == 'HEAD~1' then - return { 'return "older"' } - end - if rev == 'feature' then - return { 'return "feature"' } - end - return nil, 'unknown rev' - end - - local text = render_text(diffspec.rev_to_rev('HEAD~1', 'feature', 'foo.lua')) - - assert.are.same({ - { 'HEAD~1', '/tmp/repo/foo.lua' }, - { 'feature', '/tmp/repo/foo.lua' }, - }, calls) - assert.is_true(text:find('-return "older"', 1, true) ~= nil) - assert.is_true(text:find('+return "feature"', 1, true) ~= nil) - end) - - it('can render against current unsaved worktree lines', function() - mock_git_content() - - local lines, err = render.file(diffspec.rev_to_worktree('HEAD', 'foo.lua'), '/tmp/repo', { - worktree_lines = { 'return "buffer"' }, - }) - - assert.is_nil(err) - local text = table.concat(lines, '\n') - assert.is_true(text:find('-return "head"', 1, true) ~= nil) - assert.is_true(text:find('+return "buffer"', 1, true) ~= nil) - assert.is_false(text:find('return "worktree"', 1, true) ~= nil) - end) - - it('renders untracked files as index to worktree additions', function() - local repo_root = create_repo() - vim.fn.writefile({ 'new file' }, repo_root .. '/new.txt') - - local lines, err = render.file(diffspec.index_to_worktree('new.txt'), repo_root) - - assert.is_nil(err) - local text = table.concat(lines, '\n') - assert.is_true(text:find('new file mode 100644', 1, true) ~= nil) - assert.is_true(text:find('--- /dev/null', 1, true) ~= nil) - assert.is_true(text:find('+new file', 1, true) ~= nil) - end) - - it('renders staged deletions as HEAD to index deletions', function() - local repo_root = create_repo() - vim.fn.delete(repo_root .. '/file.txt') - git_cmd(repo_root, { 'add', '-u', 'file.txt' }) - - local lines, err = render.file(diffspec.head_to_index('file.txt'), repo_root) - - assert.is_nil(err) - local text = table.concat(lines, '\n') - assert.is_true(text:find('deleted file mode 100644', 1, true) ~= nil) - assert.is_true(text:find('+++ /dev/null', 1, true) ~= nil) - assert.is_true(text:find('-line 1', 1, true) ~= nil) - end) - - it('renders clean index to worktree edges as empty diffs', function() - local repo_root = create_repo() - - local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root) - - assert.is_nil(err) - assert.are.same({}, lines) - end) - - it('treats a whitespace-only change as no diff when iwhiteall is set', function() - local repo_root = create_repo() - vim.fn.writefile({ 'line 1 ', 'line 2' }, repo_root .. '/file.txt') - - vim.o.diffopt = 'internal,filler,iwhiteall' - local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root) - - assert.is_nil(err) - assert.are.same({}, lines) - end) - - it('treats a blank-line-only change as no diff when iblank is set', function() - local repo_root = create_repo() - vim.fn.writefile({ 'line 1', '', 'line 2' }, repo_root .. '/file.txt') - - vim.o.diffopt = 'internal,filler,iblank' - local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root) - - assert.is_nil(err) - assert.are.same({}, lines) - end) - - it('renders clean HEAD to index edges as empty diffs', function() - local repo_root = create_repo() - - local lines, err = render.file(diffspec.head_to_index('file.txt'), repo_root) - - assert.is_nil(err) - assert.are.same({}, lines) - end) - - it('preserves real trailing blank line changes', function() - local repo_root = create_repo() - vim.fn.writefile({ 'line 1', 'line 2', '' }, repo_root .. '/file.txt') - - local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root) - - assert.is_nil(err) - local text = table.concat(lines, '\n') - assert.is_true(text:find('\n+\n', 1, true) ~= nil) - end) - - it('renders final-newline-only changes with no-newline metadata', function() - local repo_root = create_repo() - vim.fn.writefile({ 'line 1', 'line 2' }, repo_root .. '/file.txt', 'b') - - local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root) - - assert.is_nil(err) - local text = table.concat(lines, '\n') - assert.is_true(text:find('-line 2', 1, true) ~= nil) - assert.is_true(text:find('+line 2', 1, true) ~= nil) - assert.is_true(text:find('\\ No newline at end of file', 1, true) ~= nil) - end) - - it('rejects same-path rename projections before rendering actionable hunks', function() - local repo_root = create_repo() - git_cmd(repo_root, { 'mv', 'file.txt', 'renamed.txt' }) - - local lines, err = render.file(diffspec.head_to_index('renamed.txt'), repo_root) - - assert.is_nil(lines) - assert.are.equal('diff does not support rename or copy changes', err) - end) - - it('rejects mode-only changes because there are no text hunks to apply', function() - local repo_root = create_repo() - vim.fn.setfperm(repo_root .. '/file.txt', 'rwxr-xr-x') - - local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root) - - assert.is_nil(lines) - assert.are.equal('diff does not support mode-only changes', err) - end) - - it('rejects binary files before treating bytes as text hunks', function() - local repo_root = create_repo() - write_binary_file(repo_root .. '/bin.dat', 'binary\\000old') - git_cmd(repo_root, { 'add', 'bin.dat' }) - git_cmd(repo_root, { 'commit', '-qm', 'binary' }) - write_binary_file(repo_root .. '/bin.dat', 'binary\\000new') - - local lines, err = render.file(diffspec.index_to_worktree('bin.dat'), repo_root) - - assert.is_nil(lines) - assert.are.equal('diff does not support binary files', err) - end) - - it('rejects submodule gitlinks before rendering pseudo text hunks', function() - local repo_root = create_repo() - git_cmd(repo_root, { - 'update-index', - '--add', - '--cacheinfo', - '160000,0123456789012345678901234567890123456789,submodule', - }) - - local lines, err = render.file(diffspec.head_to_index('submodule'), repo_root) - - assert.is_nil(lines) - assert.are.equal('diff does not support submodule changes', err) - end) -end) diff --git a/spec/review_spec.lua b/spec/review_spec.lua deleted file mode 100644 index e2a5e73c..00000000 --- a/spec/review_spec.lua +++ /dev/null @@ -1,177 +0,0 @@ -require('spec.helpers') - -local render = require('diffs.render') -local review = require('diffs.review') - -local repo -local saved_diffopt - -local function git_result(args, opts) - local cmd = { 'git', '-C', repo } - vim.list_extend(cmd, args) - return vim.system(cmd, vim.tbl_extend('force', { text = true }, opts or {})):wait() -end - -local function git(args, opts) - local result = git_result(args, opts) - assert.are.equal(0, result.code, result.stderr) - return vim.trim(result.stdout) -end - -local function tree(text) - local blob = git({ 'hash-object', '-w', '--stdin' }, { stdin = text .. '\n' }) - return git({ 'mktree' }, { stdin = '100644 blob ' .. blob .. '\tfile.txt\n' }) -end - -local function commit(name, tree_id, parents, timestamp) - local args = { 'commit-tree', tree_id, '-m', name } - for _, parent in ipairs(parents) do - vim.list_extend(args, { '-p', parent }) - end - local date = tostring(timestamp) .. ' +0000' - local oid = git(args, { env = { GIT_AUTHOR_DATE = date, GIT_COMMITTER_DATE = date } }) - git({ 'update-ref', 'refs/heads/' .. name, oid }) - return oid -end - -local function raw_diff(expression) - return git_result({ - 'diff', - '--no-ext-diff', - '--no-color', - '--src-prefix=a/', - '--dst-prefix=b/', - '--end-of-options', - expression, - '--', - }) -end - -local function run_review(expression) - local spec, parse_err = review.parse_arg(expression) - assert.is_nil(parse_err) - local normalized, normalize_err = review.normalize(spec, repo) - assert.is_nil(normalize_err) - local lines, err = review.run(normalized, { - replace_combined_diffs = function(result) - return result - end, - }) - return lines, err, spec -end - -describe('review Git revisions', function() - before_each(function() - saved_diffopt = vim.o.diffopt - vim.o.diffopt = 'internal,filler' - repo = vim.fn.resolve(vim.fn.tempname()) - vim.fn.mkdir(repo, 'p') - git({ 'init', '-q', '-b', 'main' }) - git({ 'config', 'user.email', 'test@example.com' }) - git({ 'config', 'user.name', 'Test' }) - git({ 'config', 'commit.gpgsign', 'false' }) - local base = commit('base', tree('base'), {}, 1000000000) - commit('main', tree('main'), { base }, 1000000200) - commit('feature', tree('feature'), { base }, 1000000100) - end) - - after_each(function() - vim.o.diffopt = saved_diffopt - vim.fn.delete(repo, 'rf') - end) - - for _, expression in ipairs({ - 'main..feature', - 'main...feature', - '..feature', - 'feature..', - '...feature', - 'feature...', - }) do - it('uses Git output for ' .. expression, function() - local expected = raw_diff(expression) - assert.are.equal(0, expected.code, expected.stderr) - local lines, err = run_review(expression) - assert.is_nil(err) - assert.are.equal(expected.stdout, table.concat(lines, '\n') .. '\n') - end) - end - - it('accepts annotated tags and refs that are also filenames', function() - git({ '-c', 'tag.gpgsign=false', 'tag', '-a', 'release', 'main', '-m', 'release' }) - vim.fn.writefile({ 'path named main' }, repo .. '/main') - for _, expression in ipairs({ 'release..feature', 'main..feature' }) do - local expected = raw_diff(expression) - local lines, err = run_review(expression) - assert.is_nil(err) - assert.are.equal(expected.stdout, table.concat(lines, '\n') .. '\n') - end - end) - - it('returns the Git error for a missing revision', function() - local expected = raw_diff('main..missing') - assert.are_not.equal(0, expected.code) - local lines, err = run_review('main..missing') - assert.is_nil(lines) - assert.are.equal(vim.trim(expected.stderr), err) - end) - - it('keeps all current-state sections for a single base or the default branch', function() - git({ 'checkout', '-f', 'main' }) - vim.fn.writefile({ 'staged' }, repo .. '/file.txt') - git({ 'add', 'file.txt' }) - vim.fn.writefile({ 'working' }, repo .. '/file.txt') - vim.fn.writefile({ 'untracked' }, repo .. '/new.txt') - git({ 'update-ref', 'refs/remotes/origin/main', git({ 'rev-parse', 'base' }) }) - git({ 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main' }) - - for _, spec in ipairs({ { base = 'base' }, {} }) do - local normalized, err = review.normalize(spec, repo) - assert.is_nil(err) - assert.is_nil(normalized.target) - local lines, render_err, metadata = review.render(normalized, { - replace_combined_diffs = function(result) - return result - end, - }) - assert.is_nil(render_err) - assert.are.same( - { 'branch', 'staged', 'unstaged', 'untracked' }, - vim.tbl_map(function(section) - return section.id - end, metadata.sections) - ) - for _, text in ipairs({ '+main', '+staged', '+working', '+untracked' }) do - assert.is_true(vim.tbl_contains(lines, text), text) - end - end - end) - - it('uses the same merge base for the review and split content', function() - local base = git({ 'rev-parse', 'base' }) - local main_tree = git({ 'rev-parse', 'main^{tree}' }) - local feature_tree = git({ 'rev-parse', 'feature^{tree}' }) - local left = git({ 'rev-parse', 'main' }) - local chain = commit('chain1', feature_tree, { base }, 1000000100) - chain = commit('chain2', feature_tree, { chain }, 1000000101) - local right = commit('other', feature_tree, { chain }, 1000000150) - commit('cross1', main_tree, { left, right }, 1000000300) - commit('cross2', feature_tree, { right, left }, 1000000400) - git({ 'commit-graph', 'write', '--reachable' }) - - local expected = raw_diff('cross1...cross2') - assert.are.equal(0, expected.code, expected.stderr) - assert.is_truthy(expected.stderr:find('multiple merge bases', 1, true)) - local lines, err, spec = run_review('cross1...cross2') - assert.is_nil(err) - assert.are.equal(expected.stdout, table.concat(lines, '\n') .. '\n') - - local diff_spec, _, spec_err = review.diff_spec_for_file(spec, repo, 'file.txt') - assert.is_nil(spec_err) - local selected_base = expected.stderr:match('using (%x+)') - assert.are.equal(selected_base, diff_spec.left.rev) - local old_lines, read_err = render.read_endpoint(diff_spec.left, repo .. '/file.txt') - assert.is_nil(read_err) - assert.are.equal(git({ 'show', selected_base .. ':file.txt' }), table.concat(old_lines, '\n')) - end) -end) diff --git a/spec/runtime_spec.lua b/spec/runtime_spec.lua index 0acd46c0..560f6227 100644 --- a/spec/runtime_spec.lua +++ b/spec/runtime_spec.lua @@ -230,46 +230,6 @@ describe('diffs.runtime', function() close_window(non_diff_win) close_window(diff_win) end) - - it('neutralizes native diff highlighting on conflicted panes', function() - local win, buf = create_diff_window() - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - '<<<<<<< HEAD', - 'ours', - '=======', - 'theirs', - '>>>>>>> feature', - }) - runtime.attach_diff() - - local whl = vim.api.nvim_get_option_value('winhighlight', { win = win }) - assert.is_not_nil(whl:match('DiffText:DiffsDiffOff')) - assert.is_nil(whl:match('DiffsDiffText')) - assert.is_nil(whl:match('DiffsDiffAdd')) - - close_window(win) - end) - - it('keeps the diff remap on plain panes next to a conflicted one', function() - local conflict_win, conflict_buf = create_diff_window() - vim.api.nvim_buf_set_lines(conflict_buf, 0, -1, false, { - '<<<<<<< HEAD', - 'x', - '=======', - 'y', - '>>>>>>> feature', - }) - local plain_win, _ = create_diff_window() - runtime.attach_diff() - - local conflict_whl = vim.api.nvim_get_option_value('winhighlight', { win = conflict_win }) - local plain_whl = vim.api.nvim_get_option_value('winhighlight', { win = plain_win }) - assert.is_not_nil(conflict_whl:match('DiffAdd:DiffsDiffOff')) - assert.is_not_nil(plain_whl:match('DiffAdd:DiffsDiffAdd')) - - close_window(conflict_win) - close_window(plain_win) - end) end) describe('detach_diff', function() @@ -380,35 +340,10 @@ describe('diffs.runtime', function() if opts.name == 'Normal' then return { fg = 0xebdbb2, bg = 0x282828 } end - if opts.name == 'LineNr' then - return { fg = 0x665c54 } - end - if opts.name == 'diffAdded' then - return { fg = 0x80c080 } - end - if opts.name == 'diffRemoved' then - return { fg = 0xc08080 } - end return saved_get_hl(ns, opts) end runtime._test.compute_highlight_groups() assert.are.equal(0x282828, set_calls.DiffsClear.bg) - assert.are.equal(0xebdbb2, set_calls.DiffsRail.fg) - assert.are.equal(0x282828, set_calls.DiffsRail.bg) - assert.is_true(set_calls.DiffsRail.nocombine) - assert.are.equal(0x665c54, set_calls.DiffsRailNr.fg) - assert.are.equal(0x282828, set_calls.DiffsRailNr.bg) - assert.is_true(set_calls.DiffsRailNr.nocombine) - assert.are.equal(0x80c080, set_calls.DiffsAddRailNr.fg) - assert.is_number(set_calls.DiffsAddRailNr.bg) - assert.is_true(set_calls.DiffsAddRailNr.nocombine) - assert.are.equal(0xc08080, set_calls.DiffsDeleteRailNr.fg) - assert.is_number(set_calls.DiffsDeleteRailNr.bg) - assert.is_true(set_calls.DiffsDeleteRailNr.nocombine) - assert.are.equal(0x80c080, set_calls.DiffsAddBar.fg) - assert.is_number(set_calls.DiffsAddBar.bg) - assert.are.equal(0xc08080, set_calls.DiffsDeleteBar.fg) - assert.is_number(set_calls.DiffsDeleteBar.bg) end) it('blend_alpha controls DiffsAdd.bg intensity', function() diff --git a/spec/split_align_spec.lua b/spec/split_align_spec.lua deleted file mode 100644 index 93b636a8..00000000 --- a/spec/split_align_spec.lua +++ /dev/null @@ -1,109 +0,0 @@ -local align = require('diffs.split_align') - -local function hunk(index, old_start, new_start, lines) - return { - index = index, - old_range = { start = old_start }, - new_range = { start = new_start }, - lines = lines, - } -end - -local function kinds(rows) - local out = {} - for i, r in ipairs(rows) do - out[i] = r.kind - end - return out -end - -describe('split_align.align', function() - it('keeps both panes equal length and weaves unchanged context whole-file', function() - local old_lines = { 'a', 'OLD', 'c' } - local new_lines = { 'a', 'NEW', 'c' } - local hunks = { - hunk(1, 2, 2, { - { kind = 'delete', old_lnum = 2 }, - { kind = 'add', new_lnum = 2 }, - }), - } - - local r = align.align(old_lines, new_lines, hunks) - assert.are.same({ 'a', 'OLD', 'c' }, r.left_lines) - assert.are.same({ 'a', 'NEW', 'c' }, r.right_lines) - assert.are.equal(#r.left_lines, #r.right_lines) - assert.are.same({ 'context', 'delete', 'context' }, kinds(r.left_rows)) - assert.are.same({ 'context', 'add', 'context' }, kinds(r.right_rows)) - assert.are.equal(2, r.left_rows[2].old_lnum) - assert.are.equal(2, r.right_rows[2].new_lnum) - assert.are.equal(2, r.anchors[1]) - end) - - it('top-aligns an unequal change and pads the shorter side with fillers', function() - local old_lines = { 'a', '-- old', '--- old', 'd' } - local new_lines = { 'a', '++ new', '+++ new', 'N3', 'd' } - local hunks = require('diffs.hunks').parse({ - '@@ -2,3 +2,4 @@', - '--- old', - '---- old', - '+++ new', - '++++ new', - '+N3', - ' d', - }) - - local r = align.align(old_lines, new_lines, hunks) - assert.are.same({ 'a', '-- old', '--- old', '', 'd' }, r.left_lines) - assert.are.same(new_lines, r.right_lines) - assert.are.equal(#r.left_lines, #r.right_lines) - assert.are.same({ 'context', 'delete', 'delete', 'filler', 'context' }, kinds(r.left_rows)) - assert.are.same({ 'context', 'add', 'add', 'add', 'context' }, kinds(r.right_rows)) - end) - - it('handles a pure-add file as all fillers on the left', function() - local r = align.align({}, { 'x', 'y' }, { - hunk(1, 1, 1, { - { kind = 'add', new_lnum = 1 }, - { kind = 'add', new_lnum = 2 }, - }), - }) - assert.are.same({ '', '' }, r.left_lines) - assert.are.same({ 'x', 'y' }, r.right_lines) - assert.are.same({ 'filler', 'filler' }, kinds(r.left_rows)) - assert.are.same({ 'add', 'add' }, kinds(r.right_rows)) - end) - - it('handles a pure-delete file as all fillers on the right', function() - local r = align.align({ 'x', 'y' }, {}, { - hunk(1, 1, 1, { - { kind = 'delete', old_lnum = 1 }, - { kind = 'delete', old_lnum = 2 }, - }), - }) - assert.are.same({ 'x', 'y' }, r.left_lines) - assert.are.same({ '', '' }, r.right_lines) - assert.are.same({ 'delete', 'delete' }, kinds(r.left_rows)) - assert.are.same({ 'filler', 'filler' }, kinds(r.right_rows)) - end) - - it('returns empty alignment for no hunks and empty files', function() - local r = align.align({}, {}, {}) - assert.are.same({}, r.left_lines) - assert.are.same({}, r.right_lines) - end) - - it('weaves multiple hunks with unchanged regions between them', function() - local old_lines = { 'a', 'OLD1', 'b', 'c', 'OLD2', 'e' } - local new_lines = { 'a', 'NEW1', 'b', 'c', 'NEW2', 'e' } - local hunks = { - hunk(1, 2, 2, { { kind = 'delete', old_lnum = 2 }, { kind = 'add', new_lnum = 2 } }), - hunk(2, 5, 5, { { kind = 'delete', old_lnum = 5 }, { kind = 'add', new_lnum = 5 } }), - } - local r = align.align(old_lines, new_lines, hunks) - assert.are.same(old_lines, r.left_lines) - assert.are.same(new_lines, r.right_lines) - assert.are.equal(#r.left_lines, #r.right_lines) - assert.are.equal(2, r.anchors[1]) - assert.are.equal(5, r.anchors[2]) - end) -end) diff --git a/spec/ux_spec.lua b/spec/ux_spec.lua deleted file mode 100644 index 1c9921e0..00000000 --- a/spec/ux_spec.lua +++ /dev/null @@ -1,230 +0,0 @@ -local helpers = require('spec.helpers') - -local commands = require('diffs.commands') -local diffspec = require('diffs.spec') -local hunks = require('diffs.hunks') - -local counter = 0 - -local function create_diffs_buffer(name) - counter = counter + 1 - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr }) - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = bufnr }) - vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr }) - vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) - vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr }) - vim.api.nvim_buf_set_name(bufnr, name or ('diffs://unstaged:file_' .. counter .. '.lua')) - return bufnr -end - -local function add_hunk_metadata(bufnr, diff_spec) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - vim.api.nvim_buf_set_var( - bufnr, - 'diffs_hunks', - hunks.parse(lines, diff_spec or diffspec.index_to_worktree('file.lua')) - ) - vim.api.nvim_buf_set_var(bufnr, 'diffs_source', { repo_root = '/tmp/repo' }) -end - -local function keymap_desc(bufnr, lhs, mode) - for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, mode or 'n')) do - if keymap.lhs == lhs then - return keymap.desc - end - end - return nil -end - -describe('ux', function() - describe('q keymap', function() - it('does not replace an existing q keymap', function() - local bufnr = create_diffs_buffer() - vim.keymap.set('n', 'q', '', { buffer = bufnr, desc = 'user q' }) - - commands.setup_diff_buf(bufnr) - - assert.are.equal('user q', keymap_desc(bufnr, 'q')) - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - - it('q closes the window', function() - local bufnr = create_diffs_buffer() - commands.setup_diff_buf(bufnr) - - vim.cmd('split') - local win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(win, bufnr) - - local win_count_before = #vim.api.nvim_tabpage_list_wins(0) - - vim.api.nvim_buf_call(bufnr, function() - vim.cmd('normal q') - end) - - local win_count_after = #vim.api.nvim_tabpage_list_wins(0) - assert.equals(win_count_before - 1, win_count_after) - end) - end) - - describe('hunk keymaps', function() - it('sets hunk keymaps only on hunk-aware diff buffers', function() - local bufnr = create_diffs_buffer() - commands.setup_diff_buf(bufnr) - - assert.is_false(helpers.has_keymap(bufnr, ']c')) - assert.is_false(helpers.has_keymap(bufnr, '[c')) - assert.is_false(helpers.has_keymap(bufnr, '')) - assert.is_false(helpers.has_keymap(bufnr, 'o')) - assert.is_false(helpers.has_keymap(bufnr, 'do')) - assert.is_false(helpers.has_keymap(bufnr, 'dp')) - assert.is_false(helpers.has_keymap(bufnr, 'do', 'x')) - assert.is_false(helpers.has_keymap(bufnr, 'dp', 'x')) - - add_hunk_metadata(bufnr) - commands.setup_diff_buf(bufnr) - - assert.is_true(helpers.has_keymap(bufnr, ']c')) - assert.is_true(helpers.has_keymap(bufnr, '[c')) - assert.is_true(helpers.has_keymap(bufnr, '')) - assert.is_false(helpers.has_keymap(bufnr, 'o')) - assert.is_false(helpers.has_keymap(bufnr, 'do')) - assert.is_true(helpers.has_keymap(bufnr, 'dp')) - assert.is_false(helpers.has_keymap(bufnr, 'do', 'x')) - assert.is_true(helpers.has_keymap(bufnr, 'dp', 'x')) - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - - it('does not replace existing buffer-local hunk maps', function() - local bufnr = create_diffs_buffer() - add_hunk_metadata(bufnr) - vim.keymap.set('n', 'o', '', { buffer = bufnr, desc = 'user open' }) - vim.keymap.set('n', ']c', '', { buffer = bufnr, desc = 'user next' }) - vim.keymap.set('n', 'do', '', { buffer = bufnr, desc = 'user obtain' }) - vim.keymap.set('n', 'dp', '', { buffer = bufnr, desc = 'user put' }) - vim.keymap.set('x', 'do', '', { buffer = bufnr, desc = 'user visual obtain' }) - vim.keymap.set('x', 'dp', '', { buffer = bufnr, desc = 'user visual put' }) - - commands.setup_diff_buf(bufnr) - - assert.are.equal('user open', keymap_desc(bufnr, 'o')) - assert.are.equal('user next', keymap_desc(bufnr, ']c')) - assert.are.equal('user obtain', keymap_desc(bufnr, 'do')) - assert.are.equal('user put', keymap_desc(bufnr, 'dp')) - assert.are.equal('user visual obtain', keymap_desc(bufnr, 'do', 'x')) - assert.are.equal('user visual put', keymap_desc(bufnr, 'dp', 'x')) - assert.are.equal('Open source file', keymap_desc(bufnr, '')) - assert.are.equal('Previous diff hunk', keymap_desc(bufnr, '[c')) - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - - it('removes only plugin-owned hunk keymaps when metadata is removed', function() - local bufnr = create_diffs_buffer() - add_hunk_metadata(bufnr) - vim.keymap.set('n', 'o', '', { buffer = bufnr, desc = 'user open' }) - vim.keymap.set('x', 'do', '', { buffer = bufnr, desc = 'user visual obtain' }) - commands.setup_diff_buf(bufnr) - - assert.are.equal('user open', keymap_desc(bufnr, 'o')) - assert.are.equal('user visual obtain', keymap_desc(bufnr, 'do', 'x')) - assert.are.equal('Open source file', keymap_desc(bufnr, '')) - assert.are.equal('Stage selected diff lines', keymap_desc(bufnr, 'dp', 'x')) - - vim.api.nvim_buf_del_var(bufnr, 'diffs_hunks') - commands.setup_diff_buf(bufnr) - - assert.are.equal('user open', keymap_desc(bufnr, 'o')) - assert.are.equal('user visual obtain', keymap_desc(bufnr, 'do', 'x')) - assert.is_nil(keymap_desc(bufnr, '')) - assert.is_nil(keymap_desc(bufnr, ']c')) - assert.is_nil(keymap_desc(bufnr, '[c')) - assert.is_nil(keymap_desc(bufnr, 'do')) - assert.is_nil(keymap_desc(bufnr, 'dp')) - assert.is_nil(keymap_desc(bufnr, 'dp', 'x')) - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - - it('updates plugin-owned action keymaps when hunk actionability changes', function() - local bufnr = create_diffs_buffer() - add_hunk_metadata(bufnr, diffspec.index_to_worktree('file.lua')) - commands.setup_diff_buf(bufnr) - - assert.is_true(helpers.has_keymap(bufnr, 'dp')) - assert.is_false(helpers.has_keymap(bufnr, 'do')) - - add_hunk_metadata(bufnr, diffspec.head_to_index('file.lua')) - commands.setup_diff_buf(bufnr) - - assert.is_false(helpers.has_keymap(bufnr, 'dp')) - assert.is_false(helpers.has_keymap(bufnr, 'dp', 'x')) - assert.is_true(helpers.has_keymap(bufnr, 'do')) - assert.is_true(helpers.has_keymap(bufnr, 'do', 'x')) - assert.is_true(helpers.has_keymap(bufnr, ']c')) - assert.is_true(helpers.has_keymap(bufnr, '[c')) - assert.is_true(helpers.has_keymap(bufnr, '')) - assert.is_false(helpers.has_keymap(bufnr, 'o')) - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - end) - - describe('window reuse', function() - it('returns nil when no diffs window exists', function() - local win = commands.find_diffs_window() - assert.is_nil(win) - end) - - it('finds existing diffs:// window', function() - local bufnr = create_diffs_buffer() - vim.cmd('split') - local expected_win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(expected_win, bufnr) - - local found = commands.find_diffs_window() - assert.equals(expected_win, found) - - vim.api.nvim_win_close(expected_win, true) - end) - - it('ignores non-diffs buffers', function() - local normal_buf = helpers.create_buffer({ 'hello' }) - vim.cmd('split') - local win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(win, normal_buf) - - local found = commands.find_diffs_window() - assert.is_nil(found) - - vim.api.nvim_win_close(win, true) - helpers.delete_buffer(normal_buf) - end) - - it('returns first diffs window when multiple exist', function() - local buf1 = create_diffs_buffer() - local buf2 = create_diffs_buffer() - - vim.cmd('split') - local win1 = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(win1, buf1) - - vim.cmd('split') - local win2 = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(win2, buf2) - - local found = commands.find_diffs_window() - assert.is_not_nil(found) - assert.is_true(found == win1 or found == win2) - - vim.api.nvim_win_close(win1, true) - vim.api.nvim_win_close(win2, true) - end) - end) -end)