diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 798b7a9..001e042 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -68,26 +68,10 @@ body: load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))() require('lazy.nvim').setup({ spec = { - { 'barrettruth/midnight.nvim', lazy = false, config = function() vim.cmd.colorscheme('midnight') end }, - { 'tpope/vim-fugitive' }, - { 'NeogitOrg/neogit', dependencies = { 'nvim-lua/plenary.nvim' } }, - { 'lewis6991/gitsigns.nvim', config = true }, - { 'rhysd/committia.vim' }, - { 'nvim-telescope/telescope.nvim', dependencies = { 'nvim-lua/plenary.nvim' } }, + 'tpope/vim-fugitive', { 'barrettruth/diffs.nvim', - init = function() - vim.g.diffs = { - debug = '/tmp/diffs.log', - integrations = { - fugitive = true, - neogit = true, - gitsigns = true, - committia = true, - telescope = true, - }, - } - end, + opts = {}, }, }, }) diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index c0b7770..77049fd 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -25,7 +25,6 @@ jobs: - '*.lua' - '.luarc.json' - '*.toml' - - 'vim.yaml' markdown: - '*.md' @@ -36,8 +35,11 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v31 - - run: nix develop --command stylua --check . + - uses: JohnnyMorganz/stylua-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: 2.1.0 + args: --check . lua-lint: name: Lua Lint Check @@ -46,8 +48,11 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v31 - - run: nix develop --command selene --display-style quiet . + - name: Lint with Selene + uses: NTBBloodbath/selene-action@v1.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --display-style quiet . lua-typecheck: name: Lua Type Check @@ -70,5 +75,15 @@ jobs: if: ${{ needs.changes.outputs.markdown == 'true' }} steps: - uses: actions/checkout@v4 - - uses: cachix/install-nix-action@v31 - - run: nix develop --command prettier --check . + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install prettier + run: pnpm add -g prettier@3.1.0 + - name: Check markdown formatting with prettier + run: prettier --check . diff --git a/.gitignore b/.gitignore index b7a91ec..d14787c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,4 @@ doc/tags CLAUDE.md .claude/ -bench/ node_modules/ - -result -result-* -.direnv/ -.envrc diff --git a/.luarc.json b/.luarc.json index bfbf500..b438cce 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,15 +1,8 @@ { - "runtime.version": "LuaJIT", + "runtime.version": "Lua 5.1", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim", "jit"], - "workspace.library": [ - "$VIMRUNTIME/lua", - "${3rd}/luv/library", - "${3rd}/busted/library", - "${3rd}/luassert/library" - ], + "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], "workspace.checkThirdParty": false, - "diagnostics.libraryFiles": "Disable", - "workspace.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" } diff --git a/.styluaignore b/.styluaignore deleted file mode 100644 index 9b42106..0000000 --- a/.styluaignore +++ /dev/null @@ -1 +0,0 @@ -.direnv/ diff --git a/README.md b/README.md index 30e7720..f0a04a8 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,26 @@ # diffs.nvim -**Treesitter-powered Diff Syntax highlighting for Neovim** +**Syntax highlighting for diffs in Neovim** -Enhance Neovim's built-in diff mode (and much more!) with language-aware syntax -highlighting driven by treesitter. +Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware +syntax highlighting. - +![diffs.nvim preview](https://github.com/user-attachments/assets/d3d64c96-b824-4fcb-af7f-4aef3f7f498a) ## Features -- Treesitter syntax highlighting in - [vim-fugitive](https://github.com/tpope/vim-fugitive), - [Neogit](https://github.com/NeogitOrg/neogit), builtin `diff` filetype, and - more! -- Character-level intra-line diff highlighting (with optional - [vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for - word-level accuracy) -- `:Gdiff` unified diff against any revision -- Inline merge conflict detection, highlighting, and resolution -- gitsigns.nvim blame popup highlighting -- Email quoting/patch syntax support (`> diff ...`) -- Vim syntax fallback -- Configurable highlighiting blend & priorities -- Context-inclusive, high-accuracy highlights +- Treesitter syntax highlighting in `:Git` diffs and commit views +- Diff header highlighting (`diff --git`, `index`, `---`, `+++`) +- `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds +- `:Gdiff` unified diff against any git revision with syntax highlighting +- Fugitive status buffer keymaps (`du`/`dU`) for unified diffs +- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`) +- Vim syntax fallback for languages without a treesitter parser +- Hunk header context highlighting (`@@ ... @@ function foo()`) +- Character-level (intra-line) diff highlighting for changed characters +- Inline merge conflict detection, highlighting, and resolution keymaps +- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and + highlight overrides ## Requirements @@ -43,63 +41,18 @@ luarocks install diffs.nvim :help diffs.nvim ``` -## FAQ - -**Q: How do I install with lazy.nvim?** - -```lua -{ - 'barrettruth/diffs.nvim', - init = function() - vim.g.diffs = { - ... - } - end, -} -``` - -Do not lazy load `diffs.nvim` with `event`, `lazy`, `ft`, `config`, or `keys` to -control loading - `diffs.nvim` lazy-loads itself. - -**Q: Does diffs.nvim support -[vim-fugitive](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)?** - -Yes. Enable integrations in your config: - -```lua -vim.g.diffs = { - integrations = { - fugitive = true, - neogit = true, - neojj = true, - gitsigns = true, - } -} -``` - -See the documentation for more information. - ## Known Limitations - **Incomplete syntax context**: Treesitter parses each diff hunk in isolation. - Context lines within the hunk provide syntactic context for the parser. In - rare cases, hunks that start or end mid-expression may produce imperfect - highlights due to treesitter error recovery. + To improve accuracy, `diffs.nvim` reads lines from disk before and after each + hunk for parsing context (`highlights.context`, enabled by default with 25 + lines). This resolves most boundary issues. Set + `highlights.context.enabled = false` to disable. -- **Syntax "flashing"**: `diffs.nvim` hooks into the `FileType fugitive` event +- **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, - causing a brief visual "flash". - -- **Cold Start**: Treesitter grammar loading (~10ms) and query compilation - (~4ms) are one-time costs per language per Neovim session. Each language pays - this cost on first encounter, which may cause a brief stutter when a diff - containing a new language first enters the viewport. - -- **Vim syntax fallback is deferred**: The vim syntax fallback (for languages - without a treesitter parser) cannot run inside the decoration provider's - redraw cycle due to Neovim's restriction on buffer mutations. Vim syntax - highlights for these hunks appear slightly delayed. + painted. The buffer is then re-painted after `debounce_ms` milliseconds, + causing an unavoidable visual "flash" even when `debounce_ms = 0`. - **Conflicting diff plugins**: `diffs.nvim` may not interact well with other plugins that modify diff highlighting. Known plugins that may conflict: @@ -117,16 +70,11 @@ See the documentation for more information. # Acknowledgements - [`vim-fugitive`](https://github.com/tpope/vim-fugitive) -- [@esmuellert](https://github.com/esmuellert) / - [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - vscode-diff - algorithm FFI backend for word-level intra-line accuracy +- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) - [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) - [`difftastic`](https://github.com/Wilfred/difftastic) - [`mini.diff`](https://github.com/echasnovski/mini.diff) - [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim) - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - [@phanen](https://github.com/phanen) - diff header highlighting, unknown - filetype fix, shebang/modeline detection, treesitter injection support, - decoration provider highlighting architecture, gitsigns blame popup - highlighting -- [@tris203](https://github.com/tris203) - support for transparent backgrounds + filetype fix, shebang/modeline detection, treesitter injection support diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 61897e2..7663150 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -6,73 +6,44 @@ License: MIT ============================================================================== 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) - -All other integrations are opt-in. See |diffs-integrations|. +diffs.nvim adds syntax highlighting to diff views. It overlays language-aware +highlights on top of default diff highlighting in vim-fugitive and Neovim's +built-in diff mode. Features: ~ -- Treesitter syntax highlighting in diff hunks -- Character-level intra-line diff highlighting +- Syntax highlighting in |:Git| summary diffs and commit detail views +- Diff header highlighting (`diff --git`, `index`, `---`, `+++`) +- Syntax highlighting in |:Gdiffsplit| / |:Gvdiffsplit| side-by-side diffs +- |:Gdiff| command for unified diff against any git revision +- Background-only diff colors for any `&diff` buffer (vimdiff, diffthis, etc.) - Vim syntax fallback for languages without a treesitter parser - Blended diff background colors that preserve syntax visibility - Optional diff prefix (`+`/`-`/` `) concealment - Gutter (line number) highlighting -- |:Gdiff| unified diff against any revision -- Email quoting/patch syntax support (`> diff ...`) - -============================================================================== -CONTENTS *diffs-contents* - - 1. Introduction ............................................... |diffs.nvim| - 2. Requirements ....................................... |diffs-requirements| - 3. Setup ..................................................... |diffs-setup| - 4. Configuration ............................................ |diffs-config| - 5. Commands ............................................... |diffs-commands| - 6. Mappings ............................................... |diffs-mappings| - 7. Integrations ..................................... |diffs-integrations| - Fugitive .......................................... |diffs-fugitive| - Neogit .............................................. |diffs-neogit| - Neojj ............................................... |diffs-neojj| - Gitsigns .......................................... |diffs-gitsigns| - Telescope ........................................ |diffs-telescope| - 8. Conflict Resolution .................................... |diffs-conflict| - 9. Merge Diff Resolution ..................................... |diffs-merge| - 10. API ......................................................... |diffs-api| - 11. Implementation ................................... |diffs-implementation| - 12. Known Limitations ................................... |diffs-limitations| - 13. Highlight Groups ..................................... |diffs-highlights| - 14. Health Check ............................................. |diffs-health| - 15. Acknowledgements ............................... |diffs-acknowledgements| +- Inline merge conflict marker detection, highlighting, and resolution ============================================================================== REQUIREMENTS *diffs-requirements* - Neovim 0.9.0+ +- vim-fugitive (https://github.com/tpope/vim-fugitive) (optional, for unified + diff syntax highlighting in |:Git| and commit views) - Treesitter parsers for languages you want highlighted +Note: The diff mode feature (background-only colors for |:diffthis|, vimdiff, +etc.) works without vim-fugitive. + ============================================================================== SETUP *diffs-setup* -Install with lazy.nvim: >lua - { 'barrettruth/diffs.nvim' } +Using lazy.nvim: >lua + { + 'barrettruth/diffs.nvim', + dependencies = { 'tpope/vim-fugitive' }, + } < - -Do not lazy load with `event`, `lazy`, `ft`, `config`, or `keys` — -diffs.nvim lazy-loads itself. - -NOTE: Load your colorscheme before diffs.nvim. With lazy.nvim, set -`priority = 1000` and `lazy = false` on your colorscheme plugin. - -See |diffs-config| for customization, |diffs-integrations| for plugin -support. +The plugin works automatically with no configuration required. For +customization, see |diffs-config|. ============================================================================== CONFIGURATION *diffs-config* @@ -81,21 +52,12 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: >lua vim.g.diffs = { debug = false, + debounce_ms = 0, hide_prefix = false, - integrations = { - fugitive = false, - neogit = false, - neojj = false, - gitsigns = false, - committia = false, - telescope = false, - }, - extra_filetypes = {}, highlights = { background = true, gutter = true, blend_alpha = 0.6, - warn_max_lines = true, context = { enabled = true, lines = 25, @@ -105,7 +67,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: max_lines = 500, }, vim = { - enabled = true, + enabled = false, max_lines = 200, }, intra = { @@ -113,26 +75,23 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: algorithm = 'default', max_lines = 500, }, - priorities = { - clear = 198, - syntax = 199, - line_bg = 200, - char_bg = 201, - }, overrides = {}, }, + fugitive = { + horizontal = 'du', + vertical = 'dU', + }, conflict = { enabled = true, disable_diagnostics = true, show_virtual_text = true, - show_actions = false, keymaps = { ours = 'doo', theirs = 'dot', both = 'dob', none = 'don', - next = ']c', - prev = '[c', + next = ']x', + prev = '[x', }, }, } @@ -143,6 +102,11 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Enable debug logging to |:messages| with `[diffs]` prefix. + {debounce_ms} (integer, default: 0) + Debounce delay in milliseconds for re-highlighting + after buffer changes. Lower values feel snappier + but use more CPU. + {hide_prefix} (boolean, default: false) Hide diff prefixes (`+`/`-`/` `) using virtual text overlay. Makes code appear without the @@ -150,92 +114,14 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: is also enabled, the overlay inherits the line's background color. - {integrations} (table, default: all false) - Integration toggles. Each key accepts `true`, - `false`, or a table with sub-options. Passing - `true` or a table enables the integration; - `false` disables it. See |diffs-integrations|. - *diffs.IntegrationsConfig* - Fields: ~ - - {fugitive} (boolean|table, default: false) - Enable vim-fugitive integration. Pass `true` - for defaults, or a table with sub-options - (see |diffs.FugitiveConfig|). When active, - the `fugitive` filetype is registered and - status buffer keymaps are set. >lua - vim.g.diffs = { - integrations = { fugitive = true }, - } - vim.g.diffs = { - integrations = { - fugitive = { horizontal = 'dd' }, - }, - } -< - - {neogit} (boolean|table, default: false) - Enable Neogit integration. When active, - `NeogitStatus`, `NeogitCommitView`, and - `NeogitDiffView` filetypes are registered. - See |diffs-neogit|. >lua - integrations = { neogit = true } -< - - {neojj} (boolean|table, default: false) - Enable neojj integration. When active, - `NeojjStatus`, `NeojjCommitView`, and - `NeojjDiffView` filetypes are registered. - See |diffs-neojj|. >lua - integrations = { neojj = true } -< - - {gitsigns} (boolean|table, default: false) - Enable gitsigns.nvim blame popup highlighting. - See |diffs-gitsigns|. >lua - integrations = { gitsigns = true } -< - - {committia} (boolean|table, default: false) - Enable committia.vim integration. When active, - committia's diff pane receives treesitter - syntax and intra-line diffs. >lua - integrations = { committia = true } -< - - {telescope} (boolean|table, default: false) - Enable telescope.nvim preview highlighting. - See |diffs-telescope|. >lua - integrations = { telescope = true } -< - - Legacy top-level keys (`vim.g.diffs.fugitive`, - etc.) still work but emit a deprecation - warning. If both `integrations` and top-level - keys are present, `integrations` wins and - the stale keys are ignored with a warning. - - {extra_filetypes} (table, default: {}) - Additional filetypes to attach to, beyond the - built-in `git`, `gitcommit`, and any enabled - integration filetypes. Use this to enable - highlighting in plain `.diff` / `.patch` - files: >lua - vim.g.diffs = { - extra_filetypes = { 'diff' }, - } -< - Adding `'diff'` also enables highlighting in - picker preview buffers that set `filetype=diff`: - telescope.nvim (git_commits, git_bcommits, - git_status), snacks.nvim (syntax style only), - and fzf-lua (builtin previewer only). Terminal- - based previewers are not supported. - {highlights} (table, default: see below) Controls which highlight features are enabled. See |diffs.Highlights| for fields. + {fugitive} (table, default: see below) + Fugitive status buffer keymap options. + See |diffs.FugitiveConfig| for fields. + {conflict} (table, default: see below) Inline merge conflict resolution options. See |diffs.ConflictConfig| for fields. @@ -252,18 +138,11 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Only visible if line numbers are enabled. {blend_alpha} (number, default: 0.6) - Alpha value for diff line background intensity. - Controls how strongly diff lines (adds/deletes) - stand out from the editor background. Intra-line - highlights use alpha + 0.3 (capped at 1.0) for - extra contrast. Must be between 0 and 1 - (inclusive). Higher values produce more vivid - backgrounds. - - {warn_max_lines} (boolean, default: true) - Show a |vim.notify()| warning when syntax - highlighting is skipped because a hunk exceeds - {max_lines}. See |diffs-max-lines|. + Alpha value for character-level blend intensity. + Controls how strongly changed characters stand + out from the line-level background. Must be + between 0 and 1 (inclusive). Higher values + produce more vivid character-level highlights. {context} (table, default: see below) Syntax parsing context options. @@ -274,17 +153,13 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: See |diffs.TreesitterConfig| for fields. {vim} (table, default: see below) - Vim syntax fallback highlighting options. + Vim syntax highlighting options (experimental). See |diffs.VimConfig| for fields. {intra} (table, default: see below) Character-level (intra-line) diff highlighting. See |diffs.IntraConfig| for fields. - {priorities} (table, default: see below) - Extmark priority values. - See |diffs.PrioritiesConfig| for fields. - {overrides} (table, default: {}) Map of highlight group names to highlight definitions (see |nvim_set_hl()|). Applied @@ -295,42 +170,16 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: *diffs.ContextConfig* Context config fields: ~ {enabled} (boolean, default: true) - Read surrounding code from the working tree - file and feed it into the treesitter string - parser. Uses the hunk's `@@ +start,count @@` - line numbers to read lines before and after - the hunk from disk. Improves syntax accuracy - when the hunk is inside an incomplete construct - (e.g., a table literal or function body whose - opening is not visible in the hunk's own - context lines). + Read lines from disk before and after each hunk + to provide surrounding syntax context. Improves + accuracy at hunk boundaries where incomplete + constructs (e.g., a function definition with no + body) would otherwise confuse the parser. {lines} (integer, default: 25) - Max context lines to read in each direction. - Files are read once per parse and cached across - hunks in the same file. - - *diffs.PrioritiesConfig* - Priorities config fields: ~ - {clear} (integer, default: 198) - Priority for `DiffsClear` extmarks that reset - underlying diff foreground colors. Must be - below {syntax}. - - {syntax} (integer, default: 199) - Priority for treesitter and vim syntax extmarks. - Must be below {line_bg} so that colorscheme - backgrounds on syntax groups do not obscure - line-level diff backgrounds. - - {line_bg} (integer, default: 200) - Priority for `DiffsAdd`/`DiffsDelete` line - background extmarks. Must be below {char_bg}. - - {char_bg} (integer, default: 201) - Priority for `DiffsAddText`/`DiffsDeleteText` - character-level background extmarks. Highest - priority so changed characters stand out. + Number of context lines to read in each + direction. Lines are read with early exit — + cost scales with this value, not file size. *diffs.TreesitterConfig* Treesitter config fields: ~ @@ -338,29 +187,23 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Apply treesitter syntax highlighting to code. {max_lines} (integer, default: 500) - Skip treesitter highlighting for hunks with more - highlighted lines (`+`/`-`) than this threshold. - Context lines are not counted. Prevents lag on - massive diffs. + Skip treesitter highlighting for hunks larger than + this many lines. Prevents lag on massive diffs. *diffs.VimConfig* Vim config fields: ~ - {enabled} (boolean, default: true) + {enabled} (boolean, default: false) Use vim syntax highlighting as fallback when no treesitter parser is available for a language. Creates a scratch buffer, sets the filetype, and queries |synID()| per character to extract - highlight groups. Deferred via |vim.schedule()| - so it never blocks the first paint. Slower than - treesitter but covers languages without a TS - parser installed (e.g., COBOL, Fortran). + highlight groups. Slower than treesitter but + covers languages without a TS parser installed. {max_lines} (integer, default: 200) - Skip vim syntax highlighting for hunks with more - highlighted lines (`+`/`-`) than this threshold. - Context lines are not counted. Lower than the - treesitter default due to the per-character cost - of |synID()|. + Skip vim syntax highlighting for hunks larger than + this many lines. Lower than the treesitter default + due to the per-character cost of |synID()|. *diffs.IntraConfig* Intra config fields: ~ @@ -379,9 +222,8 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: (falls back to default if not available). {max_lines} (integer, default: 500) - Skip character-level highlighting for hunks with - more highlighted lines (`+`/`-`) than this - threshold. Context lines are not counted. + Skip character-level highlighting for hunks larger + than this many lines. Note: Header context (e.g., `@@ -10,3 +10,4 @@ func()`) is always highlighted with treesitter when a parser is available. @@ -391,33 +233,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: or register treesitter parsers for custom filetypes, use |vim.filetype.add()| and |vim.treesitter.language.register()|. -============================================================================== -MAX LINES *diffs-max-lines* - -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.nvim]: Syntax highlighting skipped for 1 hunk(s) — too large. -< -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. - ============================================================================== COMMANDS *diffs-commands* @@ -496,36 +311,10 @@ Example configuration: >lua vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)') vim.keymap.set('n', 'cb', '(diffs-conflict-both)') vim.keymap.set('n', 'cn', '(diffs-conflict-none)') - vim.keymap.set('n', ']c', '(diffs-conflict-next)') - vim.keymap.set('n', '[c', '(diffs-conflict-prev)') + 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. - - *(diffs-merge-none)* -(diffs-merge-none) - Reject both in a merge diff view. - - *(diffs-merge-next)* -(diffs-merge-next) - Jump to next unresolved conflict hunk in merge diff. - - *(diffs-merge-prev)* -(diffs-merge-prev) - Jump to previous unresolved conflict hunk in merge - diff. - Diff buffer mappings: ~ *diffs-q* q Close the diff window. Available in all `diffs://` @@ -533,47 +322,10 @@ Diff buffer mappings: ~ or the fugitive status keymaps. ============================================================================== -INTEGRATIONS *diffs-integrations* +FUGITIVE STATUS KEYMAPS *diffs-fugitive* -diffs.nvim integrates with several plugins. There are two attachment -patterns: - -Automatic: ~ -Enable via config toggles. The plugin registers `FileType` autocmds for -each integration's filetypes and attaches automatically. ->lua - vim.g.diffs = { - integrations = { - fugitive = true, - neogit = true, - neojj = true, - gitsigns = true, - }, - } -< - -Opt-in: ~ -For filetypes not covered by a built-in integration, use `extra_filetypes` -to attach to any buffer whose content looks like a diff. ->lua - vim.g.diffs = { extra_filetypes = { 'diff' } } -< - ------------------------------------------------------------------------------- -FUGITIVE *diffs-fugitive* - -Enable vim-fugitive (https://github.com/tpope/vim-fugitive) support: >lua - vim.g.diffs = { integrations = { fugitive = true } } -< - -|:Git| status and commit views receive treesitter syntax, line backgrounds, -and intra-line diffs. |:Gdiff| opens a unified diff against any revision -(see |diffs-commands|). - -Fugitive status keymaps: ~ - -When inside a |:Git| status buffer, diffs.nvim provides keymaps to open -unified diffs for files or entire sections. +When inside a vim-fugitive |:Git| status buffer, diffs.nvim provides keymaps +to open unified diffs for files or entire sections. Keymaps: ~ *diffs-du* *diffs-dU* @@ -593,7 +345,6 @@ Behavior by file status: ~ A Staged (empty) index file as all-added D Staged HEAD (empty) file as all-removed R Staged HEAD:oldname index:newname content diff - U Unstaged :2: (ours) :3: (theirs) merge diff ? Untracked (empty) working tree file as all-added On section headers, the keymap runs `git diff` (or `git diff --cached` for @@ -604,17 +355,12 @@ Configuration: ~ *diffs.FugitiveConfig* >lua vim.g.diffs = { - integrations = { - fugitive = { - horizontal = 'du', -- keymap for horizontal split, false to disable - vertical = 'dU', -- keymap for vertical split, false to disable - }, + fugitive = { + horizontal = 'du', -- keymap for horizontal split, false to disable + vertical = 'dU', -- keymap for vertical split, false to disable }, } < - Passing a table enables fugitive integration. Use `fugitive = false` - to disable. There is no `enabled` field; table presence is sufficient. - Fields: ~ {horizontal} (string|false, default: 'du') Keymap for unified diff in horizontal split. @@ -624,64 +370,6 @@ Configuration: ~ Keymap for unified diff in vertical split. Set to `false` to disable. ------------------------------------------------------------------------------- -NEOGIT *diffs-neogit* - -Enable Neogit (https://github.com/NeogitOrg/neogit) support: >lua - vim.g.diffs = { integrations = { neogit = true } } -< - -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-neojj* - -Enable neojj (https://github.com/NicholasZolton/neojj) support: >lua - vim.g.diffs = { integrations = { neojj = true } } -< - -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-gitsigns* - -Enable gitsigns.nvim (https://github.com/lewis6991/gitsigns.nvim) blame -popup highlighting: >lua - vim.g.diffs = { integrations = { gitsigns = true } } -< - -`: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. - ------------------------------------------------------------------------------- -TELESCOPE *diffs-telescope* - -Enable telescope.nvim (https://github.com/nvim-telescope/telescope.nvim) -preview highlighting: >lua - vim.g.diffs = { integrations = { telescope = true } } -< - -Telescope does not set `filetype=diff` on preview buffers — it calls -`vim.treesitter.start(bufnr, "diff")` directly, so diffs.nvim's `FileType` -autocmd never fires. This integration listens for the -`User TelescopePreviewerLoaded` event and attaches to the preview buffer. - -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 - ============================================================================== CONFLICT RESOLUTION *diffs-conflict* @@ -701,15 +389,13 @@ Configuration: ~ enabled = true, disable_diagnostics = true, show_virtual_text = true, - show_actions = false, - priority = 200, keymaps = { ours = 'doo', theirs = 'dot', both = 'dob', none = 'don', - next = ']c', - prev = '[c', + next = ']x', + prev = '[x', }, }, } @@ -729,37 +415,9 @@ Configuration: ~ diagnostics alone. {show_virtual_text} (boolean, default: true) - Show `(current)` and `(incoming)` labels at - the end of `<<<<<<<` and `>>>>>>>` marker - lines. Also controls hunk hints in merge - diff views. - - {format_virtual_text} (function|nil, default: nil) - Custom formatter for virtual text labels. - Receives `(side, keymap)` where `side` is - `"ours"` or `"theirs"` and `keymap` is the - configured keymap string or `false`. Return - a string (label text without parens) or - `nil` to hide the label. Example: >lua - format_virtual_text = function(side, keymap) - if keymap then - return side .. ' [' .. keymap .. ']' - end - return side - end -< - - {show_actions} (boolean, default: false) - Show a codelens-style action line above each - `<<<<<<<` marker listing available resolution - keymaps. Renders as virtual lines using the - `DiffsConflictActions` highlight group. - Only keymaps that are not `false` appear. - - {priority} (integer, default: 200) - Extmark priority for conflict region - backgrounds and markers. Adjust if other - plugins use the same priority range. + Show virtual text labels (" current" and + " incoming") at the end of `<<<<<<<` and + `>>>>>>>` marker lines. {keymaps} (table, default: see above) Buffer-local keymaps for conflict resolution @@ -780,10 +438,10 @@ Configuration: ~ {none} (string|false, default: 'don') Reject both changes (delete entire block). - {next} (string|false, default: ']c') + {next} (string|false, default: ']x') Jump to next conflict marker. Wraps around. - {prev} (string|false, default: '[c') + {prev} (string|false, default: '[x') Jump to previous conflict marker. Wraps around. @@ -800,31 +458,6 @@ User events: ~ }) < -============================================================================== -MERGE DIFF RESOLUTION *diffs-merge* - -When pressing `du`/`dU` on an unmerged (`U`) file in the fugitive status -buffer, diffs.nvim opens a unified diff of ours (`git show :2:path`) vs -theirs (`git show :3:path`) with full treesitter and intra-line highlighting. - -The same conflict resolution keymaps (`doo`/`dot`/`dob`/`don`/`]c`/`[c`) -are available on the diff buffer. They resolve conflicts in the working -file by matching diff hunks to conflict markers: - -- `doo` replaces the conflict region with ours content -- `dot` replaces the conflict region with theirs content -- `dob` replaces with both (ours then theirs) -- `don` removes the conflict region entirely -- `]c`/`[c` navigate between unresolved conflict hunks - -Resolved hunks are marked with `(resolved)` virtual text. Hunks that -correspond to auto-merged content (no conflict markers) show an -informational notification and are left unchanged. - -The working file buffer is modified in place; save it when ready. -Phase 1 inline conflict highlights (see |diffs-conflict|) are refreshed -automatically after each resolution. - ============================================================================== API *diffs-api* @@ -846,8 +479,8 @@ refresh({bufnr}) *diffs.refresh()* IMPLEMENTATION *diffs-implementation* Summary / commit detail views: ~ -1. `FileType` autocmd for computed filetypes (see |diffs-config|) triggers - |diffs.attach()|. For `git` buffers, only `fugitive://` URIs are attached. +1. `FileType fugitive` or `FileType git` (for `fugitive://` buffers) + triggers |diffs.attach()| 2. The buffer is parsed to detect file headers (`M path/to/file`, `diff --git a/... b/...`) and hunk headers (`@@ -10,3 +10,4 @@`) 3. For each hunk: @@ -856,14 +489,13 @@ Summary / commit detail views: ~ - Code is parsed with |vim.treesitter.get_string_parser()| - If no treesitter parser and `vim.enabled`: vim syntax fallback via scratch buffer and |synID()| - - `DiffsClear` extmarks at priority 198 clear underlying diff foreground - - Syntax highlights are applied as extmarks at priority 199 - - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 200 + - `Normal` extmarks at priority 198 clear underlying diff foreground + - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199 + - Syntax highlights are applied as extmarks at priority 200 - Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at priority 201 overlay changed characters with an intense background - Conceal extmarks hide diff prefixes when `hide_prefix` is enabled - - All priorities are configurable via |diffs.PrioritiesConfig| -4. A decoration provider re-highlights visible hunks on each redraw +4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events Diff mode views: ~ 1. `OptionSet diff` detects when any window enters diff mode @@ -877,14 +509,15 @@ KNOWN LIMITATIONS *diffs-limitations* Incomplete Syntax Context ~ *diffs-syntax-context* -Treesitter parses each diff hunk in isolation. When `highlights.context` is -enabled (the default), surrounding code is read from the working tree file -and fed into the parser to improve accuracy at hunk boundaries. This helps -when a hunk is inside a table, function body, or loop whose opening is -beyond the hunk's own context lines. Requires `repo_root` and -`file_new_start` to be available on the hunk (true for standard unified -diffs). In rare cases, hunks that start or end mid-expression may still -produce imperfect highlights due to treesitter error recovery. +Treesitter parses each diff hunk in isolation. To provide surrounding code +context, diffs.nvim reads lines from disk before and after each hunk +(see |diffs.ContextConfig|, enabled by default). This resolves most boundary +issues where incomplete constructs (e.g., a function definition at the edge +of a hunk with no body) would confuse the parser. + +Set `highlights.context.enabled = false` to disable context padding. In rare +cases, context padding may not help if the relevant surrounding code is very +far from the hunk boundaries. Syntax Highlighting Flash ~ *diffs-flash* @@ -893,8 +526,14 @@ the buffer briefly shows fugitive's default diff highlighting before diffs.nvim applies treesitter highlights. This occurs because diffs.nvim hooks into the `FileType fugitive` event, -which fires after vim-fugitive has already painted the buffer. The -decoration provider applies highlights on the next redraw cycle. +which fires after vim-fugitive has already painted the buffer. Even with +`debounce_ms = 0`, the re-painting goes through Neovim's event loop. + +To minimize the flash, use a low debounce value: >lua + vim.g.diffs = { + debounce_ms = 0, + } +< Conflicting Diff Plugins ~ *diffs-plugin-conflicts* @@ -936,7 +575,6 @@ character-level groups blend at 60% for more contrast. Line-number groups combine both: background from the line-level blend, foreground from the character-level blend. - Fugitive unified diff highlights: ~ *DiffsAdd* DiffsAdd Background for `+` lines. Derived by blending @@ -997,10 +635,6 @@ Conflict highlights: ~ *DiffsConflictBaseNr* DiffsConflictBaseNr Line number for base content lines (diff3). - *DiffsConflictActions* - DiffsConflictActions Dimmed foreground (no bold) for the codelens-style - action line shown when `show_actions` is true. - Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. @@ -1053,9 +687,7 @@ ACKNOWLEDGEMENTS *diffs-acknowledgements* - codediff.nvim (https://github.com/esmuellert/codediff.nvim) - diffview.nvim (https://github.com/sindrets/diffview.nvim) - @phanen (https://github.com/phanen) - diff header highlighting, - treesitter injection support, blame_hl.nvim (gitsigns blame popup - highlighting inspiration) -- @tris203 (https://github.com/tris203) - support for transparent backgrounds + treesitter injection support ============================================================================== vim:tw=78:ts=8:ft=help:norl: diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 0c2cb09..0000000 --- a/flake.lock +++ /dev/null @@ -1,43 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1770812194, - "narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs", - "systems": "systems" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 2221bdf..0000000 --- a/flake.nix +++ /dev/null @@ -1,53 +0,0 @@ -{ - description = "diffs.nvim — syntax highlighting for diffs in Neovim"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - systems.url = "github:nix-systems/default"; - }; - - outputs = - { - nixpkgs, - systems, - ... - }: - let - forEachSystem = - f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); - in - { - formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); - - devShells = forEachSystem (pkgs: { - default = - let - ts-plugin = pkgs.vimPlugins.nvim-treesitter.withPlugins (p: [ p.diff ]); - diff-grammar = pkgs.vimPlugins.nvim-treesitter-parsers.diff; - luaEnv = pkgs.luajit.withPackages ( - ps: with ps; [ - busted - nlua - ] - ); - busted-with-grammar = pkgs.writeShellScriptBin "busted" '' - nvim_bin=$(which nvim) - tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' EXIT - printf '#!/bin/sh\nexec "%s" --cmd "set rtp+=${ts-plugin}/runtime" --cmd "set rtp+=${diff-grammar}" "$@"\n' "$nvim_bin" > "$tmpdir/nvim" - chmod +x "$tmpdir/nvim" - PATH="$tmpdir:$PATH" exec ${luaEnv}/bin/busted "$@" - ''; - in - pkgs.mkShell { - packages = [ - busted-with-grammar - pkgs.prettier - pkgs.stylua - pkgs.selene - pkgs.lua-language-server - ]; - }; - }); - }; -} diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 01f1d3a..dc6cfe2 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -36,24 +36,6 @@ function M.find_hunk_line(diff_lines, hunk_position) return nil end ----@param lines string[] ----@return string[] -function M.filter_combined_diffs(lines) - local result = {} - local skip = false - for _, line in ipairs(lines) do - if line:match('^diff %-%-cc ') then - skip = true - elseif line:match('^diff %-%-git ') then - skip = false - end - if not skip then - table.insert(result, line) - end - end - return result -end - ---@param old_lines string[] ---@param new_lines string[] ---@param old_name string @@ -87,33 +69,6 @@ local function generate_unified_diff(old_lines, new_lines, old_name, new_name) return result end ----@param raw_lines string[] ----@param repo_root string ----@return string[] -local function replace_combined_diffs(raw_lines, repo_root) - local unmerged_files = {} - for _, line in ipairs(raw_lines) do - local cc_file = line:match('^diff %-%-cc (.+)$') - if cc_file then - table.insert(unmerged_files, cc_file) - end - end - - local result = M.filter_combined_diffs(raw_lines) - - for _, filename in ipairs(unmerged_files) do - local filepath = repo_root .. '/' .. filename - local old_lines = git.get_file_content(':2', filepath) or {} - local new_lines = git.get_file_content(':3', filepath) or {} - local diff_lines = generate_unified_diff(old_lines, new_lines, filename, filename) - for _, dl in ipairs(diff_lines) do - table.insert(result, dl) - end - end - - return result -end - ---@param revision? string ---@param vertical? boolean function M.gdiff(revision, vertical) @@ -183,7 +138,6 @@ end ---@field vertical? boolean ---@field staged? boolean ---@field untracked? boolean ----@field unmerged? boolean ---@field old_filepath? string ---@field hunk_position? { hunk_header: string, offset: integer } @@ -203,17 +157,7 @@ function M.gdiff_file(filepath, opts) local old_lines, new_lines, err local diff_label - 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 + if opts.untracked then old_lines = {} new_lines, err = git.get_working_content(filepath) if not new_lines then @@ -292,14 +236,6 @@ function M.gdiff_file(filepath, opts) end M.setup_diff_buf(diff_buf) - - if diff_label == 'unmerged' then - vim.api.nvim_buf_set_var(diff_buf, 'diffs_unmerged', true) - vim.api.nvim_buf_set_var(diff_buf, 'diffs_working_path', filepath) - local conflict_config = require('diffs').get_conflict_config() - require('diffs.merge').setup_keymaps(diff_buf, conflict_config) - end - dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) vim.schedule(function() @@ -327,8 +263,6 @@ function M.gdiff_section(repo_root, opts) return end - result = replace_combined_diffs(result, repo_root) - if #result == 0 then vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO) return @@ -391,8 +325,6 @@ function M.read_buffer(bufnr) if vim.v.shell_error ~= 0 then diff_lines = {} end - - diff_lines = replace_combined_diffs(diff_lines, repo_root) else local abs_path = repo_root .. '/' .. path @@ -402,10 +334,7 @@ function M.read_buffer(bufnr) local old_lines, new_lines - if label == 'unmerged' then - old_lines = git.get_file_content(':2', abs_path) or {} - new_lines = git.get_file_content(':3', abs_path) or {} - elseif label == 'untracked' then + if label == 'untracked' then old_lines = {} new_lines = git.get_working_content(abs_path) or {} elseif label == 'staged' then diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 5ed8009..9e62a15 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -20,6 +20,8 @@ local attached_buffers = {} ---@type table local diagnostics_suppressed = {} +local PRIORITY_LINE_BG = 200 + ---@param lines string[] ---@return diffs.ConflictRegion[] function M.parse(lines) @@ -90,17 +92,6 @@ local function parse_buffer(bufnr) return M.parse(lines) end ----@param side string ----@param config diffs.ConflictConfig ----@return string? -local function get_virtual_text_label(side, config) - if config.format_virtual_text then - local keymap = side == 'ours' and config.keymaps.ours or config.keymaps.theirs - return config.format_virtual_text(side, keymap) - end - return side == 'ours' and 'current' or 'incoming' -end - ---@param bufnr integer ---@param regions diffs.ConflictRegion[] ---@param config diffs.ConflictConfig @@ -112,41 +103,14 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_ours + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) if config.show_virtual_text then - local ours_label = get_virtual_text_label('ours', config) - if ours_label then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { - virt_text = { { ' (' .. ours_label .. ')', 'DiffsConflictMarker' } }, - virt_text_pos = 'eol', - }) - end - end - - if config.show_actions then - local parts = {} - local actions = { - { 'Current', config.keymaps.ours }, - { 'Incoming', config.keymaps.theirs }, - { 'Both', config.keymaps.both }, - { 'None', config.keymaps.none }, - } - for _, action in ipairs(actions) do - if action[2] then - if #parts > 0 then - table.insert(parts, { ' \226\148\130 ', 'DiffsConflictActions' }) - end - table.insert(parts, { ('%s (%s)'):format(action[1], action[2]), 'DiffsConflictActions' }) - end - end - if #parts > 0 then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { - virt_lines = { parts }, - virt_lines_above = true, - }) - end + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { + virt_text = { { ' (current)', 'DiffsConflictMarker' } }, + virt_text_pos = 'eol', + }) end for line = region.ours_start, region.ours_end - 1 do @@ -154,11 +118,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictOurs', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictOursNr', - priority = config.priority, + priority = PRIORITY_LINE_BG, }) end @@ -167,7 +131,7 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_base + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) for line = region.base_start, region.base_end - 1 do @@ -175,11 +139,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictBase', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictBaseNr', - priority = config.priority, + priority = PRIORITY_LINE_BG, }) end end @@ -188,7 +152,7 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_sep + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) for line = region.theirs_start, region.theirs_end - 1 do @@ -196,11 +160,11 @@ local function apply_highlights(bufnr, regions, config) end_row = line + 1, hl_group = 'DiffsConflictTheirs', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { number_hl_group = 'DiffsConflictTheirsNr', - priority = config.priority, + priority = PRIORITY_LINE_BG, }) end @@ -208,17 +172,14 @@ local function apply_highlights(bufnr, regions, config) end_row = region.marker_theirs + 1, hl_group = 'DiffsConflictMarker', hl_eol = true, - priority = config.priority, + priority = PRIORITY_LINE_BG, }) if config.show_virtual_text then - local theirs_label = get_virtual_text_label('theirs', config) - if theirs_label then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { - virt_text = { { ' (' .. theirs_label .. ')', 'DiffsConflictMarker' } }, - virt_text_pos = 'eol', - }) - end + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { + virt_text = { { ' (incoming)', 'DiffsConflictMarker' } }, + virt_text_pos = 'eol', + }) end end end @@ -238,7 +199,7 @@ end ---@param bufnr integer ---@param region diffs.ConflictRegion ---@param replacement string[] -function M.replace_region(bufnr, region, replacement) +local function replace_region(bufnr, region, replacement) vim.api.nvim_buf_set_lines( bufnr, region.marker_ours, @@ -250,7 +211,7 @@ end ---@param bufnr integer ---@param config diffs.ConflictConfig -function M.refresh(bufnr, config) +local function refresh(bufnr, config) local regions = parse_buffer(bufnr) if #regions == 0 then vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) @@ -283,8 +244,8 @@ function M.resolve_ours(bufnr, config) return end local lines = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false) - M.replace_region(bufnr, region, lines) - M.refresh(bufnr, config) + replace_region(bufnr, region, lines) + refresh(bufnr, config) end ---@param bufnr integer @@ -301,8 +262,8 @@ function M.resolve_theirs(bufnr, config) return end local lines = vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false) - M.replace_region(bufnr, region, lines) - M.refresh(bufnr, config) + replace_region(bufnr, region, lines) + refresh(bufnr, config) end ---@param bufnr integer @@ -327,8 +288,8 @@ function M.resolve_both(bufnr, config) for _, l in ipairs(theirs) do table.insert(combined, l) end - M.replace_region(bufnr, region, combined) - M.refresh(bufnr, config) + replace_region(bufnr, region, combined) + refresh(bufnr, config) end ---@param bufnr integer @@ -344,8 +305,8 @@ function M.resolve_none(bufnr, config) if not region then return end - M.replace_region(bufnr, region, {}) - M.refresh(bufnr, config) + replace_region(bufnr, region, {}) + refresh(bufnr, config) end ---@param bufnr integer @@ -362,7 +323,6 @@ function M.goto_next(bufnr) return end end - vim.notify('[diffs.nvim]: wrapped to first conflict', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { regions[1].marker_ours + 1, 0 }) end @@ -380,7 +340,6 @@ function M.goto_prev(bufnr) return end end - vim.notify('[diffs.nvim]: wrapped to last conflict', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { regions[#regions].marker_ours + 1, 0 }) end @@ -458,7 +417,7 @@ function M.attach(bufnr, config) if not attached_buffers[bufnr] then return true end - M.refresh(bufnr, config) + refresh(bufnr, config) end, }) diff --git a/lua/diffs/debug.lua b/lua/diffs/debug.lua index c2ec8dd..c234c32 100644 --- a/lua/diffs/debug.lua +++ b/lua/diffs/debug.lua @@ -63,7 +63,7 @@ function M.dump() if f then f:write(vim.json.encode(result)) f:close() - vim.notify('[diffs.nvim]: debug dump: ' .. path, vim.log.levels.INFO) + vim.notify('[diffs.nvim] debug dump: ' .. path, vim.log.levels.INFO) end end diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index d4c3782..a588a22 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -26,65 +26,20 @@ function M.get_section_at_line(bufnr, lnum) return nil end ----@param s string ----@return string -local function unquote(s) - if s:sub(1, 1) ~= '"' then - return s - end - local inner = s:sub(2, -2) - local result = {} - local i = 1 - while i <= #inner do - if inner:sub(i, i) == '\\' and i < #inner then - local next_char = inner:sub(i + 1, i + 1) - if next_char == 'n' then - table.insert(result, '\n') - i = i + 2 - elseif next_char == 't' then - table.insert(result, '\t') - i = i + 2 - elseif next_char == '"' then - table.insert(result, '"') - i = i + 2 - elseif next_char == '\\' then - table.insert(result, '\\') - i = i + 2 - elseif next_char:match('%d') then - local oct = inner:match('^(%d%d%d)', i + 1) - if oct then - table.insert(result, string.char(tonumber(oct, 8))) - i = i + 4 - else - table.insert(result, next_char) - i = i + 2 - end - else - table.insert(result, next_char) - i = i + 2 - end - else - table.insert(result, inner:sub(i, i)) - i = i + 1 - end - end - return table.concat(result) -end - ---@param line string ----@return string?, string?, string? +---@return string?, string? local function parse_file_line(line) local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$') if old and new then - return unquote(vim.trim(new)), unquote(vim.trim(old)), 'R' + return vim.trim(new), vim.trim(old) end - local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$') - if status and filename then - return unquote(vim.trim(filename)), nil, status + local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$') + if filename then + return vim.trim(filename), nil end - return nil, nil, nil + return nil, nil end ---@param line string @@ -102,34 +57,34 @@ end ---@param bufnr integer ---@param lnum integer ----@return string?, diffs.FugitiveSection, boolean, string?, string? +---@return string?, diffs.FugitiveSection, boolean, string? function M.get_file_at_line(bufnr, lnum) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local current_line = lines[lnum] if not current_line then - return nil, nil, false, nil, nil + return nil, nil, false, nil end local section_header = parse_section_header(current_line) if section_header then - return nil, section_header, true, nil, nil + return nil, section_header, true, nil end - local filename, old_filename, status = parse_file_line(current_line) + local filename, old_filename = parse_file_line(current_line) if filename then local section = M.get_section_at_line(bufnr, lnum) - return filename, section, false, old_filename, status + return filename, section, false, old_filename end local prefix = current_line:sub(1, 1) if prefix == '+' or prefix == '-' or prefix == ' ' then for i = lnum - 1, 1, -1 do local prev_line = lines[i] - filename, old_filename, status = parse_file_line(prev_line) + filename, old_filename = parse_file_line(prev_line) if filename then local section = M.get_section_at_line(bufnr, i) - return filename, section, false, old_filename, status + return filename, section, false, old_filename end if prev_line:match('^%w+ %(') or prev_line == '' then break @@ -137,7 +92,7 @@ function M.get_file_at_line(bufnr, lnum) end end - return nil, nil, false, nil, nil + return nil, nil, false, nil end ---@class diffs.HunkPosition @@ -195,7 +150,7 @@ function M.diff_file_under_cursor(vertical) local bufnr = vim.api.nvim_get_current_buf() local lnum = vim.api.nvim_win_get_cursor(0)[1] - local filename, section, is_header, old_filename, status = M.get_file_at_line(bufnr, lnum) + local filename, section, is_header, old_filename = M.get_file_at_line(bufnr, lnum) local repo_root = get_repo_root_from_fugitive(bufnr) if not repo_root then @@ -237,7 +192,6 @@ function M.diff_file_under_cursor(vertical) vertical = vertical, staged = section == 'staged', untracked = section == 'untracked', - unmerged = status == 'U', old_filepath = old_filepath, hunk_position = hunk_position, }) diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua index 1aa6328..7695283 100644 --- a/lua/diffs/git.lua +++ b/lua/diffs/git.lua @@ -1,19 +1,13 @@ local M = {} -local repo_root_cache = {} - ---@param filepath string ---@return string? function M.get_repo_root(filepath) local dir = vim.fn.fnamemodify(filepath, ':h') - if repo_root_cache[dir] ~= nil then - return repo_root_cache[dir] - end local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' }) if vim.v.shell_error ~= 0 then return nil end - repo_root_cache[dir] = result[1] return result[1] end diff --git a/lua/diffs/gitsigns.lua b/lua/diffs/gitsigns.lua deleted file mode 100644 index 0439fb2..0000000 --- a/lua/diffs/gitsigns.lua +++ /dev/null @@ -1,172 +0,0 @@ -local M = {} - -local api = vim.api -local fn = vim.fn -local dbg = require('diffs.log').dbg - -local ns = api.nvim_create_namespace('diffs-gitsigns') -local gs_popup_ns = api.nvim_create_namespace('gitsigns_popup') - -local patched = false - ----@param bufnr integer ----@param src_filename string ----@param src_ft string? ----@param src_lang string? ----@return diffs.Hunk[] -function M.parse_blame_hunks(bufnr, src_filename, src_ft, src_lang) - local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false) - local hunks = {} - local hunk_lines = {} - local hunk_start = nil - - for i, line in ipairs(lines) do - if line:match('^Hunk %d+ of %d+') then - if hunk_start and #hunk_lines > 0 then - table.insert(hunks, { - filename = src_filename, - ft = src_ft, - lang = src_lang, - start_line = hunk_start, - prefix_width = 1, - quote_width = 0, - lines = hunk_lines, - }) - end - hunk_lines = {} - hunk_start = i - elseif hunk_start then - if line:match('^%(guessed:') then - hunk_start = i - else - local prefix = line:sub(1, 1) - if prefix == ' ' or prefix == '+' or prefix == '-' then - if #hunk_lines == 0 then - hunk_start = i - 1 - end - table.insert(hunk_lines, line) - end - end - end - end - - if hunk_start and #hunk_lines > 0 then - table.insert(hunks, { - filename = src_filename, - ft = src_ft, - lang = src_lang, - start_line = hunk_start, - prefix_width = 1, - quote_width = 0, - lines = hunk_lines, - }) - end - - return hunks -end - ----@param preview_winid integer ----@param preview_bufnr integer -local function on_preview(preview_winid, preview_bufnr) - local ok, err = pcall(function() - if not api.nvim_buf_is_valid(preview_bufnr) then - return - end - if not api.nvim_win_is_valid(preview_winid) then - return - end - - local win = api.nvim_get_current_win() - if win == preview_winid then - win = fn.win_getid(fn.winnr('#')) - end - if win == -1 or win == 0 or not api.nvim_win_is_valid(win) then - return - end - - local srcbuf = api.nvim_win_get_buf(win) - if not api.nvim_buf_is_loaded(srcbuf) then - return - end - - local ft = vim.bo[srcbuf].filetype - local name = api.nvim_buf_get_name(srcbuf) - if not name or name == '' then - name = ft and ('a.' .. ft) or 'unknown' - end - local lang = ft and require('diffs.parser').get_lang_from_ft(ft) or nil - - local hunks = M.parse_blame_hunks(preview_bufnr, name, ft, lang) - if #hunks == 0 then - return - end - - local diff_start = hunks[1].start_line - local last = hunks[#hunks] - local diff_end = last.start_line + #last.lines - - api.nvim_buf_clear_namespace(preview_bufnr, gs_popup_ns, diff_start, diff_end) - api.nvim_buf_clear_namespace(preview_bufnr, ns, diff_start, diff_end) - - local opts = require('diffs').get_highlight_opts() - local highlight = require('diffs.highlight') - for _, hunk in ipairs(hunks) do - highlight.highlight_hunk(preview_bufnr, ns, hunk, opts) - for j, line in ipairs(hunk.lines) do - local ch = line:sub(1, 1) - if ch == '+' or ch == '-' then - pcall(api.nvim_buf_set_extmark, preview_bufnr, ns, hunk.start_line + j - 1, 0, { - end_col = 1, - hl_group = ch == '+' and '@diff.plus' or '@diff.minus', - priority = opts.highlights.priorities.syntax, - }) - end - end - end - - dbg('gitsigns blame: highlighted %d hunks in popup buf %d', #hunks, preview_bufnr) - end) - if not ok then - dbg('gitsigns blame error: %s', err) - end -end - ----@return boolean -function M.setup() - if patched then - return true - end - - local pop_ok, Popup = pcall(require, 'gitsigns.popup') - if not pop_ok or not Popup then - return false - end - - Popup.create = (function(orig) - return function(...) - local winid, bufnr = orig(...) - on_preview(winid, bufnr) - return winid, bufnr - end - end)(Popup.create) - - Popup.update = (function(orig) - return function(winid, bufnr, ...) - orig(winid, bufnr, ...) - on_preview(winid, bufnr) - end - end)(Popup.update) - - patched = true - dbg('gitsigns popup patched') - return true -end - -M._test = { - parse_blame_hunks = M.parse_blame_hunks, - on_preview = on_preview, - ns = ns, - gs_popup_ns = gs_popup_ns, -} - -return M diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index ed9329b..306b0e5 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -3,6 +3,38 @@ local M = {} local dbg = require('diffs.log').dbg local diff = require('diffs.diff') +---@param filepath string +---@param from_line integer +---@param count integer +---@return string[] +local function read_line_range(filepath, from_line, count) + if count <= 0 then + return {} + end + local f = io.open(filepath, 'r') + if not f then + return {} + end + local result = {} + local line_num = 0 + for line in f:lines() do + line_num = line_num + 1 + if line_num >= from_line then + table.insert(result, line) + if #result >= count then + break + end + end + end + f:close() + return result +end + +local PRIORITY_CLEAR = 198 +local PRIORITY_SYNTAX = 199 +local PRIORITY_LINE_BG = 200 +local PRIORITY_CHAR_BG = 201 + ---@param bufnr integer ---@param ns integer ---@param hunk diffs.Hunk @@ -10,9 +42,8 @@ local diff = require('diffs.diff') ---@param text string ---@param lang string ---@param context_lines? string[] ----@param priorities diffs.PrioritiesConfig ---@return integer -local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines, priorities) +local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines) local parse_text = text if context_lines and #context_lines > 0 then parse_text = text .. '\n' .. table.concat(context_lines, '\n') @@ -46,7 +77,7 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_l local buf_sc = col_offset + sc local buf_ec = col_offset + ec - local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or priorities.syntax + local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { end_row = buf_er, @@ -64,12 +95,6 @@ end ---@class diffs.HunkOpts ---@field hide_prefix boolean ---@field highlights diffs.Highlights ----@field defer_vim_syntax? boolean ----@field syntax_only? boolean - ----@class diffs.TSContext ----@field before string[]? ----@field after string[]? ---@param bufnr integer ---@param ns integer @@ -78,9 +103,6 @@ end ---@param line_map table ---@param col_offset integer ---@param covered_lines? table ----@param priorities diffs.PrioritiesConfig ----@param force_high_priority? boolean ----@param context? diffs.TSContext ---@return integer local function highlight_treesitter( bufnr, @@ -89,36 +111,9 @@ local function highlight_treesitter( lang, line_map, col_offset, - covered_lines, - priorities, - force_high_priority, - context + covered_lines ) - local prefix_count = 0 - local parse_lines = code_lines - if context then - local before = context.before - local after = context.after - if (before and #before > 0) or (after and #after > 0) then - parse_lines = {} - if before then - prefix_count = #before - for _, l in ipairs(before) do - parse_lines[#parse_lines + 1] = l - end - end - for _, l in ipairs(code_lines) do - parse_lines[#parse_lines + 1] = l - end - if after then - for _, l in ipairs(after) do - parse_lines[#parse_lines + 1] = l - end - end - end - end - - local code = table.concat(parse_lines, '\n') + local code = table.concat(code_lines, '\n') if code == '' then return 0 end @@ -148,8 +143,6 @@ local function highlight_treesitter( if capture ~= 'spell' and capture ~= 'nospell' then local capture_name = '@' .. capture .. '.' .. tree_lang local sr, sc, er, ec = node:range() - sr = sr - prefix_count - er = er - prefix_count local buf_sr = line_map[sr] if buf_sr then @@ -158,10 +151,8 @@ local function highlight_treesitter( local buf_sc = sc + col_offset local buf_ec = ec + col_offset - local meta_prio = tonumber(metadata.priority) or 100 - local priority = tree_lang == 'diff' - and ((col_offset > 0 or force_high_priority) and (priorities.syntax + meta_prio - 100) or meta_prio) - or priorities.syntax + local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100) + or PRIORITY_SYNTAX pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { end_row = buf_er, @@ -228,17 +219,8 @@ end ---@param code_lines string[] ---@param covered_lines? table ---@param leading_offset? integer ----@param priorities diffs.PrioritiesConfig ---@return integer -local function highlight_vim_syntax( - bufnr, - ns, - hunk, - code_lines, - covered_lines, - leading_offset, - priorities -) +local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset) local ft = hunk.ft if not ft then return 0 @@ -256,9 +238,9 @@ local function highlight_vim_syntax( local spans = {} - pcall(vim.api.nvim_buf_call, scratch, function() + vim.api.nvim_buf_call(scratch, function() vim.cmd('setlocal syntax=' .. ft) - vim.cmd.redraw() + vim.cmd('redraw') ---@param line integer ---@param col integer @@ -274,19 +256,18 @@ local function highlight_vim_syntax( spans = M.coalesce_syntax_spans(query_fn, code_lines) end) - pcall(vim.api.nvim_buf_delete, scratch, { force = true }) + vim.api.nvim_buf_delete(scratch, { force = true }) local hunk_line_count = #hunk.lines - local col_off = (hunk.prefix_width or 1) + (hunk.quote_width or 0) - 1 local extmark_count = 0 for _, span in ipairs(spans) do local adj = span.line - leading_offset if adj >= 1 and adj <= hunk_line_count then local buf_line = hunk.start_line + adj - 1 - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + col_off, { - end_col = span.col_end + col_off, + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { + end_col = span.col_end, hl_group = span.hl_name, - priority = priorities.syntax, + priority = PRIORITY_SYNTAX, }) extmark_count = extmark_count + 1 if covered_lines then @@ -303,48 +284,44 @@ end ---@param hunk diffs.Hunk ---@param opts diffs.HunkOpts function M.highlight_hunk(bufnr, ns, hunk, opts) - local p = opts.highlights.priorities - local pw = hunk.prefix_width or 1 - local qw = hunk.quote_width or 0 local use_ts = hunk.lang and opts.highlights.treesitter.enabled local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled local max_lines = use_ts and opts.highlights.treesitter.max_lines or opts.highlights.vim.max_lines - if use_ts or use_vim then - local hl_count = 0 - for _, line in ipairs(hunk.lines) do - local c = line:sub(1, 1) - if c == '+' or c == '-' then - hl_count = hl_count + 1 - end - end - hunk._hl_line_count = hl_count - if hl_count > max_lines then - dbg( - 'skipping hunk %s:%d (%d highlighted lines > %d max)', - hunk.filename, - hunk.start_line, - hl_count, - max_lines - ) - hunk._skipped_max_lines = true - use_ts = false - use_vim = false - end - end - - if use_vim and opts.defer_vim_syntax then + if (use_ts or use_vim) and #hunk.lines > max_lines then + dbg( + 'skipping hunk %s:%d (%d lines > %d max)', + hunk.filename, + hunk.start_line, + #hunk.lines, + max_lines + ) + use_ts = false use_vim = false end ---@type table local covered_lines = {} - local extmark_count = 0 - ---@type string[] - local new_code = {} + local ctx_cfg = opts.highlights.context + local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0 + local leading = {} + local trailing = {} + if (use_ts or use_vim) and context > 0 and hunk.file_new_start and hunk.repo_root then + local filepath = vim.fs.joinpath(hunk.repo_root, hunk.filename) + local lead_from = math.max(1, hunk.file_new_start - context) + local lead_count = hunk.file_new_start - lead_from + if lead_count > 0 then + leading = read_line_range(filepath, lead_from, lead_count) + end + local trail_from = hunk.file_new_start + (hunk.file_new_count or 0) + trailing = read_line_range(filepath, trail_from, context) + end + local extmark_count = 0 if use_ts then + ---@type string[] + local new_code = {} ---@type table local new_map = {} ---@type string[] @@ -352,17 +329,20 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) ---@type table local old_map = {} - for i, line in ipairs(hunk.lines) do - local prefix = line:sub(1, pw) - local stripped = line:sub(pw + 1) - local buf_line = hunk.start_line + i - 1 - local has_add = prefix:find('+', 1, true) ~= nil - local has_del = prefix:find('-', 1, true) ~= nil + for _, pad_line in ipairs(leading) do + table.insert(new_code, pad_line) + table.insert(old_code, pad_line) + end - if has_add and not has_del then + for i, line in ipairs(hunk.lines) do + local prefix = line:sub(1, 1) + local stripped = line:sub(2) + local buf_line = hunk.start_line + i - 1 + + if prefix == '+' then new_map[#new_code] = buf_line table.insert(new_code, stripped) - elseif has_del and not has_add then + elseif prefix == '-' then old_map[#old_code] = buf_line table.insert(old_code, stripped) else @@ -372,38 +352,22 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end - local ts_context = nil - if opts.highlights.context.enabled and (hunk.context_before or hunk.context_after) then - ts_context = { before = hunk.context_before, after = hunk.context_after } + for _, pad_line in ipairs(trailing) do + table.insert(new_code, pad_line) + table.insert(old_code, pad_line) end - extmark_count = highlight_treesitter( - bufnr, - ns, - new_code, - hunk.lang, - new_map, - pw + qw, - covered_lines, - p, - nil, - ts_context - ) + extmark_count = highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines) extmark_count = extmark_count - + highlight_treesitter( - bufnr, - ns, - old_code, - hunk.lang, - old_map, - pw + qw, - covered_lines, - p, - nil, - ts_context - ) + + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines) if hunk.header_context and hunk.header_context_col then + local header_line = hunk.start_line - 1 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, { + end_col = hunk.header_context_col + #hunk.header_context, + hl_group = 'DiffsClear', + priority = PRIORITY_CLEAR, + }) local header_extmarks = highlight_text( bufnr, ns, @@ -411,8 +375,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) hunk.header_context_col, hunk.header_context, hunk.lang, - new_code, - p + new_code ) if header_extmarks > 0 then dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks) @@ -422,10 +385,16 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) elseif use_vim then ---@type string[] local code_lines = {} - for _, line in ipairs(hunk.lines) do - table.insert(code_lines, line:sub(pw + 1)) + for _, pad_line in ipairs(leading) do + table.insert(code_lines, pad_line) end - extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, 0, p) + for _, line in ipairs(hunk.lines) do + table.insert(code_lines, line:sub(2)) + end + for _, pad_line in ipairs(trailing) do + table.insert(code_lines, pad_line) + end + extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, #leading) end if @@ -440,35 +409,13 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) header_map[i] = hunk.header_start_line - 1 + i end extmark_count = extmark_count - + highlight_treesitter( - bufnr, - ns, - hunk.header_lines, - 'diff', - header_map, - qw, - nil, - p, - qw > 0 or pw > 1 - ) - end - - local at_raw_line - if (qw > 0 or pw > 1) and opts.highlights.treesitter.enabled then - local at_buf_line = hunk.start_line - 1 - at_raw_line = vim.api.nvim_buf_get_lines(bufnr, at_buf_line, at_buf_line + 1, false)[1] + + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0) end ---@type diffs.IntraChanges? local intra = nil local intra_cfg = opts.highlights.intra - if - not opts.syntax_only - and intra_cfg - and intra_cfg.enabled - and pw == 1 - and (hunk._hl_line_count or #hunk.lines) <= intra_cfg.max_lines - then + if intra_cfg and intra_cfg.enabled and #hunk.lines <= intra_cfg.max_lines then dbg('computing intra for hunk %s:%d (%d lines)', hunk.filename, hunk.start_line, #hunk.lines) intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm) if intra then @@ -478,12 +425,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end elseif intra_cfg and not intra_cfg.enabled then dbg('intra disabled by config') - elseif intra_cfg and (hunk._hl_line_count or #hunk.lines) > intra_cfg.max_lines then - dbg( - 'intra skipped: %d highlighted lines > %d max', - hunk._hl_line_count or #hunk.lines, - intra_cfg.max_lines - ) + elseif intra_cfg and #hunk.lines > intra_cfg.max_lines then + dbg('intra skipped: %d lines > %d max', #hunk.lines, intra_cfg.max_lines) end ---@type table @@ -503,187 +446,69 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end - if - (qw > 0 or pw > 1) - and hunk.header_start_line - and hunk.header_lines - and #hunk.header_lines > 0 - and opts.highlights.treesitter.enabled - then - for i = 0, #hunk.header_lines - 1 do - local buf_line = hunk.header_start_line - 1 + i - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = #hunk.header_lines[i + 1] + qw, - hl_group = 'DiffsClear', - priority = p.clear, - }) - - if pw > 1 then - local hline = hunk.header_lines[i + 1] - if hline:match('^index ') then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, qw, { - end_col = 5 + qw, - hl_group = '@keyword.diff', - priority = p.syntax, - }) - local dot_pos = hline:find('%.%.', 1, false) - if dot_pos then - local rest = hline:sub(dot_pos + 2) - local hash = rest:match('^(%x+)') - if hash then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, dot_pos + 1 + qw, { - end_col = dot_pos + 1 + #hash + qw, - hl_group = '@constant.diff', - priority = p.syntax, - }) - end - end - end - end - end - end - - if (qw > 0 or pw > 1) and at_raw_line then - local at_buf_line = hunk.start_line - 1 - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, { - end_col = #at_raw_line, - hl_group = 'DiffsClear', - priority = p.clear, - }) - if opts.highlights.treesitter.enabled then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, qw, { - end_col = #at_raw_line, - hl_group = '@attribute.diff', - priority = p.syntax, - }) - end - end - - if use_ts and hunk.header_context and hunk.header_context_col then - local header_line = hunk.start_line - 1 - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, { - end_col = hunk.header_context_col + #hunk.header_context, - hl_group = 'DiffsClear', - priority = p.clear, - }) - end - - local raw_body_lines - if qw > 0 then - raw_body_lines = - vim.api.nvim_buf_get_lines(bufnr, hunk.start_line, hunk.start_line + #hunk.lines, false) - end - for i, line in ipairs(hunk.lines) do local buf_line = hunk.start_line + i - 1 local line_len = #line - local raw_len = raw_body_lines and #raw_body_lines[i] or nil - local prefix = line:sub(1, pw) - local has_add = prefix:find('+', 1, true) ~= nil - local has_del = prefix:find('-', 1, true) ~= nil - local is_diff_line = has_add or has_del - local line_hl = is_diff_line and (has_add and 'DiffsAdd' or 'DiffsDelete') or nil - local number_hl = is_diff_line and (has_add and 'DiffsAddNr' or 'DiffsDeleteNr') or nil + local prefix = line:sub(1, 1) - 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 + local is_diff_line = prefix == '+' or prefix == '-' + local line_hl = is_diff_line and (prefix == '+' and 'DiffsAdd' or 'DiffsDelete') or nil + local number_hl = is_diff_line and (prefix == '+' and 'DiffsAddNr' or 'DiffsDeleteNr') or nil - if not opts.syntax_only then - if opts.hide_prefix then - local virt_hl = (opts.highlights.background and line_hl) or nil - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - virt_text = { { string.rep(' ', pw + qw), virt_hl } }, - virt_text_pos = 'overlay', - }) - end - - if qw > 0 or pw > 1 then - local prefix_end = pw + qw - if raw_len and prefix_end > raw_len then - prefix_end = raw_len - end - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = prefix_end, - hl_group = 'DiffsClear', - priority = p.clear, - }) - for ci = 0, pw - 1 do - local ch = line:sub(ci + 1, ci + 1) - if ch == '+' or ch == '-' then - local char_col = ci + qw - if raw_len and char_col >= raw_len then - break - end - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, char_col, { - end_col = char_col + 1, - hl_group = ch == '+' and '@diff.plus' or '@diff.minus', - priority = p.syntax, - }) - end - end - elseif opts.highlights.background and is_diff_line then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = 1, - hl_group = number_hl, - priority = p.syntax, - }) - end - - if opts.highlights.background and is_diff_line then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - line_hl_group = line_hl, - number_hl_group = opts.highlights.gutter and number_hl or nil, - priority = p.line_bg, - }) - end - - if is_marker and line_len > pw then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, { - end_col = line_len + qw, - hl_group = 'DiffsConflictMarker', - priority = p.char_bg, - }) - end - - if char_spans_by_line[i] then - local char_hl = has_add and 'DiffsAddText' or 'DiffsDeleteText' - for _, span in ipairs(char_spans_by_line[i]) do - dbg( - 'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"', - i, - buf_line, - span.col_start, - span.col_end, - char_hl, - line:sub(span.col_start + 1, span.col_end) - ) - local ok, err = - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start + qw, { - end_col = span.col_end + qw, - hl_group = char_hl, - priority = p.char_bg, - }) - if not ok then - dbg('char extmark FAILED: %s', err) - end - extmark_count = extmark_count + 1 - end - end - end - - if line_len > pw and covered_lines[buf_line] then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, { - end_col = line_len + qw, - hl_group = 'DiffsClear', - priority = p.clear, + if opts.hide_prefix then + local virt_hl = (opts.highlights.background and line_hl) or nil + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + virt_text = { { ' ', virt_hl } }, + virt_text_pos = 'overlay', }) end + + if line_len > 1 and covered_lines[buf_line] then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { + end_col = line_len, + hl_group = 'DiffsClear', + priority = PRIORITY_CLEAR, + }) + end + + if opts.highlights.background and is_diff_line then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_row = buf_line + 1, + hl_group = line_hl, + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + if opts.highlights.gutter then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + number_hl_group = number_hl, + priority = PRIORITY_LINE_BG, + }) + end + end + + if char_spans_by_line[i] then + local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText' + for _, span in ipairs(char_spans_by_line[i]) do + dbg( + 'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"', + i, + buf_line, + span.col_start, + span.col_end, + char_hl, + line:sub(span.col_start + 1, span.col_end) + ) + local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { + end_col = span.col_end, + hl_group = char_hl, + priority = PRIORITY_CHAR_BG, + }) + if not ok then + dbg('char extmark FAILED: %s', err) + end + extmark_count = extmark_count + 1 + end + end end dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index b986eae..cdc29d1 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -15,38 +15,20 @@ ---@field enabled boolean ---@field lines integer ----@class diffs.PrioritiesConfig ----@field clear integer ----@field syntax integer ----@field line_bg integer ----@field char_bg integer - ---@class diffs.Highlights ---@field background boolean ---@field gutter boolean ---@field blend_alpha? number ---@field overrides? table ----@field warn_max_lines boolean ---@field context diffs.ContextConfig ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig ---@field intra diffs.IntraConfig ----@field priorities diffs.PrioritiesConfig ---@class diffs.FugitiveConfig ---@field horizontal string|false ---@field vertical string|false ----@class diffs.NeogitConfig - ----@class diffs.NeojjConfig - ----@class diffs.GitsignsConfig - ----@class diffs.CommittiaConfig - ----@class diffs.TelescopeConfig - ---@class diffs.ConflictKeymaps ---@field ours string|false ---@field theirs string|false @@ -59,31 +41,14 @@ ---@field enabled boolean ---@field disable_diagnostics boolean ---@field show_virtual_text boolean ----@field format_virtual_text? fun(side: string, keymap: string|false): string? ----@field show_actions boolean ----@field priority integer ---@field keymaps diffs.ConflictKeymaps ----@class diffs.IntegrationsConfig ----@field fugitive diffs.FugitiveConfig|false ----@field neogit diffs.NeogitConfig|false ----@field neojj diffs.NeojjConfig|false ----@field gitsigns diffs.GitsignsConfig|false ----@field committia diffs.CommittiaConfig|false ----@field telescope diffs.TelescopeConfig|false - ---@class diffs.Config ----@field debug boolean|string +---@field debug boolean +---@field debounce_ms integer ---@field hide_prefix boolean ----@field extra_filetypes string[] ---@field highlights diffs.Highlights ----@field integrations diffs.IntegrationsConfig ----@field fugitive? diffs.FugitiveConfig|false deprecated: use integrations.fugitive ----@field neogit? diffs.NeogitConfig|false deprecated: use integrations.neogit ----@field neojj? diffs.NeojjConfig|false deprecated: use integrations.neojj ----@field gitsigns? diffs.GitsignsConfig|false deprecated: use integrations.gitsigns ----@field committia? diffs.CommittiaConfig|false deprecated: use integrations.committia ----@field telescope? diffs.TelescopeConfig|false deprecated: use integrations.telescope +---@field fugitive diffs.FugitiveConfig ---@field conflict diffs.ConflictConfig ---@class diffs @@ -132,12 +97,11 @@ end ---@type diffs.Config local default_config = { debug = false, + debounce_ms = 0, hide_prefix = false, - extra_filetypes = {}, highlights = { background = true, gutter = true, - warn_max_lines = true, context = { enabled = true, lines = 25, @@ -147,7 +111,7 @@ local default_config = { max_lines = 500, }, vim = { - enabled = true, + enabled = false, max_lines = 200, }, intra = { @@ -155,34 +119,22 @@ local default_config = { algorithm = 'default', max_lines = 500, }, - priorities = { - clear = 198, - syntax = 199, - line_bg = 200, - char_bg = 201, - }, }, - integrations = { - fugitive = false, - neogit = false, - neojj = false, - gitsigns = false, - committia = false, - telescope = false, + fugitive = { + horizontal = 'du', + vertical = 'dU', }, conflict = { enabled = true, disable_diagnostics = true, show_virtual_text = true, - show_actions = false, - priority = 200, keymaps = { ours = 'doo', theirs = 'dot', both = 'dob', none = 'don', - next = ']c', - prev = '[c', + next = ']x', + prev = '[x', }, }, } @@ -191,382 +143,102 @@ local default_config = { local config = vim.deepcopy(default_config) local initialized = false -local hl_retry_pending = false - ----@diagnostic disable-next-line: missing-fields -local fast_hl_opts = {} ---@type diffs.HunkOpts ---@type table local attached_buffers = {} ----@type table -local ft_retry_pending = {} - ---@type table local diff_windows = {} ----@class diffs.HunkCacheEntry ----@field hunks diffs.Hunk[] ----@field tick integer ----@field highlighted table ----@field pending_clear boolean ----@field warned_max_lines boolean ----@field line_count integer ----@field byte_count integer - ----@type table -local hunk_cache = {} - ---@param bufnr integer ---@return boolean function M.is_fugitive_buffer(bufnr) return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil end ----@param opts table ----@return string[] -function M.compute_filetypes(opts) - local fts = { 'git', 'gitcommit' } - local intg = opts.integrations or {} - local fug = intg.fugitive - if fug == nil then - fug = opts.fugitive - end - if fug == true or type(fug) == 'table' then - table.insert(fts, 'fugitive') - end - local neo = intg.neogit - if neo == nil then - neo = opts.neogit - end - if neo == true or type(neo) == 'table' then - table.insert(fts, 'NeogitStatus') - table.insert(fts, 'NeogitCommitView') - table.insert(fts, 'NeogitDiffView') - end - local njj = intg.neojj - if njj == nil then - njj = opts.neojj - end - if njj == true or type(njj) == 'table' then - table.insert(fts, 'NeojjStatus') - table.insert(fts, 'NeojjCommitView') - table.insert(fts, 'NeojjDiffView') - end - if type(opts.extra_filetypes) == 'table' then - for _, ft in ipairs(opts.extra_filetypes) do - table.insert(fts, ft) - end - end - return fts -end - local dbg = log.dbg ---@param bufnr integer -local function invalidate_cache(bufnr) - local entry = hunk_cache[bufnr] - if entry then - entry.tick = -1 - entry.pending_clear = true - end -end - ----@param a diffs.Hunk ----@param b diffs.Hunk ----@return boolean -local function hunks_eq(a, b) - local n = #a.lines - if n ~= #b.lines or a.filename ~= b.filename then - return false - end - if a.lines[1] ~= b.lines[1] then - return false - end - if n > 1 and a.lines[n] ~= b.lines[n] then - return false - end - if n > 2 then - local mid = math.floor(n / 2) + 1 - if a.lines[mid] ~= b.lines[mid] then - return false - end - end - return true -end - ----@param old_entry diffs.HunkCacheEntry ----@param new_hunks diffs.Hunk[] ----@return table? -local function carry_forward_highlighted(old_entry, new_hunks) - local old_hunks = old_entry.hunks - local old_hl = old_entry.highlighted - local old_n = #old_hunks - local new_n = #new_hunks - local highlighted = {} - - local prefix_len = 0 - local limit = math.min(old_n, new_n) - for i = 1, limit do - if not hunks_eq(old_hunks[i], new_hunks[i]) then - break - end - if old_hl[i] then - highlighted[i] = true - end - prefix_len = i +local function highlight_buffer(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return end - local suffix_len = 0 - local max_suffix = limit - prefix_len - for j = 0, max_suffix - 1 do - local old_idx = old_n - j - local new_idx = new_n - j - if not hunks_eq(old_hunks[old_idx], new_hunks[new_idx]) then - break - end - if old_hl[old_idx] then - highlighted[new_idx] = true - end - suffix_len = j + 1 - end - - dbg( - 'carry_forward: %d prefix + %d suffix of %d old -> %d new hunks', - prefix_len, - suffix_len, - old_n, - new_n - ) - if next(highlighted) == nil then - return nil - end - return highlighted -end - ----@param path string ----@return string[]? -local function read_file_lines(path) - if vim.fn.isdirectory(path) == 1 then - return nil - end - local f = io.open(path, 'r') - if not f then - return nil - end - local lines = {} - for line in f:lines() do - lines[#lines + 1] = line - end - f:close() - return lines -end - ----@param hunks diffs.Hunk[] ----@param max_lines integer -local function compute_hunk_context(hunks, max_lines) - ---@type table - local file_cache = {} + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + local hunks = parser.parse_buffer(bufnr) + dbg('found %d hunks in buffer %d', #hunks, bufnr) for _, hunk in ipairs(hunks) do - if not hunk.repo_root or not hunk.filename or not hunk.file_new_start then - goto continue - end - - local path = vim.fs.joinpath(hunk.repo_root, hunk.filename) - local file_lines = file_cache[path] - if file_lines == nil then - file_lines = read_file_lines(path) or false - file_cache[path] = file_lines - end - if not file_lines then - goto continue - end - - local new_start = hunk.file_new_start - local new_count = hunk.file_new_count or 0 - local total = #file_lines - - local before_start = math.max(1, new_start - max_lines) - if before_start < new_start then - local before = {} - for i = before_start, new_start - 1 do - before[#before + 1] = file_lines[i] - end - hunk.context_before = before - end - - local after_start = new_start + new_count - local after_end = math.min(total, after_start + max_lines - 1) - if after_start <= total then - local after = {} - for i = after_start, after_end do - after[#after + 1] = file_lines[i] - end - hunk.context_after = after - end - - ::continue:: + highlight.highlight_hunk(bufnr, ns, hunk, { + hide_prefix = config.hide_prefix, + highlights = config.highlights, + }) end end ---@param bufnr integer -local function ensure_cache(bufnr) - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - local tick = vim.api.nvim_buf_get_changedtick(bufnr) - local entry = hunk_cache[bufnr] - if entry and entry.tick == tick then - return - end - if entry and not entry.pending_clear then - local lc = vim.api.nvim_buf_line_count(bufnr) - local bc = vim.api.nvim_buf_get_offset(bufnr, lc) - if lc == entry.line_count and bc == entry.byte_count then - entry.tick = tick - entry.pending_clear = true - dbg('content unchanged in buffer %d (tick %d), skipping reparse', bufnr, tick) +---@return fun() +local function create_debounced_highlight(bufnr) + local timer = nil ---@type table? + return function() + if timer then + timer:stop() ---@diagnostic disable-line: undefined-field + timer:close() ---@diagnostic disable-line: undefined-field + timer = nil + end + local t = vim.uv.new_timer() + if not t then + highlight_buffer(bufnr) return end - end - local hunks = parser.parse_buffer(bufnr) - local lc = vim.api.nvim_buf_line_count(bufnr) - local bc = vim.api.nvim_buf_get_offset(bufnr, lc) - dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick) - if config.highlights.context.enabled then - compute_hunk_context(hunks, config.highlights.context.lines) - end - local carried = entry and not entry.pending_clear and carry_forward_highlighted(entry, hunks) - hunk_cache[bufnr] = { - hunks = hunks, - tick = tick, - highlighted = carried or {}, - pending_clear = not carried, - warned_max_lines = false, - line_count = lc, - byte_count = bc, - } - - local has_nil_ft = false - for _, hunk in ipairs(hunks) do - if not has_nil_ft and not hunk.ft and hunk.filename then - has_nil_ft = true - end - end - if has_nil_ft and vim.fn.did_filetype() ~= 0 and not ft_retry_pending[bufnr] then - ft_retry_pending[bufnr] = true - vim.schedule(function() - if vim.api.nvim_buf_is_valid(bufnr) and hunk_cache[bufnr] then - dbg('retrying filetype detection for buffer %d (was blocked by did_filetype)', bufnr) - invalidate_cache(bufnr) - vim.cmd('redraw!') - end - ft_retry_pending[bufnr] = nil - end) + timer = t + t:start( + config.debounce_ms, + 0, + vim.schedule_wrap(function() + if timer == t then + timer = nil + t:close() + end + highlight_buffer(bufnr) + end) + ) end end ----@param hunks diffs.Hunk[] ----@param toprow integer ----@param botrow integer ----@return integer first ----@return integer last -local function find_visible_hunks(hunks, toprow, botrow) - local n = #hunks - if n == 0 then - return 0, 0 - end - - local lo, hi = 1, n + 1 - while lo < hi do - local mid = math.floor((lo + hi) / 2) - local h = hunks[mid] - local bottom = h.start_line - 1 + #h.lines - 1 - if bottom < toprow then - lo = mid + 1 - else - hi = mid - end - end - - if lo > n then - return 0, 0 - end - - local first = lo - local h = hunks[first] - local top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1) - if top >= botrow then - return 0, 0 - end - - local last = first - for i = first + 1, n do - h = hunks[i] - top = (h.header_start_line and (h.header_start_line - 1)) or (h.start_line - 1) - if top >= botrow then - break - end - last = i - end - - return first, last -end - -local function compute_highlight_groups(is_default) +local function compute_highlight_groups() local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' }) local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' }) local diff_added = resolve_hl('diffAdded') local diff_removed = resolve_hl('diffRemoved') - local 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 bg = normal.bg or 0x1e1e2e + local add_bg = diff_add.bg or 0x2e4a3a + local del_bg = diff_delete.bg or 0x4a2e3a + local add_fg = diff_added.fg or diff_add.fg or 0x80c080 + local del_fg = diff_removed.fg or diff_delete.fg or 0xc08080 - if transparent and not hl_retry_pending then - hl_retry_pending = true - vim.schedule(function() - compute_highlight_groups(false) - for bufnr, _ in pairs(attached_buffers) do - invalidate_cache(bufnr) - end - end) - end - - local dflt = is_default or false - local normal_fg = normal.fg or (dark and 0xcccccc or 0x333333) + local blended_add = blend_color(add_bg, bg, 0.4) + local blended_del = blend_color(del_bg, bg, 0.4) local alpha = config.highlights.blend_alpha or 0.6 - local text_alpha = math.min(alpha + 0.3, 1.0) - local blended_add = blend_color(add_bg, bg, alpha) - local blended_del = blend_color(del_bg, bg, alpha) - local blended_add_text = blend_color(add_bg, bg, text_alpha) - local blended_del_text = blend_color(del_bg, bg, text_alpha) + local blended_add_text = blend_color(add_fg, bg, alpha) + local blended_del_text = blend_color(del_fg, bg, alpha) - local clear_hl = { default = dflt, fg = normal_fg } - if not transparent then - clear_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, 'DiffsAddNr', { default = dflt, fg = add_fg, bg = blended_add }) - vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { default = dflt, fg = del_fg, bg = blended_del }) - 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 }) - - dbg( - 'highlight groups: Normal.bg=%s DiffAdd.bg=#%06x diffAdded.fg=#%06x', - normal.bg and string.format('#%06x', normal.bg) or 'NONE', - add_bg, - add_fg + vim.api.nvim_set_hl(0, 'DiffsClear', { default = true, fg = normal.fg or 0xc0c0c0 }) + vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add }) + vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del }) + vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add }) + vim.api.nvim_set_hl( + 0, + 'DiffsDeleteNr', + { default = true, fg = blended_del_text, bg = blended_del } ) + vim.api.nvim_set_hl(0, 'DiffsAddText', { default = true, bg = blended_add_text }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { default = true, bg = blended_del_text }) + + dbg('highlight groups: Normal.bg=#%06x DiffAdd.bg=#%06x diffAdded.fg=#%06x', bg, add_bg, add_fg) dbg( 'DiffsAdd.bg=#%06x DiffsAddText.bg=#%06x DiffsAddNr.fg=#%06x', blended_add, @@ -578,46 +250,44 @@ local function compute_highlight_groups(is_default) local diff_change = resolve_hl('DiffChange') local diff_text = resolve_hl('DiffText') - vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { default = dflt, bg = diff_add.bg }) + vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { default = true, bg = diff_add.bg }) vim.api.nvim_set_hl( 0, 'DiffsDiffDelete', - { default = dflt, fg = diff_delete.fg, bg = diff_delete.bg } + { default = true, fg = diff_delete.fg, bg = diff_delete.bg } ) - 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, 'DiffsDiffChange', { default = true, bg = diff_change.bg }) + vim.api.nvim_set_hl(0, 'DiffsDiffText', { default = true, bg = diff_text.bg }) local change_bg = diff_change.bg or 0x3a3a4a local text_bg = diff_text.bg or 0x4a4a5a local change_fg = diff_change.fg or diff_text.fg or 0x80a0c0 - local 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 + local blended_ours = blend_color(add_bg, bg, 0.4) + local blended_theirs = blend_color(change_bg, bg, 0.4) + local blended_base = blend_color(text_bg, bg, 0.3) + local blended_ours_nr = blend_color(add_fg, bg, alpha) + local blended_theirs_nr = blend_color(change_fg, bg, alpha) + local blended_base_nr = blend_color(change_fg, bg, 0.4) - vim.api.nvim_set_hl(0, 'DiffsConflictOurs', { default = 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, 'DiffsConflictActions', { default = dflt, fg = 0x808080 }) + vim.api.nvim_set_hl(0, 'DiffsConflictOurs', { default = true, bg = blended_ours }) + vim.api.nvim_set_hl(0, 'DiffsConflictTheirs', { default = true, bg = blended_theirs }) + vim.api.nvim_set_hl(0, 'DiffsConflictBase', { default = true, bg = blended_base }) + vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { default = true, fg = 0x808080, bold = true }) vim.api.nvim_set_hl( 0, 'DiffsConflictOursNr', - { default = dflt, fg = blended_ours_nr, bg = blended_ours } + { default = true, fg = blended_ours_nr, bg = blended_ours } ) vim.api.nvim_set_hl( 0, 'DiffsConflictTheirsNr', - { default = dflt, fg = blended_theirs_nr, bg = blended_theirs } + { default = true, fg = blended_theirs_nr, bg = blended_theirs } ) vim.api.nvim_set_hl( 0, 'DiffsConflictBaseNr', - { default = dflt, fg = blended_base_nr, bg = blended_base } + { default = true, fg = blended_base_nr, bg = blended_base } ) if config.highlights.overrides then @@ -627,48 +297,6 @@ local function compute_highlight_groups(is_default) end end -local integration_keys = { 'fugitive', 'neogit', 'neojj', 'gitsigns', 'committia', 'telescope' } - -local function migrate_integrations(opts) - if opts.integrations then - local stale = {} - for _, key in ipairs(integration_keys) do - if opts[key] ~= nil then - stale[#stale + 1] = key - opts[key] = nil - end - end - if #stale > 0 then - local old = 'vim.g.diffs.{' .. table.concat(stale, ', ') .. '}' - local new = 'vim.g.diffs.integrations.{' .. table.concat(stale, ', ') .. '}' - vim.notify( - '[diffs.nvim]: ignoring ' .. old .. '; move to ' .. new .. ' or remove', - vim.log.levels.WARN - ) - end - return - end - local has_legacy = false - for _, key in ipairs(integration_keys) do - if opts[key] ~= nil then - has_legacy = true - break - end - end - if not has_legacy then - return - end - vim.deprecate('vim.g.diffs.', 'vim.g.diffs.integrations.*', '0.3.2', 'diffs.nvim') - local legacy = {} - for _, key in ipairs(integration_keys) do - if opts[key] ~= nil then - legacy[key] = opts[key] - opts[key] = nil - end - end - opts.integrations = legacy -end - local function init() if initialized then return @@ -677,160 +305,111 @@ local function init() local opts = vim.g.diffs or {} - migrate_integrations(opts) - - local intg = opts.integrations or {} - local fugitive_defaults = { horizontal = 'du', vertical = 'dU' } - if intg.fugitive == true then - intg.fugitive = vim.deepcopy(fugitive_defaults) - elseif type(intg.fugitive) == 'table' then - intg.fugitive = vim.tbl_extend('keep', intg.fugitive, fugitive_defaults) - end - - if intg.neogit == true then - intg.neogit = {} - end - - if intg.neojj == true then - intg.neojj = {} - end - - if intg.gitsigns == true then - intg.gitsigns = {} - end - - if intg.committia == true then - intg.committia = {} - end - - if intg.telescope == true then - intg.telescope = {} - end - - opts.integrations = intg - - vim.validate('debug', opts.debug, function(v) - return v == nil or type(v) == 'boolean' or type(v) == 'string' - end, 'boolean or string (file path)') - vim.validate('hide_prefix', opts.hide_prefix, 'boolean', true) - vim.validate('integrations', opts.integrations, 'table', true) - local integration_validator = function(v) - return v == nil or v == false or type(v) == 'table' - end - for _, key in ipairs(integration_keys) do - vim.validate('integrations.' .. key, intg[key], integration_validator, 'table or false') - end - vim.validate('extra_filetypes', opts.extra_filetypes, 'table', true) - vim.validate('highlights', opts.highlights, 'table', true) + vim.validate({ + debug = { opts.debug, 'boolean', true }, + debounce_ms = { opts.debounce_ms, 'number', true }, + hide_prefix = { opts.hide_prefix, 'boolean', true }, + highlights = { opts.highlights, 'table', true }, + }) if opts.highlights then - vim.validate('highlights.background', opts.highlights.background, 'boolean', true) - vim.validate('highlights.gutter', opts.highlights.gutter, 'boolean', true) - vim.validate('highlights.blend_alpha', opts.highlights.blend_alpha, 'number', true) - vim.validate('highlights.overrides', opts.highlights.overrides, 'table', true) - vim.validate('highlights.warn_max_lines', opts.highlights.warn_max_lines, 'boolean', true) - vim.validate('highlights.context', opts.highlights.context, 'table', true) - vim.validate('highlights.treesitter', opts.highlights.treesitter, 'table', true) - vim.validate('highlights.vim', opts.highlights.vim, 'table', true) - vim.validate('highlights.intra', opts.highlights.intra, 'table', true) - vim.validate('highlights.priorities', opts.highlights.priorities, 'table', true) + vim.validate({ + ['highlights.background'] = { opts.highlights.background, 'boolean', true }, + ['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true }, + ['highlights.blend_alpha'] = { opts.highlights.blend_alpha, 'number', true }, + ['highlights.overrides'] = { opts.highlights.overrides, 'table', true }, + ['highlights.context'] = { opts.highlights.context, 'table', true }, + ['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true }, + ['highlights.vim'] = { opts.highlights.vim, 'table', true }, + ['highlights.intra'] = { opts.highlights.intra, 'table', true }, + }) if opts.highlights.context then - vim.validate('highlights.context.enabled', opts.highlights.context.enabled, 'boolean', true) - vim.validate('highlights.context.lines', opts.highlights.context.lines, 'number', true) + vim.validate({ + ['highlights.context.enabled'] = { opts.highlights.context.enabled, 'boolean', true }, + ['highlights.context.lines'] = { opts.highlights.context.lines, 'number', true }, + }) end if opts.highlights.treesitter then - vim.validate( - 'highlights.treesitter.enabled', - opts.highlights.treesitter.enabled, - 'boolean', - true - ) - vim.validate( - 'highlights.treesitter.max_lines', - opts.highlights.treesitter.max_lines, - 'number', - true - ) + vim.validate({ + ['highlights.treesitter.enabled'] = { opts.highlights.treesitter.enabled, 'boolean', true }, + ['highlights.treesitter.max_lines'] = { + opts.highlights.treesitter.max_lines, + 'number', + true, + }, + }) end if opts.highlights.vim then - vim.validate('highlights.vim.enabled', opts.highlights.vim.enabled, 'boolean', true) - vim.validate('highlights.vim.max_lines', opts.highlights.vim.max_lines, 'number', true) + vim.validate({ + ['highlights.vim.enabled'] = { opts.highlights.vim.enabled, 'boolean', true }, + ['highlights.vim.max_lines'] = { opts.highlights.vim.max_lines, 'number', true }, + }) end if opts.highlights.intra then - vim.validate('highlights.intra.enabled', opts.highlights.intra.enabled, 'boolean', true) - vim.validate('highlights.intra.algorithm', opts.highlights.intra.algorithm, function(v) - return v == nil or v == 'default' or v == 'vscode' - end, "'default' or 'vscode'") - vim.validate('highlights.intra.max_lines', opts.highlights.intra.max_lines, 'number', true) - end - - if opts.highlights.priorities then - vim.validate('highlights.priorities.clear', opts.highlights.priorities.clear, 'number', true) - vim.validate( - 'highlights.priorities.syntax', - opts.highlights.priorities.syntax, - 'number', - true - ) - vim.validate( - 'highlights.priorities.line_bg', - opts.highlights.priorities.line_bg, - 'number', - true - ) - vim.validate( - 'highlights.priorities.char_bg', - opts.highlights.priorities.char_bg, - 'number', - true - ) + vim.validate({ + ['highlights.intra.enabled'] = { opts.highlights.intra.enabled, 'boolean', true }, + ['highlights.intra.algorithm'] = { + opts.highlights.intra.algorithm, + function(v) + return v == nil or v == 'default' or v == 'vscode' + end, + "'default' or 'vscode'", + }, + ['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true }, + }) end end - if type(intg.fugitive) == 'table' then - ---@type diffs.FugitiveConfig - local fug = intg.fugitive - vim.validate('integrations.fugitive.horizontal', fug.horizontal, function(v) - return v == nil or v == false or type(v) == 'string' - end, 'string or false') - vim.validate('integrations.fugitive.vertical', fug.vertical, function(v) - return v == nil or v == false or type(v) == 'string' - end, 'string or false') + if opts.fugitive then + vim.validate({ + ['fugitive.horizontal'] = { + opts.fugitive.horizontal, + function(v) + return v == false or type(v) == 'string' + end, + 'string or false', + }, + ['fugitive.vertical'] = { + opts.fugitive.vertical, + function(v) + return v == false or type(v) == 'string' + end, + 'string or false', + }, + }) end if opts.conflict then - vim.validate('conflict.enabled', opts.conflict.enabled, 'boolean', true) - vim.validate('conflict.disable_diagnostics', opts.conflict.disable_diagnostics, 'boolean', true) - vim.validate('conflict.show_virtual_text', opts.conflict.show_virtual_text, 'boolean', true) - vim.validate( - 'conflict.format_virtual_text', - opts.conflict.format_virtual_text, - 'function', - true - ) - vim.validate('conflict.show_actions', opts.conflict.show_actions, 'boolean', true) - vim.validate('conflict.priority', opts.conflict.priority, 'number', true) - vim.validate('conflict.keymaps', opts.conflict.keymaps, 'table', true) + vim.validate({ + ['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true }, + ['conflict.disable_diagnostics'] = { opts.conflict.disable_diagnostics, 'boolean', true }, + ['conflict.show_virtual_text'] = { opts.conflict.show_virtual_text, 'boolean', true }, + ['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true }, + }) if opts.conflict.keymaps then local keymap_validator = function(v) return v == false or type(v) == 'string' end for _, key in ipairs({ 'ours', 'theirs', 'both', 'none', 'next', 'prev' }) do - vim.validate( - 'conflict.keymaps.' .. key, - opts.conflict.keymaps[key], - keymap_validator, - 'string or false' - ) + vim.validate({ + ['conflict.keymaps.' .. key] = { + opts.conflict.keymaps[key], + keymap_validator, + 'string or false', + }, + }) end end end + if opts.debounce_ms and opts.debounce_ms < 0 then + error('diffs: debounce_ms must be >= 0') + end if opts.highlights and opts.highlights.context @@ -870,150 +449,17 @@ local function init() then error('diffs: highlights.blend_alpha must be >= 0 and <= 1') end - if opts.highlights and opts.highlights.priorities then - for _, key in ipairs({ 'clear', 'syntax', 'line_bg', 'char_bg' }) do - local v = opts.highlights.priorities[key] - if v and v < 0 then - error('diffs: highlights.priorities.' .. key .. ' must be >= 0') - end - end - end - if opts.conflict and opts.conflict.priority and opts.conflict.priority < 0 then - error('diffs: conflict.priority must be >= 0') - end config = vim.tbl_deep_extend('force', default_config, opts) log.set_enabled(config.debug) - fast_hl_opts = { - hide_prefix = config.hide_prefix, - highlights = vim.tbl_deep_extend('force', config.highlights, { - treesitter = { enabled = false }, - }), - defer_vim_syntax = true, - } - - compute_highlight_groups(true) + compute_highlight_groups() vim.api.nvim_create_autocmd('ColorScheme', { callback = function() - hl_retry_pending = false - compute_highlight_groups(false) + compute_highlight_groups() for bufnr, _ in pairs(attached_buffers) do - invalidate_cache(bufnr) - end - end, - }) - - vim.api.nvim_set_decoration_provider(ns, { - on_buf = function(_, bufnr) - if not attached_buffers[bufnr] then - return false - end - local t0 = config.debug and vim.uv.hrtime() or nil - ensure_cache(bufnr) - local entry = hunk_cache[bufnr] - if entry and entry.pending_clear then - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - entry.highlighted = {} - entry.pending_clear = false - end - if t0 then - dbg('on_buf %d: %.2fms', bufnr, (vim.uv.hrtime() - t0) / 1e6) - end - end, - on_win = function(_, _, bufnr, toprow, botrow) - if not attached_buffers[bufnr] then - return false - end - local entry = hunk_cache[bufnr] - if not entry then - return - end - local first, last = find_visible_hunks(entry.hunks, toprow, botrow) - if first == 0 then - return - end - local t0 = config.debug and vim.uv.hrtime() or nil - local deferred_syntax = {} - local skipped_count = 0 - local count = 0 - for i = first, last do - if not entry.highlighted[i] then - local hunk = entry.hunks[i] - local clear_start = hunk.start_line - 1 - local clear_end = hunk.start_line + #hunk.lines - if hunk.header_start_line then - clear_start = hunk.header_start_line - 1 - end - vim.api.nvim_buf_clear_namespace(bufnr, ns, clear_start, clear_end) - highlight.highlight_hunk(bufnr, ns, hunk, fast_hl_opts) - entry.highlighted[i] = true - count = count + 1 - if hunk._skipped_max_lines then - skipped_count = skipped_count + 1 - end - local has_syntax = hunk.lang and config.highlights.treesitter.enabled - local needs_vim = not hunk.lang and hunk.ft and config.highlights.vim.enabled - if has_syntax or needs_vim then - table.insert(deferred_syntax, hunk) - end - end - end - if skipped_count > 0 and not entry.warned_max_lines and config.highlights.warn_max_lines then - entry.warned_max_lines = true - local n = skipped_count - vim.schedule(function() - vim.notify( - ( - '[diffs.nvim]: Syntax highlighting skipped for %d hunk(s) — too large.' - .. ' See :h diffs-max-lines to resolve or suppress this warning.' - ):format(n), - vim.log.levels.WARN - ) - end) - end - if #deferred_syntax > 0 then - local tick = entry.tick - dbg('deferred syntax scheduled: %d hunks tick=%d', #deferred_syntax, tick) - vim.schedule(function() - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - local cur = hunk_cache[bufnr] - if not cur or cur.tick ~= tick then - dbg( - 'deferred syntax stale: cur.tick=%s captured=%d', - cur and tostring(cur.tick) or 'nil', - tick - ) - return - end - local t1 = config.debug and vim.uv.hrtime() or nil - local syntax_opts = { - hide_prefix = config.hide_prefix, - highlights = config.highlights, - syntax_only = true, - } - for _, hunk in ipairs(deferred_syntax) do - highlight.highlight_hunk(bufnr, ns, hunk, syntax_opts) - end - if t1 then - dbg('deferred pass: %d hunks in %.2fms', #deferred_syntax, (vim.uv.hrtime() - t1) / 1e6) - end - end) - end - if t0 and count > 0 then - dbg( - 'on_win %d: %d hunks [%d..%d] in %.2fms (viewport %d-%d)', - bufnr, - count, - first, - last, - (vim.uv.hrtime() - t0) / 1e6, - toprow, - botrow - ) + highlight_buffer(bufnr) end end, }) @@ -1038,52 +484,37 @@ function M.attach(bufnr) end attached_buffers[bufnr] = true - local neogit_augroup = nil - if config.integrations.neogit and vim.bo[bufnr].filetype:match('^Neogit') then - vim.b[bufnr].neogit_disable_hunk_highlight = true - neogit_augroup = vim.api.nvim_create_augroup('diffs_neogit_' .. bufnr, { clear = true }) - vim.api.nvim_create_autocmd('User', { - pattern = 'NeogitDiffLoaded', - group = neogit_augroup, - callback = function() - if vim.api.nvim_buf_is_valid(bufnr) and attached_buffers[bufnr] then - M.refresh(bufnr) - end - end, - }) - end - - local neojj_augroup = nil - if config.integrations.neojj and vim.bo[bufnr].filetype:match('^Neojj') then - vim.b[bufnr].neojj_disable_hunk_highlight = true - neojj_augroup = vim.api.nvim_create_augroup('diffs_neojj_' .. bufnr, { clear = true }) - vim.api.nvim_create_autocmd('User', { - pattern = 'NeojjDiffLoaded', - group = neojj_augroup, - callback = function() - if vim.api.nvim_buf_is_valid(bufnr) and attached_buffers[bufnr] then - M.refresh(bufnr) - end - end, - }) - end - dbg('attaching to buffer %d', bufnr) - ensure_cache(bufnr) + local debounced = create_debounced_highlight(bufnr) + + highlight_buffer(bufnr) + + vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { + buffer = bufnr, + callback = debounced, + }) + + vim.api.nvim_create_autocmd('Syntax', { + buffer = bufnr, + callback = function() + dbg('syntax event, re-highlighting buffer %d', bufnr) + highlight_buffer(bufnr) + end, + }) + + vim.api.nvim_create_autocmd('BufReadPost', { + buffer = bufnr, + callback = function() + dbg('BufReadPost event, re-highlighting buffer %d', bufnr) + highlight_buffer(bufnr) + end, + }) vim.api.nvim_create_autocmd('BufWipeout', { buffer = bufnr, callback = function() attached_buffers[bufnr] = nil - hunk_cache[bufnr] = nil - ft_retry_pending[bufnr] = nil - if neogit_augroup then - pcall(vim.api.nvim_del_augroup_by_id, neogit_augroup) - end - if neojj_augroup then - pcall(vim.api.nvim_del_augroup_by_id, neojj_augroup) - end end, }) end @@ -1091,7 +522,7 @@ end ---@param bufnr? integer function M.refresh(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() - invalidate_cache(bufnr) + highlight_buffer(bufnr) end local DIFF_WINHIGHLIGHT = table.concat({ @@ -1134,28 +565,10 @@ function M.detach_diff() end end ----@return diffs.FugitiveConfig|false +---@return diffs.FugitiveConfig function M.get_fugitive_config() init() - return config.integrations.fugitive -end - ----@return diffs.NeojjConfig|false -function M.get_neojj_config() - init() - return config.integrations.neojj -end - ----@return diffs.CommittiaConfig|false -function M.get_committia_config() - init() - return config.integrations.committia -end - ----@return diffs.TelescopeConfig|false -function M.get_telescope_config() - init() - return config.integrations.telescope + return config.fugitive end ---@return diffs.ConflictConfig @@ -1164,40 +577,4 @@ function M.get_conflict_config() return config.conflict end ----@return diffs.HunkOpts -function M.get_highlight_opts() - init() - return { hide_prefix = config.hide_prefix, highlights = config.highlights } -end - -local function process_pending_clear(bufnr) - local entry = hunk_cache[bufnr] - if entry and entry.pending_clear then - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - entry.highlighted = {} - entry.pending_clear = false - end -end - -M._test = { - find_visible_hunks = find_visible_hunks, - hunk_cache = hunk_cache, - ensure_cache = ensure_cache, - invalidate_cache = invalidate_cache, - hunks_eq = hunks_eq, - process_pending_clear = process_pending_clear, - ft_retry_pending = ft_retry_pending, - compute_hunk_context = compute_hunk_context, - compute_highlight_groups = compute_highlight_groups, - get_hl_retry_pending = function() - return hl_retry_pending - end, - set_hl_retry_pending = function(v) - hl_retry_pending = v - end, - get_config = function() - return config - end, -} - return M diff --git a/lua/diffs/lib.lua b/lua/diffs/lib.lua index 1b5bfe3..5b3254b 100644 --- a/lua/diffs/lib.lua +++ b/lua/diffs/lib.lua @@ -8,9 +8,6 @@ local cached_handle = nil ---@type boolean local download_in_progress = false ----@type fun(handle: table?)[] -local pending_callbacks = {} - ---@return string local function get_os() local os_name = jit.os:lower() @@ -167,10 +164,9 @@ function M.ensure(callback) return end - table.insert(pending_callbacks, callback) - if download_in_progress then - dbg('download already in progress, queued callback') + dbg('download already in progress') + callback(nil) return end @@ -183,38 +179,34 @@ function M.ensure(callback) local arch = get_arch() local ext = get_ext() local filename = ('libvscode_diff_%s_%s_%s.%s'):format(os_name, arch, EXPECTED_VERSION, ext) - local url = ('https://github.com/esmuellert/codediff.nvim/releases/download/v%s/%s'):format( + local url = ('https://github.com/esmuellert/vscode-diff.nvim/releases/download/v%s/%s'):format( EXPECTED_VERSION, filename ) local dest = lib_path() - vim.notify('[diffs.nvim]: downloading libvscode_diff...', vim.log.levels.INFO) + vim.notify('[diffs] downloading libvscode_diff...', vim.log.levels.INFO) local cmd = { 'curl', '-fSL', '-o', dest, url } vim.system(cmd, {}, function(result) download_in_progress = false vim.schedule(function() - local handle = nil if result.code ~= 0 then - vim.notify('[diffs.nvim]: failed to download libvscode_diff', vim.log.levels.WARN) + vim.notify('[diffs] failed to download libvscode_diff', vim.log.levels.WARN) dbg('curl failed: %s', result.stderr or '') - else - local f = io.open(version_path(), 'w') - if f then - f:write(EXPECTED_VERSION) - f:close() - end - vim.notify('[diffs.nvim]: libvscode_diff downloaded', vim.log.levels.INFO) - handle = M.load() + callback(nil) + return end - local cbs = pending_callbacks - pending_callbacks = {} - for _, cb in ipairs(cbs) do - cb(handle) + local f = io.open(version_path(), 'w') + if f then + f:write(EXPECTED_VERSION) + f:close() end + + vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) + callback(M.load()) end) end) end diff --git a/lua/diffs/log.lua b/lua/diffs/log.lua index 525f578..08abcc6 100644 --- a/lua/diffs/log.lua +++ b/lua/diffs/log.lua @@ -1,17 +1,10 @@ local M = {} local enabled = false -local log_file = nil ----@param val boolean|string +---@param val boolean function M.set_enabled(val) - if type(val) == 'string' then - enabled = true - log_file = val - else - enabled = val - log_file = nil - end + enabled = val end ---@param msg string @@ -20,16 +13,7 @@ function M.dbg(msg, ...) if not enabled then return end - local formatted = '[diffs.nvim]: ' .. string.format(msg, ...) - if log_file then - local f = io.open(log_file, 'a') - if f then - f:write(string.format('%.6fs', vim.uv.hrtime() / 1e9) .. ' ' .. formatted .. '\n') - f:close() - end - else - vim.notify(formatted, vim.log.levels.DEBUG) - end + vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG) end return M diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua deleted file mode 100644 index daf72ac..0000000 --- a/lua/diffs/merge.lua +++ /dev/null @@ -1,418 +0,0 @@ -local M = {} - -local conflict = require('diffs.conflict') - -local ns = vim.api.nvim_create_namespace('diffs-merge') - ----@type table> -local resolved_hunks = {} - ----@class diffs.MergeHunkInfo ----@field index integer ----@field start_line integer ----@field end_line integer ----@field del_lines string[] ----@field add_lines string[] - ----@param bufnr integer ----@return diffs.MergeHunkInfo[] -function M.parse_hunks(bufnr) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local hunks = {} - local current = nil - - for i, line in ipairs(lines) do - local idx = i - 1 - if line:match('^@@') then - if current then - current.end_line = idx - 1 - table.insert(hunks, current) - end - current = { - index = #hunks + 1, - start_line = idx, - end_line = idx, - del_lines = {}, - add_lines = {}, - } - elseif current then - local prefix = line:sub(1, 1) - if prefix == '-' then - table.insert(current.del_lines, line:sub(2)) - elseif prefix == '+' then - table.insert(current.add_lines, line:sub(2)) - elseif prefix ~= ' ' and prefix ~= '\\' then - current.end_line = idx - 1 - table.insert(hunks, current) - current = nil - end - if current then - current.end_line = idx - end - end - end - - if current then - table.insert(hunks, current) - end - - return hunks -end - ----@param bufnr integer ----@return diffs.MergeHunkInfo? -function M.find_hunk_at_cursor(bufnr) - local hunks = M.parse_hunks(bufnr) - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 - - for _, hunk in ipairs(hunks) do - if cursor_line >= hunk.start_line and cursor_line <= hunk.end_line then - return hunk - end - end - - return nil -end - ----@param hunk diffs.MergeHunkInfo ----@param working_bufnr integer ----@return diffs.ConflictRegion? -function M.match_hunk_to_conflict(hunk, working_bufnr) - local working_lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) - local regions = conflict.parse(working_lines) - - for _, region in ipairs(regions) do - local ours_lines = {} - for line = region.ours_start + 1, region.ours_end do - table.insert(ours_lines, working_lines[line]) - end - - if #ours_lines == #hunk.del_lines then - local match = true - for j = 1, #ours_lines do - if ours_lines[j] ~= hunk.del_lines[j] then - match = false - break - end - end - if match then - return region - end - end - end - - return nil -end - ----@param diff_bufnr integer ----@return integer? -function M.get_or_load_working_buf(diff_bufnr) - local ok, working_path = pcall(vim.api.nvim_buf_get_var, diff_bufnr, 'diffs_working_path') - if not ok or not working_path then - return nil - end - - local existing = vim.fn.bufnr(working_path) - if existing ~= -1 then - return existing - end - - local bufnr = vim.fn.bufadd(working_path) - vim.fn.bufload(bufnr) - return bufnr -end - ----@param diff_bufnr integer ----@param hunk_index integer -local function mark_resolved(diff_bufnr, hunk_index) - if not resolved_hunks[diff_bufnr] then - resolved_hunks[diff_bufnr] = {} - end - resolved_hunks[diff_bufnr][hunk_index] = true -end - ----@param diff_bufnr integer ----@param hunk_index integer ----@return boolean -function M.is_resolved(diff_bufnr, hunk_index) - return resolved_hunks[diff_bufnr] and resolved_hunks[diff_bufnr][hunk_index] or false -end - ----@param diff_bufnr integer ----@param hunk diffs.MergeHunkInfo -local function add_resolved_virtual_text(diff_bufnr, hunk) - pcall(vim.api.nvim_buf_set_extmark, diff_bufnr, ns, hunk.start_line, 0, { - virt_text = { { ' (resolved)', 'Comment' } }, - virt_text_pos = 'eol', - }) -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -function M.resolve_ours(bufnr, config) - local hunk = M.find_hunk_at_cursor(bufnr) - if not hunk then - return - end - if M.is_resolved(bufnr, hunk.index) then - vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) - return - end - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - local region = M.match_hunk_to_conflict(hunk, working_bufnr) - if not region then - vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) - return - end - local lines = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false) - conflict.replace_region(working_bufnr, region, lines) - conflict.refresh(working_bufnr, config) - mark_resolved(bufnr, hunk.index) - add_resolved_virtual_text(bufnr, hunk) -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -function M.resolve_theirs(bufnr, config) - local hunk = M.find_hunk_at_cursor(bufnr) - if not hunk then - return - end - if M.is_resolved(bufnr, hunk.index) then - vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) - return - end - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - local region = M.match_hunk_to_conflict(hunk, working_bufnr) - if not region then - vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) - return - end - local lines = - vim.api.nvim_buf_get_lines(working_bufnr, region.theirs_start, region.theirs_end, false) - conflict.replace_region(working_bufnr, region, lines) - conflict.refresh(working_bufnr, config) - mark_resolved(bufnr, hunk.index) - add_resolved_virtual_text(bufnr, hunk) -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -function M.resolve_both(bufnr, config) - local hunk = M.find_hunk_at_cursor(bufnr) - if not hunk then - return - end - if M.is_resolved(bufnr, hunk.index) then - vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) - return - end - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - local region = M.match_hunk_to_conflict(hunk, working_bufnr) - if not region then - vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) - return - end - local ours = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false) - local theirs = - vim.api.nvim_buf_get_lines(working_bufnr, region.theirs_start, region.theirs_end, false) - local combined = {} - for _, l in ipairs(ours) do - table.insert(combined, l) - end - for _, l in ipairs(theirs) do - table.insert(combined, l) - end - conflict.replace_region(working_bufnr, region, combined) - conflict.refresh(working_bufnr, config) - mark_resolved(bufnr, hunk.index) - add_resolved_virtual_text(bufnr, hunk) -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -function M.resolve_none(bufnr, config) - local hunk = M.find_hunk_at_cursor(bufnr) - if not hunk then - return - end - if M.is_resolved(bufnr, hunk.index) then - vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) - return - end - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - local region = M.match_hunk_to_conflict(hunk, working_bufnr) - if not region then - vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) - return - end - conflict.replace_region(working_bufnr, region, {}) - conflict.refresh(working_bufnr, config) - mark_resolved(bufnr, hunk.index) - add_resolved_virtual_text(bufnr, hunk) -end - ----@param bufnr integer -function M.goto_next(bufnr) - local hunks = M.parse_hunks(bufnr) - if #hunks == 0 then - return - end - - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 - - local candidates = {} - for _, hunk in ipairs(hunks) do - if not M.is_resolved(bufnr, hunk.index) then - if M.match_hunk_to_conflict(hunk, working_bufnr) then - table.insert(candidates, hunk) - end - end - end - - if #candidates == 0 then - return - end - - for _, hunk in ipairs(candidates) do - if hunk.start_line > cursor_line then - vim.api.nvim_win_set_cursor(0, { hunk.start_line + 1, 0 }) - return - end - end - - vim.notify('[diffs.nvim]: wrapped to first hunk', vim.log.levels.INFO) - vim.api.nvim_win_set_cursor(0, { candidates[1].start_line + 1, 0 }) -end - ----@param bufnr integer -function M.goto_prev(bufnr) - local hunks = M.parse_hunks(bufnr) - if #hunks == 0 then - return - end - - local working_bufnr = M.get_or_load_working_buf(bufnr) - if not working_bufnr then - return - end - - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 - - local candidates = {} - for _, hunk in ipairs(hunks) do - if not M.is_resolved(bufnr, hunk.index) then - if M.match_hunk_to_conflict(hunk, working_bufnr) then - table.insert(candidates, hunk) - end - end - end - - if #candidates == 0 then - return - end - - for i = #candidates, 1, -1 do - if candidates[i].start_line < cursor_line then - vim.api.nvim_win_set_cursor(0, { candidates[i].start_line + 1, 0 }) - return - end - end - - vim.notify('[diffs.nvim]: wrapped to last hunk', vim.log.levels.INFO) - vim.api.nvim_win_set_cursor(0, { candidates[#candidates].start_line + 1, 0 }) -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -local function apply_hunk_hints(bufnr, config) - if not config.show_virtual_text then - return - end - - local hunks = M.parse_hunks(bufnr) - for _, hunk in ipairs(hunks) do - if M.is_resolved(bufnr, hunk.index) then - add_resolved_virtual_text(bufnr, hunk) - else - local parts = {} - local actions = { - { 'current', config.keymaps.ours }, - { 'incoming', config.keymaps.theirs }, - { 'both', config.keymaps.both }, - { 'none', config.keymaps.none }, - } - for _, action in ipairs(actions) do - if action[2] then - if #parts > 0 then - table.insert(parts, { ' | ', 'Comment' }) - end - table.insert(parts, { ('%s: %s'):format(action[2], action[1]), 'Comment' }) - end - end - if #parts > 0 then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, hunk.start_line, 0, { - virt_text = parts, - virt_text_pos = 'eol', - }) - end - end - end -end - ----@param bufnr integer ----@param config diffs.ConflictConfig -function M.setup_keymaps(bufnr, config) - resolved_hunks[bufnr] = nil - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - - local km = config.keymaps - - local maps = { - { km.ours, '(diffs-merge-ours)' }, - { km.theirs, '(diffs-merge-theirs)' }, - { km.both, '(diffs-merge-both)' }, - { km.none, '(diffs-merge-none)' }, - { km.next, '(diffs-merge-next)' }, - { km.prev, '(diffs-merge-prev)' }, - } - - for _, map in ipairs(maps) do - if map[1] then - vim.keymap.set('n', map[1], map[2], { buffer = bufnr }) - end - end - - apply_hunk_hints(bufnr, config) - - vim.api.nvim_create_autocmd('BufWipeout', { - buffer = bufnr, - callback = function() - resolved_hunks[bufnr] = nil - end, - }) -end - ----@return integer -function M.get_namespace() - return ns -end - -return M diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 6f84b83..43eb1f6 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -12,21 +12,12 @@ ---@field file_old_count integer? ---@field file_new_start integer? ---@field file_new_count integer? ----@field prefix_width integer ----@field quote_width integer ---@field repo_root string? ----@field context_before string[]? ----@field context_after string[]? ----@field _hl_line_count integer? ----@field _skipped_max_lines boolean? local M = {} local dbg = require('diffs.log').dbg ----@type table -local ft_lang_cache = {} - ---@param filepath string ---@param n integer ---@return string[]? @@ -65,15 +56,6 @@ local function get_ft_from_filename(filename, repo_root) end local ft = vim.filetype.match({ filename = filename }) - if not ft and vim.fn.did_filetype() ~= 0 then - dbg('retrying filetype match for %s (clearing did_filetype)', filename) - local saved = rawget(vim.fn, 'did_filetype') - rawset(vim.fn, 'did_filetype', function() - return 0 - end) - ft = vim.filetype.match({ filename = filename }) - rawset(vim.fn, 'did_filetype', saved) - end if ft then dbg('filetype from filename: %s', ft) return ft @@ -124,26 +106,7 @@ local function get_repo_root(bufnr) return vim.fn.fnamemodify(git_dir, ':h') end - local ok3, neogit_git_dir = pcall(vim.api.nvim_buf_get_var, bufnr, 'neogit_git_dir') - if ok3 and neogit_git_dir then - return vim.fn.fnamemodify(neogit_git_dir, ':h') - end - - if vim.bo[bufnr].filetype:match('^Neojj') then - local jj_ok, jj_mod = pcall(require, 'neojj.lib.jj') - if jj_ok then - local rok, repo = pcall(function() - return jj_mod.repo - end) - if rok and repo and repo.worktree_root then - return repo.worktree_root - end - end - end - - local cwd = vim.fn.getcwd() - local git = require('diffs.git') - return git.get_repo_root(cwd .. '/.') + return nil end ---@param bufnr integer @@ -151,18 +114,6 @@ end function M.parse_buffer(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local repo_root = get_repo_root(bufnr) - - local quote_prefix = nil - local quote_width = 0 - for _, l in ipairs(lines) do - local qp = l:match('^(>+ )diff %-%-') or l:match('^(>+ )@@ %-') - if qp then - quote_prefix = qp - quote_width = #qp - break - end - end - ---@type diffs.Hunk[] local hunks = {} @@ -182,8 +133,6 @@ function M.parse_buffer(bufnr) local hunk_lines = {} ---@type integer? local hunk_count = nil - ---@type integer - local hunk_prefix_width = 1 ---@type integer? local header_start = nil ---@type string[] @@ -196,11 +145,6 @@ function M.parse_buffer(bufnr) local file_new_start = nil ---@type integer? local file_new_count = nil - ---@type integer? - local old_remaining = nil - ---@type integer? - local new_remaining = nil - local current_quote_width = 0 local function flush_hunk() if hunk_start and #hunk_lines > 0 then @@ -212,8 +156,6 @@ function M.parse_buffer(bufnr) header_context = hunk_header_context, header_context_col = hunk_header_context_col, lines = hunk_lines, - prefix_width = hunk_prefix_width, - quote_width = current_quote_width, file_old_start = file_old_start, file_old_count = file_old_count, file_new_start = file_new_start, @@ -234,125 +176,51 @@ function M.parse_buffer(bufnr) file_old_count = nil file_new_start = nil file_new_count = nil - old_remaining = nil - new_remaining = nil end for i, line in ipairs(lines) do - local logical = line - if quote_prefix then - if line:sub(1, quote_width) == quote_prefix then - logical = line:sub(quote_width + 1) - elseif line:match('^>+$') then - logical = '' - end - end - - local diff_git_file = logical:match('^diff %-%-git a/.+ b/(.+)$') - or logical:match('^diff %-%-combined (.+)$') - or logical:match('^diff %-%-cc (.+)$') - local neogit_file = logical:match('^modified%s+(.+)$') - or (not logical:match('^new file mode') and logical:match('^new file%s+(.+)$')) - or (not logical:match('^deleted file mode') and logical:match('^deleted%s+(.+)$')) - or logical:match('^renamed%s+(.+)$') - or logical:match('^copied%s+(.+)$') - or logical:match('^added%s+(.+)$') - or logical:match('^updated%s+(.+)$') - or logical:match('^changed%s+(.+)$') - or logical:match('^unmerged%s+(.+)$') - local bare_file = not hunk_start and logical:match('^([^%s]+%.[^%s]+)$') - local filename = logical:match('^[MADRCU%?!]%s+(.+)$') - or diff_git_file - or neogit_file - or bare_file + local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$') if filename then flush_hunk() current_filename = filename - current_quote_width = (logical ~= line) and quote_width or 0 - local cache_key = (repo_root or '') .. '\0' .. filename - local cached = ft_lang_cache[cache_key] - if cached then - current_ft = cached.ft - current_lang = cached.lang - else - current_ft = get_ft_from_filename(filename, repo_root) - current_lang = current_ft and get_lang_from_ft(current_ft) or nil - if current_ft or vim.fn.did_filetype() == 0 then - ft_lang_cache[cache_key] = { ft = current_ft, lang = current_lang } - end - end + current_ft = get_ft_from_filename(filename, repo_root) + current_lang = current_ft and get_lang_from_ft(current_ft) or nil if current_lang then dbg('file: %s -> lang: %s', filename, current_lang) elseif current_ft then dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft) end hunk_count = 0 - hunk_prefix_width = 1 header_start = i header_lines = {} - elseif logical:match('^@@+') then + elseif line:match('^@@.-@@') then flush_hunk() hunk_start = i - local at_prefix = logical:match('^(@@+)') - hunk_prefix_width = #at_prefix - 1 - if #at_prefix == 2 then - local hs, hc, hs2, hc2 = logical:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') - if hs then - file_old_start = tonumber(hs) - file_old_count = tonumber(hc) or 1 - file_new_start = tonumber(hs2) - file_new_count = tonumber(hc2) or 1 - old_remaining = file_old_count - new_remaining = file_new_count - end - else - local hs, hc = logical:match('%-(%d+),?(%d*)') - if hs then - file_old_start = tonumber(hs) - file_old_count = tonumber(hc) or 1 - old_remaining = file_old_count - end - local hs2, hc2 = logical:match('%+(%d+),?(%d*) @@') - if hs2 then - file_new_start = tonumber(hs2) - file_new_count = tonumber(hc2) or 1 - new_remaining = file_new_count - end + local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') + if hs then + file_old_start = tonumber(hs) + file_old_count = tonumber(hc) or 1 + file_new_start = tonumber(hs2) + file_new_count = tonumber(hc2) or 1 end - local at_end, context = logical:match('^(@@+.-@@+%s*)(.*)') + local prefix, context = line:match('^(@@.-@@%s*)(.*)') if context and context ~= '' then hunk_header_context = context - hunk_header_context_col = #at_end + current_quote_width + hunk_header_context_col = #prefix end if hunk_count then hunk_count = hunk_count + 1 end elseif hunk_start then - local prefix = logical:sub(1, 1) + local prefix = line:sub(1, 1) if prefix == ' ' or prefix == '+' or prefix == '-' then - table.insert(hunk_lines, logical) - if old_remaining and (prefix == ' ' or prefix == '-') then - old_remaining = old_remaining - 1 - end - if new_remaining and (prefix == ' ' or prefix == '+') then - new_remaining = new_remaining - 1 - end + table.insert(hunk_lines, line) elseif - logical == '' - and old_remaining - and old_remaining > 0 - and new_remaining - and new_remaining > 0 - then - table.insert(hunk_lines, string.rep(' ', hunk_prefix_width)) - old_remaining = old_remaining - 1 - new_remaining = new_remaining - 1 - elseif - logical == '' - or logical:match('^[MADRC%?!]%s+') - or logical:match('^diff ') - or logical:match('^index ') - or logical:match('^Binary ') + line == '' + or line:match('^[MADRC%?!]%s+') + or line:match('^diff ') + or line:match('^index ') + or line:match('^Binary ') then flush_hunk() current_filename = nil @@ -362,7 +230,7 @@ function M.parse_buffer(bufnr) end end if header_start and not hunk_start then - table.insert(header_lines, logical) + table.insert(header_lines, line) end end @@ -371,10 +239,4 @@ function M.parse_buffer(bufnr) return hunks end -M.get_lang_from_ft = get_lang_from_ft - -M._test = { - ft_lang_cache = ft_lang_cache, -} - return M diff --git a/minimal_init.lua b/minimal_init.lua deleted file mode 100644 index efcd279..0000000 --- a/minimal_init.lua +++ /dev/null @@ -1,61 +0,0 @@ -vim.cmd([[set runtimepath=$VIMRUNTIME]]) -vim.o.background = 'dark' -vim.o.number = true -vim.o.relativenumber = true - -local root = vim.fn.fnamemodify('/tmp/diffs-harivansh-repro', ':p') -vim.opt.packpath = { root } -vim.env.XDG_CONFIG_HOME = root -vim.env.XDG_DATA_HOME = root -vim.env.XDG_STATE_HOME = root -vim.env.XDG_CACHE_HOME = root - -vim.opt.rtp:prepend(vim.fn.expand('~/dev/diffs.nvim')) - -local lazypath = root .. '/lazy.nvim' -if not vim.uv.fs_stat(lazypath) then - vim.fn.system({ - 'git', - 'clone', - '--filter=blob:none', - '--branch=stable', - 'https://github.com/folke/lazy.nvim.git', - lazypath, - }) -end -vim.opt.rtp:prepend(lazypath) - -require('lazy').setup({ - { - dir = vim.fn.expand('~/dev/midnight.nvim'), - lazy = false, - priority = 1000, - config = function() - vim.cmd.colorscheme('midnight') - end, - }, - { 'tpope/vim-fugitive' }, - { - dir = vim.fn.expand('~/dev/diffs.nvim'), - init = function() - vim.g.diffs = { - integrations = { - fugitive = { - enabled = true, - horizontal = false, - vertical = false, - }, - }, - hide_prefix = false, - highlights = { - gutter = true, - intra = { enabled = true }, - overrides = { - DiffsAdd = { bg = '#ff0000' }, - DiffsDelete = { bg = '#0000ff' }, - }, - }, - } - end, - }, -}, { root = root .. '/plugins' }) diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 2b785ef..5d3c8b2 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -5,50 +5,12 @@ vim.g.loaded_diffs = 1 require('diffs.commands').setup() -local function get_raw_integration(key) - local user = vim.g.diffs or {} - local intg = user.integrations or {} - local v = intg[key] - if v ~= nil then - return v - end - return user[key] -end - -local gs_cfg = get_raw_integration('gitsigns') -if gs_cfg == true or type(gs_cfg) == 'table' then - if not require('diffs.gitsigns').setup() then - vim.api.nvim_create_autocmd('User', { - pattern = 'GitAttach', - once = true, - callback = function() - require('diffs.gitsigns').setup() - end, - }) - end -end - -local tel_cfg = get_raw_integration('telescope') -if tel_cfg == true or type(tel_cfg) == 'table' then - vim.api.nvim_create_autocmd('User', { - pattern = 'TelescopePreviewerLoaded', - callback = function() - require('diffs').attach(vim.api.nvim_get_current_buf()) - end, - }) -end - vim.api.nvim_create_autocmd('FileType', { - pattern = require('diffs').compute_filetypes(vim.g.diffs or {}), + pattern = { 'fugitive', 'git' }, callback = function(args) local diffs = require('diffs') - if args.match == 'git' then - local is_fugitive = diffs.get_fugitive_config() and diffs.is_fugitive_buffer(args.buf) - local is_committia = diffs.get_committia_config() - and vim.api.nvim_buf_get_name(args.buf):match('__committia_diff__$') - if not is_fugitive and not is_committia then - return - end + if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then + return end diffs.attach(args.buf) @@ -120,28 +82,3 @@ end, { desc = 'Jump to next conflict' }) vim.keymap.set('n', '(diffs-conflict-prev)', function() require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf()) end, { desc = 'Jump to previous conflict' }) - -local function merge_action(fn) - local bufnr = vim.api.nvim_get_current_buf() - local config = require('diffs').get_conflict_config() - fn(bufnr, config) -end - -vim.keymap.set('n', '(diffs-merge-ours)', function() - merge_action(require('diffs.merge').resolve_ours) -end, { desc = 'Accept ours in merge diff' }) -vim.keymap.set('n', '(diffs-merge-theirs)', function() - merge_action(require('diffs.merge').resolve_theirs) -end, { desc = 'Accept theirs in merge diff' }) -vim.keymap.set('n', '(diffs-merge-both)', function() - merge_action(require('diffs.merge').resolve_both) -end, { desc = 'Accept both in merge diff' }) -vim.keymap.set('n', '(diffs-merge-none)', function() - merge_action(require('diffs.merge').resolve_none) -end, { desc = 'Reject both in merge diff' }) -vim.keymap.set('n', '(diffs-merge-next)', function() - require('diffs.merge').goto_next(vim.api.nvim_get_current_buf()) -end, { desc = 'Jump to next conflict hunk' }) -vim.keymap.set('n', '(diffs-merge-prev)', function() - require('diffs.merge').goto_prev(vim.api.nvim_get_current_buf()) -end, { desc = 'Jump to previous conflict hunk' }) diff --git a/scripts/ci.sh b/scripts/ci.sh deleted file mode 100755 index e06bf09..0000000 --- a/scripts/ci.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -set -eu - -nix develop --command stylua --check . -git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet -nix develop --command prettier --check . -nix fmt -git diff --exit-code -- '*.nix' -nix develop --command lua-language-server --check . --checklevel=Warning -nix develop --command busted diff --git a/selene.toml b/selene.toml index f2ada4b..96cf5ab 100644 --- a/selene.toml +++ b/selene.toml @@ -1,4 +1 @@ std = 'vim' - -[lints] -bad_string_escape = 'allow' diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index 1bf9e61..daba5d5 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -40,78 +40,6 @@ describe('commands', function() end) end) - describe('filter_combined_diffs', function() - it('strips diff --cc entries entirely', function() - local lines = { - 'diff --cc main.lua', - 'index d13ab94,b113aee..0000000', - '--- a/main.lua', - '+++ b/main.lua', - '@@@ -1,7 -1,7 +1,11 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '++=======', - '+ return 2', - '++>>>>>>> theirs', - ' end', - } - local result = commands.filter_combined_diffs(lines) - assert.are.equal(0, #result) - end) - - it('preserves diff --git entries', function() - local lines = { - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,3 +1,3 @@', - ' local M = {}', - '-local x = 1', - '+local x = 2', - ' return M', - } - local result = commands.filter_combined_diffs(lines) - assert.are.equal(8, #result) - assert.are.same(lines, result) - end) - - it('strips combined but keeps unified in mixed output', function() - local lines = { - 'diff --cc conflict.lua', - 'index aaa,bbb..000', - '@@@ -1,1 -1,1 +1,5 @@@', - '++<<<<<<< HEAD', - 'diff --git a/clean.lua b/clean.lua', - '--- a/clean.lua', - '+++ b/clean.lua', - '@@ -1,1 +1,1 @@', - '-old', - '+new', - } - local result = commands.filter_combined_diffs(lines) - assert.are.equal(6, #result) - assert.are.equal('diff --git a/clean.lua b/clean.lua', result[1]) - assert.are.equal('+new', result[6]) - end) - - it('returns empty for empty input', function() - local result = commands.filter_combined_diffs({}) - assert.are.equal(0, #result) - end) - - it('returns empty when all entries are combined', function() - local lines = { - 'diff --cc a.lua', - 'some content', - 'diff --cc b.lua', - 'more content', - } - local result = commands.filter_combined_diffs(lines) - assert.are.equal(0, #result) - end) - end) - describe('find_hunk_line', function() it('finds matching @@ header and returns target line', function() local diff_lines = { diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua index 9f3df5d..75eac23 100644 --- a/spec/conflict_spec.lua +++ b/spec/conflict_spec.lua @@ -6,14 +6,13 @@ local function default_config(overrides) enabled = true, disable_diagnostics = false, show_virtual_text = true, - show_actions = false, keymaps = { ours = 'doo', theirs = 'dot', both = 'dob', none = 'don', - next = ']c', - prev = '[c', + next = ']x', + prev = '[x', }, } if overrides then @@ -235,6 +234,29 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) + it('does not apply virtual text when disabled', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config({ show_virtual_text = false })) + + local extmarks = get_extmarks(bufnr) + local virt_text_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + virt_text_count = virt_text_count + 1 + end + end + assert.are.equal(0, virt_text_count) + + helpers.delete_buffer(bufnr) + end) + it('applies number_hl_group to content lines', function() local bufnr = create_file_buffer({ '<<<<<<< HEAD', @@ -509,33 +531,6 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) - it('goto_next notifies on wrap-around', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'a', - '=======', - 'b', - '>>>>>>> feat', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('wrapped to first conflict') then - notified = true - end - end - - conflict.goto_next(bufnr) - vim.notify = orig_notify - - assert.is_true(notified) - - helpers.delete_buffer(bufnr) - end) - it('goto_prev jumps to previous conflict', function() local bufnr = create_file_buffer({ '<<<<<<< HEAD', @@ -580,33 +575,6 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) - it('goto_prev notifies on wrap-around', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'a', - '=======', - 'b', - '>>>>>>> feat', - }) - vim.api.nvim_set_current_buf(bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('wrapped to last conflict') then - notified = true - end - end - - conflict.goto_prev(bufnr) - vim.notify = orig_notify - - assert.is_true(notified) - - helpers.delete_buffer(bufnr) - end) - it('goto_next does nothing with no conflicts', function() local bufnr = create_file_buffer({ 'normal line' }) vim.api.nvim_set_current_buf(bufnr) @@ -717,158 +685,4 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) end) - - describe('virtual text formatting', function() - after_each(function() - conflict.detach(vim.api.nvim_get_current_buf()) - end) - - it('default labels show current and incoming without keymaps', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach(bufnr, default_config()) - - local extmarks = get_extmarks(bufnr) - local labels = {} - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - table.insert(labels, mark[4].virt_text[1][1]) - end - end - assert.are.equal(2, #labels) - assert.are.equal(' (current)', labels[1]) - assert.are.equal(' (incoming)', labels[2]) - - helpers.delete_buffer(bufnr) - end) - - it('uses custom format_virtual_text function', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach( - bufnr, - default_config({ - format_virtual_text = function(side) - return side == 'ours' and 'OURS' or 'THEIRS' - end, - }) - ) - - local extmarks = get_extmarks(bufnr) - local labels = {} - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - table.insert(labels, mark[4].virt_text[1][1]) - end - end - assert.are.equal(2, #labels) - assert.are.equal(' (OURS)', labels[1]) - assert.are.equal(' (THEIRS)', labels[2]) - - helpers.delete_buffer(bufnr) - end) - - it('hides label when format_virtual_text returns nil', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach( - bufnr, - default_config({ - format_virtual_text = function() - return nil - end, - }) - ) - - local extmarks = get_extmarks(bufnr) - local virt_text_count = 0 - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - virt_text_count = virt_text_count + 1 - end - end - assert.are.equal(0, virt_text_count) - - helpers.delete_buffer(bufnr) - end) - end) - - describe('action lines', function() - after_each(function() - conflict.detach(vim.api.nvim_get_current_buf()) - end) - - it('adds virt_lines when show_actions is true', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach(bufnr, default_config({ show_actions = true })) - - local extmarks = get_extmarks(bufnr) - local virt_lines_count = 0 - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_lines then - virt_lines_count = virt_lines_count + 1 - end - end - assert.are.equal(1, virt_lines_count) - - helpers.delete_buffer(bufnr) - end) - - it('omits disabled keymaps from action line', function() - local bufnr = create_file_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }) - - conflict.attach( - bufnr, - default_config({ show_actions = true, keymaps = { both = false, none = false } }) - ) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_lines then - local line = mark[4].virt_lines[1] - local text = '' - for _, chunk in ipairs(line) do - text = text .. chunk[1] - end - assert.is_truthy(text:find('Current')) - assert.is_truthy(text:find('Incoming')) - assert.is_falsy(text:find('Both')) - assert.is_falsy(text:find('None')) - end - end - - helpers.delete_buffer(bufnr) - end) - end) end) diff --git a/spec/context_spec.lua b/spec/context_spec.lua deleted file mode 100644 index 84243f4..0000000 --- a/spec/context_spec.lua +++ /dev/null @@ -1,415 +0,0 @@ -require('spec.helpers') -local diffs = require('diffs') -local highlight = require('diffs.highlight') -local compute_hunk_context = diffs._test.compute_hunk_context - -describe('context', function() - describe('compute_hunk_context', function() - local tmpdir - - before_each(function() - tmpdir = vim.fn.tempname() - vim.fn.mkdir(tmpdir, 'p') - end) - - after_each(function() - vim.fn.delete(tmpdir, 'rf') - end) - - local function write_file(filename, lines) - local path = vim.fs.joinpath(tmpdir, filename) - local dir = vim.fn.fnamemodify(path, ':h') - if vim.fn.isdirectory(dir) == 0 then - vim.fn.mkdir(dir, 'p') - end - local f = io.open(path, 'w') - f:write(table.concat(lines, '\n') .. '\n') - f:close() - end - - local function make_hunk(filename, opts) - return { - filename = filename, - ft = 'lua', - lang = 'lua', - start_line = opts.start_line or 1, - lines = opts.lines, - prefix_width = opts.prefix_width or 1, - quote_width = 0, - repo_root = tmpdir, - file_new_start = opts.file_new_start, - file_new_count = opts.file_new_count, - } - end - - it('reads context_before from file lines preceding the hunk', function() - write_file('a.lua', { - 'local M = {}', - 'function M.foo()', - ' local x = 1', - ' local y = 2', - 'end', - 'return M', - }) - - local hunks = { - make_hunk('a.lua', { - file_new_start = 3, - file_new_count = 3, - lines = { ' local x = 1', '+local new = true', ' local y = 2' }, - }), - } - compute_hunk_context(hunks, 25) - - assert.same({ 'local M = {}', 'function M.foo()' }, hunks[1].context_before) - end) - - it('reads context_after from file lines following the hunk', function() - write_file('a.lua', { - 'local M = {}', - 'function M.foo()', - ' local x = 1', - 'end', - 'return M', - }) - - local hunks = { - make_hunk('a.lua', { - file_new_start = 2, - file_new_count = 2, - lines = { ' function M.foo()', '+ local x = 1' }, - }), - } - compute_hunk_context(hunks, 25) - - assert.same({ 'end', 'return M' }, hunks[1].context_after) - end) - - it('caps context_before to max_lines', function() - write_file('a.lua', { - 'line1', - 'line2', - 'line3', - 'line4', - 'line5', - 'target', - }) - - local hunks = { - make_hunk('a.lua', { - file_new_start = 6, - file_new_count = 1, - lines = { '+target' }, - }), - } - compute_hunk_context(hunks, 2) - - assert.same({ 'line4', 'line5' }, hunks[1].context_before) - end) - - it('caps context_after to max_lines', function() - write_file('a.lua', { - 'target', - 'after1', - 'after2', - 'after3', - 'after4', - }) - - local hunks = { - make_hunk('a.lua', { - file_new_start = 1, - file_new_count = 1, - lines = { '+target' }, - }), - } - compute_hunk_context(hunks, 2) - - assert.same({ 'after1', 'after2' }, hunks[1].context_after) - end) - - it('skips hunks without file_new_start', function() - write_file('a.lua', { 'line1', 'line2' }) - - local hunks = { - make_hunk('a.lua', { - file_new_start = nil, - file_new_count = nil, - lines = { '+something' }, - }), - } - compute_hunk_context(hunks, 25) - - assert.is_nil(hunks[1].context_before) - assert.is_nil(hunks[1].context_after) - end) - - it('skips hunks without repo_root', function() - local hunks = { - { - filename = 'a.lua', - ft = 'lua', - lang = 'lua', - start_line = 1, - lines = { '+x' }, - prefix_width = 1, - quote_width = 0, - repo_root = nil, - file_new_start = 1, - file_new_count = 1, - }, - } - compute_hunk_context(hunks, 25) - - assert.is_nil(hunks[1].context_before) - assert.is_nil(hunks[1].context_after) - end) - - it('skips when path is a directory', function() - vim.fn.mkdir(vim.fs.joinpath(tmpdir, 'subdir'), 'p') - - local hunks = { - make_hunk('subdir', { - file_new_start = 1, - file_new_count = 1, - lines = { '+x' }, - }), - } - compute_hunk_context(hunks, 25) - - assert.is_nil(hunks[1].context_before) - assert.is_nil(hunks[1].context_after) - end) - - it('skips when path is a symlink to a directory', function() - vim.fn.mkdir(vim.fs.joinpath(tmpdir, 'real_dir'), 'p') - vim.uv.fs_symlink(vim.fs.joinpath(tmpdir, 'real_dir'), vim.fs.joinpath(tmpdir, 'link_dir')) - - local hunks = { - make_hunk('link_dir', { - file_new_start = 1, - file_new_count = 1, - lines = { '+x' }, - }), - } - compute_hunk_context(hunks, 25) - - assert.is_nil(hunks[1].context_before) - assert.is_nil(hunks[1].context_after) - end) - - it('skips when file does not exist on disk', function() - local hunks = { - make_hunk('nonexistent.lua', { - file_new_start = 1, - file_new_count = 1, - lines = { '+x' }, - }), - } - compute_hunk_context(hunks, 25) - - assert.is_nil(hunks[1].context_before) - assert.is_nil(hunks[1].context_after) - end) - - it('returns nil context_before for hunk at line 1', function() - write_file('a.lua', { 'first', 'second' }) - - local hunks = { - make_hunk('a.lua', { - file_new_start = 1, - file_new_count = 1, - lines = { '+first' }, - }), - } - compute_hunk_context(hunks, 25) - - assert.is_nil(hunks[1].context_before) - end) - - it('returns nil context_after for hunk at end of file', function() - write_file('a.lua', { 'first', 'last' }) - - local hunks = { - make_hunk('a.lua', { - file_new_start = 1, - file_new_count = 2, - lines = { ' first', '+last' }, - }), - } - compute_hunk_context(hunks, 25) - - assert.is_nil(hunks[1].context_after) - end) - - it('reads file once for multiple hunks in same file', function() - write_file('a.lua', { - 'local M = {}', - 'function M.foo()', - ' return 1', - 'end', - 'function M.bar()', - ' return 2', - 'end', - 'return M', - }) - - local hunks = { - make_hunk('a.lua', { - file_new_start = 2, - file_new_count = 3, - lines = { ' function M.foo()', '+ return 1', ' end' }, - }), - make_hunk('a.lua', { - file_new_start = 5, - file_new_count = 3, - lines = { ' function M.bar()', '+ return 2', ' end' }, - }), - } - compute_hunk_context(hunks, 25) - - assert.same({ 'local M = {}' }, hunks[1].context_before) - assert.same({ 'function M.bar()', ' return 2', 'end', 'return M' }, hunks[1].context_after) - assert.same({ - 'local M = {}', - 'function M.foo()', - ' return 1', - 'end', - }, hunks[2].context_before) - assert.same({ 'return M' }, hunks[2].context_after) - end) - end) - - describe('highlight_treesitter with context', function() - local ns - - before_each(function() - ns = vim.api.nvim_create_namespace('diffs_context_test') - local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) - vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 }) - end) - - local function create_buffer(lines) - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - return bufnr - end - - local function delete_buffer(bufnr) - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end - end - - local function get_extmarks(bufnr) - return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) - end - - local function default_opts(overrides) - local opts = { - hide_prefix = false, - highlights = { - background = false, - gutter = false, - context = { enabled = true, lines = 25 }, - treesitter = { enabled = true, max_lines = 500 }, - vim = { enabled = false, max_lines = 200 }, - intra = { enabled = false, algorithm = 'default', max_lines = 500 }, - priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, - }, - } - if overrides then - if overrides.highlights then - opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights) - end - end - return opts - end - - it('applies extmarks only to hunk lines, not context lines', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,3 @@', - ' local x = 1', - ' local y = 2', - '+local z = 3', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 2, - lines = { ' local x = 1', ' local y = 2', '+local z = 3' }, - prefix_width = 1, - quote_width = 0, - context_before = { 'local function foo()' }, - context_after = { 'end' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - local row = mark[2] - assert.is_true(row >= 1 and row <= 3, 'extmark row ' .. row .. ' outside hunk range') - end - assert.is_true(#extmarks > 0) - delete_buffer(bufnr) - end) - - it('does not pass context when context.enabled = false', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 2, - lines = { ' local x = 1', '+local y = 2' }, - prefix_width = 1, - quote_width = 0, - context_before = { 'local function foo()' }, - context_after = { 'end' }, - } - - local opts_enabled = default_opts({ highlights = { context = { enabled = true } } }) - highlight.highlight_hunk(bufnr, ns, hunk, opts_enabled) - local extmarks_with = get_extmarks(bufnr) - - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - - local opts_disabled = default_opts({ highlights = { context = { enabled = false } } }) - highlight.highlight_hunk(bufnr, ns, hunk, opts_disabled) - local extmarks_without = get_extmarks(bufnr) - - assert.is_true(#extmarks_with > 0) - assert.is_true(#extmarks_without > 0) - delete_buffer(bufnr) - end) - - it('skips context fields that are nil', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 2, - lines = { ' local x = 1', '+local y = 2' }, - prefix_width = 1, - quote_width = 0, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - assert.is_true(#extmarks > 0) - delete_buffer(bufnr) - end) - end) -end) diff --git a/spec/decoration_provider_spec.lua b/spec/decoration_provider_spec.lua deleted file mode 100644 index 172ca11..0000000 --- a/spec/decoration_provider_spec.lua +++ /dev/null @@ -1,329 +0,0 @@ -require('spec.helpers') -local diffs = require('diffs') - -describe('decoration_provider', function() - local function create_buffer(lines) - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) - return bufnr - end - - local function delete_buffer(bufnr) - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end - end - - describe('ensure_cache', function() - it('populates hunk cache for a buffer with diff content', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,2 +1,3 @@', - ' local x = 1', - '-local y = 2', - '+local y = 3', - ' local z = 4', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_not_nil(entry) - assert.is_table(entry.hunks) - assert.is_true(#entry.hunks > 0) - delete_buffer(bufnr) - end) - - it('cache tick matches buffer changedtick after attach', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - local tick = vim.api.nvim_buf_get_changedtick(bufnr) - assert.are.equal(tick, entry.tick) - delete_buffer(bufnr) - end) - - it('re-parses and advances tick when buffer content changes', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - local tick_before = diffs._test.hunk_cache[bufnr].tick - vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' }) - diffs._test.ensure_cache(bufnr) - local tick_after = diffs._test.hunk_cache[bufnr].tick - assert.is_true(tick_after > tick_before) - delete_buffer(bufnr) - end) - - it('skips reparse when fingerprint unchanged but sets pending_clear', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - local original_hunks = entry.hunks - entry.pending_clear = false - - local lc = vim.api.nvim_buf_line_count(bufnr) - local bc = vim.api.nvim_buf_get_offset(bufnr, lc) - entry.line_count = lc - entry.byte_count = bc - entry.tick = -1 - - diffs._test.ensure_cache(bufnr) - - local updated = diffs._test.hunk_cache[bufnr] - local current_tick = vim.api.nvim_buf_get_changedtick(bufnr) - assert.are.equal(original_hunks, updated.hunks) - assert.are.equal(current_tick, updated.tick) - assert.is_true(updated.pending_clear) - delete_buffer(bufnr) - end) - - it('does nothing for invalid buffer', function() - local bufnr = create_buffer({}) - diffs.attach(bufnr) - vim.api.nvim_buf_delete(bufnr, { force = true }) - assert.has_no.errors(function() - diffs._test.ensure_cache(bufnr) - end) - end) - end) - - describe('pending_clear', function() - it('is true after invalidate_cache', function() - local bufnr = create_buffer({}) - diffs.attach(bufnr) - diffs._test.invalidate_cache(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_true(entry.pending_clear) - delete_buffer(bufnr) - end) - - it('is true immediately after fresh ensure_cache', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_true(entry.pending_clear) - delete_buffer(bufnr) - end) - - it('clears namespace extmarks when on_buf processes pending_clear', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - local ns_id = vim.api.nvim_create_namespace('diffs') - vim.api.nvim_buf_set_extmark(bufnr, ns_id, 0, 0, { line_hl_group = 'DiffAdd' }) - assert.are.equal(1, #vim.api.nvim_buf_get_extmarks(bufnr, ns_id, 0, -1, {})) - - diffs._test.invalidate_cache(bufnr) - diffs._test.ensure_cache(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_true(entry.pending_clear) - - diffs._test.process_pending_clear(bufnr) - - entry = diffs._test.hunk_cache[bufnr] - assert.is_false(entry.pending_clear) - assert.are.same({}, vim.api.nvim_buf_get_extmarks(bufnr, ns_id, 0, -1, {})) - delete_buffer(bufnr) - end) - end) - - describe('BufWipeout cleanup', function() - it('removes hunk_cache entry after buffer wipeout', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - assert.is_not_nil(diffs._test.hunk_cache[bufnr]) - vim.api.nvim_buf_delete(bufnr, { force = true }) - assert.is_nil(diffs._test.hunk_cache[bufnr]) - end) - end) - - describe('hunk stability', function() - it('carries forward highlighted for stable hunks on section expansion', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,2 +1,2 @@', - ' local x = 1', - '-local y = 2', - '+local y = 3', - '@@ -10,2 +10,3 @@', - ' function M.foo()', - '+ return true', - ' end', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.are.equal(2, #entry.hunks) - - entry.pending_clear = false - entry.highlighted = { [1] = true, [2] = true } - - vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, { - '@@ -5,1 +5,2 @@', - ' local z = 4', - '+local w = 5', - }) - diffs._test.ensure_cache(bufnr) - - local updated = diffs._test.hunk_cache[bufnr] - assert.are.equal(3, #updated.hunks) - assert.is_true(updated.highlighted[1] == true) - assert.is_nil(updated.highlighted[2]) - assert.is_true(updated.highlighted[3] == true) - assert.is_false(updated.pending_clear) - delete_buffer(bufnr) - end) - - it('carries forward highlighted for stable hunks on section collapse', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,2 +1,2 @@', - ' local x = 1', - '-local y = 2', - '+local y = 3', - '@@ -5,1 +5,2 @@', - ' local z = 4', - '+local w = 5', - '@@ -10,2 +10,3 @@', - ' function M.foo()', - '+ return true', - ' end', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.are.equal(3, #entry.hunks) - - entry.pending_clear = false - entry.highlighted = { [1] = true, [2] = true, [3] = true } - - vim.api.nvim_buf_set_lines(bufnr, 5, 8, false, {}) - diffs._test.ensure_cache(bufnr) - - local updated = diffs._test.hunk_cache[bufnr] - assert.are.equal(2, #updated.hunks) - assert.is_true(updated.highlighted[1] == true) - assert.is_true(updated.highlighted[2] == true) - assert.is_false(updated.pending_clear) - delete_buffer(bufnr) - end) - - it('bypasses carry-forward when pending_clear was true', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,2 +1,2 @@', - ' local x = 1', - '-local y = 2', - '+local y = 3', - '@@ -10,2 +10,3 @@', - ' function M.foo()', - '+ return true', - ' end', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - entry.highlighted = { [1] = true, [2] = true } - entry.pending_clear = true - - vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, { - '@@ -5,1 +5,2 @@', - ' local z = 4', - '+local w = 5', - }) - diffs._test.ensure_cache(bufnr) - - local updated = diffs._test.hunk_cache[bufnr] - assert.are.same({}, updated.highlighted) - assert.is_true(updated.pending_clear) - delete_buffer(bufnr) - end) - - it('does not carry forward when all hunks changed', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,2 +1,2 @@', - ' local x = 1', - '-local y = 2', - '+local y = 3', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - - entry.pending_clear = false - entry.highlighted = { [1] = true } - - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { - 'M other.lua', - '@@ -1,1 +1,2 @@', - ' local a = 1', - '+local b = 2', - }) - diffs._test.ensure_cache(bufnr) - - local updated = diffs._test.hunk_cache[bufnr] - assert.is_nil(updated.highlighted[1]) - assert.is_true(updated.pending_clear) - delete_buffer(bufnr) - end) - end) - - describe('multiple hunks in cache', function() - it('stores all parsed hunks for a multi-hunk buffer', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,2 +1,2 @@', - ' local x = 1', - '-local y = 2', - '+local y = 3', - '@@ -10,2 +10,3 @@', - ' function M.foo()', - '+ return true', - ' end', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_not_nil(entry) - assert.are.equal(2, #entry.hunks) - delete_buffer(bufnr) - end) - - it('stores empty hunks table for buffer with no diff content', function() - local bufnr = create_buffer({ - 'Head: main', - 'Help: g?', - '', - 'Nothing to see here', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_not_nil(entry) - assert.are.same({}, entry.hunks) - delete_buffer(bufnr) - end) - end) -end) diff --git a/spec/email_quote_spec.lua b/spec/email_quote_spec.lua deleted file mode 100644 index e1744a1..0000000 --- a/spec/email_quote_spec.lua +++ /dev/null @@ -1,477 +0,0 @@ -require('spec.helpers') -local highlight = require('diffs.highlight') -local parser = require('diffs.parser') - -describe('email-quoted diffs', function() - local function create_buffer(lines) - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - return bufnr - end - - local function delete_buffer(bufnr) - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end - end - - describe('parser', function() - it('parses a fully email-quoted unified diff', function() - local bufnr = create_buffer({ - '> diff --git a/foo.py b/foo.py', - '> index abc1234..def5678 100644', - '> --- a/foo.py', - '> +++ b/foo.py', - '> @@ -0,0 +1,3 @@', - '> +from typing import Annotated, final', - '> +', - '> +class Foo:', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('foo.py', hunks[1].filename) - assert.are.equal(3, #hunks[1].lines) - assert.are.equal('+from typing import Annotated, final', hunks[1].lines[1]) - assert.are.equal(2, hunks[1].quote_width) - delete_buffer(bufnr) - end) - - it('parses a quoted diff embedded in an email reply', function() - local bufnr = create_buffer({ - 'Looks good, one nit:', - '', - '> diff --git a/foo.py b/foo.py', - '> @@ -0,0 +1,3 @@', - '> +from typing import Annotated, final', - '> +', - '> +class Foo:', - '', - 'Maybe rename Foo to Bar?', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('foo.py', hunks[1].filename) - assert.are.equal(3, #hunks[1].lines) - assert.are.equal(2, hunks[1].quote_width) - delete_buffer(bufnr) - end) - - it('sets quote_width = 0 on normal (unquoted) diffs', function() - local bufnr = create_buffer({ - 'diff --git a/bar.lua b/bar.lua', - '@@ -1,2 +1,2 @@', - '-old_line', - '+new_line', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(0, hunks[1].quote_width) - delete_buffer(bufnr) - end) - - it('treats bare > lines as empty quoted lines', function() - local bufnr = create_buffer({ - '> diff --git a/foo.py b/foo.py', - '> @@ -1,3 +1,3 @@', - '> -old', - '>', - '> +new', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(3, #hunks[1].lines) - assert.are.equal('-old', hunks[1].lines[1]) - assert.are.equal(' ', hunks[1].lines[2]) - assert.are.equal('+new', hunks[1].lines[3]) - delete_buffer(bufnr) - end) - - it('handles deeply nested quotes', function() - local bufnr = create_buffer({ - '>> diff --git a/foo.py b/foo.py', - '>> @@ -0,0 +1,2 @@', - '>> +line1', - '>> +line2', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(3, hunks[1].quote_width) - assert.are.equal('+line1', hunks[1].lines[1]) - delete_buffer(bufnr) - end) - - it('adjusts header_context_col for quote width', function() - local bufnr = create_buffer({ - '> diff --git a/foo.py b/foo.py', - '> @@ -1,2 +1,2 @@ def hello():', - '> -old', - '> +new', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('def hello():', hunks[1].header_context) - assert.are.equal(#'@@ -1,2 +1,2 @@ ' + 2, hunks[1].header_context_col) - delete_buffer(bufnr) - end) - - it('does not false-positive on prose containing > diff', function() - local bufnr = create_buffer({ - '> diff between approaches is small', - '> I think we should go with option A', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(0, #hunks) - delete_buffer(bufnr) - end) - - it('stores header lines stripped of quote prefix', function() - local bufnr = create_buffer({ - '> diff --git a/foo.lua b/foo.lua', - '> index abc1234..def5678 100644', - '> --- a/foo.lua', - '> +++ b/foo.lua', - '> @@ -1,1 +1,1 @@', - '> -old', - '> +new', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.is_not_nil(hunks[1].header_lines) - for _, hline in ipairs(hunks[1].header_lines) do - assert.is_nil(hline:match('^> ')) - end - delete_buffer(bufnr) - end) - end) - - describe('highlight', function() - local ns - - before_each(function() - ns = vim.api.nvim_create_namespace('diffs_email_test') - vim.api.nvim_set_hl(0, 'DiffsClear', { fg = 0xc0c0c0, bg = 0x1e1e1e }) - vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = 0x1a3a1a }) - vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = 0x3a1a1a }) - vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { fg = 0x808080, bold = true }) - end) - - local function get_extmarks(bufnr) - return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) - end - - local function default_opts(overrides) - local opts = { - hide_prefix = false, - highlights = { - background = true, - gutter = false, - context = { enabled = false, lines = 0 }, - treesitter = { - enabled = true, - max_lines = 500, - }, - vim = { - enabled = false, - max_lines = 200, - }, - intra = { - enabled = false, - algorithm = 'default', - max_lines = 500, - }, - priorities = { - clear = 198, - syntax = 199, - line_bg = 200, - char_bg = 201, - }, - }, - } - if overrides then - if overrides.highlights then - opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights) - end - for k, v in pairs(overrides) do - if k ~= 'highlights' then - opts[k] = v - end - end - end - return opts - end - - it('applies DiffsClear on email-quoted header lines covering full buffer width', function() - local buf_lines = { - '> diff --git a/foo.lua b/foo.lua', - '> index abc1234..def5678 100644', - '> --- a/foo.lua', - '> +++ b/foo.lua', - '> @@ -1,1 +1,1 @@', - '> -old', - '> +new', - } - local bufnr = create_buffer(buf_lines) - - local hunk = { - filename = 'foo.lua', - lang = 'lua', - ft = 'lua', - start_line = 5, - lines = { '-old', '+new' }, - prefix_width = 1, - quote_width = 2, - header_start_line = 1, - header_lines = { - 'diff --git a/foo.lua b/foo.lua', - 'index abc1234..def5678 100644', - '--- a/foo.lua', - '+++ b/foo.lua', - }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local header_clears = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.hl_group == 'DiffsClear' and mark[2] < 4 then - table.insert(header_clears, { row = mark[2], col = mark[3], end_col = d.end_col }) - end - end - assert.is_true(#header_clears > 0) - for _, c in ipairs(header_clears) do - assert.are.equal(0, c.col) - local buf_line_len = #buf_lines[c.row + 1] - assert.are.equal(buf_line_len, c.end_col) - end - - delete_buffer(bufnr) - end) - - it('applies body prefix DiffsClear covering [0, pw+qw)', function() - local bufnr = create_buffer({ - '> @@ -1,1 +1,1 @@', - '> -old', - '> +new', - }) - - local hunk = { - filename = 'foo.lua', - lang = 'lua', - ft = 'lua', - start_line = 1, - lines = { '-old', '+new' }, - prefix_width = 1, - quote_width = 2, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local prefix_clears = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.hl_group == 'DiffsClear' and d.end_col == 3 and mark[3] == 0 then - table.insert(prefix_clears, { row = mark[2] }) - end - end - assert.are.equal(2, #prefix_clears) - - delete_buffer(bufnr) - end) - - it('clamps body prefix DiffsClear on bare > lines (1-byte buffer line)', function() - local bufnr = create_buffer({ - '> @@ -1,3 +1,3 @@', - '> -old', - '>', - '> +new', - }) - - local hunk = { - filename = 'foo.lua', - ft = 'lua', - lang = 'lua', - start_line = 1, - lines = { '-old', ' ', '+new' }, - prefix_width = 1, - quote_width = 2, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local bare_line_row = 2 - local bare_clears = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.hl_group == 'DiffsClear' and mark[2] == bare_line_row and mark[3] == 0 then - table.insert(bare_clears, { end_col = d.end_col }) - end - end - assert.are.equal(1, #bare_clears) - assert.are.equal(1, bare_clears[1].end_col) - - delete_buffer(bufnr) - end) - - it('applies per-char @diff.plus/@diff.minus at ci + qw', function() - local bufnr = create_buffer({ - '> @@ -1,1 +1,1 @@', - '> -old', - '> +new', - }) - - local hunk = { - filename = 'foo.lua', - lang = 'lua', - ft = 'lua', - start_line = 1, - lines = { '-old', '+new' }, - prefix_width = 1, - quote_width = 2, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local diff_marks = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and (d.hl_group == '@diff.plus' or d.hl_group == '@diff.minus') then - table.insert( - diff_marks, - { row = mark[2], col = mark[3], end_col = d.end_col, hl = d.hl_group } - ) - end - end - assert.is_true(#diff_marks >= 2) - for _, dm in ipairs(diff_marks) do - assert.are.equal(2, dm.col) - assert.are.equal(3, dm.end_col) - end - - delete_buffer(bufnr) - end) - - it('offsets treesitter extmarks by pw + qw', function() - local bufnr = create_buffer({ - '> @@ -1,1 +1,2 @@', - '> local x = 1', - '> +local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - ft = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - prefix_width = 1, - quote_width = 2, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local ts_marks = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.hl_group and d.hl_group:match('^@.*%.lua$') then - table.insert(ts_marks, { row = mark[2], col = mark[3] }) - end - end - assert.is_true(#ts_marks > 0) - for _, tm in ipairs(ts_marks) do - assert.is_true(tm.col >= 3) - end - - delete_buffer(bufnr) - end) - - it('offsets intra-line char span extmarks by qw', function() - local bufnr = create_buffer({ - '> @@ -1,1 +1,1 @@', - '> -hello world', - '> +hello earth', - }) - - local hunk = { - filename = 'test.txt', - ft = nil, - lang = nil, - start_line = 1, - lines = { '-hello world', '+hello earth' }, - prefix_width = 1, - quote_width = 2, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } }, - }) - ) - - local extmarks = get_extmarks(bufnr) - local char_marks = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then - table.insert(char_marks, { row = mark[2], col = mark[3], end_col = d.end_col }) - end - end - if #char_marks > 0 then - for _, cm in ipairs(char_marks) do - assert.is_true(cm.col >= 2) - end - end - - delete_buffer(bufnr) - end) - - it('does not produce duplicate extmarks with syntax_only + qw', function() - local bufnr = create_buffer({ - '> @@ -1,1 +1,2 @@', - '> local x = 1', - '> +local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - ft = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - prefix_width = 1, - quote_width = 2, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ syntax_only = true })) - - local extmarks = get_extmarks(bufnr) - local line_bg_count = 0 - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.line_hl_group == 'DiffsAdd' then - line_bg_count = line_bg_count + 1 - end - end - assert.are.equal(1, line_bg_count) - - delete_buffer(bufnr) - end) - end) -end) diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index 95b40a3..244e1b7 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -87,6 +87,28 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + it('parses added file', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'A newfile.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('newfile.lua', filename) + assert.equals('staged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses deleted file', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'D oldfile.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('oldfile.lua', filename) + assert.equals('staged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + it('parses renamed file and returns both names', function() local buf = create_status_buffer({ 'Staged (1)', @@ -135,6 +157,28 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + it('handles renamed file in subdirectory', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R src/old.lua -> src/new.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('src/new.lua', filename) + assert.equals('src/old.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed file moved to different directory', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R old/file.lua -> new/file.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('new/file.lua', filename) + assert.equals('old/file.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + it('KNOWN LIMITATION: filename containing arrow parsed incorrectly', function() local buf = create_status_buffer({ 'Staged (1)', @@ -146,54 +190,77 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) 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) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('unquotes escaped quotes in filenames', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M "file\\"name.lua"', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('file"name.lua', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('unquotes octal escapes in filenames', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M "\\303\\251le.lua"', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('\195\169le.lua', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('passes through unquoted filenames unchanged', function() - local buf = create_status_buffer({ - 'Unstaged (1)', - 'M normal.lua', - }) - local filename = fugitive.get_file_at_line(buf, 2) - assert.equals('normal.lua', filename) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('unquotes renamed files with quotes', function() + it('handles double extensions', function() local buf = create_status_buffer({ 'Staged (1)', - 'R100 "old name.lua" -> "new name.lua"', + 'M test.spec.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) + assert.equals('test.spec.lua', filename) + assert.is_nil(old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles hyphenated filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M my-component-test.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('my-component-test.lua', filename) + assert.equals('unstaged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles underscores and numbers', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'A test_file_123.lua', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('test_file_123.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles dotfiles', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M .gitignore', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('.gitignore', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed with complex names', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R src/old-file.spec.lua -> src/new-file.spec.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('src/new-file.spec.lua', filename) + assert.equals('src/old-file.spec.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles deeply nested paths', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M lua/diffs/ui/components/diff-view.lua', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('lua/diffs/ui/components/diff-view.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses untracked file', function() + local buf = create_status_buffer({ + 'Untracked (1)', + '? untracked.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('untracked.lua', filename) + assert.equals('untracked', section) vim.api.nvim_buf_delete(buf, { force = true }) end) @@ -254,6 +321,30 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + it('detects section header for Unstaged', function() + local buf = create_status_buffer({ + 'Unstaged (3)', + 'M file1.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 1) + assert.is_nil(filename) + assert.equals('unstaged', section) + assert.is_true(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('detects section header for Untracked', function() + local buf = create_status_buffer({ + 'Untracked (1)', + '? newfile.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 1) + assert.is_nil(filename) + assert.equals('untracked', section) + assert.is_true(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + it('returns is_header=false for file lines', function() local buf = create_status_buffer({ 'Staged (1)', @@ -315,6 +406,22 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + it('returns hunk header and offset for - line', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local old = false', + ' return M', + }) + local pos = fugitive.get_hunk_position(buf, 5) + assert.is_not_nil(pos) + assert.equals('@@ -1,3 +1,3 @@', pos.hunk_header) + assert.equals(2, pos.offset) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + it('returns hunk header and offset for context line', function() local buf = create_status_buffer({ 'Unstaged (1)', diff --git a/spec/gitsigns_spec.lua b/spec/gitsigns_spec.lua deleted file mode 100644 index b2b6e2a..0000000 --- a/spec/gitsigns_spec.lua +++ /dev/null @@ -1,236 +0,0 @@ -require('spec.helpers') -local gs = require('diffs.gitsigns') - -local function setup_highlight_groups() - local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) - local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' }) - local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' }) - vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 }) - vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg or 0x2e4a3a }) - vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg or 0x4a2e3a }) - vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) - vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) -end - -local function create_buffer(lines) - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) - return bufnr -end - -local function delete_buffer(bufnr) - if bufnr and vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end -end - -describe('gitsigns', function() - describe('parse_blame_hunks', function() - it('parses a single hunk', function() - local bufnr = create_buffer({ - 'commit abc1234', - 'Author: Test User', - '', - 'Hunk 1 of 1', - ' local x = 1', - '-local y = 2', - '+local y = 3', - ' local z = 4', - }) - local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua') - assert.are.equal(1, #hunks) - assert.are.equal('test.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - assert.are.equal('lua', hunks[1].lang) - assert.are.equal(1, hunks[1].prefix_width) - assert.are.equal(0, hunks[1].quote_width) - assert.are.equal(4, #hunks[1].lines) - assert.are.equal(4, hunks[1].start_line) - assert.are.equal(' local x = 1', hunks[1].lines[1]) - assert.are.equal('-local y = 2', hunks[1].lines[2]) - assert.are.equal('+local y = 3', hunks[1].lines[3]) - delete_buffer(bufnr) - end) - - it('parses multiple hunks', function() - local bufnr = create_buffer({ - 'commit abc1234', - '', - 'Hunk 1 of 2', - '-local a = 1', - '+local a = 2', - 'Hunk 2 of 2', - ' local b = 3', - '+local c = 4', - }) - local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua') - assert.are.equal(2, #hunks) - assert.are.equal(2, #hunks[1].lines) - assert.are.equal(2, #hunks[2].lines) - delete_buffer(bufnr) - end) - - it('skips guessed-offset lines', function() - local bufnr = create_buffer({ - 'commit abc1234', - '', - 'Hunk 1 of 1', - '(guessed: hunk offset may be wrong)', - ' local x = 1', - '+local y = 2', - }) - local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua') - assert.are.equal(1, #hunks) - assert.are.equal(2, #hunks[1].lines) - assert.are.equal(' local x = 1', hunks[1].lines[1]) - delete_buffer(bufnr) - end) - - it('returns empty table when no hunks present', function() - local bufnr = create_buffer({ - 'commit abc1234', - 'Author: Test User', - 'Date: 2024-01-01', - }) - local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua') - assert.are.equal(0, #hunks) - delete_buffer(bufnr) - end) - - it('handles hunk with no diff lines after header', function() - local bufnr = create_buffer({ - 'Hunk 1 of 1', - 'some non-diff text', - }) - local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua') - assert.are.equal(0, #hunks) - delete_buffer(bufnr) - end) - end) - - describe('on_preview', function() - before_each(function() - setup_highlight_groups() - end) - - it('applies extmarks to popup buffer with diff content', function() - local bufnr = create_buffer({ - 'commit abc1234', - '', - 'Hunk 1 of 1', - ' local x = 1', - '-local y = 2', - '+local y = 3', - }) - - local winid = vim.api.nvim_open_win(bufnr, false, { - relative = 'editor', - width = 40, - height = 10, - row = 0, - col = 0, - }) - - gs._test.on_preview(winid, bufnr) - - local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, gs._test.ns, 0, -1, { details = true }) - assert.is_true(#extmarks > 0) - - vim.api.nvim_win_close(winid, true) - delete_buffer(bufnr) - end) - - it('clears gitsigns_popup namespace on diff region', function() - local bufnr = create_buffer({ - 'commit abc1234', - '', - 'Hunk 1 of 1', - ' local x = 1', - '+local y = 2', - }) - - vim.api.nvim_buf_set_extmark(bufnr, gs._test.gs_popup_ns, 3, 0, { - end_col = 12, - hl_group = 'GitSignsAddPreview', - }) - vim.api.nvim_buf_set_extmark(bufnr, gs._test.gs_popup_ns, 4, 0, { - end_col = 12, - hl_group = 'GitSignsAddPreview', - }) - - local winid = vim.api.nvim_open_win(bufnr, false, { - relative = 'editor', - width = 40, - height = 10, - row = 0, - col = 0, - }) - - gs._test.on_preview(winid, bufnr) - - local gs_extmarks = - vim.api.nvim_buf_get_extmarks(bufnr, gs._test.gs_popup_ns, 0, -1, { details = true }) - assert.are.equal(0, #gs_extmarks) - - vim.api.nvim_win_close(winid, true) - delete_buffer(bufnr) - end) - - it('does not error on invalid buffer', function() - assert.has_no.errors(function() - gs._test.on_preview(0, 99999) - end) - end) - end) - - describe('setup', function() - it('returns false when gitsigns.popup is not available', function() - local saved = package.loaded['gitsigns.popup'] - package.loaded['gitsigns.popup'] = nil - package.preload['gitsigns.popup'] = nil - - local fresh = loadfile('lua/diffs/gitsigns.lua')() - local result = fresh.setup() - assert.is_false(result) - - package.loaded['gitsigns.popup'] = saved - end) - - it('patches gitsigns.popup when available', function() - local create_called = false - local update_called = false - local mock_popup = { - create = function() - create_called = true - local bufnr = create_buffer({ 'test' }) - local winid = vim.api.nvim_open_win(bufnr, false, { - relative = 'editor', - width = 10, - height = 1, - row = 0, - col = 0, - }) - return winid, bufnr - end, - update = function() - update_called = true - end, - } - - local saved = package.loaded['gitsigns.popup'] - package.loaded['gitsigns.popup'] = mock_popup - - local fresh = loadfile('lua/diffs/gitsigns.lua')() - local result = fresh.setup() - assert.is_true(result) - - mock_popup.create() - assert.is_true(create_called) - - mock_popup.update(0, 0) - assert.is_true(update_called) - - package.loaded['gitsigns.popup'] = saved - end) - end) -end) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 22b884c..0b9051e 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -13,7 +13,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) local function create_buffer(lines) @@ -52,12 +51,6 @@ describe('highlight', function() algorithm = 'default', max_lines = 500, }, - priorities = { - clear = 198, - syntax = 199, - line_bg = 200, - char_bg = 201, - }, }, } if overrides then @@ -71,6 +64,27 @@ describe('highlight', function() return opts end + it('applies extmarks for lua code', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) + it('applies DiffsClear extmarks to clear diff colors', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -135,8 +149,8 @@ describe('highlight', function() local lines = { '@@ -1,100 +1,101 @@' } local hunk_lines = {} for i = 1, 600 do - table.insert(lines, '+line ' .. i) - table.insert(hunk_lines, '+line ' .. i) + table.insert(lines, ' line ' .. i) + table.insert(hunk_lines, ' line ' .. i) end local bufnr = create_buffer(lines) @@ -176,6 +190,36 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('highlights header context when enabled', function() + local bufnr = create_buffer({ + '@@ -10,3 +10,4 @@ function hello()', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + header_context = 'function hello()', + header_context_col = 18, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_header_extmark = false + for _, mark in ipairs(extmarks) do + if mark[2] == 0 then + has_header_extmark = true + break + end + end + assert.is_true(has_header_extmark) + delete_buffer(bufnr) + end) + it('highlights function keyword in header context', function() local bufnr = create_buffer({ '@@ -5,3 +5,4 @@ function M.setup()', @@ -236,6 +280,44 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('handles empty hunk lines', function() + local bufnr = create_buffer({ + '@@ -1,0 +1,0 @@', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = {}, + } + + assert.has_no.errors(function() + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + end) + delete_buffer(bufnr) + end) + + it('handles code that is just whitespace', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' ', + '+ ', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' ', '+ ' }, + } + + assert.has_no.errors(function() + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + end) + delete_buffer(bufnr) + end) + it('applies overlay extmarks when hide_prefix enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -263,6 +345,33 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('does not apply overlay extmarks when hide_prefix disabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = false })) + + local extmarks = get_extmarks(bufnr) + local overlay_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text_pos == 'overlay' then + overlay_count = overlay_count + 1 + end + end + assert.are.equal(0, overlay_count) + delete_buffer(bufnr) + end) + it('applies DiffAdd background to + lines when background enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -287,7 +396,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -320,7 +429,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_delete = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then + if mark[4] and mark[4].hl_group == 'DiffsDelete' then has_diff_delete = true break end @@ -329,6 +438,39 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('does not apply background when background disabled', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = false } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_line_hl = false + for _, mark in ipairs(extmarks) do + if mark[4] and (mark[4].hl_group == 'DiffsAdd' or mark[4].hl_group == 'DiffsDelete') then + has_line_hl = true + break + end + end + assert.is_false(has_line_hl) + delete_buffer(bufnr) + end) + it('applies number_hl_group when gutter enabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -362,7 +504,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('line bg uses line_hl_group not hl_group with end_row', function() + it('does not apply number_hl_group when gutter disabled', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' local x = 1', @@ -380,101 +522,51 @@ describe('highlight', function() bufnr, ns, hunk, - default_opts({ highlights = { background = true } }) + default_opts({ highlights = { background = true, gutter = false } }) ) local extmarks = get_extmarks(bufnr) + local has_number_hl = false for _, mark in ipairs(extmarks) do - local d = mark[4] - assert.is_not_equal('DiffsAdd', d and d.hl_group) - assert.is_not_equal('DiffsDelete', d and d.hl_group) - end - delete_buffer(bufnr) - end) - - it('line bg extmark survives adjacent clear_namespace starting at next row', function() - local bufnr = create_buffer({ - 'diff --git a/foo.py b/foo.py', - '@@ -1,2 +1,2 @@', - '-old', - '+new', - }) - - local hunk = { - filename = 'foo.py', - header_start_line = 1, - start_line = 2, - lines = { '-old', '+new' }, - prefix_width = 1, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true, treesitter = { enabled = false } } }) - ) - - local last_body_row = hunk.start_line + #hunk.lines - 1 - vim.api.nvim_buf_clear_namespace(bufnr, ns, last_body_row + 1, last_body_row + 10) - - local marks = vim.api.nvim_buf_get_extmarks( - bufnr, - ns, - { last_body_row, 0 }, - { last_body_row, -1 }, - { details = true } - ) - local has_line_bg = false - for _, mark in ipairs(marks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then - has_line_bg = true + if mark[4] and mark[4].number_hl_group then + has_number_hl = true + break end end - assert.is_true(has_line_bg) + assert.is_false(has_number_hl) delete_buffer(bufnr) end) - it('clear range covers last body line of hunk with header', function() + it('skips treesitter highlights when treesitter disabled', function() local bufnr = create_buffer({ - 'diff --git a/foo.py b/foo.py', - 'index abc..def 100644', - '--- a/foo.py', - '+++ b/foo.py', - '@@ -1,3 +1,3 @@', - ' ctx', - '-old', - '+new', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', }) local hunk = { - filename = 'foo.py', - header_start_line = 1, - start_line = 5, - lines = { ' ctx', '-old', '+new' }, - prefix_width = 1, + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, } highlight.highlight_hunk( bufnr, ns, hunk, - default_opts({ highlights = { background = true, treesitter = { enabled = false } } }) + default_opts({ highlights = { treesitter = { enabled = false }, background = true } }) ) - local last_body_row = hunk.start_line + #hunk.lines - 1 - local clear_start = hunk.header_start_line - 1 - local clear_end = hunk.start_line + #hunk.lines - vim.api.nvim_buf_clear_namespace(bufnr, ns, clear_start, clear_end) - - local marks = vim.api.nvim_buf_get_extmarks( - bufnr, - ns, - { last_body_row, 0 }, - { last_body_row, -1 }, - { details = false } - ) - assert.are.equal(0, #marks) + local extmarks = get_extmarks(bufnr) + local has_ts_highlight = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then + has_ts_highlight = true + break + end + end + assert.is_false(has_ts_highlight) delete_buffer(bufnr) end) @@ -502,7 +594,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -511,83 +603,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('applies DiffsAddNr prefix extmark on + line for pw=1', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,2 @@', - '-old', - '+new', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-old', '+new' }, - prefix_width = 1, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true, treesitter = { enabled = false } } }) - ) - - local extmarks = get_extmarks(bufnr) - local add_prefix = false - local del_prefix = false - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.end_col == 1 and mark[3] == 0 then - if d.hl_group == 'DiffsAddNr' and mark[2] == 2 then - add_prefix = true - end - if d.hl_group == 'DiffsDeleteNr' and mark[2] == 1 then - del_prefix = true - end - end - end - assert.is_true(add_prefix, 'DiffsAddNr on + prefix') - assert.is_true(del_prefix, 'DiffsDeleteNr on - prefix') - delete_buffer(bufnr) - end) - - it('does not apply prefix extmark on context line', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,2 @@', - ' ctx', - '+new', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' ctx', '+new' }, - prefix_width = 1, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true, treesitter = { enabled = false } } }) - ) - - local extmarks = get_extmarks(bufnr) - local ctx_prefix = false - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and mark[2] == 1 and mark[3] == 0 and d.end_col == 1 then - if d.hl_group == 'DiffsAddNr' or d.hl_group == 'DiffsDeleteNr' then - ctx_prefix = true - end - end - end - assert.is_false(ctx_prefix, 'no prefix extmark on context line') - delete_buffer(bufnr) - end) - it('applies vim syntax extmarks when vim.enabled and no TS parser', function() local orig_synID = vim.fn.synID local orig_synIDtrans = vim.fn.synIDtrans @@ -639,6 +654,40 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('skips vim fallback when vim.enabled is false', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + ft = 'abap', + lang = nil, + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { vim = { enabled = false } } }) + ) + + local extmarks = get_extmarks(bufnr) + local has_syntax_hl = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then + has_syntax_hl = true + break + end + end + assert.is_false(has_syntax_hl) + delete_buffer(bufnr) + end) + it('respects vim.max_lines', function() local lines = { '@@ -1,100 +1,101 @@' } local hunk_lines = {} @@ -693,7 +742,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -753,7 +802,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('uses hl_group with hl_eol for line backgrounds', function() + it('uses hl_group not line_hl_group for line backgrounds', function() local bufnr = create_buffer({ '@@ -1,2 +1,1 @@', '-local x = 1', @@ -775,14 +824,44 @@ describe('highlight', function() ) local extmarks = get_extmarks(bufnr) - local found = false for _, mark in ipairs(extmarks) do local d = mark[4] - if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then - found = true + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then + assert.is_true(d.hl_eol == true) + assert.is_nil(d.line_hl_group) + end + end + delete_buffer(bufnr) + end) + + it('hl_eol background extmarks are multiline so hl_eol takes effect', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,1 @@', + '-local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then + assert.is_true(d.end_row > mark[2]) end end - assert.is_true(found) delete_buffer(bufnr) end) @@ -821,6 +900,92 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('line bg priority > DiffsClear priority', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,1 @@', + '-local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true } }) + ) + + local extmarks = get_extmarks(bufnr) + local clear_priority = nil + local line_bg_priority = nil + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsClear' then + clear_priority = d.priority + end + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then + line_bg_priority = d.priority + end + end + assert.is_not_nil(clear_priority) + assert.is_not_nil(line_bg_priority) + assert.is_true(line_bg_priority > clear_priority) + delete_buffer(bufnr) + end) + + it('char-level extmarks have higher priority than line bg', function() + vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) + + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { + background = true, + intra = { enabled = true, algorithm = 'default', max_lines = 500 }, + }, + }) + ) + + local extmarks = get_extmarks(bufnr) + local line_bg_priority = nil + local char_bg_priority = nil + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then + line_bg_priority = d.priority + end + if d and (d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText') then + char_bg_priority = d.priority + end + end + assert.is_not_nil(line_bg_priority) + assert.is_not_nil(char_bg_priority) + assert.is_true(char_bg_priority > line_bg_priority) + delete_buffer(bufnr) + end) + it('creates char-level extmarks for changed characters', function() vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) @@ -864,6 +1029,38 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('does not create char-level extmarks when intra disabled', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,2 @@', + '-local x = 1', + '+local x = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { '-local x = 1', '+local x = 2' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ + highlights = { intra = { enabled = false, algorithm = 'default', max_lines = 500 } }, + }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local d = mark[4] + assert.is_not_equal('DiffsAddText', d and d.hl_group) + assert.is_not_equal('DiffsDeleteText', d and d.hl_group) + end + delete_buffer(bufnr) + end) + it('does not create char-level extmarks for pure additions', function() vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) @@ -934,7 +1131,7 @@ describe('highlight', function() if d then if d.hl_group == 'DiffsClear' then table.insert(priorities.clear, d.priority) - elseif d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete' then + elseif d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete' then table.insert(priorities.line_bg, d.priority) elseif d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then table.insert(priorities.char_bg, d.priority) @@ -960,6 +1157,142 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('context padding produces no extmarks on padding lines', function() + local repo_root = '/tmp/diffs-test-context' + vim.fn.mkdir(repo_root, 'p') + + local f = io.open(repo_root .. '/test.lua', 'w') + f:write('local M = {}\n') + f:write('function M.hello()\n') + f:write(' return "hi"\n') + f:write('end\n') + f:write('return M\n') + f:close() + + local bufnr = create_buffer({ + '@@ -3,1 +3,2 @@', + ' return "hi"', + '+"bye"', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' return "hi"', '+"bye"' }, + file_old_start = 3, + file_old_count = 1, + file_new_start = 3, + file_new_count = 2, + repo_root = repo_root, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { context = { enabled = true, lines = 25 } } }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local row = mark[2] + assert.is_true(row >= 1 and row <= 2) + end + + delete_buffer(bufnr) + os.remove(repo_root .. '/test.lua') + vim.fn.delete(repo_root, 'rf') + end) + + it('context disabled matches behavior without padding', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + file_new_start = 1, + file_new_count = 2, + repo_root = '/nonexistent', + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { context = { enabled = false, lines = 0 } } }) + ) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) + + it('gracefully handles missing file for context padding', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + file_new_start = 1, + file_new_count = 2, + repo_root = '/nonexistent/path', + } + + assert.has_no.errors(function() + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { context = { enabled = true, lines = 25 } } }) + ) + end) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) + + it('highlights treesitter injections', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+vim.cmd([[ echo 1 ]])', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+vim.cmd([[ echo 1 ]])' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_vim_capture = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.vim$') then + has_vim_capture = true + break + end + end + assert.is_true(has_vim_capture) + delete_buffer(bufnr) + end) + it('includes captures from both base and injected languages', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -994,313 +1327,6 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('classifies all combined diff prefix types for background', function() - local bufnr = create_buffer({ - '@@@ -1,5 -1,5 +1,9 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '+ local greeting = "hi"', - '++=======', - '+ return 2', - '++>>>>>>> feature', - ' end', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '+ local greeting = "hi"', - '++=======', - '+ return 2', - '++>>>>>>> feature', - ' end', - }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true } }) - ) - - local extmarks = get_extmarks(bufnr) - local line_bgs = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then - line_bgs[mark[2]] = d.line_hl_group - end - end - assert.is_nil(line_bgs[1]) - assert.are.equal('DiffsAdd', line_bgs[2]) - assert.are.equal('DiffsAdd', line_bgs[3]) - assert.are.equal('DiffsAdd', line_bgs[4]) - assert.are.equal('DiffsAdd', line_bgs[5]) - assert.are.equal('DiffsAdd', line_bgs[6]) - assert.are.equal('DiffsAdd', line_bgs[7]) - assert.is_nil(line_bgs[8]) - delete_buffer(bufnr) - end) - - it('conceals full 2-char prefix for all combined diff line types', function() - local bufnr = create_buffer({ - '@@@ -1,3 -1,3 +1,5 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '+ local x = 2', - ' end', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '+ local x = 2', - ' end', - }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ hide_prefix = true })) - - local extmarks = get_extmarks(bufnr) - local overlays = {} - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text_pos == 'overlay' then - overlays[mark[2]] = mark[4].virt_text[1][1] - end - end - assert.are.equal(5, vim.tbl_count(overlays)) - for _, text in pairs(overlays) do - assert.are.equal(' ', text) - end - delete_buffer(bufnr) - end) - - it('places treesitter captures at col_offset 2 for combined diffs', function() - local bufnr = create_buffer({ - '@@@ -1,2 -1,2 +1,2 @@@', - ' local x = 1', - ' +local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { ' local x = 1', ' +local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local ts_marks = {} - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then - table.insert(ts_marks, mark) - end - end - assert.is_true(#ts_marks > 0) - for _, mark in ipairs(ts_marks) do - assert.is_true(mark[3] >= 2) - end - delete_buffer(bufnr) - end) - - it('applies DiffsClear starting at col 2 for combined diffs', function() - local bufnr = create_buffer({ - '@@@ -1,1 -1,1 +1,2 @@@', - ' local x = 1', - ' +local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { ' local x = 1', ' +local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local content_clear_count = 0 - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'DiffsClear' then - assert.is_true(mark[3] == 0 or mark[3] == 2, 'DiffsClear at unexpected col ' .. mark[3]) - if mark[3] == 2 then - content_clear_count = content_clear_count + 1 - end - end - end - assert.are.equal(2, content_clear_count) - delete_buffer(bufnr) - end) - - it('skips intra-line diffing for combined diffs', function() - vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) - vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) - - local bufnr = create_buffer({ - '@@@ -1,2 -1,2 +1,3 @@@', - ' local x = 1', - ' +local y = 2', - '+ local y = 3', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { ' local x = 1', ' +local y = 2', '+ local y = 3' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ - highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } }, - }) - ) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - local d = mark[4] - assert.is_not_equal('DiffsAddText', d and d.hl_group) - assert.is_not_equal('DiffsDeleteText', d and d.hl_group) - end - delete_buffer(bufnr) - end) - - it('applies DiffsConflictMarker text on markers with DiffsAdd bg', function() - local bufnr = create_buffer({ - '@@@ -1,5 -1,5 +1,9 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - '+ local x = 1', - '++||||||| base', - '++=======', - ' +local y = 2', - '++>>>>>>> feature', - ' return M', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - prefix_width = 2, - lines = { - ' local M = {}', - '++<<<<<<< HEAD', - '+ local x = 1', - '++||||||| base', - '++=======', - ' +local y = 2', - '++>>>>>>> feature', - ' return M', - }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true, gutter = true } }) - ) - - local extmarks = get_extmarks(bufnr) - local line_bgs = {} - local gutter_hls = {} - local marker_text = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then - line_bgs[mark[2]] = d.line_hl_group - end - if d and d.number_hl_group then - gutter_hls[mark[2]] = d.number_hl_group - end - if d and d.hl_group == 'DiffsConflictMarker' then - marker_text[mark[2]] = true - end - end - - assert.is_nil(line_bgs[1]) - assert.are.equal('DiffsAdd', line_bgs[2]) - assert.are.equal('DiffsAdd', line_bgs[3]) - assert.are.equal('DiffsAdd', line_bgs[4]) - assert.are.equal('DiffsAdd', line_bgs[5]) - assert.are.equal('DiffsAdd', line_bgs[6]) - assert.are.equal('DiffsAdd', line_bgs[7]) - assert.is_nil(line_bgs[8]) - - assert.is_nil(gutter_hls[1]) - assert.are.equal('DiffsAddNr', gutter_hls[2]) - assert.are.equal('DiffsAddNr', gutter_hls[3]) - assert.are.equal('DiffsAddNr', gutter_hls[4]) - assert.are.equal('DiffsAddNr', gutter_hls[5]) - assert.are.equal('DiffsAddNr', gutter_hls[6]) - assert.are.equal('DiffsAddNr', gutter_hls[7]) - assert.is_nil(gutter_hls[8]) - - assert.is_true(marker_text[2] ~= nil) - assert.is_nil(marker_text[3]) - assert.is_true(marker_text[4] ~= nil) - assert.is_true(marker_text[5] ~= nil) - assert.is_nil(marker_text[6]) - assert.is_true(marker_text[7] ~= nil) - delete_buffer(bufnr) - end) - - it('does not apply DiffsConflictMarker in unified diffs', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,4 @@', - ' local M = {}', - '+<<<<<<< HEAD', - '+local x = 1', - '+=======', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local M = {}', '+<<<<<<< HEAD', '+local x = 1', '+=======' }, - } - - highlight.highlight_hunk( - bufnr, - ns, - hunk, - default_opts({ highlights = { background = true } }) - ) - - local extmarks = get_extmarks(bufnr) - for _, mark in ipairs(extmarks) do - local d = mark[4] - assert.is_not_equal('DiffsConflictMarker', d and d.hl_group) - end - delete_buffer(bufnr) - end) - it('filters @spell and @nospell captures from injections', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', @@ -1326,123 +1352,6 @@ describe('highlight', function() end delete_buffer(bufnr) end) - - it('two-pass rendering produces no duplicate extmarks', function() - vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) - vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) - vim.api.nvim_set_hl(0, 'DiffsAddNr', { fg = 0x80c080, bg = 0x2e4a3a }) - vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { fg = 0xc08080, bg = 0x4a2e3a }) - - local bufnr = create_buffer({ - '@@ -1,2 +1,2 @@', - '-local x = 1', - '+local x = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-local x = 1', '+local x = 2' }, - } - - local fast = default_opts({ - highlights = { - treesitter = { enabled = false }, - background = true, - gutter = true, - intra = { enabled = true, algorithm = 'default', max_lines = 500 }, - }, - }) - - local syntax = default_opts({ - highlights = { - treesitter = { enabled = true }, - background = true, - gutter = true, - intra = { enabled = true, algorithm = 'default', max_lines = 500 }, - }, - }) - syntax.syntax_only = true - - highlight.highlight_hunk(bufnr, ns, hunk, fast) - highlight.highlight_hunk(bufnr, ns, hunk, syntax) - - local extmarks = get_extmarks(bufnr) - for row = 1, 2 do - local line_hl_count = 0 - local number_hl_count = 0 - local intra_count = 0 - for _, mark in ipairs(extmarks) do - if mark[2] == row then - local d = mark[4] - if d.line_hl_group then - line_hl_count = line_hl_count + 1 - end - if d.number_hl_group then - number_hl_count = number_hl_count + 1 - end - if d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then - intra_count = intra_count + 1 - end - end - end - assert.are.equal(1, line_hl_count, 'row ' .. row .. ' has duplicate line_hl_group') - assert.are.equal(1, number_hl_count, 'row ' .. row .. ' has duplicate number_hl_group') - assert.is_true(intra_count <= 1, 'row ' .. row .. ' has duplicate intra extmarks') - end - delete_buffer(bufnr) - end) - - it('syntax_only pass adds treesitter without duplicating backgrounds', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,3 @@', - ' local x = 1', - '+local y = 2', - ' return x', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2', ' return x' }, - } - - local fast = default_opts({ - highlights = { - treesitter = { enabled = false }, - background = true, - }, - }) - - local syntax = default_opts({ - highlights = { - treesitter = { enabled = true }, - background = true, - }, - }) - syntax.syntax_only = true - - highlight.highlight_hunk(bufnr, ns, hunk, fast) - highlight.highlight_hunk(bufnr, ns, hunk, syntax) - - local extmarks = get_extmarks(bufnr) - local has_ts = false - local line_hl_count = 0 - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.hl_group and d.hl_group:match('^@.*%.lua$') then - has_ts = true - end - if d and d.line_hl_group then - line_hl_count = line_hl_count + 1 - end - end - assert.is_true(has_ts) - assert.are.equal(1, line_hl_count) - delete_buffer(bufnr) - end) end) describe('diff header highlighting', function() @@ -1477,7 +1386,6 @@ describe('highlight', function() context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, - priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, }, } end @@ -1561,7 +1469,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('does not apply DiffsClear to header lines for non-quoted diffs', function() + it('does not apply header highlights when treesitter disabled', function() local bufnr = create_buffer({ 'diff --git a/parser.lua b/parser.lua', 'index 3e8afa0..018159c 100644', @@ -1586,446 +1494,19 @@ describe('highlight', function() }, } - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + local opts = default_opts() + opts.highlights.treesitter.enabled = false + + highlight.highlight_hunk(bufnr, ns, hunk, opts) local extmarks = get_extmarks(bufnr) + local header_extmarks = 0 for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.hl_group == 'DiffsClear' and mark[3] == 0 and mark[2] < 4 then - error('unexpected DiffsClear on header row ' .. mark[2] .. ' for non-quoted diff') + if mark[2] < 4 and mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@') then + header_extmarks = header_extmarks + 1 end end - delete_buffer(bufnr) - end) - - it('preserves diff grammar treesitter on headers for non-quoted diffs', function() - local bufnr = create_buffer({ - 'diff --git a/parser.lua b/parser.lua', - '--- a/parser.lua', - '+++ b/parser.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local x = 1', - }) - - local hunk = { - filename = 'parser.lua', - lang = 'lua', - start_line = 4, - lines = { ' local M = {}', '+local x = 1' }, - header_start_line = 1, - header_lines = { - 'diff --git a/parser.lua b/parser.lua', - '--- a/parser.lua', - '+++ b/parser.lua', - }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local header_ts_count = 0 - for _, mark in ipairs(extmarks) do - local d = mark[4] - if mark[2] < 3 and d and d.hl_group and d.hl_group:match('^@.*%.diff$') then - header_ts_count = header_ts_count + 1 - end - end - assert.is_true(header_ts_count > 0, 'expected diff grammar treesitter on header lines') - delete_buffer(bufnr) - end) - - it('applies syntax extmarks to combined diff body lines', function() - local bufnr = create_buffer({ - '@@@ -1,2 -1,2 +1,3 @@@', - ' local M = {}', - '+ local x = 1', - ' -local y = 2', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - prefix_width = 2, - start_line = 1, - lines = { ' local M = {}', '+ local x = 1', ' -local y = 2' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local syntax_on_body = 0 - for _, mark in ipairs(extmarks) do - local d = mark[4] - if mark[2] >= 1 and d and d.hl_group and d.hl_group:match('^@.*%.lua$') then - syntax_on_body = syntax_on_body + 1 - end - end - assert.is_true(syntax_on_body > 0, 'expected lua treesitter syntax on combined diff body') - delete_buffer(bufnr) - end) - - it('applies DiffsClear and per-char diff fg to combined diff body prefixes', function() - local bufnr = create_buffer({ - '@@@', - ' unchanged', - '+ added', - ' -removed', - '++both', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - prefix_width = 2, - start_line = 1, - lines = { ' unchanged', '+ added', ' -removed', '++both' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local prefix_clears = {} - local plus_marks = {} - local minus_marks = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if mark[2] >= 1 and d then - if d.hl_group == 'DiffsClear' and mark[3] == 0 and d.end_col == 2 then - prefix_clears[mark[2]] = true - end - if d.hl_group == '@diff.plus' and d.priority == 199 then - if not plus_marks[mark[2]] then - plus_marks[mark[2]] = {} - end - table.insert(plus_marks[mark[2]], mark[3]) - end - if d.hl_group == '@diff.minus' and d.priority == 199 then - if not minus_marks[mark[2]] then - minus_marks[mark[2]] = {} - end - table.insert(minus_marks[mark[2]], mark[3]) - end - end - end - - assert.is_true(prefix_clears[1] ~= nil, 'DiffsClear on context prefix') - assert.is_true(prefix_clears[2] ~= nil, 'DiffsClear on add prefix') - assert.is_true(prefix_clears[3] ~= nil, 'DiffsClear on del prefix') - assert.is_true(prefix_clears[4] ~= nil, 'DiffsClear on both-add prefix') - - assert.is_true(plus_marks[2] ~= nil, '@diff.plus on + in "+ added"') - assert.are.equal(0, plus_marks[2][1]) - - assert.is_true(minus_marks[3] ~= nil, '@diff.minus on - in " -removed"') - assert.are.equal(1, minus_marks[3][1]) - - assert.is_true(plus_marks[4] ~= nil, '@diff.plus on ++ in "++both"') - assert.are.equal(2, #plus_marks[4]) - - assert.is_nil(plus_marks[1], 'no @diff.plus on context " unchanged"') - assert.is_nil(minus_marks[1], 'no @diff.minus on context " unchanged"') - delete_buffer(bufnr) - end) - - it('applies DiffsClear to headers for combined diffs', function() - local bufnr = create_buffer({ - 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..a6b9012', - '--- a/lua/merge/target.lua', - '+++ b/lua/merge/target.lua', - '@@@ -1,2 -1,2 +1,3 @@@', - ' local M = {}', - '+ local x = 1', - }) - - local hunk = { - filename = 'lua/merge/target.lua', - lang = 'lua', - prefix_width = 2, - start_line = 5, - lines = { ' local M = {}', '+ local x = 1' }, - header_start_line = 1, - header_lines = { - 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..a6b9012', - '--- a/lua/merge/target.lua', - '+++ b/lua/merge/target.lua', - }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local clear_lines = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.hl_group == 'DiffsClear' and mark[3] == 0 and mark[2] < 4 then - clear_lines[mark[2]] = true - end - end - assert.is_true(clear_lines[0] ~= nil, 'DiffsClear on diff --combined line') - assert.is_true(clear_lines[1] ~= nil, 'DiffsClear on index line') - assert.is_true(clear_lines[2] ~= nil, 'DiffsClear on --- line') - assert.is_true(clear_lines[3] ~= nil, 'DiffsClear on +++ line') - delete_buffer(bufnr) - end) - - it('applies @attribute.diff at syntax priority to @@@ line for combined diffs', function() - local bufnr = create_buffer({ - '@@@ -1,2 -1,2 +1,3 @@@', - ' local M = {}', - '+ local x = 1', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - prefix_width = 2, - start_line = 1, - lines = { ' local M = {}', '+ local x = 1' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local has_attr = false - for _, mark in ipairs(extmarks) do - local d = mark[4] - if mark[2] == 0 and d and d.hl_group == '@attribute.diff' and (d.priority or 0) >= 199 then - has_attr = true - end - end - assert.is_true(has_attr, '@attribute.diff at p>=199 on @@@ line') - delete_buffer(bufnr) - end) - - it('applies DiffsClear to @@@ line for combined diffs', function() - local bufnr = create_buffer({ - '@@@ -1,2 -1,2 +1,3 @@@', - ' local M = {}', - '+ local x = 1', - }) - - local hunk = { - filename = 'test.lua', - lang = 'lua', - prefix_width = 2, - start_line = 1, - lines = { ' local M = {}', '+ local x = 1' }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local has_at_clear = false - for _, mark in ipairs(extmarks) do - local d = mark[4] - if mark[2] == 0 and d and d.hl_group == 'DiffsClear' and mark[3] == 0 then - has_at_clear = true - end - end - assert.is_true(has_at_clear, 'DiffsClear on @@@ line') - delete_buffer(bufnr) - end) - - it('applies header diff grammar at syntax priority for combined diffs', function() - local bufnr = create_buffer({ - 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..a6b9012', - '--- a/lua/merge/target.lua', - '+++ b/lua/merge/target.lua', - '@@@ -1,2 -1,2 +1,3 @@@', - ' local M = {}', - '+ local x = 1', - }) - - local hunk = { - filename = 'lua/merge/target.lua', - lang = 'lua', - prefix_width = 2, - start_line = 5, - lines = { ' local M = {}', '+ local x = 1' }, - header_start_line = 1, - header_lines = { - 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..a6b9012', - '--- a/lua/merge/target.lua', - '+++ b/lua/merge/target.lua', - }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local high_prio_diff = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if - mark[2] < 4 - and d - and d.hl_group - and d.hl_group:match('^@.*%.diff$') - and (d.priority or 0) >= 199 - then - high_prio_diff[mark[2]] = true - end - end - assert.is_true(high_prio_diff[2] ~= nil, 'diff grammar at p>=199 on --- line') - assert.is_true(high_prio_diff[3] ~= nil, 'diff grammar at p>=199 on +++ line') - delete_buffer(bufnr) - end) - - it('@diff.minus wins over @punctuation.special on combined diff headers', function() - local bufnr = create_buffer({ - 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..a6b9012', - '--- a/lua/merge/target.lua', - '+++ b/lua/merge/target.lua', - '@@@ -1,2 -1,2 +1,3 @@@', - ' local M = {}', - '+ local x = 1', - }) - - local hunk = { - filename = 'lua/merge/target.lua', - lang = 'lua', - prefix_width = 2, - start_line = 5, - lines = { ' local M = {}', '+ local x = 1' }, - header_start_line = 1, - header_lines = { - 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..a6b9012', - '--- a/lua/merge/target.lua', - '+++ b/lua/merge/target.lua', - }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local minus_prio, punct_prio_minus = 0, 0 - local plus_prio, punct_prio_plus = 0, 0 - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.hl_group then - if mark[2] == 2 then - if d.hl_group == '@diff.minus.diff' then - minus_prio = math.max(minus_prio, d.priority or 0) - elseif d.hl_group == '@punctuation.special.diff' then - punct_prio_minus = math.max(punct_prio_minus, d.priority or 0) - end - elseif mark[2] == 3 then - if d.hl_group == '@diff.plus.diff' then - plus_prio = math.max(plus_prio, d.priority or 0) - elseif d.hl_group == '@punctuation.special.diff' then - punct_prio_plus = math.max(punct_prio_plus, d.priority or 0) - end - end - end - end - assert.is_true( - minus_prio > punct_prio_minus, - '@diff.minus.diff should beat @punctuation.special.diff on --- line' - ) - assert.is_true( - plus_prio > punct_prio_plus, - '@diff.plus.diff should beat @punctuation.special.diff on +++ line' - ) - delete_buffer(bufnr) - end) - - it('applies @keyword.diff on index word for combined diffs', function() - local bufnr = create_buffer({ - 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..a6b9012', - '--- a/lua/merge/target.lua', - '+++ b/lua/merge/target.lua', - '@@@ -1,2 -1,2 +1,3 @@@', - ' local M = {}', - '+ local x = 1', - }) - - local hunk = { - filename = 'lua/merge/target.lua', - lang = 'lua', - prefix_width = 2, - start_line = 5, - lines = { ' local M = {}', '+ local x = 1' }, - header_start_line = 1, - header_lines = { - 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..a6b9012', - '--- a/lua/merge/target.lua', - '+++ b/lua/merge/target.lua', - }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local has_keyword = false - for _, mark in ipairs(extmarks) do - local d = mark[4] - if - mark[2] == 1 - and d - and d.hl_group == '@keyword.diff' - and mark[3] == 0 - and (d.end_col or 0) == 5 - then - has_keyword = true - end - end - assert.is_true(has_keyword, '@keyword.diff at row 1, cols 0-5') - delete_buffer(bufnr) - end) - - it('applies @constant.diff on result hash for combined diffs', function() - local bufnr = create_buffer({ - 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..a6b9012', - '--- a/lua/merge/target.lua', - '+++ b/lua/merge/target.lua', - '@@@ -1,2 -1,2 +1,3 @@@', - ' local M = {}', - '+ local x = 1', - }) - - local hunk = { - filename = 'lua/merge/target.lua', - lang = 'lua', - prefix_width = 2, - start_line = 5, - lines = { ' local M = {}', '+ local x = 1' }, - header_start_line = 1, - header_lines = { - 'diff --combined lua/merge/target.lua', - 'index abc1234,def5678..a6b9012', - '--- a/lua/merge/target.lua', - '+++ b/lua/merge/target.lua', - }, - } - - highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) - - local extmarks = get_extmarks(bufnr) - local has_result_hash = false - for _, mark in ipairs(extmarks) do - local d = mark[4] - if - mark[2] == 1 - and mark[3] == 23 - and d - and d.hl_group == '@constant.diff' - and d.end_col == 30 - and (d.priority or 0) >= 199 - then - has_result_hash = true - end - end - assert.is_true(has_result_hash, '@constant.diff on result hash at cols 23-30') + assert.are.equal(0, header_extmarks) delete_buffer(bufnr) end) end) @@ -2062,11 +1543,40 @@ describe('highlight', function() context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, - priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, }, } end + it('uses priority 199 for code languages', function() + local bufnr = create_buffer({ + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '+local y = 2' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_priority_199 = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then + if mark[4].priority == 199 then + has_priority_199 = true + break + end + end + end + assert.is_true(has_priority_199) + delete_buffer(bufnr) + end) + it('uses treesitter priority for diff language', function() local bufnr = create_buffer({ 'diff --git a/test.lua b/test.lua', diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 5ee7532..1bf71d1 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -24,6 +24,7 @@ describe('diffs', function() it('accepts full config', function() vim.g.diffs = { debug = true, + debounce_ms = 100, hide_prefix = false, highlights = { background = true, @@ -45,7 +46,7 @@ describe('diffs', function() it('accepts partial config', function() vim.g.diffs = { - hide_prefix = true, + debounce_ms = 25, } assert.has_no.errors(function() diffs.attach() @@ -151,265 +152,6 @@ describe('diffs', function() end) end) - describe('find_visible_hunks', function() - local find_visible_hunks = diffs._test.find_visible_hunks - - local function make_hunk(start_row, end_row, opts) - local lines = {} - for i = 1, end_row - start_row + 1 do - lines[i] = 'line' .. i - end - local h = { start_line = start_row + 1, lines = lines } - if opts and opts.header_start_line then - h.header_start_line = opts.header_start_line - end - return h - end - - it('returns (0, 0) for empty hunk list', function() - local first, last = find_visible_hunks({}, 0, 50) - assert.are.equal(0, first) - assert.are.equal(0, last) - end) - - it('finds single hunk fully inside viewport', function() - local h = make_hunk(5, 10) - local first, last = find_visible_hunks({ h }, 0, 50) - assert.are.equal(1, first) - assert.are.equal(1, last) - end) - - it('returns (0, 0) for single hunk fully above viewport', function() - local h = make_hunk(5, 10) - local first, last = find_visible_hunks({ h }, 20, 50) - assert.are.equal(0, first) - assert.are.equal(0, last) - end) - - it('returns (0, 0) for single hunk fully below viewport', function() - local h = make_hunk(50, 60) - local first, last = find_visible_hunks({ h }, 0, 20) - assert.are.equal(0, first) - assert.are.equal(0, last) - end) - - it('finds single hunk partially visible at top edge', function() - local h = make_hunk(5, 15) - local first, last = find_visible_hunks({ h }, 10, 30) - assert.are.equal(1, first) - assert.are.equal(1, last) - end) - - it('finds single hunk partially visible at bottom edge', function() - local h = make_hunk(25, 35) - local first, last = find_visible_hunks({ h }, 10, 30) - assert.are.equal(1, first) - assert.are.equal(1, last) - end) - - it('finds subset of visible hunks', function() - local h1 = make_hunk(5, 10) - local h2 = make_hunk(25, 30) - local h3 = make_hunk(55, 60) - local first, last = find_visible_hunks({ h1, h2, h3 }, 20, 40) - assert.are.equal(2, first) - assert.are.equal(2, last) - end) - - it('finds all hunks when all are visible', function() - local h1 = make_hunk(5, 10) - local h2 = make_hunk(15, 20) - local h3 = make_hunk(25, 30) - local first, last = find_visible_hunks({ h1, h2, h3 }, 0, 50) - assert.are.equal(1, first) - assert.are.equal(3, last) - end) - - it('returns (0, 0) when no hunks are visible', function() - local h1 = make_hunk(5, 10) - local h2 = make_hunk(15, 20) - local first, last = find_visible_hunks({ h1, h2 }, 30, 50) - assert.are.equal(0, first) - assert.are.equal(0, last) - end) - - it('uses header_start_line for top boundary', function() - local h = make_hunk(5, 10, { header_start_line = 4 }) - local first, last = find_visible_hunks({ h }, 0, 50) - assert.are.equal(1, first) - assert.are.equal(1, last) - end) - - it('finds both adjacent hunks at viewport edge', function() - local h1 = make_hunk(10, 20) - local h2 = make_hunk(20, 30) - local first, last = find_visible_hunks({ h1, h2 }, 15, 25) - assert.are.equal(1, first) - assert.are.equal(2, last) - end) - end) - - describe('hunk_cache', function() - local function create_buffer(lines) - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) - return bufnr - end - - local function delete_buffer(bufnr) - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end - end - - it('creates entry on attach', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_not_nil(entry) - assert.is_table(entry.hunks) - assert.is_number(entry.tick) - assert.is_true(entry.tick >= 0) - delete_buffer(bufnr) - end) - - it('is idempotent on repeated attach', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - local entry1 = diffs._test.hunk_cache[bufnr] - local tick1 = entry1.tick - local hunks1 = entry1.hunks - diffs._test.ensure_cache(bufnr) - local entry2 = diffs._test.hunk_cache[bufnr] - assert.are.equal(tick1, entry2.tick) - assert.are.equal(hunks1, entry2.hunks) - delete_buffer(bufnr) - end) - - it('marks stale on invalidate', function() - local bufnr = create_buffer({}) - diffs.attach(bufnr) - diffs._test.invalidate_cache(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.are.equal(-1, entry.tick) - assert.is_true(entry.pending_clear) - delete_buffer(bufnr) - end) - - it('evicts on buffer wipeout', function() - local bufnr = create_buffer({}) - diffs.attach(bufnr) - assert.is_not_nil(diffs._test.hunk_cache[bufnr]) - vim.api.nvim_buf_delete(bufnr, { force = true }) - assert.is_nil(diffs._test.hunk_cache[bufnr]) - end) - - it('detects content change via tick', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - local tick_before = diffs._test.hunk_cache[bufnr].tick - vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' }) - diffs._test.ensure_cache(bufnr) - local tick_after = diffs._test.hunk_cache[bufnr].tick - assert.is_true(tick_after > tick_before) - delete_buffer(bufnr) - end) - end) - - describe('compute_filetypes', function() - local compute = diffs.compute_filetypes - - it('returns core filetypes with empty config', function() - local fts = compute({}) - assert.are.same({ 'git', 'gitcommit' }, fts) - end) - - it('includes fugitive when integrations.fugitive = true', function() - local fts = compute({ integrations = { fugitive = true } }) - assert.is_true(vim.tbl_contains(fts, 'fugitive')) - end) - - it('includes fugitive when integrations.fugitive is a table', function() - local fts = compute({ integrations = { fugitive = { horizontal = 'dd' } } }) - assert.is_true(vim.tbl_contains(fts, 'fugitive')) - end) - - it('excludes fugitive when integrations.fugitive = false', function() - local fts = compute({ integrations = { fugitive = false } }) - assert.is_false(vim.tbl_contains(fts, 'fugitive')) - end) - - it('excludes fugitive when integrations.fugitive is nil', function() - local fts = compute({ integrations = {} }) - assert.is_false(vim.tbl_contains(fts, 'fugitive')) - end) - - it('includes neogit filetypes when integrations.neogit = true', function() - local fts = compute({ integrations = { neogit = true } }) - assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) - assert.is_true(vim.tbl_contains(fts, 'NeogitCommitView')) - assert.is_true(vim.tbl_contains(fts, 'NeogitDiffView')) - end) - - it('includes neogit filetypes when integrations.neogit is a table', function() - local fts = compute({ integrations = { neogit = {} } }) - assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) - end) - - it('excludes neogit when integrations.neogit = false', function() - local fts = compute({ integrations = { neogit = false } }) - assert.is_false(vim.tbl_contains(fts, 'NeogitStatus')) - end) - - it('excludes neogit when integrations.neogit is nil', function() - local fts = compute({ integrations = {} }) - assert.is_false(vim.tbl_contains(fts, 'NeogitStatus')) - end) - - it('includes extra_filetypes', function() - local fts = compute({ extra_filetypes = { 'diff' } }) - assert.is_true(vim.tbl_contains(fts, 'diff')) - end) - - it('combines integrations and extra_filetypes', function() - local fts = compute({ - integrations = { fugitive = true, neogit = true }, - extra_filetypes = { 'diff' }, - }) - assert.is_true(vim.tbl_contains(fts, 'git')) - assert.is_true(vim.tbl_contains(fts, 'fugitive')) - assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) - assert.is_true(vim.tbl_contains(fts, 'diff')) - end) - - it('falls back to legacy top-level fugitive key', function() - local fts = compute({ fugitive = true }) - assert.is_true(vim.tbl_contains(fts, 'fugitive')) - end) - - it('falls back to legacy top-level neogit key', function() - local fts = compute({ neogit = true }) - assert.is_true(vim.tbl_contains(fts, 'NeogitStatus')) - end) - - it('prefers integrations key over legacy top-level key', function() - local fts = compute({ integrations = { fugitive = false }, fugitive = true }) - assert.is_false(vim.tbl_contains(fts, 'fugitive')) - end) - end) - describe('diff mode', function() local function create_diff_window() vim.cmd('new') @@ -527,115 +269,4 @@ describe('diffs', function() end) end) end) - - describe('compute_highlight_groups', function() - local saved_get_hl, saved_set_hl, saved_schedule - local set_calls, schedule_cbs - - before_each(function() - saved_get_hl = vim.api.nvim_get_hl - saved_set_hl = vim.api.nvim_set_hl - saved_schedule = vim.schedule - set_calls = {} - schedule_cbs = {} - vim.api.nvim_set_hl = function(_, group, opts) - set_calls[group] = opts - end - vim.schedule = function(cb) - table.insert(schedule_cbs, cb) - end - diffs._test.set_hl_retry_pending(false) - end) - - after_each(function() - vim.api.nvim_get_hl = saved_get_hl - vim.api.nvim_set_hl = saved_set_hl - vim.schedule = saved_schedule - diffs._test.set_hl_retry_pending(false) - end) - - it('omits DiffsClear.bg when Normal.bg is nil (transparent)', function() - vim.api.nvim_get_hl = function(ns, opts) - if opts.name == 'Normal' then - return { fg = 0xc0c0c0 } - end - return saved_get_hl(ns, opts) - end - diffs._test.compute_highlight_groups() - assert.is_nil(set_calls.DiffsClear.bg) - assert.is_table(set_calls.DiffsAdd) - assert.is_table(set_calls.DiffsDelete) - end) - - it('sets DiffsClear.bg to Normal.bg on opaque themes', function() - vim.api.nvim_get_hl = function(ns, opts) - if opts.name == 'Normal' then - return { fg = 0xebdbb2, bg = 0x282828 } - end - return saved_get_hl(ns, opts) - end - diffs._test.compute_highlight_groups() - assert.are.equal(0x282828, set_calls.DiffsClear.bg) - end) - - it('blend_alpha controls DiffsAdd.bg intensity', function() - local saved_config_alpha = diffs._test.get_config().highlights.blend_alpha - diffs._test.get_config().highlights.blend_alpha = 0.3 - vim.api.nvim_get_hl = function(ns, opts) - if opts.name == 'Normal' then - return { fg = 0xc0c0c0, bg = 0x1e1e2e } - end - if opts.name == 'DiffAdd' then - return { bg = 0x1a3a1a } - end - if opts.name == 'DiffDelete' then - return { bg = 0x3a1a1a } - end - return saved_get_hl(ns, opts) - end - diffs._test.compute_highlight_groups() - local bg_03 = set_calls.DiffsAdd.bg - - diffs._test.get_config().highlights.blend_alpha = 0.9 - diffs._test.compute_highlight_groups() - local bg_09 = set_calls.DiffsAdd.bg - - assert.is_not.equal(bg_03, bg_09) - - diffs._test.get_config().highlights.blend_alpha = saved_config_alpha - end) - - it('retries once then stops when Normal.bg stays nil', function() - vim.api.nvim_get_hl = function(ns, opts) - if opts.name == 'Normal' then - return { fg = 0xc0c0c0 } - end - return saved_get_hl(ns, opts) - end - diffs._test.compute_highlight_groups() - assert.are.equal(1, #schedule_cbs) - schedule_cbs[1]() - assert.are.equal(1, #schedule_cbs) - assert.is_true(diffs._test.get_hl_retry_pending()) - end) - - it('picks up bg on retry when colorscheme loads late', function() - local call_count = 0 - vim.api.nvim_get_hl = function(ns, opts) - if opts.name == 'Normal' then - call_count = call_count + 1 - if call_count <= 1 then - return { fg = 0xc0c0c0 } - end - return { fg = 0xc0c0c0, bg = 0x1e1e2e } - end - return saved_get_hl(ns, opts) - end - diffs._test.compute_highlight_groups() - assert.are.equal(1, #schedule_cbs) - schedule_cbs[1]() - assert.are.equal(0x1e1e2e, set_calls.DiffsClear.bg) - assert.are.equal(1, #schedule_cbs) - end) - end) end) diff --git a/spec/integration_spec.lua b/spec/integration_spec.lua deleted file mode 100644 index 23e870b..0000000 --- a/spec/integration_spec.lua +++ /dev/null @@ -1,434 +0,0 @@ -require('spec.helpers') -local diffs = require('diffs') -local highlight = require('diffs.highlight') - -local function setup_highlight_groups() - local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) - local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' }) - local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' }) - vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 }) - vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg or 0x2e4a3a }) - vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg or 0x4a2e3a }) - vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 }) - vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 }) -end - -local function create_buffer(lines) - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) - return bufnr -end - -local function delete_buffer(bufnr) - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end -end - -local function get_diffs_ns() - return vim.api.nvim_get_namespaces()['diffs'] -end - -local function get_extmarks(bufnr, ns) - return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) -end - -local function highlight_opts_with_background() - return { - hide_prefix = false, - highlights = { - background = true, - gutter = false, - context = { enabled = false, lines = 0 }, - treesitter = { enabled = true, max_lines = 500 }, - vim = { enabled = false, max_lines = 200 }, - intra = { enabled = false, algorithm = 'default', max_lines = 500 }, - priorities = { clear = 198, syntax = 199, line_bg = 200, char_bg = 201 }, - }, - } -end - -describe('integration', function() - before_each(function() - setup_highlight_groups() - end) - - describe('attach and parse', function() - it('attach populates hunk cache for unified diff buffer', function() - local bufnr = create_buffer({ - 'diff --git a/foo.lua b/foo.lua', - 'index abc..def 100644', - '--- a/foo.lua', - '+++ b/foo.lua', - '@@ -1,3 +1,3 @@', - ' local x = 1', - '-local y = 2', - '+local y = 3', - ' local z = 4', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_not_nil(entry) - assert.are.equal(1, #entry.hunks) - assert.are.equal('foo.lua', entry.hunks[1].filename) - delete_buffer(bufnr) - end) - - it('attach parses multiple hunks across multiple files', function() - local bufnr = create_buffer({ - 'M foo.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - 'M bar.lua', - '@@ -1,1 +1,2 @@', - ' local a = 1', - '+local b = 2', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_not_nil(entry) - assert.are.equal(2, #entry.hunks) - delete_buffer(bufnr) - end) - - it('re-attach on same buffer is idempotent', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - local entry_before = diffs._test.hunk_cache[bufnr] - local tick_before = entry_before.tick - diffs.attach(bufnr) - local entry_after = diffs._test.hunk_cache[bufnr] - assert.are.equal(tick_before, entry_after.tick) - delete_buffer(bufnr) - end) - - it('refresh after content change invalidates cache', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - local tick_before = diffs._test.hunk_cache[bufnr].tick - vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { '+local z = 3' }) - diffs.refresh(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.are.equal(-1, entry.tick) - assert.is_true(entry.pending_clear) - assert.is_true(tick_before >= 0) - delete_buffer(bufnr) - end) - end) - - describe('ft_retry_pending', function() - before_each(function() - rawset(vim.fn, 'did_filetype', function() - return 1 - end) - require('diffs.parser')._test.ft_lang_cache = {} - end) - - after_each(function() - rawset(vim.fn, 'did_filetype', nil) - end) - - it('sets ft_retry_pending when nil-ft hunks detected under did_filetype', function() - local bufnr = create_buffer({ - 'diff --git a/app.conf b/app.conf', - '@@ -1,2 +1,2 @@', - ' server {', - '- listen 80;', - '+ listen 8080;', - }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_not_nil(entry) - assert.is_nil(entry.hunks[1].ft) - assert.is_true(diffs._test.ft_retry_pending[bufnr] == true) - delete_buffer(bufnr) - end) - - it('clears ft_retry_pending after scheduled callback fires', function() - local bufnr = create_buffer({ - 'diff --git a/app.conf b/app.conf', - '@@ -1,2 +1,2 @@', - ' server {', - '- listen 80;', - '+ listen 8080;', - }) - diffs.attach(bufnr) - assert.is_true(diffs._test.ft_retry_pending[bufnr] == true) - - local done = false - vim.schedule(function() - done = true - end) - vim.wait(1000, function() - return done - end) - - assert.is_nil(diffs._test.ft_retry_pending[bufnr]) - delete_buffer(bufnr) - end) - - it('invalidates cache after scheduled callback fires', function() - local bufnr = create_buffer({ - 'diff --git a/app.conf b/app.conf', - '@@ -1,2 +1,2 @@', - ' server {', - '- listen 80;', - '+ listen 8080;', - }) - diffs.attach(bufnr) - local tick_after_attach = diffs._test.hunk_cache[bufnr].tick - assert.is_true(tick_after_attach >= 0) - - local done = false - vim.schedule(function() - done = true - end) - vim.wait(1000, function() - return done - end) - - local entry = diffs._test.hunk_cache[bufnr] - assert.are.equal(-1, entry.tick) - assert.is_true(entry.pending_clear) - delete_buffer(bufnr) - end) - - it('does not set ft_retry_pending when did_filetype() is zero', function() - rawset(vim.fn, 'did_filetype', nil) - local bufnr = create_buffer({ - 'diff --git a/test.sh b/test.sh', - '@@ -1,2 +1,3 @@', - ' #!/usr/bin/env bash', - '-old line', - '+new line', - }) - diffs.attach(bufnr) - assert.is_falsy(diffs._test.ft_retry_pending[bufnr]) - delete_buffer(bufnr) - end) - - it('does not set ft_retry_pending for files with resolvable ft', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - assert.is_falsy(diffs._test.ft_retry_pending[bufnr]) - delete_buffer(bufnr) - end) - end) - - describe('extmarks from highlight pipeline', function() - it('DiffsAdd background applied to + lines', function() - local bufnr = create_buffer({ - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - local ns = vim.api.nvim_create_namespace('diffs_integration_test_add') - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2' }, - } - highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) - local extmarks = get_extmarks(bufnr, ns) - local has_diff_add = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then - has_diff_add = true - break - end - end - assert.is_true(has_diff_add) - delete_buffer(bufnr) - end) - - it('DiffsDelete background applied to - lines', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,1 @@', - ' local x = 1', - '-local y = 2', - }) - local ns = vim.api.nvim_create_namespace('diffs_integration_test_del') - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '-local y = 2' }, - } - highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) - local extmarks = get_extmarks(bufnr, ns) - local has_diff_delete = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then - has_diff_delete = true - break - end - end - assert.is_true(has_diff_delete) - delete_buffer(bufnr) - end) - - it('mixed hunk produces both DiffsAdd and DiffsDelete backgrounds', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,2 @@', - '-local x = 1', - '+local x = 2', - }) - local ns = vim.api.nvim_create_namespace('diffs_integration_test_mixed') - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-local x = 1', '+local x = 2' }, - } - highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) - local extmarks = get_extmarks(bufnr, ns) - local has_add = false - local has_delete = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then - has_add = true - end - if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then - has_delete = true - end - end - assert.is_true(has_add) - assert.is_true(has_delete) - delete_buffer(bufnr) - end) - - it('no background extmarks for context lines', function() - local bufnr = create_buffer({ - '@@ -1,3 +1,3 @@', - ' local x = 1', - '-local y = 2', - '+local y = 3', - ' local z = 4', - }) - local ns = vim.api.nvim_create_namespace('diffs_integration_test_ctx') - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '-local y = 2', '+local y = 3', ' local z = 4' }, - } - highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) - local extmarks = get_extmarks(bufnr, ns) - local line_bgs = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then - line_bgs[mark[2]] = d.line_hl_group - end - end - assert.is_nil(line_bgs[1]) - assert.is_nil(line_bgs[4]) - assert.are.equal('DiffsDelete', line_bgs[2]) - assert.are.equal('DiffsAdd', line_bgs[3]) - delete_buffer(bufnr) - end) - - it('treesitter extmarks applied for lua hunks', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,3 @@', - ' local x = 1', - '+local y = 2', - ' return x', - }) - local ns = vim.api.nvim_create_namespace('diffs_integration_test_ts') - local hunk = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { ' local x = 1', '+local y = 2', ' return x' }, - } - highlight.highlight_hunk(bufnr, ns, hunk, highlight_opts_with_background()) - local extmarks = get_extmarks(bufnr, ns) - local has_ts = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then - has_ts = true - break - end - end - assert.is_true(has_ts) - delete_buffer(bufnr) - end) - - it('diffs namespace exists after attach', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - diffs.attach(bufnr) - local ns = get_diffs_ns() - assert.is_not_nil(ns) - assert.is_number(ns) - delete_buffer(bufnr) - end) - end) - - describe('multiple hunks highlighting', function() - it('both hunks in multi-hunk buffer get background extmarks', function() - local bufnr = create_buffer({ - '@@ -1,2 +1,2 @@', - '-local x = 1', - '+local x = 10', - '@@ -10,2 +10,2 @@', - '-local y = 2', - '+local y = 20', - }) - local ns = vim.api.nvim_create_namespace('diffs_integration_test_multi') - local hunk1 = { - filename = 'test.lua', - lang = 'lua', - start_line = 1, - lines = { '-local x = 1', '+local x = 10' }, - } - local hunk2 = { - filename = 'test.lua', - lang = 'lua', - start_line = 4, - lines = { '-local y = 2', '+local y = 20' }, - } - highlight.highlight_hunk(bufnr, ns, hunk1, highlight_opts_with_background()) - highlight.highlight_hunk(bufnr, ns, hunk2, highlight_opts_with_background()) - local extmarks = get_extmarks(bufnr, ns) - local add_lines = {} - local del_lines = {} - for _, mark in ipairs(extmarks) do - local d = mark[4] - if d and d.line_hl_group == 'DiffsAdd' then - add_lines[mark[2]] = true - end - if d and d.line_hl_group == 'DiffsDelete' then - del_lines[mark[2]] = true - end - end - assert.is_true(del_lines[1] ~= nil) - assert.is_true(add_lines[2] ~= nil) - assert.is_true(del_lines[4] ~= nil) - assert.is_true(add_lines[5] ~= nil) - delete_buffer(bufnr) - end) - end) -end) diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua deleted file mode 100644 index 5e4b854..0000000 --- a/spec/merge_spec.lua +++ /dev/null @@ -1,815 +0,0 @@ -local helpers = require('spec.helpers') -local merge = require('diffs.merge') - -local function default_config(overrides) - local cfg = { - enabled = true, - disable_diagnostics = false, - show_virtual_text = true, - show_actions = false, - keymaps = { - ours = 'doo', - theirs = 'dot', - both = 'dob', - none = 'don', - next = ']c', - prev = '[c', - }, - } - if overrides then - cfg = vim.tbl_deep_extend('force', cfg, overrides) - end - return cfg -end - -local function create_diff_buffer(lines, working_path) - local bufnr = helpers.create_buffer(lines) - if working_path then - vim.api.nvim_buf_set_var(bufnr, 'diffs_working_path', working_path) - end - return bufnr -end - -local function create_working_buffer(lines, name) - local bufnr = vim.api.nvim_create_buf(true, false) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - if name then - vim.api.nvim_buf_set_name(bufnr, name) - end - return bufnr -end - -describe('merge', function() - describe('parse_hunks', function() - it('parses a single hunk', function() - local bufnr = helpers.create_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,3 +1,3 @@', - ' local M = {}', - '-local x = 1', - '+local x = 2', - ' return M', - }) - - local hunks = merge.parse_hunks(bufnr) - assert.are.equal(1, #hunks) - assert.are.equal(3, hunks[1].start_line) - assert.are.equal(7, hunks[1].end_line) - assert.are.same({ 'local x = 1' }, hunks[1].del_lines) - assert.are.same({ 'local x = 2' }, hunks[1].add_lines) - - helpers.delete_buffer(bufnr) - end) - - it('parses multiple hunks', function() - local bufnr = helpers.create_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,3 +1,3 @@', - ' local M = {}', - '-local x = 1', - '+local x = 2', - ' return M', - '@@ -10,3 +10,3 @@', - ' function M.foo()', - '- return 1', - '+ return 2', - ' end', - }) - - local hunks = merge.parse_hunks(bufnr) - assert.are.equal(2, #hunks) - assert.are.equal(3, hunks[1].start_line) - assert.are.equal(8, hunks[2].start_line) - - helpers.delete_buffer(bufnr) - end) - - it('parses add-only hunk', function() - local bufnr = helpers.create_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local new = true', - ' return M', - }) - - local hunks = merge.parse_hunks(bufnr) - assert.are.equal(1, #hunks) - assert.are.same({}, hunks[1].del_lines) - assert.are.same({ 'local new = true' }, hunks[1].add_lines) - - helpers.delete_buffer(bufnr) - end) - - it('parses delete-only hunk', function() - local bufnr = helpers.create_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,3 +1,2 @@', - ' local M = {}', - '-local old = false', - ' return M', - }) - - local hunks = merge.parse_hunks(bufnr) - assert.are.equal(1, #hunks) - assert.are.same({ 'local old = false' }, hunks[1].del_lines) - assert.are.same({}, hunks[1].add_lines) - - helpers.delete_buffer(bufnr) - end) - - it('returns empty for buffer with no hunks', function() - local bufnr = helpers.create_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - }) - - local hunks = merge.parse_hunks(bufnr) - assert.are.equal(0, #hunks) - - helpers.delete_buffer(bufnr) - end) - end) - - describe('match_hunk_to_conflict', function() - it('matches hunk to conflict region', function() - local working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, '/tmp/diffs_test_match.lua') - - local hunk = { - index = 1, - start_line = 3, - end_line = 7, - del_lines = { 'local x = 1' }, - add_lines = { 'local x = 2' }, - } - - local region = merge.match_hunk_to_conflict(hunk, working_bufnr) - assert.is_not_nil(region) - assert.are.equal(0, region.marker_ours) - - helpers.delete_buffer(working_bufnr) - end) - - it('returns nil for auto-merged content', function() - local working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, '/tmp/diffs_test_auto.lua') - - local hunk = { - index = 1, - start_line = 3, - end_line = 7, - del_lines = { 'local y = 3' }, - add_lines = { 'local y = 4' }, - } - - local region = merge.match_hunk_to_conflict(hunk, working_bufnr) - assert.is_nil(region) - - helpers.delete_buffer(working_bufnr) - end) - - it('matches with empty ours section', function() - local working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, '/tmp/diffs_test_empty_ours.lua') - - local hunk = { - index = 1, - start_line = 3, - end_line = 5, - del_lines = {}, - add_lines = { 'local x = 2' }, - } - - local region = merge.match_hunk_to_conflict(hunk, working_bufnr) - assert.is_not_nil(region) - - helpers.delete_buffer(working_bufnr) - end) - - it('matches correct region among multiple conflicts', function() - local working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local a = 1', - '=======', - 'local a = 2', - '>>>>>>> feature', - 'middle', - '<<<<<<< HEAD', - 'local b = 3', - '=======', - 'local b = 4', - '>>>>>>> feature', - }, '/tmp/diffs_test_multi.lua') - - local hunk = { - index = 2, - start_line = 8, - end_line = 12, - del_lines = { 'local b = 3' }, - add_lines = { 'local b = 4' }, - } - - local region = merge.match_hunk_to_conflict(hunk, working_bufnr) - assert.is_not_nil(region) - assert.are.equal(6, region.marker_ours) - - helpers.delete_buffer(working_bufnr) - end) - - it('matches with diff3 format', function() - local working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '||||||| base', - 'local x = 0', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, '/tmp/diffs_test_diff3.lua') - - local hunk = { - index = 1, - start_line = 3, - end_line = 7, - del_lines = { 'local x = 1' }, - add_lines = { 'local x = 2' }, - } - - local region = merge.match_hunk_to_conflict(hunk, working_bufnr) - assert.is_not_nil(region) - assert.are.equal(2, region.marker_base) - - helpers.delete_buffer(working_bufnr) - end) - end) - - describe('resolution', function() - local diff_bufnr, working_bufnr - - local function setup_buffers() - local working_path = '/tmp/diffs_test_resolve.lua' - working_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - diff_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(diff_bufnr) - end - - local function cleanup() - helpers.delete_buffer(diff_bufnr) - helpers.delete_buffer(working_bufnr) - end - - it('resolve_ours keeps ours content in working file', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_ours(diff_bufnr, default_config()) - - local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) - assert.are.equal(1, #lines) - assert.are.equal('local x = 1', lines[1]) - - cleanup() - end) - - it('resolve_theirs keeps theirs content in working file', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_theirs(diff_bufnr, default_config()) - - local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) - assert.are.equal(1, #lines) - assert.are.equal('local x = 2', lines[1]) - - cleanup() - end) - - it('resolve_both keeps ours then theirs in working file', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_both(diff_bufnr, default_config()) - - local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) - assert.are.equal(2, #lines) - assert.are.equal('local x = 1', lines[1]) - assert.are.equal('local x = 2', lines[2]) - - cleanup() - end) - - it('resolve_none removes entire block from working file', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_none(diff_bufnr, default_config()) - - local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) - assert.are.equal(1, #lines) - assert.are.equal('', lines[1]) - - cleanup() - end) - - it('tracks resolved hunks', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - assert.is_false(merge.is_resolved(diff_bufnr, 1)) - merge.resolve_ours(diff_bufnr, default_config()) - assert.is_true(merge.is_resolved(diff_bufnr, 1)) - - cleanup() - end) - - it('adds virtual text for resolved hunks', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_ours(diff_bufnr, default_config()) - - local extmarks = - vim.api.nvim_buf_get_extmarks(diff_bufnr, merge.get_namespace(), 0, -1, { details = true }) - local has_resolved_text = false - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - for _, chunk in ipairs(mark[4].virt_text) do - if chunk[1]:match('resolved') then - has_resolved_text = true - end - end - end - end - assert.is_true(has_resolved_text) - - cleanup() - end) - - it('notifies when hunk is already resolved', function() - setup_buffers() - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_ours(diff_bufnr, default_config()) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('already resolved') then - notified = true - end - end - - merge.resolve_ours(diff_bufnr, default_config()) - vim.notify = orig_notify - - assert.is_true(notified) - - cleanup() - end) - - it('notifies when hunk does not match a conflict', function() - local working_path = '/tmp/diffs_test_no_conflict.lua' - local w_bufnr = create_working_buffer({ - 'local y = 1', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('does not correspond') then - notified = true - end - end - - merge.resolve_ours(d_bufnr, default_config()) - vim.notify = orig_notify - - assert.is_true(notified) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - end) - - describe('navigation', function() - it('goto_next jumps to next conflict hunk', function() - local working_path = '/tmp/diffs_test_nav.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local a = 1', - '=======', - 'local a = 2', - '>>>>>>> feature', - 'middle', - '<<<<<<< HEAD', - 'local b = 3', - '=======', - 'local b = 4', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local a = 1', - '+local a = 2', - '@@ -5,1 +5,1 @@', - '-local b = 3', - '+local b = 4', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - merge.goto_next(d_bufnr) - assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) - - merge.goto_next(d_bufnr) - assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('goto_next wraps around', function() - local working_path = '/tmp/diffs_test_wrap.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 6, 0 }) - - merge.goto_next(d_bufnr) - assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('goto_next notifies on wrap-around', function() - local working_path = '/tmp/diffs_test_wrap_notify.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 6, 0 }) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('wrapped to first hunk') then - notified = true - end - end - - merge.goto_next(d_bufnr) - vim.notify = orig_notify - - assert.is_true(notified) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('goto_prev jumps to previous conflict hunk', function() - local working_path = '/tmp/diffs_test_prev.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local a = 1', - '=======', - 'local a = 2', - '>>>>>>> feature', - 'middle', - '<<<<<<< HEAD', - 'local b = 3', - '=======', - 'local b = 4', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local a = 1', - '+local a = 2', - '@@ -5,1 +5,1 @@', - '-local b = 3', - '+local b = 4', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 9, 0 }) - - merge.goto_prev(d_bufnr) - assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) - - merge.goto_prev(d_bufnr) - assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('goto_prev wraps around', function() - local working_path = '/tmp/diffs_test_prev_wrap.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - merge.goto_prev(d_bufnr) - assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('goto_prev notifies on wrap-around', function() - local working_path = '/tmp/diffs_test_prev_wrap_notify.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - - local notified = false - local orig_notify = vim.notify - vim.notify = function(msg) - if msg:match('wrapped to last hunk') then - notified = true - end - end - - merge.goto_prev(d_bufnr) - vim.notify = orig_notify - - assert.is_true(notified) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - - it('skips resolved hunks', function() - local working_path = '/tmp/diffs_test_skip_resolved.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local a = 1', - '=======', - 'local a = 2', - '>>>>>>> feature', - 'middle', - '<<<<<<< HEAD', - 'local b = 3', - '=======', - 'local b = 4', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local a = 1', - '+local a = 2', - '@@ -5,1 +5,1 @@', - '-local b = 3', - '+local b = 4', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - merge.resolve_ours(d_bufnr, default_config()) - - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - merge.goto_next(d_bufnr) - assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - end) - - describe('hunk hints', function() - it('adds keymap hints on hunk header lines', function() - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }) - - merge.setup_keymaps(d_bufnr, default_config()) - - local extmarks = - vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) - local hint_marks = {} - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - local text = '' - for _, chunk in ipairs(mark[4].virt_text) do - text = text .. chunk[1] - end - table.insert(hint_marks, { line = mark[2], text = text }) - end - end - assert.are.equal(1, #hint_marks) - assert.are.equal(3, hint_marks[1].line) - assert.is_truthy(hint_marks[1].text:find('doo')) - assert.is_truthy(hint_marks[1].text:find('dot')) - - helpers.delete_buffer(d_bufnr) - end) - end) - - describe('setup_keymaps', function() - it('clears resolved state on re-init', function() - local working_path = '/tmp/diffs_test_reinit.lua' - local w_bufnr = create_working_buffer({ - '<<<<<<< HEAD', - 'local x = 1', - '=======', - 'local x = 2', - '>>>>>>> feature', - }, working_path) - - local d_bufnr = create_diff_buffer({ - 'diff --git a/file.lua b/file.lua', - '--- a/file.lua', - '+++ b/file.lua', - '@@ -1,1 +1,1 @@', - '-local x = 1', - '+local x = 2', - }, working_path) - vim.api.nvim_set_current_buf(d_bufnr) - vim.api.nvim_win_set_cursor(0, { 5, 0 }) - - local cfg = default_config() - merge.resolve_ours(d_bufnr, cfg) - assert.is_true(merge.is_resolved(d_bufnr, 1)) - - local extmarks = - vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) - assert.is_true(#extmarks > 0) - - merge.setup_keymaps(d_bufnr, cfg) - - assert.is_false(merge.is_resolved(d_bufnr, 1)) - extmarks = - vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) - local resolved_count = 0 - for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].virt_text then - for _, chunk in ipairs(mark[4].virt_text) do - if chunk[1]:match('resolved') then - resolved_count = resolved_count + 1 - end - end - end - end - assert.are.equal(0, resolved_count) - - helpers.delete_buffer(d_bufnr) - helpers.delete_buffer(w_bufnr) - end) - end) - - describe('fugitive integration', function() - it('parse_file_line returns status for unmerged files', function() - local fugitive = require('diffs.fugitive') - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'Unstaged (1)', - 'U conflict.lua', - }) - local filename, section, is_header, old_filename, status = fugitive.get_file_at_line(buf, 2) - assert.are.equal('conflict.lua', filename) - assert.are.equal('unstaged', section) - assert.is_false(is_header) - assert.is_nil(old_filename) - assert.are.equal('U', status) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('walkback from hunk line propagates status', function() - local fugitive = require('diffs.fugitive') - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'Unstaged (1)', - 'U conflict.lua', - '@@ -1,3 +1,4 @@', - ' local M = {}', - '+local new = true', - }) - local _, _, _, _, status = fugitive.get_file_at_line(buf, 5) - assert.are.equal('U', status) - vim.api.nvim_buf_delete(buf, { force = true }) - end) - end) -end) diff --git a/spec/neogit_integration_spec.lua b/spec/neogit_integration_spec.lua deleted file mode 100644 index 958df7f..0000000 --- a/spec/neogit_integration_spec.lua +++ /dev/null @@ -1,126 +0,0 @@ -require('spec.helpers') - -vim.g.diffs = { integrations = { neogit = true } } - -local diffs = require('diffs') -local parser = require('diffs.parser') - -local function create_buffer(lines) - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) - return bufnr -end - -local function delete_buffer(bufnr) - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end -end - -describe('neogit_integration', function() - describe('neogit_disable_hunk_highlight', function() - it('sets neogit_disable_hunk_highlight on NeogitStatus buffer after attach', function() - local bufnr = create_buffer({ - 'modified test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - vim.api.nvim_set_option_value('filetype', 'NeogitStatus', { buf = bufnr }) - diffs.attach(bufnr) - - assert.is_true(vim.b[bufnr].neogit_disable_hunk_highlight) - - delete_buffer(bufnr) - end) - - it('does not set neogit_disable_hunk_highlight on non-Neogit buffer', function() - local bufnr = create_buffer({}) - vim.api.nvim_set_option_value('filetype', 'git', { buf = bufnr }) - diffs.attach(bufnr) - - assert.is_not_true(vim.b[bufnr].neogit_disable_hunk_highlight) - - delete_buffer(bufnr) - end) - end) - - describe('NeogitStatus buffer attach', function() - it('populates hunk_cache for NeogitStatus buffer with diff content', function() - local bufnr = create_buffer({ - 'modified hello.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local x = 1', - ' return M', - }) - vim.api.nvim_set_option_value('filetype', 'NeogitStatus', { buf = bufnr }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_not_nil(entry) - assert.is_table(entry.hunks) - assert.are.equal(1, #entry.hunks) - assert.are.equal('hello.lua', entry.hunks[1].filename) - delete_buffer(bufnr) - end) - - it('populates hunk_cache for NeogitDiffView buffer', function() - local bufnr = create_buffer({ - 'new file newmod.lua', - '@@ -0,0 +1,2 @@', - '+local M = {}', - '+return M', - }) - vim.api.nvim_set_option_value('filetype', 'NeogitDiffView', { buf = bufnr }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_not_nil(entry) - assert.is_table(entry.hunks) - assert.are.equal(1, #entry.hunks) - delete_buffer(bufnr) - end) - end) - - describe('parser neogit patterns', function() - it('detects renamed prefix via parser', function() - local bufnr = create_buffer({ - 'renamed old.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local x = 1', - ' return M', - }) - local hunks = parser.parse_buffer(bufnr) - assert.are.equal(1, #hunks) - assert.are.equal('old.lua', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('detects copied prefix via parser', function() - local bufnr = create_buffer({ - 'copied orig.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local x = 1', - ' return M', - }) - local hunks = parser.parse_buffer(bufnr) - assert.are.equal(1, #hunks) - assert.are.equal('orig.lua', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('detects deleted prefix via parser', function() - local bufnr = create_buffer({ - 'deleted gone.lua', - '@@ -1,2 +0,0 @@', - '-local M = {}', - '-return M', - }) - local hunks = parser.parse_buffer(bufnr) - assert.are.equal(1, #hunks) - assert.are.equal('gone.lua', hunks[1].filename) - delete_buffer(bufnr) - end) - end) -end) diff --git a/spec/neojj_integration_spec.lua b/spec/neojj_integration_spec.lua deleted file mode 100644 index 2088305..0000000 --- a/spec/neojj_integration_spec.lua +++ /dev/null @@ -1,173 +0,0 @@ -require('spec.helpers') - -vim.g.diffs = { integrations = { neojj = true } } - -local diffs = require('diffs') -local parser = require('diffs.parser') - -local function create_buffer(lines) - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) - return bufnr -end - -local function delete_buffer(bufnr) - if vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - end -end - -describe('neojj_integration', function() - describe('neojj_disable_hunk_highlight', function() - it('sets neojj_disable_hunk_highlight on NeojjStatus buffer after attach', function() - local bufnr = create_buffer({ - 'modified test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - vim.api.nvim_set_option_value('filetype', 'NeojjStatus', { buf = bufnr }) - diffs.attach(bufnr) - - assert.is_true(vim.b[bufnr].neojj_disable_hunk_highlight) - - delete_buffer(bufnr) - end) - - it('does not set neojj_disable_hunk_highlight on non-Neojj buffer', function() - local bufnr = create_buffer({}) - vim.api.nvim_set_option_value('filetype', 'git', { buf = bufnr }) - diffs.attach(bufnr) - - assert.is_not_true(vim.b[bufnr].neojj_disable_hunk_highlight) - - delete_buffer(bufnr) - end) - end) - - describe('NeojjStatus buffer attach', function() - it('populates hunk_cache for NeojjStatus buffer with diff content', function() - local bufnr = create_buffer({ - 'modified hello.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local x = 1', - ' return M', - }) - vim.api.nvim_set_option_value('filetype', 'NeojjStatus', { buf = bufnr }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_not_nil(entry) - assert.is_table(entry.hunks) - assert.are.equal(1, #entry.hunks) - assert.are.equal('hello.lua', entry.hunks[1].filename) - delete_buffer(bufnr) - end) - - it('populates hunk_cache for NeojjDiffView buffer', function() - local bufnr = create_buffer({ - 'new file newmod.lua', - '@@ -0,0 +1,2 @@', - '+local M = {}', - '+return M', - }) - vim.api.nvim_set_option_value('filetype', 'NeojjDiffView', { buf = bufnr }) - diffs.attach(bufnr) - local entry = diffs._test.hunk_cache[bufnr] - assert.is_not_nil(entry) - assert.is_table(entry.hunks) - assert.are.equal(1, #entry.hunks) - delete_buffer(bufnr) - end) - end) - - describe('parser neojj patterns', function() - it('detects added prefix via parser', function() - local bufnr = create_buffer({ - 'added utils.py', - '@@ -0,0 +1,2 @@', - '+def hello():', - '+ pass', - }) - local hunks = parser.parse_buffer(bufnr) - assert.are.equal(1, #hunks) - assert.are.equal('utils.py', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('detects updated prefix via parser', function() - local bufnr = create_buffer({ - 'updated config.toml', - '@@ -1,2 +1,3 @@', - ' [section]', - '+key = "val"', - ' other = 1', - }) - local hunks = parser.parse_buffer(bufnr) - assert.are.equal(1, #hunks) - assert.are.equal('config.toml', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('detects changed prefix via parser', function() - local bufnr = create_buffer({ - 'changed main.rs', - '@@ -1,1 +1,2 @@', - ' fn main() {}', - '+fn helper() {}', - }) - local hunks = parser.parse_buffer(bufnr) - assert.are.equal(1, #hunks) - assert.are.equal('main.rs', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('detects unmerged prefix via parser', function() - local bufnr = create_buffer({ - 'unmerged conflict.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - local hunks = parser.parse_buffer(bufnr) - assert.are.equal(1, #hunks) - assert.are.equal('conflict.lua', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('parses multi-file neojj buffer with modified and added', function() - local bufnr = create_buffer({ - 'modified test.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local x = 1', - ' return M', - 'added utils.py', - '@@ -0,0 +1,2 @@', - '+def hello():', - '+ pass', - }) - local hunks = parser.parse_buffer(bufnr) - assert.are.equal(2, #hunks) - assert.are.equal('test.lua', hunks[1].filename) - assert.are.equal('utils.py', hunks[2].filename) - delete_buffer(bufnr) - end) - end) - - describe('compute_filetypes', function() - it('includes Neojj filetypes when neojj integration is enabled', function() - local fts = diffs.compute_filetypes({ integrations = { neojj = true } }) - assert.is_true(vim.tbl_contains(fts, 'NeojjStatus')) - assert.is_true(vim.tbl_contains(fts, 'NeojjCommitView')) - assert.is_true(vim.tbl_contains(fts, 'NeojjDiffView')) - end) - - it('excludes Neojj filetypes when neojj integration is disabled', function() - local fts = diffs.compute_filetypes({ integrations = { neojj = false } }) - assert.is_false(vim.tbl_contains(fts, 'NeojjStatus')) - assert.is_false(vim.tbl_contains(fts, 'NeojjCommitView')) - assert.is_false(vim.tbl_contains(fts, 'NeojjDiffView')) - end) - end) -end) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index adbbd37..11ac3be 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -163,10 +163,10 @@ describe('parser', function() end end) - it('stops hunk at blank line when remaining counts exhausted', function() + it('stops hunk at blank line', function() local bufnr = create_buffer({ 'M test.lua', - '@@ -1,1 +1,2 @@', + '@@ -1,2 +1,3 @@', ' local x = 1', '+local y = 2', '', @@ -391,27 +391,35 @@ describe('parser', function() vim.fn.delete(repo_root, 'rf') end) - it('detects filetype for .sh files when did_filetype() is non-zero', function() - rawset(vim.fn, 'did_filetype', function() - return 1 - end) + it('detects python from shebang without open buffer', function() + local repo_root = '/tmp/diffs-test-shebang-py' + vim.fn.mkdir(repo_root, 'p') - parser._test.ft_lang_cache = {} - local bufnr = create_buffer({ - 'diff --git a/test.sh b/test.sh', - '@@ -1,3 +1,4 @@', - ' #!/usr/bin/env bash', - ' set -euo pipefail', - '-echo "running tests..."', - '+echo "running tests with coverage..."', + local file_path = repo_root .. '/deploy' + local f = io.open(file_path, 'w') + f:write('#!/usr/bin/env python3\n') + f:write('import sys\n') + f:write('print("hi")\n') + f:close() + + local diff_buf = create_buffer({ + 'M deploy', + '@@ -1,2 +1,3 @@', + ' #!/usr/bin/env python3', + '+import sys', + ' print("hi")', }) - local hunks = parser.parse_buffer(bufnr) + vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) + + local hunks = parser.parse_buffer(diff_buf) assert.are.equal(1, #hunks) - assert.are.equal('test.sh', hunks[1].filename) - assert.are.equal('sh', hunks[1].ft) - delete_buffer(bufnr) - rawset(vim.fn, 'did_filetype', nil) + assert.are.equal('deploy', hunks[1].filename) + assert.are.equal('python', hunks[1].ft) + + delete_buffer(diff_buf) + os.remove(file_path) + vim.fn.delete(repo_root, 'rf') end) it('extracts file line numbers from @@ header', function() @@ -432,6 +440,22 @@ describe('parser', function() delete_buffer(bufnr) end) + it('extracts large line numbers from @@ header', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -100,20 +200,30 @@', + ' local M = {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(100, hunks[1].file_old_start) + assert.are.equal(20, hunks[1].file_old_count) + assert.are.equal(200, hunks[1].file_new_start) + assert.are.equal(30, hunks[1].file_new_count) + delete_buffer(bufnr) + end) + it('defaults count to 1 when omitted in @@ header', function() local bufnr = create_buffer({ 'M lua/test.lua', @@ -448,154 +472,6 @@ describe('parser', function() delete_buffer(bufnr) end) - it('recognizes U prefix for unmerged files', function() - local bufnr = create_buffer({ - 'U merge_me.lua', - '@@@ -1,3 -1,5 +1,9 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '++=======', - '+ return 2', - '++>>>>>>> feature', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('merge_me.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - delete_buffer(bufnr) - end) - - it('sets prefix_width 2 from @@@ combined diff header', function() - local bufnr = create_buffer({ - 'U test.lua', - '@@@ -1,3 -1,5 +1,9 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(2, hunks[1].prefix_width) - delete_buffer(bufnr) - end) - - it('sets prefix_width 1 for standard @@ unified diff', function() - local bufnr = create_buffer({ - 'M test.lua', - '@@ -1,2 +1,3 @@', - ' local x = 1', - '+local y = 2', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(1, hunks[1].prefix_width) - delete_buffer(bufnr) - end) - - it('collects all combined diff line types as hunk content', function() - local bufnr = create_buffer({ - 'U test.lua', - '@@@ -1,3 -1,3 +1,5 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - ' + return 1', - '+ local x = 2', - ' end', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(5, #hunks[1].lines) - assert.are.equal(' local M = {}', hunks[1].lines[1]) - assert.are.equal('++<<<<<<< HEAD', hunks[1].lines[2]) - assert.are.equal(' + return 1', hunks[1].lines[3]) - assert.are.equal('+ local x = 2', hunks[1].lines[4]) - assert.are.equal(' end', hunks[1].lines[5]) - delete_buffer(bufnr) - end) - - it('extracts new range from combined diff header', function() - local bufnr = create_buffer({ - 'U test.lua', - '@@@ -1,3 -1,5 +1,9 @@@', - ' local M = {}', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal(1, hunks[1].file_new_start) - assert.are.equal(9, hunks[1].file_new_count) - assert.are.equal(1, hunks[1].file_old_start) - assert.are.equal(3, hunks[1].file_old_count) - delete_buffer(bufnr) - end) - - it('extracts header context from combined diff header', function() - local bufnr = create_buffer({ - 'U test.lua', - '@@@ -1,3 -1,5 +1,9 @@@ function M.greet()', - ' local M = {}', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('function M.greet()', hunks[1].header_context) - delete_buffer(bufnr) - end) - - it('resets prefix_width when switching from combined to unified diff', function() - local bufnr = create_buffer({ - 'U merge.lua', - '@@@ -1,1 -1,1 +1,3 @@@', - ' local M = {}', - '++<<<<<<< HEAD', - 'M other.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(2, #hunks) - assert.are.equal(2, hunks[1].prefix_width) - assert.are.equal(1, hunks[2].prefix_width) - delete_buffer(bufnr) - end) - - it('parses diff from gitcommit verbose buffer', function() - local bufnr = create_buffer({ - '', - '# Please enter the commit message for your changes.', - '#', - '# On branch main', - '# Changes to be committed:', - '#\tmodified: test.lua', - '#', - '# ------------------------ >8 ------------------------', - '# Do not modify or remove the line above.', - 'diff --git a/test.lua b/test.lua', - 'index abc1234..def5678 100644', - '--- a/test.lua', - '+++ b/test.lua', - '@@ -1,3 +1,3 @@', - ' local function hello()', - '- print("hello world")', - '+ print("hello universe")', - ' return true', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('test.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - assert.are.equal(4, #hunks[1].lines) - delete_buffer(bufnr) - end) - it('stores repo_root on hunk when available', function() local bufnr = create_buffer({ 'M lua/test.lua', @@ -612,388 +488,16 @@ describe('parser', function() delete_buffer(bufnr) end) - it('detects neogit modified prefix', function() + it('repo_root is nil when not available', function() local bufnr = create_buffer({ - 'modified hello.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local x = 1', - ' return M', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('hello.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - assert.are.equal(3, #hunks[1].lines) - delete_buffer(bufnr) - end) - - it('detects neogit new file prefix', function() - local bufnr = create_buffer({ - 'new file hello.lua', - '@@ -0,0 +1,2 @@', - '+local M = {}', - '+return M', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('hello.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - assert.are.equal(2, #hunks[1].lines) - delete_buffer(bufnr) - end) - - it('detects neogit deleted prefix', function() - local bufnr = create_buffer({ - 'deleted hello.lua', - '@@ -1,2 +0,0 @@', - '-local M = {}', - '-return M', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('hello.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - assert.are.equal(2, #hunks[1].lines) - delete_buffer(bufnr) - end) - - it('detects neogit renamed prefix', function() - local bufnr = create_buffer({ - 'renamed old.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local x = 1', - ' return M', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('old.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - delete_buffer(bufnr) - end) - - it('detects neogit copied prefix', function() - local bufnr = create_buffer({ - 'copied orig.lua', - '@@ -1,2 +1,3 @@', - ' local M = {}', - '+local x = 1', - ' return M', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('orig.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - delete_buffer(bufnr) - end) - - it('does not treat "new file mode" as a filename', function() - local bufnr = create_buffer({ - 'diff --git a/src/new.lua b/src/new.lua', - 'new file mode 100644', - 'index 0000000..abc1234', - '--- /dev/null', - '+++ b/src/new.lua', - '@@ -0,0 +1,2 @@', - '+local M = {}', - '+return M', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('src/new.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - delete_buffer(bufnr) - end) - - it('does not treat "new file mode 100755" as a filename', function() - local bufnr = create_buffer({ - 'diff --git a/bin/run b/bin/run', - 'new file mode 100755', - 'index 0000000..abc1234', - '--- /dev/null', - '+++ b/bin/run', - '@@ -0,0 +1,2 @@', - '+#!/bin/bash', - '+echo hello', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('bin/run', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('does not treat "deleted file mode" as a filename', function() - local bufnr = create_buffer({ - 'diff --git a/src/old.lua b/src/old.lua', - 'deleted file mode 100644', - 'index abc1234..0000000', - '--- a/src/old.lua', - '+++ /dev/null', - '@@ -1,2 +0,0 @@', - '-local M = {}', - '-return M', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('src/old.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - delete_buffer(bufnr) - end) - - it('does not treat "deleted file mode 100755" as a filename', function() - local bufnr = create_buffer({ - 'diff --git a/bin/old b/bin/old', - 'deleted file mode 100755', - 'index abc1234..0000000', - '--- a/bin/old', - '+++ /dev/null', - '@@ -1,1 +0,0 @@', - '-#!/bin/bash', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('bin/old', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('does not treat "old mode" or "new mode" as filenames', function() - local bufnr = create_buffer({ - 'diff --git a/script.sh b/script.sh', - 'old mode 100644', - 'new mode 100755', - '@@ -1,1 +1,2 @@', - ' echo hello', - '+echo world', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('script.sh', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('does not treat "rename from/to" as filenames', function() - local bufnr = create_buffer({ - 'diff --git a/old.lua b/new.lua', - 'similarity index 95%', - 'rename from old.lua', - 'rename to new.lua', - '@@ -1,2 +1,2 @@', - ' local M = {}', - '-local x = 1', - '+local x = 2', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('new.lua', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('does not treat "copy from/to" as filenames', function() - local bufnr = create_buffer({ - 'diff --git a/orig.lua b/copy.lua', - 'similarity index 100%', - 'copy from orig.lua', - 'copy to copy.lua', - '@@ -1,1 +1,1 @@', + 'M lua/test.lua', + '@@ -1,3 +1,4 @@', ' local M = {}', }) local hunks = parser.parse_buffer(bufnr) assert.are.equal(1, #hunks) - assert.are.equal('copy.lua', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('does not treat "similarity index" or "dissimilarity index" as filenames', function() - local bufnr = create_buffer({ - 'diff --git a/foo.lua b/bar.lua', - 'similarity index 85%', - 'rename from foo.lua', - 'rename to bar.lua', - '@@ -1,2 +1,2 @@', - ' local M = {}', - '-return 1', - '+return 2', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('bar.lua', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('does not treat "index" line as a filename', function() - local bufnr = create_buffer({ - 'diff --git a/test.lua b/test.lua', - 'index abc1234..def5678 100644', - '--- a/test.lua', - '+++ b/test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('test.lua', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('neogit new file with diff containing new file mode metadata', function() - local bufnr = create_buffer({ - 'new file src/foo.lua', - 'diff --git a/src/foo.lua b/src/foo.lua', - 'new file mode 100644', - 'index 0000000..abc1234', - '--- /dev/null', - '+++ b/src/foo.lua', - '@@ -0,0 +1,3 @@', - '+local M = {}', - '+M.x = 1', - '+return M', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('src/foo.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - assert.are.equal(3, #hunks[1].lines) - delete_buffer(bufnr) - end) - - it('neogit deleted with diff containing deleted file mode metadata', function() - local bufnr = create_buffer({ - 'deleted src/old.lua', - 'diff --git a/src/old.lua b/src/old.lua', - 'deleted file mode 100644', - 'index abc1234..0000000', - '--- a/src/old.lua', - '+++ /dev/null', - '@@ -1,2 +0,0 @@', - '-local M = {}', - '-return M', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('src/old.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - assert.are.equal(2, #hunks[1].lines) - delete_buffer(bufnr) - end) - - it('multiple new files with mode metadata do not corrupt filenames', function() - local bufnr = create_buffer({ - 'diff --git a/a.lua b/a.lua', - 'new file mode 100644', - 'index 0000000..abc1234', - '--- /dev/null', - '+++ b/a.lua', - '@@ -0,0 +1,1 @@', - '+local a = 1', - 'diff --git a/b.lua b/b.lua', - 'new file mode 100644', - 'index 0000000..def5678', - '--- /dev/null', - '+++ b/b.lua', - '@@ -0,0 +1,1 @@', - '+local b = 2', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(2, #hunks) - assert.are.equal('a.lua', hunks[1].filename) - assert.are.equal('b.lua', hunks[2].filename) - delete_buffer(bufnr) - end) - - it('fugitive status with new and deleted files containing mode metadata', function() - local bufnr = create_buffer({ - 'Head: main', - '', - 'Staged (2)', - 'A src/new.lua', - 'diff --git a/src/new.lua b/src/new.lua', - 'new file mode 100644', - 'index 0000000..abc1234', - '--- /dev/null', - '+++ b/src/new.lua', - '@@ -0,0 +1,2 @@', - '+local M = {}', - '+return M', - 'D src/old.lua', - 'diff --git a/src/old.lua b/src/old.lua', - 'deleted file mode 100644', - 'index abc1234..0000000', - '--- a/src/old.lua', - '+++ /dev/null', - '@@ -1,1 +0,0 @@', - '-local x = 1', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(2, #hunks) - assert.are.equal('src/new.lua', hunks[1].filename) - assert.are.equal('lua', hunks[1].ft) - assert.are.equal('src/old.lua', hunks[2].filename) - assert.are.equal('lua', hunks[2].ft) - delete_buffer(bufnr) - end) - - it('neogit new file with deep nested path', function() - local bufnr = create_buffer({ - 'new file src/deep/nested/path/module.lua', - '@@ -0,0 +1,1 @@', - '+return {}', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('src/deep/nested/path/module.lua', hunks[1].filename) - delete_buffer(bufnr) - end) - - it('detects bare filename for untracked files', function() - local bufnr = create_buffer({ - 'newfile.rs', - '@@ -0,0 +1,3 @@', - '+fn main() {', - '+ println!("hello");', - '+}', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('newfile.rs', hunks[1].filename) - assert.are.equal(3, #hunks[1].lines) - delete_buffer(bufnr) - end) - - it('does not match section headers as bare filenames', function() - local bufnr = create_buffer({ - 'Untracked files (1)', - 'newfile.rs', - '@@ -0,0 +1,3 @@', - '+fn main() {', - '+ println!("hello");', - '+}', - }) - local hunks = parser.parse_buffer(bufnr) - - assert.are.equal(1, #hunks) - assert.are.equal('newfile.rs', hunks[1].filename) + assert.is_nil(hunks[1].repo_root) delete_buffer(bufnr) end) end) diff --git a/vim.toml b/vim.toml new file mode 100644 index 0000000..3a84ac2 --- /dev/null +++ b/vim.toml @@ -0,0 +1,33 @@ +[selene] +base = "lua51" +name = "vim" + +[vim] +any = true + +[jit] +any = true + +[bit] +any = true + +[assert] +any = true + +[describe] +any = true + +[it] +any = true + +[before_each] +any = true + +[after_each] +any = true + +[spy] +any = true + +[stub] +any = true diff --git a/vim.yaml b/vim.yaml deleted file mode 100644 index 3821d25..0000000 --- a/vim.yaml +++ /dev/null @@ -1,26 +0,0 @@ ---- -base: lua51 -name: vim -lua_versions: - - luajit -globals: - vim: - any: true - jit: - any: true - assert: - any: true - describe: - any: true - it: - any: true - before_each: - any: true - after_each: - any: true - spy: - any: true - stub: - any: true - bit: - any: true