diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yaml b/.github/DISCUSSION_TEMPLATE/q-a.yaml new file mode 100644 index 0000000..7e81117 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/q-a.yaml @@ -0,0 +1,17 @@ +title: 'Q&A' +labels: [] +body: + - type: markdown + attributes: + value: | + Use this space for questions, ideas, and general discussion about diffs.nvim. + For bug reports, please [open an issue](https://github.com/barrettruth/diffs.nvim/issues/new/choose) instead. + - type: textarea + attributes: + label: Question or topic + validations: + required: true + - type: textarea + attributes: + label: Context + description: Any relevant details (Neovim version, config, screenshots) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yaml similarity index 77% rename from .github/ISSUE_TEMPLATE/bug_report.yml rename to .github/ISSUE_TEMPLATE/bug_report.yaml index 371051d..001e042 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,29 +1,31 @@ name: Bug Report description: Report a bug -title: "bug: " +title: 'bug: ' labels: [bug] body: - type: checkboxes attributes: label: Prerequisites options: - - label: I have searched [existing issues](https://github.com/barrettruth/fugitive-ts.nvim/issues) + - label: + I have searched [existing + issues](https://github.com/barrettruth/diffs.nvim/issues) required: true - label: I have updated to the latest version required: true - type: textarea attributes: - label: "Neovim version" - description: "Output of `nvim --version`" + label: 'Neovim version' + description: 'Output of `nvim --version`' render: text validations: required: true - type: input attributes: - label: "Operating system" - placeholder: "e.g. Arch Linux, macOS 15, Ubuntu 24.04" + label: 'Operating system' + placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04' validations: required: true @@ -47,8 +49,8 @@ body: - type: textarea attributes: - label: "Health check" - description: "Output of `:checkhealth fugitive-ts`" + label: 'Health check' + description: 'Output of `:checkhealth diffs`' render: text - type: textarea @@ -68,7 +70,7 @@ body: spec = { 'tpope/vim-fugitive', { - 'barrettruth/fugitive-ts.nvim', + 'barrettruth/diffs.nvim', opts = {}, }, }, diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yaml similarity index 60% rename from .github/ISSUE_TEMPLATE/config.yml rename to .github/ISSUE_TEMPLATE/config.yaml index 017dbad..e511c0e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -1,5 +1,5 @@ blank_issues_enabled: false contact_links: - name: Questions - url: https://github.com/barrettruth/fugitive-ts.nvim/discussions + url: https://github.com/barrettruth/diffs.nvim/discussions about: Ask questions and discuss ideas diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yaml similarity index 78% rename from .github/ISSUE_TEMPLATE/feature_request.yml rename to .github/ISSUE_TEMPLATE/feature_request.yaml index e85ddba..a66bc36 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,13 +1,15 @@ name: Feature Request description: Suggest a feature -title: "feat: " +title: 'feat: ' labels: [enhancement] body: - type: checkboxes attributes: label: Prerequisites options: - - label: I have searched [existing issues](https://github.com/barrettruth/fugitive-ts.nvim/issues) + - label: + I have searched [existing + issues](https://github.com/barrettruth/diffs.nvim/issues) required: true - type: textarea diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index 54c4e82..77049fd 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -1,6 +1,7 @@ name: quality on: + workflow_call: pull_request: branches: [main] push: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6910389..c850e2d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -20,3 +20,9 @@ jobs: - uses: nvim-neorocks/nvim-busted-action@v1 with: nvim_version: ${{ matrix.nvim }} + before: | + git clone --depth 1 https://github.com/the-mikedavis/tree-sitter-diff /tmp/ts-diff + cd /tmp/ts-diff && cc -shared -fPIC -o diff.so -I./src src/parser.c + mkdir -p ~/.local/share/nvim/site/parser ~/.local/share/nvim/site/queries/diff + cp diff.so ~/.local/share/nvim/site/parser/ + cp queries/*.scm ~/.local/share/nvim/site/queries/diff/ diff --git a/.luarc.json b/.luarc.json index 3ccfeda..b438cce 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,7 +1,7 @@ { "runtime.version": "Lua 5.1", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], - "diagnostics.globals": ["vim"], + "diagnostics.globals": ["vim", "jit"], "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], "workspace.checkThirdParty": false, "completion.callSnippet": "Replace" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2edf043..5d1f13f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,4 +14,4 @@ repos: hooks: - id: prettier name: prettier - files: \.(md|toml|yaml|sh)$ + files: \.(md|toml|yaml|yml|sh)$ diff --git a/.prettierrc b/.prettierrc index 039a474..0663621 100644 --- a/.prettierrc +++ b/.prettierrc @@ -5,13 +5,5 @@ "useTabs": false, "trailingComma": "none", "semi": false, - "singleQuote": true, - "overrides": [ - { - "files": ["**/*.md"], - "options": { - "parser": "markdown" - } - } - ] + "singleQuote": true } diff --git a/README.md b/README.md index 5d08a83..f0a04a8 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,80 @@ -# fugitive-ts.nvim +# diffs.nvim -**Treesitter syntax highlighting for vim-fugitive** +**Syntax highlighting for diffs in Neovim** -Enhance the great `vim-fugitive` with syntax-aware code to easily work with -diffs. +Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware +syntax highlighting. -![fugitive-ts.nvim preview](https://github.com/user-attachments/assets/fc849310-09c8-4282-8a92-a2edaf8fe2b4) +![diffs.nvim preview](https://github.com/user-attachments/assets/d3d64c96-b824-4fcb-af7f-4aef3f7f498a) ## Features - Treesitter syntax highlighting in `:Git` diffs and commit views +- Diff header highlighting (`diff --git`, `index`, `---`, `+++`) - `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds +- `:Gdiff` unified diff against any git revision with syntax highlighting +- Fugitive status buffer keymaps (`du`/`dU`) for unified diffs +- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`) - Vim syntax fallback for languages without a treesitter parser - Hunk header context highlighting (`@@ ... @@ function foo()`) -- Configurable debouncing, max lines, and diff prefix concealment +- 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 - Neovim 0.9.0+ -- [vim-fugitive](https://github.com/tpope/vim-fugitive) ## Installation -Using [lazy.nvim](https://github.com/folke/lazy.nvim): +Install with your package manager of choice or via +[luarocks](https://luarocks.org/modules/barrettruth/diffs.nvim): -```lua -{ - 'barrettruth/fugitive-ts.nvim', - dependencies = { 'tpope/vim-fugitive' }, - opts = {}, -} +``` +luarocks install diffs.nvim ``` ## Documentation ```vim -:help fugitive-ts.nvim +:help diffs.nvim ``` ## Known Limitations -- Syntax "flashing": `fugitive-ts.nvim` hooks into the `FileType fugitive` event - triggered by `vim-fugitive`, at which point the `fugitive` buffer is - preliminarily painted. The buffer is then re-painted after `debounce_ms` - milliseconds, causing an unavoidable visual "flash" even when - `debounce_ms = 0`. Feel free to reach out if you know how to fix this! +- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation. + To improve accuracy, `diffs.nvim` reads lines from disk before and after each + hunk for parsing context (`highlights.context`, enabled by default with 25 + lines). This resolves most boundary issues. Set + `highlights.context.enabled = false` to disable. -## Acknowledgements +- **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event + triggered by `vim-fugitive`, at which point the buffer is preliminarily + painted. The buffer is then re-painted after `debounce_ms` milliseconds, + causing an unavoidable visual "flash" even when `debounce_ms = 0`. -- [vim-fugitive](https://github.com/tpope/vim-fugitive) -- [codediff.nvim](https://github.com/esmuellert/codediff.nvim) -- [diffview.nvim](https://github.com/sindrets/diffview.nvim) -- [resolve.nvim](https://github.com/spacedentist/resolve.nvim) +- **Conflicting diff plugins**: `diffs.nvim` may not interact well with other + plugins that modify diff highlighting. Known plugins that may conflict: + - [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) - provides its + own diff highlighting and conflict resolution UI + - [`mini.diff`](https://github.com/echasnovski/mini.diff) - visualizes buffer + differences with its own highlighting system + - [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim) - generally + compatible, but both plugins modifying line highlights may produce + unexpected results + - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - + `diffs.nvim` now includes built-in conflict resolution; disable one or the + other to avoid overlap + +# Acknowledgements + +- [`vim-fugitive`](https://github.com/tpope/vim-fugitive) +- [`codediff.nvim`](https://github.com/esmuellert/codediff.nvim) +- [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) +- [`difftastic`](https://github.com/Wilfred/difftastic) +- [`mini.diff`](https://github.com/echasnovski/mini.diff) +- [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim) +- [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) +- [@phanen](https://github.com/phanen) - diff header highlighting, unknown + filetype fix, shebang/modeline detection, treesitter injection support diff --git a/fugitive-ts.nvim-scm-1.rockspec b/diffs.nvim-scm-1.rockspec similarity index 52% rename from fugitive-ts.nvim-scm-1.rockspec rename to diffs.nvim-scm-1.rockspec index 16658be..b673e59 100644 --- a/fugitive-ts.nvim-scm-1.rockspec +++ b/diffs.nvim-scm-1.rockspec @@ -1,14 +1,14 @@ rockspec_format = '3.0' -package = 'fugitive-ts.nvim' +package = 'diffs.nvim' version = 'scm-1' source = { - url = 'git+https://github.com/barrettruth/fugitive-ts.nvim.git', + url = 'git+https://github.com/barrettruth/diffs.nvim.git', } description = { - summary = 'Treesitter syntax highlighting for vim-fugitive', - homepage = 'https://github.com/barrettruth/fugitive-ts.nvim', + summary = 'Syntax highlighting for diffs in Neovim', + homepage = 'https://github.com/barrettruth/diffs.nvim', license = 'MIT', } diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt new file mode 100644 index 0000000..7663150 --- /dev/null +++ b/doc/diffs.nvim.txt @@ -0,0 +1,693 @@ +*diffs.nvim.txt* Syntax highlighting for diffs in Neovim + +Author: Barrett Ruth +License: MIT + +============================================================================== +INTRODUCTION *diffs.nvim* + +diffs.nvim adds syntax highlighting to diff views. It overlays language-aware +highlights on top of default diff highlighting in vim-fugitive and Neovim's +built-in diff mode. + +Features: ~ +- Syntax highlighting in |:Git| summary diffs and commit detail views +- Diff header highlighting (`diff --git`, `index`, `---`, `+++`) +- Syntax highlighting in |:Gdiffsplit| / |:Gvdiffsplit| side-by-side diffs +- |:Gdiff| command for unified diff against any git revision +- Background-only diff colors for any `&diff` buffer (vimdiff, diffthis, etc.) +- Vim syntax fallback for languages without a treesitter parser +- Blended diff background colors that preserve syntax visibility +- Optional diff prefix (`+`/`-`/` `) concealment +- Gutter (line number) highlighting +- Inline merge conflict marker detection, highlighting, and resolution + +============================================================================== +REQUIREMENTS *diffs-requirements* + +- Neovim 0.9.0+ +- vim-fugitive (https://github.com/tpope/vim-fugitive) (optional, for unified + diff syntax highlighting in |:Git| and commit views) +- Treesitter parsers for languages you want highlighted + +Note: The diff mode feature (background-only colors for |:diffthis|, vimdiff, +etc.) works without vim-fugitive. + +============================================================================== +SETUP *diffs-setup* + +Using lazy.nvim: >lua + { + 'barrettruth/diffs.nvim', + dependencies = { 'tpope/vim-fugitive' }, + } +< +The plugin works automatically with no configuration required. For +customization, see |diffs-config|. + +============================================================================== +CONFIGURATION *diffs-config* + +Configuration is done via `vim.g.diffs`. Set this before the plugin loads: +>lua + vim.g.diffs = { + debug = false, + debounce_ms = 0, + hide_prefix = false, + highlights = { + background = true, + gutter = true, + blend_alpha = 0.6, + context = { + enabled = true, + lines = 25, + }, + treesitter = { + enabled = true, + max_lines = 500, + }, + vim = { + enabled = false, + max_lines = 200, + }, + intra = { + enabled = true, + algorithm = 'default', + max_lines = 500, + }, + overrides = {}, + }, + fugitive = { + horizontal = 'du', + vertical = 'dU', + }, + conflict = { + enabled = true, + disable_diagnostics = true, + show_virtual_text = true, + keymaps = { + ours = 'doo', + theirs = 'dot', + both = 'dob', + none = 'don', + next = ']x', + prev = '[x', + }, + }, + } +< + *diffs.Config* + Fields: ~ + {debug} (boolean, default: false) + Enable debug logging to |:messages| with + `[diffs]` prefix. + + {debounce_ms} (integer, default: 0) + Debounce delay in milliseconds for re-highlighting + after buffer changes. Lower values feel snappier + but use more CPU. + + {hide_prefix} (boolean, default: false) + Hide diff prefixes (`+`/`-`/` `) using virtual + text overlay. Makes code appear without the + leading diff character. When `highlights.background` + is also enabled, the overlay inherits the line's + background color. + + {highlights} (table, default: see below) + Controls which highlight features are enabled. + See |diffs.Highlights| for fields. + + {fugitive} (table, default: see below) + Fugitive status buffer keymap options. + See |diffs.FugitiveConfig| for fields. + + {conflict} (table, default: see below) + Inline merge conflict resolution options. + See |diffs.ConflictConfig| for fields. + + *diffs.Highlights* + Highlights table fields: ~ + {background} (boolean, default: true) + Apply background highlighting to `+`/`-` lines + using `DiffsAdd`/`DiffsDelete` groups (derived + from `DiffAdd`/`DiffDelete` backgrounds). + + {gutter} (boolean, default: true) + Highlight line numbers with matching colors. + Only visible if line numbers are enabled. + + {blend_alpha} (number, default: 0.6) + Alpha value for character-level blend intensity. + Controls how strongly changed characters stand + out from the line-level background. Must be + between 0 and 1 (inclusive). Higher values + produce more vivid character-level highlights. + + {context} (table, default: see below) + Syntax parsing context options. + See |diffs.ContextConfig| for fields. + + {treesitter} (table, default: see below) + Treesitter highlighting options. + See |diffs.TreesitterConfig| for fields. + + {vim} (table, default: see below) + Vim syntax highlighting options (experimental). + See |diffs.VimConfig| for fields. + + {intra} (table, default: see below) + Character-level (intra-line) diff highlighting. + See |diffs.IntraConfig| for fields. + + {overrides} (table, default: {}) + Map of highlight group names to highlight + definitions (see |nvim_set_hl()|). Applied + after all computed groups without `default`, + so overrides always win over both computed + defaults and colorscheme definitions. + + *diffs.ContextConfig* + Context config fields: ~ + {enabled} (boolean, default: true) + Read lines from disk before and after each hunk + to provide surrounding syntax context. Improves + accuracy at hunk boundaries where incomplete + constructs (e.g., a function definition with no + body) would otherwise confuse the parser. + + {lines} (integer, default: 25) + Number of context lines to read in each + direction. Lines are read with early exit — + cost scales with this value, not file size. + + *diffs.TreesitterConfig* + Treesitter config fields: ~ + {enabled} (boolean, default: true) + Apply treesitter syntax highlighting to code. + + {max_lines} (integer, default: 500) + Skip treesitter highlighting for hunks larger than + this many lines. Prevents lag on massive diffs. + + *diffs.VimConfig* + Vim config fields: ~ + {enabled} (boolean, default: false) + Use vim syntax highlighting as fallback when no + treesitter parser is available for a language. + Creates a scratch buffer, sets the filetype, and + queries |synID()| per character to extract + highlight groups. Slower than treesitter but + covers languages without a TS parser installed. + + {max_lines} (integer, default: 200) + Skip vim syntax highlighting for hunks larger than + this many lines. Lower than the treesitter default + due to the per-character cost of |synID()|. + + *diffs.IntraConfig* + Intra config fields: ~ + {enabled} (boolean, default: true) + Enable character-level diff highlighting within + changed lines. When a line changes from `local x = 1` + to `local x = 2`, only the `1`/`2` characters get + an intense background overlay while the rest of the + line keeps the softer line-level background. + + {algorithm} (string, default: 'default') + Diff algorithm for character-level analysis. + `'default'`: use |vim.diff()| with settings + inherited from |'diffopt'| (`algorithm` and + `linematch`). `'vscode'`: use libvscodediff FFI + (falls back to default if not available). + + {max_lines} (integer, default: 500) + Skip character-level highlighting for hunks larger + than this many lines. + + Note: Header context (e.g., `@@ -10,3 +10,4 @@ func()`) is always + highlighted with treesitter when a parser is available. + + Language detection uses Neovim's built-in |vim.filetype.match()| and + |vim.treesitter.language.get_lang()|. To customize filetype detection + or register treesitter parsers for custom filetypes, use + |vim.filetype.add()| and |vim.treesitter.language.register()|. + +============================================================================== +COMMANDS *diffs-commands* + +:Gdiff [revision] *:Gdiff* + Open a unified diff of the current file against a git revision. Displays + in a horizontal split below the current window. + + The diff buffer shows `+`/`-` lines with full syntax highlighting for the + code language, plus diff header highlighting for `diff --git`, `---`, + `+++`, and `@@` lines. + + If a `diffs://` window already exists in the current tabpage, the new + diff replaces its buffer instead of creating another split. + + Parameters: ~ + {revision} (string, optional) Git revision to diff against. + Defaults to HEAD. + + Examples: >vim + :Gdiff " diff against HEAD + :Gdiff main " diff against main branch + :Gdiff HEAD~3 " diff against 3 commits ago + :Gdiff abc123 " diff against specific commit +< + +:Gvdiff [revision] *:Gvdiff* + Like |:Gdiff| but opens in a vertical split. + +:Ghdiff [revision] *:Ghdiff* + Like |:Gdiff| but explicitly opens in a horizontal split. + +============================================================================== +MAPPINGS *diffs-mappings* + + *(diffs-gdiff)* +(diffs-gdiff) Show unified diff against HEAD in a horizontal + split. Equivalent to |:Gdiff| with no arguments. + + *(diffs-gvdiff)* +(diffs-gvdiff) Show unified diff against HEAD in a vertical + split. Equivalent to |:Gvdiff| with no arguments. + +Example configuration: >lua + vim.keymap.set('n', 'gd', '(diffs-gdiff)') + vim.keymap.set('n', 'gD', '(diffs-gvdiff)') +< + + *(diffs-conflict-ours)* +(diffs-conflict-ours) + Accept current (ours) change. Replaces the + conflict block with ours content. + + *(diffs-conflict-theirs)* +(diffs-conflict-theirs) + Accept incoming (theirs) change. Replaces the + conflict block with theirs content. + + *(diffs-conflict-both)* +(diffs-conflict-both) + Accept both changes (ours then theirs). + + *(diffs-conflict-none)* +(diffs-conflict-none) + Reject both changes (delete entire block). + + *(diffs-conflict-next)* +(diffs-conflict-next) + Jump to next conflict marker. Wraps around. + + *(diffs-conflict-prev)* +(diffs-conflict-prev) + Jump to previous conflict marker. Wraps around. + +Example configuration: >lua + vim.keymap.set('n', 'co', '(diffs-conflict-ours)') + vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)') + vim.keymap.set('n', 'cb', '(diffs-conflict-both)') + vim.keymap.set('n', 'cn', '(diffs-conflict-none)') + vim.keymap.set('n', ']x', '(diffs-conflict-next)') + vim.keymap.set('n', '[x', '(diffs-conflict-prev)') +< + +Diff buffer mappings: ~ + *diffs-q* + q Close the diff window. Available in all `diffs://` + buffers created by |:Gdiff|, |:Gvdiff|, |:Ghdiff|, + or the fugitive status keymaps. + +============================================================================== +FUGITIVE STATUS KEYMAPS *diffs-fugitive* + +When inside a vim-fugitive |:Git| status buffer, diffs.nvim provides keymaps +to open unified diffs for files or entire sections. + +Keymaps: ~ + *diffs-du* *diffs-dU* + du Open unified diff in a horizontal split. + dU Open unified diff in a vertical split. + +These keymaps work on: +- File lines (e.g., `M src/foo.lua`) - opens diff for that file +- Section headers (e.g., `Staged (3)`) - opens diff for all files in section +- Hunk/context lines below a file - opens diff for the parent file + +Behavior by file status: ~ + + Status Section Base Current Result ~ + M Unstaged index working tree unstaged changes + M Staged HEAD index staged changes + A Staged (empty) index file as all-added + D Staged HEAD (empty) file as all-removed + R Staged HEAD:oldname index:newname content diff + ? Untracked (empty) working tree file as all-added + +On section headers, the keymap runs `git diff` (or `git diff --cached` for +staged) and displays all changes in that section as a single unified diff. +Untracked section headers show a warning since there is no meaningful diff. + +Configuration: ~ + *diffs.FugitiveConfig* +>lua + vim.g.diffs = { + fugitive = { + horizontal = 'du', -- keymap for horizontal split, false to disable + vertical = 'dU', -- keymap for vertical split, false to disable + }, + } +< + Fields: ~ + {horizontal} (string|false, default: 'du') + Keymap for unified diff in horizontal split. + Set to `false` to disable. + + {vertical} (string|false, default: 'dU') + Keymap for unified diff in vertical split. + Set to `false` to disable. + +============================================================================== +CONFLICT RESOLUTION *diffs-conflict* + +diffs.nvim detects inline merge conflict markers (`<<<<<<<`/`=======`/ +`>>>>>>>`) in working files and provides highlighting and resolution keymaps. +Both standard and diff3 (`|||||||`) formats are supported. + +Conflict regions are detected automatically on `BufReadPost` and re-scanned +on `TextChanged`. When all conflicts in a buffer are resolved, highlighting +is removed and diagnostics are re-enabled. + +Configuration: ~ + *diffs.ConflictConfig* +>lua + vim.g.diffs = { + conflict = { + enabled = true, + disable_diagnostics = true, + show_virtual_text = true, + keymaps = { + ours = 'doo', + theirs = 'dot', + both = 'dob', + none = 'don', + next = ']x', + prev = '[x', + }, + }, + } +< + Fields: ~ + {enabled} (boolean, default: true) + Enable conflict marker detection and + resolution. Set to `false` to disable + entirely. + + {disable_diagnostics} (boolean, default: true) + Suppress LSP diagnostics on buffers with + conflict markers. Markers produce syntax + errors that clutter the diagnostic list. + Diagnostics are re-enabled when all conflicts + are resolved. Set `false` to leave + diagnostics alone. + + {show_virtual_text} (boolean, default: true) + Show virtual text labels (" current" and + " incoming") at the end of `<<<<<<<` and + `>>>>>>>` marker lines. + + {keymaps} (table, default: see above) + Buffer-local keymaps for conflict resolution + and navigation. Each value accepts a string + (custom key) or `false` (disabled). + + *diffs.ConflictKeymaps* + Keymap fields: ~ + {ours} (string|false, default: 'doo') + Accept current (ours) change. + + {theirs} (string|false, default: 'dot') + Accept incoming (theirs) change. + + {both} (string|false, default: 'dob') + Accept both changes (ours then theirs). + + {none} (string|false, default: 'don') + Reject both changes (delete entire block). + + {next} (string|false, default: ']x') + Jump to next conflict marker. Wraps around. + + {prev} (string|false, default: '[x') + Jump to previous conflict marker. Wraps + around. + +User events: ~ + *DiffsConflictResolved* + DiffsConflictResolved Fired when the last conflict in a buffer is + resolved. Useful for triggering custom actions + (e.g., auto-staging the file). >lua + vim.api.nvim_create_autocmd('User', { + pattern = 'DiffsConflictResolved', + callback = function() + print('all conflicts resolved!') + end, + }) +< + +============================================================================== +API *diffs-api* + +attach({bufnr}) *diffs.attach()* + Manually attach highlighting to a buffer. Called automatically for + fugitive buffers via the `FileType fugitive` autocmd. + + Parameters: ~ + {bufnr} (integer, optional) Buffer number. Defaults to current buffer. + +refresh({bufnr}) *diffs.refresh()* + Manually refresh highlighting for a buffer. Useful after external changes + or for debugging. + + Parameters: ~ + {bufnr} (integer, optional) Buffer number. Defaults to current buffer. + +============================================================================== +IMPLEMENTATION *diffs-implementation* + +Summary / commit detail views: ~ +1. `FileType fugitive` or `FileType git` (for `fugitive://` buffers) + triggers |diffs.attach()| +2. The buffer is parsed to detect file headers (`M path/to/file`, + `diff --git a/... b/...`) and hunk headers (`@@ -10,3 +10,4 @@`) +3. For each hunk: + - Language is detected from the filename using |vim.filetype.match()| + - Diff prefixes (`+`/`-`/` `) are stripped from code lines + - Code is parsed with |vim.treesitter.get_string_parser()| + - If no treesitter parser and `vim.enabled`: vim syntax fallback via + scratch buffer and |synID()| + - `Normal` extmarks at priority 198 clear underlying diff foreground + - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 199 + - Syntax highlights are applied as extmarks at priority 200 + - Character-level diff extmarks (`DiffsAddText`/`DiffsDeleteText`) at + priority 201 overlay changed characters with an intense background + - Conceal extmarks hide diff prefixes when `hide_prefix` is enabled +4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events + +Diff mode views: ~ +1. `OptionSet diff` detects when any window enters diff mode +2. All `&diff` windows in the tabpage receive a window-local 'winhighlight' + override that remaps `DiffAdd`/`DiffDelete`/`DiffChange`/`DiffText` to + background-only variants, allowing existing treesitter highlighting to + show through the diff colors + +============================================================================== +KNOWN LIMITATIONS *diffs-limitations* + +Incomplete Syntax Context ~ + *diffs-syntax-context* +Treesitter parses each diff hunk in isolation. To provide surrounding code +context, diffs.nvim reads lines from disk before and after each hunk +(see |diffs.ContextConfig|, enabled by default). This resolves most boundary +issues where incomplete constructs (e.g., a function definition at the edge +of a hunk with no body) would confuse the parser. + +Set `highlights.context.enabled = false` to disable context padding. In rare +cases, context padding may not help if the relevant surrounding code is very +far from the hunk boundaries. + +Syntax Highlighting Flash ~ + *diffs-flash* +When opening a fugitive buffer, there is an unavoidable visual "flash" where +the buffer briefly shows fugitive's default diff highlighting before +diffs.nvim applies treesitter highlights. + +This occurs because diffs.nvim hooks into the `FileType fugitive` event, +which fires after vim-fugitive has already painted the buffer. Even with +`debounce_ms = 0`, the re-painting goes through Neovim's event loop. + +To minimize the flash, use a low debounce value: >lua + vim.g.diffs = { + debounce_ms = 0, + } +< + +Conflicting Diff Plugins ~ + *diffs-plugin-conflicts* +diffs.nvim may not interact well with other plugins that modify diff +highlighting or the sign column in diff views. Known plugins that may +conflict: + + - diffview.nvim (sindrets/diffview.nvim) + Provides its own diff highlighting and conflict resolution UI. + When using diffview.nvim for viewing diffs, you may want to disable + diffs.nvim's diff mode attachment or use one plugin exclusively. + + - mini.diff (echasnovski/mini.diff) + Visualizes buffer differences with its own highlighting system. + May override or conflict with diffs.nvim's background highlighting. + + - gitsigns.nvim (lewis6991/gitsigns.nvim) + Generally compatible for sign column decorations, but both plugins + modifying line highlights may produce unexpected results. + + - git-conflict.nvim (akinsho/git-conflict.nvim) + Provides conflict marker highlighting and resolution keymaps. + diffs.nvim now has built-in conflict resolution (see + |diffs-conflict|). Disable one or the other to avoid overlap. + +If you experience visual conflicts, try disabling the conflicting plugin's +diff-related features. + +============================================================================== +HIGHLIGHT GROUPS *diffs-highlights* + +diffs.nvim defines custom highlight groups. All groups use `default = true`, +so colorschemes can override them by defining the group before the plugin +loads. + +All derived groups are computed by alpha-blending a source color into the +`Normal` background. Line-level groups blend at 40% alpha for a subtle tint; +character-level groups blend at 60% for more contrast. Line-number groups +combine both: background from the line-level blend, foreground from the +character-level blend. + +Fugitive unified diff highlights: ~ + *DiffsAdd* + DiffsAdd Background for `+` lines. Derived by blending + `DiffAdd` background with `Normal` at 40% alpha. + + *DiffsDelete* + DiffsDelete Background for `-` lines. Derived by blending + `DiffDelete` background with `Normal` at 40% alpha. + + *DiffsAddNr* + DiffsAddNr Line number for `+` lines. Foreground from + `DiffsAddText`, background from `DiffsAdd`. + + *DiffsDeleteNr* + DiffsDeleteNr Line number for `-` lines. Foreground from + `DiffsDeleteText`, background from `DiffsDelete`. + + *DiffsAddText* + DiffsAddText Character-level background for changed characters + within `+` lines. Derived by blending `diffAdded` + foreground with `Normal` background at 60% alpha. + Only sets `bg`, so treesitter foreground colors show + through. + + *DiffsDeleteText* + DiffsDeleteText Character-level background for changed characters + within `-` lines. Derived by blending `diffRemoved` + foreground with `Normal` background at 60% alpha. + +Conflict highlights: ~ + *DiffsConflictOurs* + DiffsConflictOurs Background for "ours" (current) content lines. + Derived by blending `DiffAdd` background with + `Normal` at 40% alpha (green tint). + + *DiffsConflictTheirs* + DiffsConflictTheirs Background for "theirs" (incoming) content lines. + Derived by blending `DiffChange` background with + `Normal` at 40% alpha. + + *DiffsConflictBase* + DiffsConflictBase Background for base (ancestor) content lines in + diff3 conflicts. Derived by blending `DiffText` + background with `Normal` at 30% alpha (muted). + + *DiffsConflictMarker* + DiffsConflictMarker Dimmed foreground with bold for `<<<<<<<`, + `=======`, `>>>>>>>`, and `|||||||` marker lines. + + *DiffsConflictOursNr* + DiffsConflictOursNr Line number for "ours" content lines. Foreground + from higher-alpha blend, background from line-level + blend. + + *DiffsConflictTheirsNr* + DiffsConflictTheirsNr Line number for "theirs" content lines. + + *DiffsConflictBaseNr* + DiffsConflictBaseNr Line number for base content lines (diff3). + +Diff mode window highlights: ~ +These are used for |winhighlight| remapping in `&diff` windows. + + *DiffsDiffAdd* + DiffsDiffAdd Background-only. Derived from `DiffAdd` bg. + Treesitter provides foreground syntax highlighting. + + *DiffsDiffDelete* + DiffsDiffDelete Foreground and background from `DiffDelete`. + Used for filler lines (`/////`) which have no real + code content to highlight. + + *DiffsDiffChange* + DiffsDiffChange Background-only. Derived from `DiffChange` bg. + Treesitter provides foreground syntax highlighting. + + *DiffsDiffText* + DiffsDiffText Background-only. Derived from `DiffText` bg. + Treesitter provides foreground syntax highlighting. + +To customize these in your colorscheme: >lua + vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = '#2e4a3a' }) + vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { link = 'DiffDelete' }) +< +Or via `highlights.overrides` in config: >lua + vim.g.diffs = { + highlights = { + overrides = { + DiffsAdd = { bg = '#2e4a3a' }, + DiffsDiffDelete = { link = 'DiffDelete' }, + }, + }, + } +< + +============================================================================== +HEALTH CHECK *diffs-health* + +Run |:checkhealth| diffs to verify your setup. + +Checks performed: +- Neovim version >= 0.9.0 +- vim-fugitive is installed (optional) +- libvscode_diff shared library is available (optional) + +============================================================================== +ACKNOWLEDGEMENTS *diffs-acknowledgements* + +- vim-fugitive (https://github.com/tpope/vim-fugitive) +- codediff.nvim (https://github.com/esmuellert/codediff.nvim) +- diffview.nvim (https://github.com/sindrets/diffview.nvim) +- @phanen (https://github.com/phanen) - diff header highlighting, + treesitter injection support + +============================================================================== + vim:tw=78:ts=8:ft=help:norl: diff --git a/doc/fugitive-ts.nvim.txt b/doc/fugitive-ts.nvim.txt deleted file mode 100644 index d4c428a..0000000 --- a/doc/fugitive-ts.nvim.txt +++ /dev/null @@ -1,202 +0,0 @@ -*fugitive-ts.nvim.txt* Treesitter highlighting for vim-fugitive diffs - -Author: Barrett Ruth -License: MIT - -============================================================================== -INTRODUCTION *fugitive-ts.nvim* - -fugitive-ts.nvim adds treesitter-based syntax highlighting to vim-fugitive -diff views. It overlays language-aware highlights on top of fugitive's -default regex-based diff highlighting. - -Features: ~ -- Syntax highlighting in |:Git| summary diffs and commit detail views -- Syntax highlighting in |:Gdiffsplit| / |:Gvdiffsplit| side-by-side diffs -- 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 - -============================================================================== -REQUIREMENTS *fugitive-ts-requirements* - -- Neovim 0.9.0+ -- vim-fugitive (https://github.com/tpope/vim-fugitive) -- Treesitter parsers for languages you want highlighted - -============================================================================== -SETUP *fugitive-ts-setup* - -Using lazy.nvim: >lua - { - 'barrettruth/fugitive-ts.nvim', - dependencies = { 'tpope/vim-fugitive' }, - opts = {}, - } -< -The plugin works automatically with no configuration required. For -customization, see |fugitive-ts-config|. - -============================================================================== -CONFIGURATION *fugitive-ts-config* - - *fugitive-ts.Config* - Fields: ~ - {enabled} (boolean, default: true) - Enable or disable highlighting globally. - - {debug} (boolean, default: false) - Enable debug logging to |:messages| with - `[fugitive-ts]` prefix. - - {debounce_ms} (integer, default: 0) - Debounce delay in milliseconds for re-highlighting - after buffer changes. Lower values feel snappier - but use more CPU. - - {hide_prefix} (boolean, default: false) - Hide diff prefixes (`+`/`-`/` `) using virtual - text overlay. Makes code appear without the - leading diff character. When `highlights.background` - is also enabled, the overlay inherits the line's - background color. - - {treesitter} (table, default: see below) - Treesitter highlighting options. - See |fugitive-ts.TreesitterConfig| for fields. - - {vim} (table, default: see below) - Vim syntax highlighting options (experimental). - See |fugitive-ts.VimConfig| for fields. - - {highlights} (table, default: see below) - Controls which highlight features are enabled. - See |fugitive-ts.Highlights| for fields. - - *fugitive-ts.TreesitterConfig* - Treesitter config fields: ~ - {enabled} (boolean, default: true) - Apply treesitter syntax highlighting to code. - - {max_lines} (integer, default: 500) - Skip treesitter highlighting for hunks larger than - this many lines. Prevents lag on massive diffs. - - *fugitive-ts.VimConfig* - Vim config fields: ~ - {enabled} (boolean, default: false) - Use vim syntax highlighting as fallback when no - treesitter parser is available for a language. - Creates a scratch buffer, sets the filetype, and - queries |synID()| per character to extract - highlight groups. Slower than treesitter but - covers languages without a TS parser installed. - - {max_lines} (integer, default: 200) - Skip vim syntax highlighting for hunks larger than - this many lines. Lower than the treesitter default - due to the per-character cost of |synID()|. - - *fugitive-ts.Highlights* - Highlights table fields: ~ - {background} (boolean, default: true) - Apply background highlighting to `+`/`-` lines - using `FugitiveTsAdd`/`FugitiveTsDelete` groups - (derived from `DiffAdd`/`DiffDelete` backgrounds). - - {gutter} (boolean, default: true) - Highlight line numbers with matching colors. - Only visible if line numbers are enabled. - - Note: Header context (e.g., `@@ -10,3 +10,4 @@ func()`) is always - highlighted with treesitter when a parser is available. - - Language detection uses Neovim's built-in |vim.filetype.match()| and - |vim.treesitter.language.get_lang()|. To customize filetype detection - or register treesitter parsers for custom filetypes, use - |vim.filetype.add()| and |vim.treesitter.language.register()|. - -============================================================================== -API *fugitive-ts-api* - -setup({opts}) *fugitive-ts.setup()* - Configure the plugin with `opts`. - - Parameters: ~ - {opts} (|fugitive-ts.Config|, optional) Configuration table. - -attach({bufnr}) *fugitive-ts.attach()* - Manually attach highlighting to a buffer. Called automatically for - fugitive buffers via the `FileType fugitive` autocmd. - - Parameters: ~ - {bufnr} (integer, optional) Buffer number. Defaults to current buffer. - -refresh({bufnr}) *fugitive-ts.refresh()* - Manually refresh highlighting for a buffer. Useful after external changes - or for debugging. - - Parameters: ~ - {bufnr} (integer, optional) Buffer number. Defaults to current buffer. - -============================================================================== -IMPLEMENTATION *fugitive-ts-implementation* - -Summary / commit detail views: ~ -1. `FileType fugitive` or `FileType git` (for `fugitive://` buffers) - triggers |fugitive-ts.attach()| -2. The buffer is parsed to detect file headers (`M path/to/file`, - `diff --git a/... b/...`) and hunk headers (`@@ -10,3 +10,4 @@`) -3. For each hunk: - - Language is detected from the filename using |vim.filetype.match()| - - Diff prefixes (`+`/`-`/` `) are stripped from code lines - - Code is parsed with |vim.treesitter.get_string_parser()| - - If no treesitter parser and `vim.enabled`: vim syntax fallback via - scratch buffer and |synID()| - - Background extmarks (`FugitiveTsAdd`/`FugitiveTsDelete`) at priority 198 - - `Normal` extmarks at priority 199 clear underlying diff foreground - - Syntax highlights are applied as extmarks at priority 200 - - Conceal extmarks hide diff prefixes when `hide_prefix` is enabled -4. Re-highlighting occurs on `TextChanged` (debounced) and `Syntax` events - -Diffsplit views: ~ -1. `OptionSet diff` detects when a window enters diff mode -2. If any `&diff` window in the tabpage contains a `fugitive://` buffer, - all `&diff` windows receive a window-local 'winhighlight' override -3. The override remaps `DiffAdd`/`DiffDelete`/`DiffChange`/`DiffText` to - background-only variants, allowing existing treesitter highlighting to - show through the diff colors - -============================================================================== -KNOWN LIMITATIONS *fugitive-ts-limitations* - -Syntax Highlighting Flash ~ - *fugitive-ts-flash* -When opening a fugitive buffer, there is an unavoidable visual "flash" where -the buffer briefly shows fugitive's default diff highlighting before -fugitive-ts.nvim applies treesitter highlights. - -This occurs because fugitive-ts.nvim hooks into the `FileType fugitive` event, -which fires after vim-fugitive has already painted the buffer. Even with -`debounce_ms = 0`, the re-painting goes through Neovim's event loop. - -To minimize the flash, use a low debounce value: >lua - require('fugitive-ts').setup({ - debounce_ms = 0, - }) -< -See https://github.com/barrettruth/fugitive-ts.nvim/issues/18 for discussion -and potential solutions. - -============================================================================== -HEALTH CHECK *fugitive-ts-health* - -Run |:checkhealth| fugitive-ts to verify your setup. - -Checks performed: -- Neovim version >= 0.9.0 -- vim-fugitive is installed - -============================================================================== - vim:tw=78:ts=8:ft=help:norl: diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua new file mode 100644 index 0000000..dc6cfe2 --- /dev/null +++ b/lua/diffs/commands.lua @@ -0,0 +1,393 @@ +local M = {} + +local git = require('diffs.git') +local dbg = require('diffs.log').dbg + +---@return integer? +function M.find_diffs_window() + local tabpage = vim.api.nvim_get_current_tabpage() + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do + if vim.api.nvim_win_is_valid(win) then + local buf = vim.api.nvim_win_get_buf(win) + local name = vim.api.nvim_buf_get_name(buf) + if name:match('^diffs://') then + return win + end + end + end + return nil +end + +---@param bufnr integer +function M.setup_diff_buf(bufnr) + vim.diagnostic.enable(false, { bufnr = bufnr }) + vim.keymap.set('n', 'q', 'close', { buffer = bufnr }) +end + +---@param diff_lines string[] +---@param hunk_position { hunk_header: string, offset: integer } +---@return integer? +function M.find_hunk_line(diff_lines, hunk_position) + for i, line in ipairs(diff_lines) do + if line == hunk_position.hunk_header then + return i + hunk_position.offset + end + end + return nil +end + +---@param old_lines string[] +---@param new_lines string[] +---@param old_name string +---@param new_name string +---@return string[] +local function generate_unified_diff(old_lines, new_lines, old_name, new_name) + local old_content = table.concat(old_lines, '\n') + local new_content = table.concat(new_lines, '\n') + + local diff_fn = vim.text and vim.text.diff or vim.diff + local diff_output = diff_fn(old_content, new_content, { + result_type = 'unified', + ctxlen = 3, + }) + + if not diff_output or diff_output == '' then + return {} + end + + local diff_lines = vim.split(diff_output, '\n', { plain = true }) + + local result = { + 'diff --git a/' .. old_name .. ' b/' .. new_name, + '--- a/' .. old_name, + '+++ b/' .. new_name, + } + for _, line in ipairs(diff_lines) do + table.insert(result, line) + end + + return result +end + +---@param revision? string +---@param vertical? boolean +function M.gdiff(revision, vertical) + revision = revision or 'HEAD' + + local bufnr = vim.api.nvim_get_current_buf() + local filepath = vim.api.nvim_buf_get_name(bufnr) + + if filepath == '' then + vim.notify('[diffs.nvim]: cannot diff unnamed buffer', vim.log.levels.ERROR) + return + end + + local rel_path = git.get_relative_path(filepath) + if not rel_path then + vim.notify('[diffs.nvim]: not in a git repository', vim.log.levels.ERROR) + return + end + + local old_lines, err = git.get_file_content(revision, filepath) + if not old_lines then + vim.notify('[diffs.nvim]: ' .. (err or 'unknown error'), vim.log.levels.ERROR) + return + end + + local new_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + local diff_lines = generate_unified_diff(old_lines, new_lines, rel_path, rel_path) + + if #diff_lines == 0 then + vim.notify('[diffs.nvim]: no diff against ' .. revision, vim.log.levels.INFO) + return + end + + local repo_root = git.get_repo_root(filepath) + + local diff_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines) + vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf }) + vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf }) + vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf }) + vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf }) + vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf }) + vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. revision .. ':' .. rel_path) + if repo_root then + vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) + end + + local existing_win = M.find_diffs_window() + if existing_win then + vim.api.nvim_set_current_win(existing_win) + vim.api.nvim_win_set_buf(existing_win, diff_buf) + else + vim.cmd(vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, diff_buf) + end + + M.setup_diff_buf(diff_buf) + dbg('opened diff buffer %d for %s against %s', diff_buf, rel_path, revision) + + vim.schedule(function() + require('diffs').attach(diff_buf) + end) +end + +---@class diffs.GdiffFileOpts +---@field vertical? boolean +---@field staged? boolean +---@field untracked? boolean +---@field old_filepath? string +---@field hunk_position? { hunk_header: string, offset: integer } + +---@param filepath string +---@param opts? diffs.GdiffFileOpts +function M.gdiff_file(filepath, opts) + opts = opts or {} + + local rel_path = git.get_relative_path(filepath) + if not rel_path then + vim.notify('[diffs.nvim]: not in a git repository', vim.log.levels.ERROR) + return + end + + local old_rel_path = opts.old_filepath and git.get_relative_path(opts.old_filepath) or rel_path + + local old_lines, new_lines, err + local diff_label + + if opts.untracked then + old_lines = {} + new_lines, err = git.get_working_content(filepath) + if not new_lines then + vim.notify('[diffs.nvim]: ' .. (err or 'cannot read file'), vim.log.levels.ERROR) + return + end + diff_label = 'untracked' + elseif opts.staged then + old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath) + if not old_lines then + old_lines = {} + end + new_lines, err = git.get_index_content(filepath) + if not new_lines then + new_lines = {} + end + diff_label = 'staged' + else + old_lines, err = git.get_index_content(opts.old_filepath or filepath) + if not old_lines then + old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath) + if not old_lines then + old_lines = {} + diff_label = 'untracked' + else + diff_label = 'unstaged' + end + else + diff_label = 'unstaged' + end + new_lines, err = git.get_working_content(filepath) + if not new_lines then + new_lines = {} + end + end + + local diff_lines = generate_unified_diff(old_lines, new_lines, old_rel_path, rel_path) + + if #diff_lines == 0 then + vim.notify('[diffs.nvim]: no changes', vim.log.levels.INFO) + return + end + + local repo_root = git.get_repo_root(filepath) + + local diff_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines) + vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf }) + vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf }) + vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf }) + vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf }) + vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf }) + vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':' .. rel_path) + if repo_root then + vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) + end + if old_rel_path ~= rel_path then + vim.api.nvim_buf_set_var(diff_buf, 'diffs_old_filepath', old_rel_path) + end + + local existing_win = M.find_diffs_window() + if existing_win then + vim.api.nvim_set_current_win(existing_win) + vim.api.nvim_win_set_buf(existing_win, diff_buf) + else + vim.cmd(opts.vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, diff_buf) + end + + if opts.hunk_position then + local target_line = M.find_hunk_line(diff_lines, opts.hunk_position) + if target_line then + vim.api.nvim_win_set_cursor(0, { target_line, 0 }) + dbg('jumped to line %d for hunk', target_line) + end + end + + M.setup_diff_buf(diff_buf) + dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) + + vim.schedule(function() + require('diffs').attach(diff_buf) + end) +end + +---@class diffs.GdiffSectionOpts +---@field vertical? boolean +---@field staged? boolean + +---@param repo_root string +---@param opts? diffs.GdiffSectionOpts +function M.gdiff_section(repo_root, opts) + opts = opts or {} + + local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' } + if opts.staged then + table.insert(cmd, '--cached') + end + + local result = vim.fn.systemlist(cmd) + if vim.v.shell_error ~= 0 then + vim.notify('[diffs.nvim]: git diff failed', vim.log.levels.ERROR) + return + end + + if #result == 0 then + vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO) + return + end + + local diff_label = opts.staged and 'staged' or 'unstaged' + local diff_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, result) + vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf }) + vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf }) + vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf }) + vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf }) + vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf }) + vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':all') + vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) + + local existing_win = M.find_diffs_window() + if existing_win then + vim.api.nvim_set_current_win(existing_win) + vim.api.nvim_win_set_buf(existing_win, diff_buf) + else + vim.cmd(opts.vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, diff_buf) + end + + M.setup_diff_buf(diff_buf) + dbg('opened section diff buffer %d (%s)', diff_buf, diff_label) + + vim.schedule(function() + require('diffs').attach(diff_buf) + end) +end + +---@param bufnr integer +function M.read_buffer(bufnr) + local name = vim.api.nvim_buf_get_name(bufnr) + local url_body = name:match('^diffs://(.+)$') + if not url_body then + return + end + + local label, path = url_body:match('^([^:]+):(.+)$') + if not label or not path then + return + end + + local ok, repo_root = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_repo_root') + if not ok or not repo_root then + return + end + + local diff_lines + + if path == 'all' then + local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' } + if label == 'staged' then + table.insert(cmd, '--cached') + end + diff_lines = vim.fn.systemlist(cmd) + if vim.v.shell_error ~= 0 then + diff_lines = {} + end + else + local abs_path = repo_root .. '/' .. path + + local old_ok, old_rel_path = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_old_filepath') + local old_abs_path = old_ok and old_rel_path and (repo_root .. '/' .. old_rel_path) or abs_path + local old_name = old_ok and old_rel_path or path + + local old_lines, new_lines + + if label == 'untracked' then + old_lines = {} + new_lines = git.get_working_content(abs_path) or {} + elseif label == 'staged' then + old_lines = git.get_file_content('HEAD', old_abs_path) or {} + new_lines = git.get_index_content(abs_path) or {} + elseif label == 'unstaged' then + old_lines = git.get_index_content(old_abs_path) + if not old_lines then + old_lines = git.get_file_content('HEAD', old_abs_path) or {} + end + new_lines = git.get_working_content(abs_path) or {} + else + old_lines = git.get_file_content(label, abs_path) or {} + new_lines = git.get_working_content(abs_path) or {} + end + + diff_lines = generate_unified_diff(old_lines, new_lines, old_name, path) + end + + vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr }) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, diff_lines) + vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) + vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr }) + vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = bufnr }) + vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr }) + vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr }) + + dbg('reloaded diff buffer %d (%s:%s)', bufnr, label, path) + + require('diffs').attach(bufnr) +end + +function M.setup() + vim.api.nvim_create_user_command('Gdiff', function(opts) + M.gdiff(opts.args ~= '' and opts.args or nil, false) + end, { + nargs = '?', + desc = 'Show unified diff against git revision (default: HEAD)', + }) + + vim.api.nvim_create_user_command('Gvdiff', function(opts) + M.gdiff(opts.args ~= '' and opts.args or nil, true) + end, { + nargs = '?', + desc = 'Show unified diff against git revision in vertical split', + }) + + vim.api.nvim_create_user_command('Ghdiff', function(opts) + M.gdiff(opts.args ~= '' and opts.args or nil, false) + end, { + nargs = '?', + desc = 'Show unified diff against git revision in horizontal split', + }) +end + +return M diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua new file mode 100644 index 0000000..9e62a15 --- /dev/null +++ b/lua/diffs/conflict.lua @@ -0,0 +1,438 @@ +---@class diffs.ConflictRegion +---@field marker_ours integer +---@field ours_start integer +---@field ours_end integer +---@field marker_base integer? +---@field base_start integer? +---@field base_end integer? +---@field marker_sep integer +---@field theirs_start integer +---@field theirs_end integer +---@field marker_theirs integer + +local M = {} + +local ns = vim.api.nvim_create_namespace('diffs-conflict') + +---@type table +local attached_buffers = {} + +---@type table +local diagnostics_suppressed = {} + +local PRIORITY_LINE_BG = 200 + +---@param lines string[] +---@return diffs.ConflictRegion[] +function M.parse(lines) + local regions = {} + local state = 'idle' + ---@type table? + local current = nil + + for i, line in ipairs(lines) do + local idx = i - 1 + + if state == 'idle' then + if line:match('^<<<<<<<') then + current = { marker_ours = idx, ours_start = idx + 1 } + state = 'in_ours' + end + elseif state == 'in_ours' then + if line:match('^|||||||') then + current.ours_end = idx + current.marker_base = idx + current.base_start = idx + 1 + state = 'in_base' + elseif line:match('^=======') then + current.ours_end = idx + current.marker_sep = idx + current.theirs_start = idx + 1 + state = 'in_theirs' + elseif line:match('^<<<<<<<') then + current = { marker_ours = idx, ours_start = idx + 1 } + elseif line:match('^>>>>>>>') then + current = nil + state = 'idle' + end + elseif state == 'in_base' then + if line:match('^=======') then + current.base_end = idx + current.marker_sep = idx + current.theirs_start = idx + 1 + state = 'in_theirs' + elseif line:match('^<<<<<<<') then + current = { marker_ours = idx, ours_start = idx + 1 } + state = 'in_ours' + elseif line:match('^>>>>>>>') then + current = nil + state = 'idle' + end + elseif state == 'in_theirs' then + if line:match('^>>>>>>>') then + current.theirs_end = idx + current.marker_theirs = idx + table.insert(regions, current) + current = nil + state = 'idle' + elseif line:match('^<<<<<<<') then + current = { marker_ours = idx, ours_start = idx + 1 } + state = 'in_ours' + end + end + end + + return regions +end + +---@param bufnr integer +---@return diffs.ConflictRegion[] +local function parse_buffer(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + return M.parse(lines) +end + +---@param bufnr integer +---@param regions diffs.ConflictRegion[] +---@param config diffs.ConflictConfig +local function apply_highlights(bufnr, regions, config) + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + + for _, region in ipairs(regions) do + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { + end_row = region.marker_ours + 1, + hl_group = 'DiffsConflictMarker', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + + if config.show_virtual_text then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { + virt_text = { { ' (current)', 'DiffsConflictMarker' } }, + virt_text_pos = 'eol', + }) + end + + for line = region.ours_start, region.ours_end - 1 do + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + end_row = line + 1, + hl_group = 'DiffsConflictOurs', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + number_hl_group = 'DiffsConflictOursNr', + priority = PRIORITY_LINE_BG, + }) + end + + if region.marker_base then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_base, 0, { + end_row = region.marker_base + 1, + hl_group = 'DiffsConflictMarker', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + + for line = region.base_start, region.base_end - 1 do + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + end_row = line + 1, + hl_group = 'DiffsConflictBase', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + number_hl_group = 'DiffsConflictBaseNr', + priority = PRIORITY_LINE_BG, + }) + end + end + + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_sep, 0, { + end_row = region.marker_sep + 1, + hl_group = 'DiffsConflictMarker', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + + for line = region.theirs_start, region.theirs_end - 1 do + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + end_row = line + 1, + hl_group = 'DiffsConflictTheirs', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, line, 0, { + number_hl_group = 'DiffsConflictTheirsNr', + priority = PRIORITY_LINE_BG, + }) + end + + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { + end_row = region.marker_theirs + 1, + hl_group = 'DiffsConflictMarker', + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + + if config.show_virtual_text then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { + virt_text = { { ' (incoming)', 'DiffsConflictMarker' } }, + virt_text_pos = 'eol', + }) + end + end +end + +---@param cursor_line integer +---@param regions diffs.ConflictRegion[] +---@return diffs.ConflictRegion? +local function find_conflict_at_cursor(cursor_line, regions) + for _, region in ipairs(regions) do + if cursor_line >= region.marker_ours and cursor_line <= region.marker_theirs then + return region + end + end + return nil +end + +---@param bufnr integer +---@param region diffs.ConflictRegion +---@param replacement string[] +local function replace_region(bufnr, region, replacement) + vim.api.nvim_buf_set_lines( + bufnr, + region.marker_ours, + region.marker_theirs + 1, + false, + replacement + ) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +local function refresh(bufnr, config) + local regions = parse_buffer(bufnr) + if #regions == 0 then + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + if diagnostics_suppressed[bufnr] then + pcall(vim.diagnostic.reset, nil, bufnr) + pcall(vim.diagnostic.enable, true, { bufnr = bufnr }) + diagnostics_suppressed[bufnr] = nil + end + vim.api.nvim_exec_autocmds('User', { pattern = 'DiffsConflictResolved' }) + return + end + apply_highlights(bufnr, regions, config) + if config.disable_diagnostics and not diagnostics_suppressed[bufnr] then + pcall(vim.diagnostic.enable, false, { bufnr = bufnr }) + diagnostics_suppressed[bufnr] = true + end +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_ours(bufnr, config) + if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then + vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN) + return + end + local regions = parse_buffer(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local region = find_conflict_at_cursor(cursor[1] - 1, regions) + if not region then + return + end + local lines = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false) + replace_region(bufnr, region, lines) + refresh(bufnr, config) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_theirs(bufnr, config) + if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then + vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN) + return + end + local regions = parse_buffer(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local region = find_conflict_at_cursor(cursor[1] - 1, regions) + if not region then + return + end + local lines = vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false) + replace_region(bufnr, region, lines) + refresh(bufnr, config) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_both(bufnr, config) + if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then + vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN) + return + end + local regions = parse_buffer(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local region = find_conflict_at_cursor(cursor[1] - 1, regions) + if not region then + return + end + local ours = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false) + local theirs = vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false) + local combined = {} + for _, l in ipairs(ours) do + table.insert(combined, l) + end + for _, l in ipairs(theirs) do + table.insert(combined, l) + end + replace_region(bufnr, region, combined) + refresh(bufnr, config) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_none(bufnr, config) + if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then + vim.notify('[diffs.nvim]: buffer is not modifiable', vim.log.levels.WARN) + return + end + local regions = parse_buffer(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local region = find_conflict_at_cursor(cursor[1] - 1, regions) + if not region then + return + end + replace_region(bufnr, region, {}) + refresh(bufnr, config) +end + +---@param bufnr integer +function M.goto_next(bufnr) + local regions = parse_buffer(bufnr) + if #regions == 0 then + return + end + local cursor = vim.api.nvim_win_get_cursor(0) + local cursor_line = cursor[1] - 1 + for _, region in ipairs(regions) do + if region.marker_ours > cursor_line then + vim.api.nvim_win_set_cursor(0, { region.marker_ours + 1, 0 }) + return + end + end + vim.api.nvim_win_set_cursor(0, { regions[1].marker_ours + 1, 0 }) +end + +---@param bufnr integer +function M.goto_prev(bufnr) + local regions = parse_buffer(bufnr) + if #regions == 0 then + return + end + local cursor = vim.api.nvim_win_get_cursor(0) + local cursor_line = cursor[1] - 1 + for i = #regions, 1, -1 do + if regions[i].marker_ours < cursor_line then + vim.api.nvim_win_set_cursor(0, { regions[i].marker_ours + 1, 0 }) + return + end + end + vim.api.nvim_win_set_cursor(0, { regions[#regions].marker_ours + 1, 0 }) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +local function setup_keymaps(bufnr, config) + local km = config.keymaps + + local maps = { + { km.ours, '(diffs-conflict-ours)' }, + { km.theirs, '(diffs-conflict-theirs)' }, + { km.both, '(diffs-conflict-both)' }, + { km.none, '(diffs-conflict-none)' }, + { km.next, '(diffs-conflict-next)' }, + { km.prev, '(diffs-conflict-prev)' }, + } + + for _, map in ipairs(maps) do + if map[1] then + vim.keymap.set('n', map[1], map[2], { buffer = bufnr }) + end + end +end + +---@param bufnr integer +function M.detach(bufnr) + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + attached_buffers[bufnr] = nil + + if diagnostics_suppressed[bufnr] then + pcall(vim.diagnostic.reset, nil, bufnr) + pcall(vim.diagnostic.enable, true, { bufnr = bufnr }) + diagnostics_suppressed[bufnr] = nil + end +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.attach(bufnr, config) + if attached_buffers[bufnr] then + return + end + + local buftype = vim.api.nvim_get_option_value('buftype', { buf = bufnr }) + if buftype ~= '' then + return + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local has_marker = false + for _, line in ipairs(lines) do + if line:match('^<<<<<<<') then + has_marker = true + break + end + end + if not has_marker then + return + end + + attached_buffers[bufnr] = true + + local regions = M.parse(lines) + apply_highlights(bufnr, regions, config) + setup_keymaps(bufnr, config) + + if config.disable_diagnostics then + pcall(vim.diagnostic.enable, false, { bufnr = bufnr }) + diagnostics_suppressed[bufnr] = true + end + + vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { + buffer = bufnr, + callback = function() + if not attached_buffers[bufnr] then + return true + end + refresh(bufnr, config) + end, + }) + + vim.api.nvim_create_autocmd('BufWipeout', { + buffer = bufnr, + callback = function() + attached_buffers[bufnr] = nil + diagnostics_suppressed[bufnr] = nil + end, + }) +end + +---@return integer +function M.get_namespace() + return ns +end + +return M diff --git a/lua/diffs/debug.lua b/lua/diffs/debug.lua new file mode 100644 index 0000000..c234c32 --- /dev/null +++ b/lua/diffs/debug.lua @@ -0,0 +1,70 @@ +local M = {} + +local ns = vim.api.nvim_create_namespace('diffs') + +function M.dump() + local bufnr = vim.api.nvim_get_current_buf() + local marks = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true }) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + local by_line = {} + for _, mark in ipairs(marks) do + local id, row, col, details = mark[1], mark[2], mark[3], mark[4] + local entry = { + id = id, + row = row, + col = col, + end_row = details.end_row, + end_col = details.end_col, + hl_group = details.hl_group, + priority = details.priority, + hl_eol = details.hl_eol, + line_hl_group = details.line_hl_group, + number_hl_group = details.number_hl_group, + virt_text = details.virt_text, + } + local key = tostring(row) + if not by_line[key] then + by_line[key] = { text = lines[row + 1] or '', marks = {} } + end + table.insert(by_line[key].marks, entry) + end + + local all_ns_marks = vim.api.nvim_buf_get_extmarks(bufnr, -1, 0, -1, { details = true }) + local non_diffs = {} + for _, mark in ipairs(all_ns_marks) do + local details = mark[4] + if details.ns_id ~= ns then + table.insert(non_diffs, { + ns_id = details.ns_id, + row = mark[2], + col = mark[3], + end_row = details.end_row, + end_col = details.end_col, + hl_group = details.hl_group, + priority = details.priority, + }) + end + end + + local result = { + bufnr = bufnr, + buf_name = vim.api.nvim_buf_get_name(bufnr), + ns_id = ns, + total_diffs_marks = #marks, + total_all_marks = #all_ns_marks, + non_diffs_marks = non_diffs, + lines = by_line, + } + + local state_dir = vim.fn.stdpath('state') + local path = state_dir .. '/diffs_debug.json' + local f = io.open(path, 'w') + if f then + f:write(vim.json.encode(result)) + f:close() + vim.notify('[diffs.nvim] debug dump: ' .. path, vim.log.levels.INFO) + end +end + +return M diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua new file mode 100644 index 0000000..edf0275 --- /dev/null +++ b/lua/diffs/diff.lua @@ -0,0 +1,401 @@ +---@class diffs.CharSpan +---@field line integer +---@field col_start integer +---@field col_end integer + +---@class diffs.IntraChanges +---@field add_spans diffs.CharSpan[] +---@field del_spans diffs.CharSpan[] + +---@class diffs.ChangeGroup +---@field del_lines {idx: integer, text: string}[] +---@field add_lines {idx: integer, text: string}[] + +---@class diffs.DiffOpts +---@field algorithm? string +---@field linematch? integer + +local M = {} + +local dbg = require('diffs.log').dbg + +---@param hunk_lines string[] +---@return diffs.ChangeGroup[] +function M.extract_change_groups(hunk_lines) + ---@type diffs.ChangeGroup[] + local groups = {} + ---@type {idx: integer, text: string}[] + local del_buf = {} + ---@type {idx: integer, text: string}[] + local add_buf = {} + + ---@type boolean + local in_del = false + + for i, line in ipairs(hunk_lines) do + local prefix = line:sub(1, 1) + if prefix == '-' then + if not in_del and #add_buf > 0 then + if #del_buf > 0 then + table.insert(groups, { del_lines = del_buf, add_lines = add_buf }) + end + del_buf = {} + add_buf = {} + end + in_del = true + table.insert(del_buf, { idx = i, text = line:sub(2) }) + elseif prefix == '+' then + in_del = false + table.insert(add_buf, { idx = i, text = line:sub(2) }) + else + if #del_buf > 0 and #add_buf > 0 then + table.insert(groups, { del_lines = del_buf, add_lines = add_buf }) + end + del_buf = {} + add_buf = {} + in_del = false + end + end + + if #del_buf > 0 and #add_buf > 0 then + table.insert(groups, { del_lines = del_buf, add_lines = add_buf }) + end + + return groups +end + +---@return diffs.DiffOpts +local function parse_diffopt() + local opts = {} + for _, item in ipairs(vim.split(vim.o.diffopt, ',')) do + local key, val = item:match('^(%w+):(.+)$') + if key == 'algorithm' then + opts.algorithm = val + elseif key == 'linematch' then + opts.linematch = tonumber(val) + end + end + return opts +end + +---@param old_text string +---@param new_text string +---@param diff_opts? diffs.DiffOpts +---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[] +local function byte_diff(old_text, new_text, diff_opts) + local vim_opts = { result_type = 'indices' } + if diff_opts then + if diff_opts.algorithm then + vim_opts.algorithm = diff_opts.algorithm + end + if diff_opts.linematch then + vim_opts.linematch = diff_opts.linematch + end + end + local ok, result = pcall(vim.diff, old_text, new_text, vim_opts) + if not ok or not result then + return {} + end + ---@type {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[] + local hunks = {} + for _, h in ipairs(result) do + table.insert(hunks, { + old_start = h[1], + old_count = h[2], + new_start = h[3], + new_count = h[4], + }) + end + return hunks +end + +---@param s string +---@return string[] +local function split_bytes(s) + local bytes = {} + for i = 1, #s do + table.insert(bytes, s:sub(i, i)) + end + return bytes +end + +---@param old_line string +---@param new_line string +---@param del_idx integer +---@param add_idx integer +---@param diff_opts? diffs.DiffOpts +---@return diffs.CharSpan[], diffs.CharSpan[] +local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts) + ---@type diffs.CharSpan[] + local del_spans = {} + ---@type diffs.CharSpan[] + local add_spans = {} + + local old_bytes = split_bytes(old_line) + local new_bytes = split_bytes(new_line) + + local old_text = table.concat(old_bytes, '\n') .. '\n' + local new_text = table.concat(new_bytes, '\n') .. '\n' + + local char_opts = diff_opts + if diff_opts and diff_opts.linematch then + char_opts = { algorithm = diff_opts.algorithm } + end + + local char_hunks = byte_diff(old_text, new_text, char_opts) + + for _, ch in ipairs(char_hunks) do + if ch.old_count > 0 then + table.insert(del_spans, { + line = del_idx, + col_start = ch.old_start, + col_end = ch.old_start + ch.old_count, + }) + end + + if ch.new_count > 0 then + table.insert(add_spans, { + line = add_idx, + col_start = ch.new_start, + col_end = ch.new_start + ch.new_count, + }) + end + end + + return del_spans, add_spans +end + +---@param group diffs.ChangeGroup +---@param diff_opts? diffs.DiffOpts +---@return diffs.CharSpan[], diffs.CharSpan[] +local function diff_group_native(group, diff_opts) + ---@type diffs.CharSpan[] + local all_del = {} + ---@type diffs.CharSpan[] + local all_add = {} + + local del_count = #group.del_lines + local add_count = #group.add_lines + + if del_count == 1 and add_count == 1 then + local ds, as = char_diff_pair( + group.del_lines[1].text, + group.add_lines[1].text, + group.del_lines[1].idx, + group.add_lines[1].idx, + diff_opts + ) + vim.list_extend(all_del, ds) + vim.list_extend(all_add, as) + return all_del, all_add + end + + local old_texts = {} + for _, l in ipairs(group.del_lines) do + table.insert(old_texts, l.text) + end + local new_texts = {} + for _, l in ipairs(group.add_lines) do + table.insert(new_texts, l.text) + end + + local old_block = table.concat(old_texts, '\n') .. '\n' + local new_block = table.concat(new_texts, '\n') .. '\n' + + local line_hunks = byte_diff(old_block, new_block, diff_opts) + + ---@type table + local old_to_new = {} + for _, lh in ipairs(line_hunks) do + if lh.old_count == lh.new_count then + for k = 0, lh.old_count - 1 do + old_to_new[lh.old_start + k] = lh.new_start + k + end + end + end + + for old_i, new_i in pairs(old_to_new) do + if group.del_lines[old_i] and group.add_lines[new_i] then + local ds, as = char_diff_pair( + group.del_lines[old_i].text, + group.add_lines[new_i].text, + group.del_lines[old_i].idx, + group.add_lines[new_i].idx, + diff_opts + ) + vim.list_extend(all_del, ds) + vim.list_extend(all_add, as) + end + end + + for _, lh in ipairs(line_hunks) do + if lh.old_count ~= lh.new_count then + local pairs_count = math.min(lh.old_count, lh.new_count) + for k = 0, pairs_count - 1 do + local oi = lh.old_start + k + local ni = lh.new_start + k + if group.del_lines[oi] and group.add_lines[ni] then + local ds, as = char_diff_pair( + group.del_lines[oi].text, + group.add_lines[ni].text, + group.del_lines[oi].idx, + group.add_lines[ni].idx, + diff_opts + ) + vim.list_extend(all_del, ds) + vim.list_extend(all_add, as) + end + end + end + end + + return all_del, all_add +end + +---@param group diffs.ChangeGroup +---@param handle table +---@return diffs.CharSpan[], diffs.CharSpan[] +local function diff_group_vscode(group, handle) + ---@type diffs.CharSpan[] + local all_del = {} + ---@type diffs.CharSpan[] + local all_add = {} + + local ffi = require('ffi') + + local old_texts = {} + for _, l in ipairs(group.del_lines) do + table.insert(old_texts, l.text) + end + local new_texts = {} + for _, l in ipairs(group.add_lines) do + table.insert(new_texts, l.text) + end + + local orig_arr = ffi.new('const char*[?]', #old_texts) + for i, t in ipairs(old_texts) do + orig_arr[i - 1] = t + end + + local mod_arr = ffi.new('const char*[?]', #new_texts) + for i, t in ipairs(new_texts) do + mod_arr[i - 1] = t + end + + local opts = ffi.new('DiffsDiffOptions', { + ignore_trim_whitespace = false, + max_computation_time_ms = 1000, + compute_moves = false, + extend_to_subwords = false, + }) + + local result = handle.compute_diff(orig_arr, #old_texts, mod_arr, #new_texts, opts) + if result == nil then + return all_del, all_add + end + + for ci = 0, result.changes.count - 1 do + local mapping = result.changes.mappings[ci] + for ii = 0, mapping.inner_change_count - 1 do + local inner = mapping.inner_changes[ii] + + local orig_line = inner.original.start_line + if group.del_lines[orig_line] then + table.insert(all_del, { + line = group.del_lines[orig_line].idx, + col_start = inner.original.start_col, + col_end = inner.original.end_col, + }) + end + + local mod_line = inner.modified.start_line + if group.add_lines[mod_line] then + table.insert(all_add, { + line = group.add_lines[mod_line].idx, + col_start = inner.modified.start_col, + col_end = inner.modified.end_col, + }) + end + end + end + + handle.free_lines_diff(result) + + return all_del, all_add +end + +---@param hunk_lines string[] +---@param algorithm? string +---@return diffs.IntraChanges? +function M.compute_intra_hunks(hunk_lines, algorithm) + local groups = M.extract_change_groups(hunk_lines) + if #groups == 0 then + return nil + end + + algorithm = algorithm or 'default' + + local vscode_handle = nil + if algorithm == 'vscode' then + vscode_handle = require('diffs.lib').load() + if not vscode_handle then + dbg('vscode algorithm requested but library not available, falling back to default') + end + end + + ---@type diffs.DiffOpts? + local diff_opts = nil + if not vscode_handle then + diff_opts = parse_diffopt() + if diff_opts.algorithm then + dbg('diffopt algorithm: %s', diff_opts.algorithm) + end + if diff_opts.linematch then + dbg('diffopt linematch: %d', diff_opts.linematch) + end + end + + ---@type diffs.CharSpan[] + local all_add = {} + ---@type diffs.CharSpan[] + local all_del = {} + + dbg( + 'intra: %d change groups, algorithm=%s, vscode=%s', + #groups, + algorithm, + vscode_handle and 'yes' or 'no' + ) + + for gi, group in ipairs(groups) do + dbg('group %d: %d del lines, %d add lines', gi, #group.del_lines, #group.add_lines) + local ds, as + if vscode_handle then + ds, as = diff_group_vscode(group, vscode_handle) + else + ds, as = diff_group_native(group, diff_opts) + end + dbg('group %d result: %d del spans, %d add spans', gi, #ds, #as) + for _, s in ipairs(ds) do + dbg(' del span: line=%d col=%d..%d', s.line, s.col_start, s.col_end) + end + for _, s in ipairs(as) do + dbg(' add span: line=%d col=%d..%d', s.line, s.col_start, s.col_end) + end + vim.list_extend(all_del, ds) + vim.list_extend(all_add, as) + end + + if #all_add == 0 and #all_del == 0 then + return nil + end + + return { add_spans = all_add, del_spans = all_del } +end + +---@return boolean +function M.has_vscode() + return require('diffs.lib').has_lib() +end + +return M diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua new file mode 100644 index 0000000..a588a22 --- /dev/null +++ b/lua/diffs/fugitive.lua @@ -0,0 +1,218 @@ +local M = {} + +local commands = require('diffs.commands') +local git = require('diffs.git') +local dbg = require('diffs.log').dbg + +---@alias diffs.FugitiveSection 'staged' | 'unstaged' | 'untracked' | nil + +---@param bufnr integer +---@param lnum integer +---@return diffs.FugitiveSection +function M.get_section_at_line(bufnr, lnum) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false) + + for i = #lines, 1, -1 do + local line = lines[i] + if line:match('^Staged ') then + return 'staged' + elseif line:match('^Unstaged ') then + return 'unstaged' + elseif line:match('^Untracked ') then + return 'untracked' + end + end + + return nil +end + +---@param line string +---@return string?, string? +local function parse_file_line(line) + local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$') + if old and new then + return vim.trim(new), vim.trim(old) + end + + local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$') + if filename then + return vim.trim(filename), nil + end + + return nil, nil +end + +---@param line string +---@return diffs.FugitiveSection? +local function parse_section_header(line) + if line:match('^Staged %(%d') then + return 'staged' + elseif line:match('^Unstaged %(%d') then + return 'unstaged' + elseif line:match('^Untracked %(%d') then + return 'untracked' + end + return nil +end + +---@param bufnr integer +---@param lnum integer +---@return string?, diffs.FugitiveSection, boolean, string? +function M.get_file_at_line(bufnr, lnum) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local current_line = lines[lnum] + + if not current_line then + return nil, nil, false, nil + end + + local section_header = parse_section_header(current_line) + if section_header then + return nil, section_header, true, nil + end + + local filename, old_filename = parse_file_line(current_line) + if filename then + local section = M.get_section_at_line(bufnr, lnum) + return filename, section, false, old_filename + end + + local prefix = current_line:sub(1, 1) + if prefix == '+' or prefix == '-' or prefix == ' ' then + for i = lnum - 1, 1, -1 do + local prev_line = lines[i] + filename, old_filename = parse_file_line(prev_line) + if filename then + local section = M.get_section_at_line(bufnr, i) + return filename, section, false, old_filename + end + if prev_line:match('^%w+ %(') or prev_line == '' then + break + end + end + end + + return nil, nil, false, nil +end + +---@class diffs.HunkPosition +---@field hunk_header string +---@field offset integer + +---@param bufnr integer +---@param lnum integer +---@return diffs.HunkPosition? +function M.get_hunk_position(bufnr, lnum) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false) + local current = lines[lnum] + + if not current then + return nil + end + + local prefix = current:sub(1, 1) + if prefix ~= '+' and prefix ~= '-' and prefix ~= ' ' then + return nil + end + + for i = lnum - 1, 1, -1 do + local line = lines[i] + if line:match('^@@.-@@') then + return { + hunk_header = line, + offset = lnum - i, + } + end + if line:match('^[MADRCU?!]%s') or line:match('^%w+ %(') then + break + end + end + + return nil +end + +---@param bufnr integer +---@return string? +local function get_repo_root_from_fugitive(bufnr) + local bufname = vim.api.nvim_buf_get_name(bufnr) + local fugitive_path = bufname:match('^fugitive://(.+)///') + if fugitive_path then + return fugitive_path + end + + local cwd = vim.fn.getcwd() + local root = git.get_repo_root(cwd .. '/.') + return root +end + +---@param vertical boolean +function M.diff_file_under_cursor(vertical) + local bufnr = vim.api.nvim_get_current_buf() + local lnum = vim.api.nvim_win_get_cursor(0)[1] + + local filename, section, is_header, old_filename = M.get_file_at_line(bufnr, lnum) + + local repo_root = get_repo_root_from_fugitive(bufnr) + if not repo_root then + vim.notify('[diffs.nvim]: could not determine repository root', vim.log.levels.ERROR) + return + end + + if is_header then + dbg('diff_section: %s', section or 'unknown') + if section == 'untracked' then + vim.notify('[diffs.nvim]: cannot diff untracked section', vim.log.levels.WARN) + return + end + commands.gdiff_section(repo_root, { + vertical = vertical, + staged = section == 'staged', + }) + return + end + + if not filename then + vim.notify('[diffs.nvim]: no file under cursor', vim.log.levels.WARN) + return + end + + local filepath = repo_root .. '/' .. filename + local old_filepath = old_filename and (repo_root .. '/' .. old_filename) or nil + local hunk_position = M.get_hunk_position(bufnr, lnum) + + dbg( + 'diff_file_under_cursor: %s (section: %s, old: %s, hunk_offset: %s)', + filename, + section or 'unknown', + old_filename or 'none', + hunk_position and tostring(hunk_position.offset) or 'none' + ) + + commands.gdiff_file(filepath, { + vertical = vertical, + staged = section == 'staged', + untracked = section == 'untracked', + old_filepath = old_filepath, + hunk_position = hunk_position, + }) +end + +---@param bufnr integer +---@param config { horizontal: string|false, vertical: string|false } +function M.setup_keymaps(bufnr, config) + if config.horizontal and config.horizontal ~= '' then + vim.keymap.set('n', config.horizontal, function() + M.diff_file_under_cursor(false) + end, { buffer = bufnr, desc = 'Unified diff (horizontal)' }) + dbg('set keymap %s for buffer %d', config.horizontal, bufnr) + end + + if config.vertical and config.vertical ~= '' then + vim.keymap.set('n', config.vertical, function() + M.diff_file_under_cursor(true) + end, { buffer = bufnr, desc = 'Unified diff (vertical)' }) + dbg('set keymap %s for buffer %d', config.vertical, bufnr) + end +end + +return M diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua new file mode 100644 index 0000000..7695283 --- /dev/null +++ b/lua/diffs/git.lua @@ -0,0 +1,113 @@ +local M = {} + +---@param filepath string +---@return string? +function M.get_repo_root(filepath) + local dir = vim.fn.fnamemodify(filepath, ':h') + local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' }) + if vim.v.shell_error ~= 0 then + return nil + end + return result[1] +end + +---@param revision string +---@param filepath string +---@return string[]?, string? +function M.get_file_content(revision, filepath) + local repo_root = M.get_repo_root(filepath) + if not repo_root then + return nil, 'not in a git repository' + end + + local rel_path = vim.fn.fnamemodify(filepath, ':.') + if vim.startswith(filepath, repo_root) then + rel_path = filepath:sub(#repo_root + 2) + end + + local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'show', revision .. ':' .. rel_path }) + if vim.v.shell_error ~= 0 then + return nil, 'failed to get file at revision: ' .. revision + end + return result, nil +end + +---@param filepath string +---@return string? +function M.get_relative_path(filepath) + local repo_root = M.get_repo_root(filepath) + if not repo_root then + return nil + end + if vim.startswith(filepath, repo_root) then + return filepath:sub(#repo_root + 2) + end + return vim.fn.fnamemodify(filepath, ':.') +end + +---@param filepath string +---@return string[]?, string? +function M.get_index_content(filepath) + local repo_root = M.get_repo_root(filepath) + if not repo_root then + return nil, 'not in a git repository' + end + + local rel_path = M.get_relative_path(filepath) + if not rel_path then + return nil, 'could not determine relative path' + end + + local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'show', ':0:' .. rel_path }) + if vim.v.shell_error ~= 0 then + return nil, 'file not in index' + end + return result, nil +end + +---@param filepath string +---@return string[]?, string? +function M.get_working_content(filepath) + if vim.fn.filereadable(filepath) ~= 1 then + return nil, 'file not readable' + end + local lines = vim.fn.readfile(filepath) + return lines, nil +end + +---@param filepath string +---@return boolean +function M.file_exists_in_index(filepath) + local repo_root = M.get_repo_root(filepath) + if not repo_root then + return false + end + + local rel_path = M.get_relative_path(filepath) + if not rel_path then + return false + end + + vim.fn.system({ 'git', '-C', repo_root, 'ls-files', '--stage', '--', rel_path }) + return vim.v.shell_error == 0 +end + +---@param revision string +---@param filepath string +---@return boolean +function M.file_exists_at_revision(revision, filepath) + local repo_root = M.get_repo_root(filepath) + if not repo_root then + return false + end + + local rel_path = M.get_relative_path(filepath) + if not rel_path then + return false + end + + vim.fn.system({ 'git', '-C', repo_root, 'cat-file', '-e', revision .. ':' .. rel_path }) + return vim.v.shell_error == 0 +end + +return M diff --git a/lua/diffs/health.lua b/lua/diffs/health.lua new file mode 100644 index 0000000..54a3189 --- /dev/null +++ b/lua/diffs/health.lua @@ -0,0 +1,27 @@ +local M = {} + +function M.check() + vim.health.start('diffs.nvim') + + if vim.fn.has('nvim-0.9.0') == 1 then + vim.health.ok('Neovim 0.9.0+ detected') + else + vim.health.error('diffs.nvim requires Neovim 0.9.0+') + end + + local fugitive_loaded = vim.fn.exists(':Git') == 2 + if fugitive_loaded then + vim.health.ok('vim-fugitive detected') + else + vim.health.warn('vim-fugitive not detected (required for unified diff highlighting)') + end + + local lib = require('diffs.lib') + if lib.has_lib() then + vim.health.ok('libvscode_diff found at ' .. lib.lib_path()) + else + vim.health.info('libvscode_diff not found (optional, using native vim.diff fallback)') + end +end + +return M diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua new file mode 100644 index 0000000..306b0e5 --- /dev/null +++ b/lua/diffs/highlight.lua @@ -0,0 +1,517 @@ +local M = {} + +local dbg = require('diffs.log').dbg +local diff = require('diffs.diff') + +---@param filepath string +---@param from_line integer +---@param count integer +---@return string[] +local function read_line_range(filepath, from_line, count) + if count <= 0 then + return {} + end + local f = io.open(filepath, 'r') + if not f then + return {} + end + local result = {} + local line_num = 0 + for line in f:lines() do + line_num = line_num + 1 + if line_num >= from_line then + table.insert(result, line) + if #result >= count then + break + end + end + end + f:close() + return result +end + +local PRIORITY_CLEAR = 198 +local PRIORITY_SYNTAX = 199 +local PRIORITY_LINE_BG = 200 +local PRIORITY_CHAR_BG = 201 + +---@param bufnr integer +---@param ns integer +---@param hunk diffs.Hunk +---@param col_offset integer +---@param text string +---@param lang string +---@param context_lines? string[] +---@return integer +local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_lines) + local parse_text = text + if context_lines and #context_lines > 0 then + parse_text = text .. '\n' .. table.concat(context_lines, '\n') + end + + local ok, parser_obj = pcall(vim.treesitter.get_string_parser, parse_text, lang) + if not ok or not parser_obj then + return 0 + end + + local trees = parser_obj:parse() + if not trees or #trees == 0 then + return 0 + end + + local query = vim.treesitter.query.get(lang, 'highlights') + if not query then + return 0 + end + + local extmark_count = 0 + local header_line = hunk.start_line - 1 + + for id, node, metadata in query:iter_captures(trees[1]:root(), parse_text) do + local sr, sc, _, ec = node:range() + if sr == 0 then + local capture_name = '@' .. query.captures[id] .. '.' .. lang + + local buf_sr = header_line + local buf_er = header_line + local buf_sc = col_offset + sc + local buf_ec = col_offset + ec + + local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX + + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { + end_row = buf_er, + end_col = buf_ec, + hl_group = capture_name, + priority = priority, + }) + extmark_count = extmark_count + 1 + end + end + + return extmark_count +end + +---@class diffs.HunkOpts +---@field hide_prefix boolean +---@field highlights diffs.Highlights + +---@param bufnr integer +---@param ns integer +---@param code_lines string[] +---@param lang string +---@param line_map table +---@param col_offset integer +---@param covered_lines? table +---@return integer +local function highlight_treesitter( + bufnr, + ns, + code_lines, + lang, + line_map, + col_offset, + covered_lines +) + local code = table.concat(code_lines, '\n') + if code == '' then + return 0 + end + + local ok, parser_obj = pcall(vim.treesitter.get_string_parser, code, lang) + if not ok or not parser_obj then + dbg('failed to create parser for lang: %s', lang) + return 0 + end + + local trees = parser_obj:parse(true) + if not trees or #trees == 0 then + dbg('parse returned no trees for lang: %s', lang) + return 0 + end + + local extmark_count = 0 + parser_obj:for_each_tree(function(tree, ltree) + local tree_lang = ltree:lang() + local query = vim.treesitter.query.get(tree_lang, 'highlights') + if not query then + return + end + + for id, node, metadata in query:iter_captures(tree:root(), code) do + local capture = query.captures[id] + if capture ~= 'spell' and capture ~= 'nospell' then + local capture_name = '@' .. capture .. '.' .. tree_lang + local sr, sc, er, ec = node:range() + + local buf_sr = line_map[sr] + if buf_sr then + local buf_er = line_map[er] or buf_sr + + local buf_sc = sc + col_offset + local buf_ec = ec + col_offset + + local priority = tree_lang == 'diff' and (tonumber(metadata.priority) or 100) + or PRIORITY_SYNTAX + + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { + end_row = buf_er, + end_col = buf_ec, + hl_group = capture_name, + priority = priority, + }) + extmark_count = extmark_count + 1 + if covered_lines then + covered_lines[buf_sr] = true + end + end + end + end + end) + + return extmark_count +end + +---@alias diffs.SyntaxQueryFn fun(line: integer, col: integer): integer, string + +---@param query_fn diffs.SyntaxQueryFn +---@param code_lines string[] +---@return {line: integer, col_start: integer, col_end: integer, hl_name: string}[] +function M.coalesce_syntax_spans(query_fn, code_lines) + local spans = {} + for i, line in ipairs(code_lines) do + local col = 1 + local line_len = #line + + while col <= line_len do + local syn_id, hl_name = query_fn(i, col) + if syn_id == 0 then + col = col + 1 + else + local span_start = col + + col = col + 1 + while col <= line_len do + local next_id, next_name = query_fn(i, col) + if next_id == 0 or next_name ~= hl_name then + break + end + col = col + 1 + end + + if hl_name ~= '' then + table.insert(spans, { + line = i, + col_start = span_start, + col_end = col, + hl_name = hl_name, + }) + end + end + end + end + return spans +end + +---@param bufnr integer +---@param ns integer +---@param hunk diffs.Hunk +---@param code_lines string[] +---@param covered_lines? table +---@param leading_offset? integer +---@return integer +local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset) + local ft = hunk.ft + if not ft then + return 0 + end + + if #code_lines == 0 then + return 0 + end + + leading_offset = leading_offset or 0 + + local scratch = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(scratch, 0, -1, false, code_lines) + vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scratch }) + + local spans = {} + + vim.api.nvim_buf_call(scratch, function() + vim.cmd('setlocal syntax=' .. ft) + vim.cmd('redraw') + + ---@param line integer + ---@param col integer + ---@return integer, string + local function query_fn(line, col) + local syn_id = vim.fn.synID(line, col, 1) + if syn_id == 0 then + return 0, '' + end + return syn_id, vim.fn.synIDattr(vim.fn.synIDtrans(syn_id), 'name') + end + + spans = M.coalesce_syntax_spans(query_fn, code_lines) + end) + + vim.api.nvim_buf_delete(scratch, { force = true }) + + local hunk_line_count = #hunk.lines + local extmark_count = 0 + for _, span in ipairs(spans) do + local adj = span.line - leading_offset + if adj >= 1 and adj <= hunk_line_count then + local buf_line = hunk.start_line + adj - 1 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { + end_col = span.col_end, + hl_group = span.hl_name, + priority = PRIORITY_SYNTAX, + }) + extmark_count = extmark_count + 1 + if covered_lines then + covered_lines[buf_line] = true + end + end + end + + return extmark_count +end + +---@param bufnr integer +---@param ns integer +---@param hunk diffs.Hunk +---@param opts diffs.HunkOpts +function M.highlight_hunk(bufnr, ns, hunk, opts) + local use_ts = hunk.lang and opts.highlights.treesitter.enabled + local use_vim = not use_ts and hunk.ft and opts.highlights.vim.enabled + + local max_lines = use_ts and opts.highlights.treesitter.max_lines or opts.highlights.vim.max_lines + if (use_ts or use_vim) and #hunk.lines > max_lines then + dbg( + 'skipping hunk %s:%d (%d lines > %d max)', + hunk.filename, + hunk.start_line, + #hunk.lines, + max_lines + ) + use_ts = false + use_vim = false + end + + ---@type table + local covered_lines = {} + + local ctx_cfg = opts.highlights.context + local context = (ctx_cfg and ctx_cfg.enabled) and ctx_cfg.lines or 0 + local leading = {} + local trailing = {} + if (use_ts or use_vim) and context > 0 and hunk.file_new_start and hunk.repo_root then + local filepath = vim.fs.joinpath(hunk.repo_root, hunk.filename) + local lead_from = math.max(1, hunk.file_new_start - context) + local lead_count = hunk.file_new_start - lead_from + if lead_count > 0 then + leading = read_line_range(filepath, lead_from, lead_count) + end + local trail_from = hunk.file_new_start + (hunk.file_new_count or 0) + trailing = read_line_range(filepath, trail_from, context) + end + + local extmark_count = 0 + if use_ts then + ---@type string[] + local new_code = {} + ---@type table + local new_map = {} + ---@type string[] + local old_code = {} + ---@type table + local old_map = {} + + for _, pad_line in ipairs(leading) do + table.insert(new_code, pad_line) + table.insert(old_code, pad_line) + end + + for i, line in ipairs(hunk.lines) do + local prefix = line:sub(1, 1) + local stripped = line:sub(2) + local buf_line = hunk.start_line + i - 1 + + if prefix == '+' then + new_map[#new_code] = buf_line + table.insert(new_code, stripped) + elseif prefix == '-' then + old_map[#old_code] = buf_line + table.insert(old_code, stripped) + else + new_map[#new_code] = buf_line + table.insert(new_code, stripped) + table.insert(old_code, stripped) + end + end + + for _, pad_line in ipairs(trailing) do + table.insert(new_code, pad_line) + table.insert(old_code, pad_line) + end + + extmark_count = highlight_treesitter(bufnr, ns, new_code, hunk.lang, new_map, 1, covered_lines) + extmark_count = extmark_count + + highlight_treesitter(bufnr, ns, old_code, hunk.lang, old_map, 1, covered_lines) + + if hunk.header_context and hunk.header_context_col then + local header_line = hunk.start_line - 1 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, { + end_col = hunk.header_context_col + #hunk.header_context, + hl_group = 'DiffsClear', + priority = PRIORITY_CLEAR, + }) + local header_extmarks = highlight_text( + bufnr, + ns, + hunk, + hunk.header_context_col, + hunk.header_context, + hunk.lang, + new_code + ) + if header_extmarks > 0 then + dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks) + end + extmark_count = extmark_count + header_extmarks + end + elseif use_vim then + ---@type string[] + local code_lines = {} + for _, pad_line in ipairs(leading) do + table.insert(code_lines, pad_line) + end + for _, line in ipairs(hunk.lines) do + table.insert(code_lines, line:sub(2)) + end + for _, pad_line in ipairs(trailing) do + table.insert(code_lines, pad_line) + end + extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, #leading) + end + + if + hunk.header_start_line + and hunk.header_lines + and #hunk.header_lines > 0 + and opts.highlights.treesitter.enabled + then + ---@type table + local header_map = {} + for i = 0, #hunk.header_lines - 1 do + header_map[i] = hunk.header_start_line - 1 + i + end + extmark_count = extmark_count + + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0) + end + + ---@type diffs.IntraChanges? + local intra = nil + local intra_cfg = opts.highlights.intra + if intra_cfg and intra_cfg.enabled and #hunk.lines <= intra_cfg.max_lines then + dbg('computing intra for hunk %s:%d (%d lines)', hunk.filename, hunk.start_line, #hunk.lines) + intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm) + if intra then + dbg('intra result: %d add spans, %d del spans', #intra.add_spans, #intra.del_spans) + else + dbg('intra result: nil (no change groups)') + end + elseif intra_cfg and not intra_cfg.enabled then + dbg('intra disabled by config') + elseif intra_cfg and #hunk.lines > intra_cfg.max_lines then + dbg('intra skipped: %d lines > %d max', #hunk.lines, intra_cfg.max_lines) + end + + ---@type table + local char_spans_by_line = {} + if intra then + for _, span in ipairs(intra.add_spans) do + if not char_spans_by_line[span.line] then + char_spans_by_line[span.line] = {} + end + table.insert(char_spans_by_line[span.line], span) + end + for _, span in ipairs(intra.del_spans) do + if not char_spans_by_line[span.line] then + char_spans_by_line[span.line] = {} + end + table.insert(char_spans_by_line[span.line], span) + end + end + + for i, line in ipairs(hunk.lines) do + local buf_line = hunk.start_line + i - 1 + local line_len = #line + local prefix = line:sub(1, 1) + + local is_diff_line = prefix == '+' or prefix == '-' + local line_hl = is_diff_line and (prefix == '+' and 'DiffsAdd' or 'DiffsDelete') or nil + local number_hl = is_diff_line and (prefix == '+' and 'DiffsAddNr' or 'DiffsDeleteNr') or nil + + if opts.hide_prefix then + local virt_hl = (opts.highlights.background and line_hl) or nil + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + virt_text = { { ' ', virt_hl } }, + virt_text_pos = 'overlay', + }) + end + + if line_len > 1 and covered_lines[buf_line] then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { + end_col = line_len, + hl_group = 'DiffsClear', + priority = PRIORITY_CLEAR, + }) + end + + if opts.highlights.background and is_diff_line then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_row = buf_line + 1, + hl_group = line_hl, + hl_eol = true, + priority = PRIORITY_LINE_BG, + }) + if opts.highlights.gutter then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + number_hl_group = number_hl, + priority = PRIORITY_LINE_BG, + }) + end + end + + if char_spans_by_line[i] then + local char_hl = prefix == '+' and 'DiffsAddText' or 'DiffsDeleteText' + for _, span in ipairs(char_spans_by_line[i]) do + dbg( + 'char extmark: line=%d buf_line=%d col=%d..%d hl=%s text="%s"', + i, + buf_line, + span.col_start, + span.col_end, + char_hl, + line:sub(span.col_start + 1, span.col_end) + ) + local ok, err = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { + end_col = span.col_end, + hl_group = char_hl, + priority = PRIORITY_CHAR_BG, + }) + if not ok then + dbg('char extmark FAILED: %s', err) + end + extmark_count = extmark_count + 1 + end + end + end + + dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) +end + +return M diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua new file mode 100644 index 0000000..cdc29d1 --- /dev/null +++ b/lua/diffs/init.lua @@ -0,0 +1,580 @@ +---@class diffs.TreesitterConfig +---@field enabled boolean +---@field max_lines integer + +---@class diffs.VimConfig +---@field enabled boolean +---@field max_lines integer + +---@class diffs.IntraConfig +---@field enabled boolean +---@field algorithm string +---@field max_lines integer + +---@class diffs.ContextConfig +---@field enabled boolean +---@field lines integer + +---@class diffs.Highlights +---@field background boolean +---@field gutter boolean +---@field blend_alpha? number +---@field overrides? table +---@field context diffs.ContextConfig +---@field treesitter diffs.TreesitterConfig +---@field vim diffs.VimConfig +---@field intra diffs.IntraConfig + +---@class diffs.FugitiveConfig +---@field horizontal string|false +---@field vertical string|false + +---@class diffs.ConflictKeymaps +---@field ours string|false +---@field theirs string|false +---@field both string|false +---@field none string|false +---@field next string|false +---@field prev string|false + +---@class diffs.ConflictConfig +---@field enabled boolean +---@field disable_diagnostics boolean +---@field show_virtual_text boolean +---@field keymaps diffs.ConflictKeymaps + +---@class diffs.Config +---@field debug boolean +---@field debounce_ms integer +---@field hide_prefix boolean +---@field highlights diffs.Highlights +---@field fugitive diffs.FugitiveConfig +---@field conflict diffs.ConflictConfig + +---@class diffs +---@field attach fun(bufnr?: integer) +---@field refresh fun(bufnr?: integer) +local M = {} + +local highlight = require('diffs.highlight') +local log = require('diffs.log') +local parser = require('diffs.parser') + +local ns = vim.api.nvim_create_namespace('diffs') + +---@param hex integer +---@param bg_hex integer +---@param alpha number +---@return integer +local function blend_color(hex, bg_hex, alpha) + ---@diagnostic disable: undefined-global + local r = bit.band(bit.rshift(hex, 16), 0xFF) + local g = bit.band(bit.rshift(hex, 8), 0xFF) + local b = bit.band(hex, 0xFF) + + local bg_r = bit.band(bit.rshift(bg_hex, 16), 0xFF) + local bg_g = bit.band(bit.rshift(bg_hex, 8), 0xFF) + local bg_b = bit.band(bg_hex, 0xFF) + + local blend_r = math.floor(r * alpha + bg_r * (1 - alpha)) + local blend_g = math.floor(g * alpha + bg_g * (1 - alpha)) + local blend_b = math.floor(b * alpha + bg_b * (1 - alpha)) + + return bit.bor(bit.lshift(blend_r, 16), bit.lshift(blend_g, 8), blend_b) + ---@diagnostic enable: undefined-global +end + +---@param name string +---@return table +local function resolve_hl(name) + local hl = vim.api.nvim_get_hl(0, { name = name }) + while hl.link do + hl = vim.api.nvim_get_hl(0, { name = hl.link }) + end + return hl +end + +---@type diffs.Config +local default_config = { + debug = false, + debounce_ms = 0, + hide_prefix = false, + highlights = { + background = true, + gutter = true, + context = { + enabled = true, + lines = 25, + }, + treesitter = { + enabled = true, + max_lines = 500, + }, + vim = { + enabled = false, + max_lines = 200, + }, + intra = { + enabled = true, + algorithm = 'default', + max_lines = 500, + }, + }, + fugitive = { + horizontal = 'du', + vertical = 'dU', + }, + conflict = { + enabled = true, + disable_diagnostics = true, + show_virtual_text = true, + keymaps = { + ours = 'doo', + theirs = 'dot', + both = 'dob', + none = 'don', + next = ']x', + prev = '[x', + }, + }, +} + +---@type diffs.Config +local config = vim.deepcopy(default_config) + +local initialized = false + +---@type table +local attached_buffers = {} + +---@type table +local diff_windows = {} + +---@param bufnr integer +---@return boolean +function M.is_fugitive_buffer(bufnr) + return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil +end + +local dbg = log.dbg + +---@param bufnr integer +local function highlight_buffer(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + + local hunks = parser.parse_buffer(bufnr) + dbg('found %d hunks in buffer %d', #hunks, bufnr) + for _, hunk in ipairs(hunks) do + highlight.highlight_hunk(bufnr, ns, hunk, { + hide_prefix = config.hide_prefix, + highlights = config.highlights, + }) + end +end + +---@param bufnr integer +---@return fun() +local function create_debounced_highlight(bufnr) + local timer = nil ---@type table? + return function() + if timer then + timer:stop() ---@diagnostic disable-line: undefined-field + timer:close() ---@diagnostic disable-line: undefined-field + timer = nil + end + local t = vim.uv.new_timer() + if not t then + highlight_buffer(bufnr) + return + end + timer = t + t:start( + config.debounce_ms, + 0, + vim.schedule_wrap(function() + if timer == t then + timer = nil + t:close() + end + highlight_buffer(bufnr) + end) + ) + end +end + +local function compute_highlight_groups() + local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) + local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' }) + local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' }) + local diff_added = resolve_hl('diffAdded') + local diff_removed = resolve_hl('diffRemoved') + + local bg = normal.bg or 0x1e1e2e + local add_bg = diff_add.bg or 0x2e4a3a + local del_bg = diff_delete.bg or 0x4a2e3a + local add_fg = diff_added.fg or diff_add.fg or 0x80c080 + local del_fg = diff_removed.fg or diff_delete.fg or 0xc08080 + + local blended_add = blend_color(add_bg, bg, 0.4) + local blended_del = blend_color(del_bg, bg, 0.4) + + local alpha = config.highlights.blend_alpha or 0.6 + local blended_add_text = blend_color(add_fg, bg, alpha) + local blended_del_text = blend_color(del_fg, bg, alpha) + + vim.api.nvim_set_hl(0, 'DiffsClear', { default = true, fg = normal.fg or 0xc0c0c0 }) + vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add }) + vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del }) + vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add }) + vim.api.nvim_set_hl( + 0, + 'DiffsDeleteNr', + { default = true, fg = blended_del_text, bg = blended_del } + ) + vim.api.nvim_set_hl(0, 'DiffsAddText', { default = true, bg = blended_add_text }) + vim.api.nvim_set_hl(0, 'DiffsDeleteText', { default = true, bg = blended_del_text }) + + dbg('highlight groups: Normal.bg=#%06x DiffAdd.bg=#%06x diffAdded.fg=#%06x', bg, add_bg, add_fg) + dbg( + 'DiffsAdd.bg=#%06x DiffsAddText.bg=#%06x DiffsAddNr.fg=#%06x', + blended_add, + blended_add_text, + add_fg + ) + dbg('DiffsDelete.bg=#%06x DiffsDeleteText.bg=#%06x', blended_del, blended_del_text) + + local diff_change = resolve_hl('DiffChange') + local diff_text = resolve_hl('DiffText') + + vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { default = true, bg = diff_add.bg }) + vim.api.nvim_set_hl( + 0, + 'DiffsDiffDelete', + { default = true, fg = diff_delete.fg, bg = diff_delete.bg } + ) + vim.api.nvim_set_hl(0, 'DiffsDiffChange', { default = true, bg = diff_change.bg }) + vim.api.nvim_set_hl(0, 'DiffsDiffText', { default = true, bg = diff_text.bg }) + + local change_bg = diff_change.bg or 0x3a3a4a + local text_bg = diff_text.bg or 0x4a4a5a + local change_fg = diff_change.fg or diff_text.fg or 0x80a0c0 + + local blended_ours = blend_color(add_bg, bg, 0.4) + local blended_theirs = blend_color(change_bg, bg, 0.4) + local blended_base = blend_color(text_bg, bg, 0.3) + local blended_ours_nr = blend_color(add_fg, bg, alpha) + local blended_theirs_nr = blend_color(change_fg, bg, alpha) + local blended_base_nr = blend_color(change_fg, bg, 0.4) + + vim.api.nvim_set_hl(0, 'DiffsConflictOurs', { default = true, bg = blended_ours }) + vim.api.nvim_set_hl(0, 'DiffsConflictTheirs', { default = true, bg = blended_theirs }) + vim.api.nvim_set_hl(0, 'DiffsConflictBase', { default = true, bg = blended_base }) + vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { default = true, fg = 0x808080, bold = true }) + vim.api.nvim_set_hl( + 0, + 'DiffsConflictOursNr', + { default = true, fg = blended_ours_nr, bg = blended_ours } + ) + vim.api.nvim_set_hl( + 0, + 'DiffsConflictTheirsNr', + { default = true, fg = blended_theirs_nr, bg = blended_theirs } + ) + vim.api.nvim_set_hl( + 0, + 'DiffsConflictBaseNr', + { default = true, fg = blended_base_nr, bg = blended_base } + ) + + if config.highlights.overrides then + for group, hl in pairs(config.highlights.overrides) do + vim.api.nvim_set_hl(0, group, hl) + end + end +end + +local function init() + if initialized then + return + end + initialized = true + + local opts = vim.g.diffs or {} + + vim.validate({ + debug = { opts.debug, 'boolean', true }, + debounce_ms = { opts.debounce_ms, 'number', true }, + hide_prefix = { opts.hide_prefix, 'boolean', true }, + highlights = { opts.highlights, 'table', true }, + }) + + if opts.highlights then + vim.validate({ + ['highlights.background'] = { opts.highlights.background, 'boolean', true }, + ['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true }, + ['highlights.blend_alpha'] = { opts.highlights.blend_alpha, 'number', true }, + ['highlights.overrides'] = { opts.highlights.overrides, 'table', true }, + ['highlights.context'] = { opts.highlights.context, 'table', true }, + ['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true }, + ['highlights.vim'] = { opts.highlights.vim, 'table', true }, + ['highlights.intra'] = { opts.highlights.intra, 'table', true }, + }) + + if opts.highlights.context then + vim.validate({ + ['highlights.context.enabled'] = { opts.highlights.context.enabled, 'boolean', true }, + ['highlights.context.lines'] = { opts.highlights.context.lines, 'number', true }, + }) + end + + if opts.highlights.treesitter then + vim.validate({ + ['highlights.treesitter.enabled'] = { opts.highlights.treesitter.enabled, 'boolean', true }, + ['highlights.treesitter.max_lines'] = { + opts.highlights.treesitter.max_lines, + 'number', + true, + }, + }) + end + + if opts.highlights.vim then + vim.validate({ + ['highlights.vim.enabled'] = { opts.highlights.vim.enabled, 'boolean', true }, + ['highlights.vim.max_lines'] = { opts.highlights.vim.max_lines, 'number', true }, + }) + end + + if opts.highlights.intra then + vim.validate({ + ['highlights.intra.enabled'] = { opts.highlights.intra.enabled, 'boolean', true }, + ['highlights.intra.algorithm'] = { + opts.highlights.intra.algorithm, + function(v) + return v == nil or v == 'default' or v == 'vscode' + end, + "'default' or 'vscode'", + }, + ['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true }, + }) + end + end + + if opts.fugitive then + vim.validate({ + ['fugitive.horizontal'] = { + opts.fugitive.horizontal, + function(v) + return v == false or type(v) == 'string' + end, + 'string or false', + }, + ['fugitive.vertical'] = { + opts.fugitive.vertical, + function(v) + return v == false or type(v) == 'string' + end, + 'string or false', + }, + }) + end + + if opts.conflict then + vim.validate({ + ['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true }, + ['conflict.disable_diagnostics'] = { opts.conflict.disable_diagnostics, 'boolean', true }, + ['conflict.show_virtual_text'] = { opts.conflict.show_virtual_text, 'boolean', true }, + ['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true }, + }) + + if opts.conflict.keymaps then + local keymap_validator = function(v) + return v == false or type(v) == 'string' + end + for _, key in ipairs({ 'ours', 'theirs', 'both', 'none', 'next', 'prev' }) do + vim.validate({ + ['conflict.keymaps.' .. key] = { + opts.conflict.keymaps[key], + keymap_validator, + 'string or false', + }, + }) + end + end + end + + if opts.debounce_ms and opts.debounce_ms < 0 then + error('diffs: debounce_ms must be >= 0') + end + if + opts.highlights + and opts.highlights.context + and opts.highlights.context.lines + and opts.highlights.context.lines < 0 + then + error('diffs: highlights.context.lines must be >= 0') + end + if + opts.highlights + and opts.highlights.treesitter + and opts.highlights.treesitter.max_lines + and opts.highlights.treesitter.max_lines < 1 + then + error('diffs: highlights.treesitter.max_lines must be >= 1') + end + if + opts.highlights + and opts.highlights.vim + and opts.highlights.vim.max_lines + and opts.highlights.vim.max_lines < 1 + then + error('diffs: highlights.vim.max_lines must be >= 1') + end + if + opts.highlights + and opts.highlights.intra + and opts.highlights.intra.max_lines + and opts.highlights.intra.max_lines < 1 + then + error('diffs: highlights.intra.max_lines must be >= 1') + end + if + opts.highlights + and opts.highlights.blend_alpha + and (opts.highlights.blend_alpha < 0 or opts.highlights.blend_alpha > 1) + then + error('diffs: highlights.blend_alpha must be >= 0 and <= 1') + end + + config = vim.tbl_deep_extend('force', default_config, opts) + log.set_enabled(config.debug) + + compute_highlight_groups() + + vim.api.nvim_create_autocmd('ColorScheme', { + callback = function() + compute_highlight_groups() + for bufnr, _ in pairs(attached_buffers) do + highlight_buffer(bufnr) + end + end, + }) + + vim.api.nvim_create_autocmd('WinClosed', { + callback = function(args) + local win = tonumber(args.match) + if win and diff_windows[win] then + diff_windows[win] = nil + end + end, + }) +end + +---@param bufnr? integer +function M.attach(bufnr) + init() + bufnr = bufnr or vim.api.nvim_get_current_buf() + + if attached_buffers[bufnr] then + return + end + attached_buffers[bufnr] = true + + dbg('attaching to buffer %d', bufnr) + + local debounced = create_debounced_highlight(bufnr) + + highlight_buffer(bufnr) + + vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { + buffer = bufnr, + callback = debounced, + }) + + vim.api.nvim_create_autocmd('Syntax', { + buffer = bufnr, + callback = function() + dbg('syntax event, re-highlighting buffer %d', bufnr) + highlight_buffer(bufnr) + end, + }) + + vim.api.nvim_create_autocmd('BufReadPost', { + buffer = bufnr, + callback = function() + dbg('BufReadPost event, re-highlighting buffer %d', bufnr) + highlight_buffer(bufnr) + end, + }) + + vim.api.nvim_create_autocmd('BufWipeout', { + buffer = bufnr, + callback = function() + attached_buffers[bufnr] = nil + end, + }) +end + +---@param bufnr? integer +function M.refresh(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + highlight_buffer(bufnr) +end + +local DIFF_WINHIGHLIGHT = table.concat({ + 'DiffAdd:DiffsDiffAdd', + 'DiffDelete:DiffsDiffDelete', + 'DiffChange:DiffsDiffChange', + 'DiffText:DiffsDiffText', +}, ',') + +function M.attach_diff() + init() + local tabpage = vim.api.nvim_get_current_tabpage() + local wins = vim.api.nvim_tabpage_list_wins(tabpage) + + local diff_wins = {} + + for _, win in ipairs(wins) do + if vim.api.nvim_win_is_valid(win) and vim.wo[win].diff then + table.insert(diff_wins, win) + end + end + + if #diff_wins == 0 then + return + end + + for _, win in ipairs(diff_wins) do + vim.api.nvim_set_option_value('winhighlight', DIFF_WINHIGHLIGHT, { win = win }) + diff_windows[win] = true + dbg('applied diff winhighlight to window %d', win) + end +end + +function M.detach_diff() + for win, _ in pairs(diff_windows) do + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_set_option_value('winhighlight', '', { win = win }) + end + diff_windows[win] = nil + end +end + +---@return diffs.FugitiveConfig +function M.get_fugitive_config() + init() + return config.fugitive +end + +---@return diffs.ConflictConfig +function M.get_conflict_config() + init() + return config.conflict +end + +return M diff --git a/lua/diffs/lib.lua b/lua/diffs/lib.lua new file mode 100644 index 0000000..5b3254b --- /dev/null +++ b/lua/diffs/lib.lua @@ -0,0 +1,214 @@ +local M = {} + +local dbg = require('diffs.log').dbg + +---@type table? +local cached_handle = nil + +---@type boolean +local download_in_progress = false + +---@return string +local function get_os() + local os_name = jit.os:lower() + if os_name == 'osx' then + return 'macos' + end + return os_name +end + +---@return string +local function get_arch() + return jit.arch:lower() +end + +---@return string +local function get_ext() + local os_name = jit.os:lower() + if os_name == 'windows' then + return 'dll' + elseif os_name == 'osx' then + return 'dylib' + end + return 'so' +end + +---@return string +local function lib_dir() + return vim.fn.stdpath('data') .. '/diffs/lib' +end + +---@return string +local function lib_path() + return lib_dir() .. '/libvscode_diff.' .. get_ext() +end + +---@return string +local function version_path() + return lib_dir() .. '/version' +end + +local EXPECTED_VERSION = '2.18.0' + +---@return boolean +function M.has_lib() + if cached_handle then + return true + end + return vim.fn.filereadable(lib_path()) == 1 +end + +---@return string +function M.lib_path() + return lib_path() +end + +---@return table? +function M.load() + if cached_handle then + return cached_handle + end + + local path = lib_path() + if vim.fn.filereadable(path) ~= 1 then + return nil + end + + local ffi = require('ffi') + + ffi.cdef([[ + typedef struct { + int start_line; + int end_line; + } DiffsLineRange; + + typedef struct { + int start_line; + int start_col; + int end_line; + int end_col; + } DiffsCharRange; + + typedef struct { + DiffsCharRange original; + DiffsCharRange modified; + } DiffsRangeMapping; + + typedef struct { + DiffsLineRange original; + DiffsLineRange modified; + DiffsRangeMapping* inner_changes; + int inner_change_count; + } DiffsDetailedMapping; + + typedef struct { + DiffsDetailedMapping* mappings; + int count; + int capacity; + } DiffsDetailedMappingArray; + + typedef struct { + DiffsLineRange original; + DiffsLineRange modified; + } DiffsMovedText; + + typedef struct { + DiffsMovedText* moves; + int count; + int capacity; + } DiffsMovedTextArray; + + typedef struct { + DiffsDetailedMappingArray changes; + DiffsMovedTextArray moves; + bool hit_timeout; + } DiffsLinesDiff; + + typedef struct { + bool ignore_trim_whitespace; + int max_computation_time_ms; + bool compute_moves; + bool extend_to_subwords; + } DiffsDiffOptions; + + DiffsLinesDiff* compute_diff( + const char** original_lines, + int original_count, + const char** modified_lines, + int modified_count, + const DiffsDiffOptions* options + ); + + void free_lines_diff(DiffsLinesDiff* diff); + ]]) + + local ok, handle = pcall(ffi.load, path) + if not ok then + dbg('failed to load libvscode_diff: %s', handle) + return nil + end + + cached_handle = handle + return handle +end + +---@param callback fun(handle: table?) +function M.ensure(callback) + if cached_handle then + callback(cached_handle) + return + end + + if M.has_lib() then + callback(M.load()) + return + end + + if download_in_progress then + dbg('download already in progress') + callback(nil) + return + end + + download_in_progress = true + + local dir = lib_dir() + vim.fn.mkdir(dir, 'p') + + local os_name = get_os() + local arch = get_arch() + local ext = get_ext() + local filename = ('libvscode_diff_%s_%s_%s.%s'):format(os_name, arch, EXPECTED_VERSION, ext) + local url = ('https://github.com/esmuellert/vscode-diff.nvim/releases/download/v%s/%s'):format( + EXPECTED_VERSION, + filename + ) + + local dest = lib_path() + vim.notify('[diffs] downloading libvscode_diff...', vim.log.levels.INFO) + + local cmd = { 'curl', '-fSL', '-o', dest, url } + + vim.system(cmd, {}, function(result) + download_in_progress = false + vim.schedule(function() + if result.code ~= 0 then + vim.notify('[diffs] failed to download libvscode_diff', vim.log.levels.WARN) + dbg('curl failed: %s', result.stderr or '') + callback(nil) + return + end + + local f = io.open(version_path(), 'w') + if f then + f:write(EXPECTED_VERSION) + f:close() + end + + vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) + callback(M.load()) + end) + end) +end + +return M diff --git a/lua/fugitive-ts/log.lua b/lua/diffs/log.lua similarity index 79% rename from lua/fugitive-ts/log.lua rename to lua/diffs/log.lua index be43978..08abcc6 100644 --- a/lua/fugitive-ts/log.lua +++ b/lua/diffs/log.lua @@ -13,7 +13,7 @@ function M.dbg(msg, ...) if not enabled then return end - vim.notify('[fugitive-ts] ' .. string.format(msg, ...), vim.log.levels.DEBUG) + vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG) end return M diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua new file mode 100644 index 0000000..43eb1f6 --- /dev/null +++ b/lua/diffs/parser.lua @@ -0,0 +1,242 @@ +---@class diffs.Hunk +---@field filename string +---@field ft string? +---@field lang string? +---@field start_line integer +---@field header_context string? +---@field header_context_col integer? +---@field lines string[] +---@field header_start_line integer? +---@field header_lines string[]? +---@field file_old_start integer? +---@field file_old_count integer? +---@field file_new_start integer? +---@field file_new_count integer? +---@field repo_root string? + +local M = {} + +local dbg = require('diffs.log').dbg + +---@param filepath string +---@param n integer +---@return string[]? +local function read_first_lines(filepath, n) + local f = io.open(filepath, 'r') + if not f then + return nil + end + local lines = {} + for _ = 1, n do + local line = f:read('*l') + if not line then + break + end + table.insert(lines, line) + end + f:close() + return #lines > 0 and lines or nil +end + +---@param filename string +---@param repo_root string? +---@return string? +local function get_ft_from_filename(filename, repo_root) + if repo_root then + local full_path = vim.fs.joinpath(repo_root, filename) + + local buf = vim.fn.bufnr(full_path) + if buf ~= -1 then + local ft = vim.api.nvim_get_option_value('filetype', { buf = buf }) + if ft and ft ~= '' then + dbg('filetype from existing buffer %d: %s', buf, ft) + return ft + end + end + end + + local ft = vim.filetype.match({ filename = filename }) + if ft then + dbg('filetype from filename: %s', ft) + return ft + end + + if repo_root then + local full_path = vim.fs.joinpath(repo_root, filename) + local contents = read_first_lines(full_path, 10) + if contents then + ft = vim.filetype.match({ filename = filename, contents = contents }) + if ft then + dbg('filetype from file content: %s', ft) + return ft + end + end + end + + dbg('no filetype for: %s', filename) + return nil +end + +---@param ft string +---@return string? +local function get_lang_from_ft(ft) + local lang = vim.treesitter.language.get_lang(ft) + if lang then + local ok = pcall(vim.treesitter.language.inspect, lang) + if ok then + return lang + end + dbg('no parser for lang: %s (ft: %s)', lang, ft) + else + dbg('no ts lang for filetype: %s', ft) + end + return nil +end + +---@param bufnr integer +---@return string? +local function get_repo_root(bufnr) + local ok, repo_root = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_repo_root') + if ok and repo_root then + return repo_root + end + + local ok2, git_dir = pcall(vim.api.nvim_buf_get_var, bufnr, 'git_dir') + if ok2 and git_dir then + return vim.fn.fnamemodify(git_dir, ':h') + end + + return nil +end + +---@param bufnr integer +---@return diffs.Hunk[] +function M.parse_buffer(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local repo_root = get_repo_root(bufnr) + ---@type diffs.Hunk[] + local hunks = {} + + ---@type string? + local current_filename = nil + ---@type string? + local current_ft = nil + ---@type string? + local current_lang = nil + ---@type integer? + local hunk_start = nil + ---@type string? + local hunk_header_context = nil + ---@type integer? + local hunk_header_context_col = nil + ---@type string[] + local hunk_lines = {} + ---@type integer? + local hunk_count = nil + ---@type integer? + local header_start = nil + ---@type string[] + local header_lines = {} + ---@type integer? + local file_old_start = nil + ---@type integer? + local file_old_count = nil + ---@type integer? + local file_new_start = nil + ---@type integer? + local file_new_count = nil + + local function flush_hunk() + if hunk_start and #hunk_lines > 0 then + local hunk = { + filename = current_filename, + ft = current_ft, + lang = current_lang, + start_line = hunk_start, + header_context = hunk_header_context, + header_context_col = hunk_header_context_col, + lines = hunk_lines, + file_old_start = file_old_start, + file_old_count = file_old_count, + file_new_start = file_new_start, + file_new_count = file_new_count, + repo_root = repo_root, + } + if hunk_count == 1 and header_start and #header_lines > 0 then + hunk.header_start_line = header_start + hunk.header_lines = header_lines + end + table.insert(hunks, hunk) + end + hunk_start = nil + hunk_header_context = nil + hunk_header_context_col = nil + hunk_lines = {} + file_old_start = nil + file_old_count = nil + file_new_start = nil + file_new_count = nil + end + + for i, line in ipairs(lines) do + local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$') + if filename then + flush_hunk() + current_filename = filename + current_ft = get_ft_from_filename(filename, repo_root) + current_lang = current_ft and get_lang_from_ft(current_ft) or nil + if current_lang then + dbg('file: %s -> lang: %s', filename, current_lang) + elseif current_ft then + dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft) + end + hunk_count = 0 + header_start = i + header_lines = {} + elseif line:match('^@@.-@@') then + flush_hunk() + hunk_start = i + local hs, hc, hs2, hc2 = line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@') + if hs then + file_old_start = tonumber(hs) + file_old_count = tonumber(hc) or 1 + file_new_start = tonumber(hs2) + file_new_count = tonumber(hc2) or 1 + end + local prefix, context = line:match('^(@@.-@@%s*)(.*)') + if context and context ~= '' then + hunk_header_context = context + hunk_header_context_col = #prefix + end + if hunk_count then + hunk_count = hunk_count + 1 + end + elseif hunk_start then + local prefix = line:sub(1, 1) + if prefix == ' ' or prefix == '+' or prefix == '-' then + table.insert(hunk_lines, line) + elseif + line == '' + or line:match('^[MADRC%?!]%s+') + or line:match('^diff ') + or line:match('^index ') + or line:match('^Binary ') + then + flush_hunk() + current_filename = nil + current_ft = nil + current_lang = nil + header_start = nil + end + end + if header_start and not hunk_start then + table.insert(header_lines, line) + end + end + + flush_hunk() + + return hunks +end + +return M diff --git a/lua/fugitive-ts/health.lua b/lua/fugitive-ts/health.lua deleted file mode 100644 index 726efb0..0000000 --- a/lua/fugitive-ts/health.lua +++ /dev/null @@ -1,20 +0,0 @@ -local M = {} - -function M.check() - vim.health.start('fugitive-ts.nvim') - - if vim.fn.has('nvim-0.9.0') == 1 then - vim.health.ok('Neovim 0.9.0+ detected') - else - vim.health.error('fugitive-ts.nvim requires Neovim 0.9.0+') - end - - local fugitive_loaded = vim.fn.exists(':Git') == 2 - if fugitive_loaded then - vim.health.ok('vim-fugitive detected') - else - vim.health.warn('vim-fugitive not detected (required for this plugin to be useful)') - end -end - -return M diff --git a/lua/fugitive-ts/highlight.lua b/lua/fugitive-ts/highlight.lua deleted file mode 100644 index 26b2548..0000000 --- a/lua/fugitive-ts/highlight.lua +++ /dev/null @@ -1,279 +0,0 @@ -local M = {} - -local dbg = require('fugitive-ts.log').dbg - ----@param bufnr integer ----@param ns integer ----@param hunk fugitive-ts.Hunk ----@param col_offset integer ----@param text string ----@param lang string ----@return integer -local function highlight_text(bufnr, ns, hunk, col_offset, text, lang) - local ok, parser_obj = pcall(vim.treesitter.get_string_parser, text, lang) - if not ok or not parser_obj then - return 0 - end - - local trees = parser_obj:parse() - if not trees or #trees == 0 then - return 0 - end - - local query = vim.treesitter.query.get(lang, 'highlights') - if not query then - return 0 - end - - local extmark_count = 0 - local header_line = hunk.start_line - 1 - - for id, node, _ in query:iter_captures(trees[1]:root(), text) do - local capture_name = '@' .. query.captures[id] - local sr, sc, er, ec = node:range() - - local buf_sr = header_line + sr - local buf_er = header_line + er - local buf_sc = col_offset + sc - local buf_ec = col_offset + ec - - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { - end_row = buf_er, - end_col = buf_ec, - hl_group = capture_name, - priority = 200, - }) - extmark_count = extmark_count + 1 - end - - return extmark_count -end - ----@class fugitive-ts.HunkOpts ----@field hide_prefix boolean ----@field treesitter fugitive-ts.TreesitterConfig ----@field vim fugitive-ts.VimConfig ----@field highlights fugitive-ts.Highlights - ----@param bufnr integer ----@param ns integer ----@param hunk fugitive-ts.Hunk ----@param code_lines string[] ----@return integer -local function highlight_treesitter(bufnr, ns, hunk, code_lines) - local lang = hunk.lang - if not lang then - return 0 - end - - local code = table.concat(code_lines, '\n') - if code == '' then - return 0 - end - - local ok, parser_obj = pcall(vim.treesitter.get_string_parser, code, lang) - if not ok or not parser_obj then - dbg('failed to create parser for lang: %s', lang) - return 0 - end - - local trees = parser_obj:parse() - if not trees or #trees == 0 then - dbg('parse returned no trees for lang: %s', lang) - return 0 - end - - local query = vim.treesitter.query.get(lang, 'highlights') - if not query then - dbg('no highlights query for lang: %s', lang) - return 0 - end - - if hunk.header_context and hunk.header_context_col then - local header_line = hunk.start_line - 1 - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, { - end_col = hunk.header_context_col + #hunk.header_context, - hl_group = 'Normal', - priority = 199, - }) - local header_extmarks = - highlight_text(bufnr, ns, hunk, hunk.header_context_col, hunk.header_context, lang) - if header_extmarks > 0 then - dbg('header %s:%d applied %d extmarks', hunk.filename, hunk.start_line, header_extmarks) - end - end - - local extmark_count = 0 - for id, node, _ in query:iter_captures(trees[1]:root(), code) do - local capture_name = '@' .. query.captures[id] - local sr, sc, er, ec = node:range() - - local buf_sr = hunk.start_line + sr - local buf_er = hunk.start_line + er - local buf_sc = sc + 1 - local buf_ec = ec + 1 - - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { - end_row = buf_er, - end_col = buf_ec, - hl_group = capture_name, - priority = 200, - }) - extmark_count = extmark_count + 1 - end - - return extmark_count -end - ----@param bufnr integer ----@param ns integer ----@param hunk fugitive-ts.Hunk ----@param code_lines string[] ----@return integer -local function highlight_vim_syntax(bufnr, ns, hunk, code_lines) - local ft = hunk.ft - if not ft then - return 0 - end - - if #code_lines == 0 then - return 0 - end - - local scratch = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(scratch, 0, -1, false, code_lines) - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scratch }) - - local extmark_count = 0 - - vim.api.nvim_buf_call(scratch, function() - vim.cmd('setlocal syntax=' .. ft) - vim.cmd('redraw') - - for i, line in ipairs(code_lines) do - local col = 1 - local line_len = #line - - while col <= line_len do - local syn_id = vim.fn.synID(i, col, 1) - if syn_id == 0 then - col = col + 1 - else - local hl_name = vim.fn.synIDattr(vim.fn.synIDtrans(syn_id), 'name') - local span_start = col - - col = col + 1 - while col <= line_len do - local next_id = vim.fn.synID(i, col, 1) - if next_id == 0 then - break - end - local next_name = vim.fn.synIDattr(vim.fn.synIDtrans(next_id), 'name') - if next_name ~= hl_name then - break - end - col = col + 1 - end - - if hl_name ~= '' then - local buf_line = hunk.start_line + i - 1 - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span_start, { - end_col = col, - hl_group = hl_name, - priority = 200, - }) - extmark_count = extmark_count + 1 - end - end - end - end - end) - - vim.api.nvim_buf_delete(scratch, { force = true }) - - return extmark_count -end - ----@param bufnr integer ----@param ns integer ----@param hunk fugitive-ts.Hunk ----@param opts fugitive-ts.HunkOpts -function M.highlight_hunk(bufnr, ns, hunk, opts) - local use_ts = hunk.lang and opts.treesitter.enabled - local use_vim = not use_ts and hunk.ft and opts.vim.enabled - - local max_lines = use_ts and opts.treesitter.max_lines or opts.vim.max_lines - if (use_ts or use_vim) and #hunk.lines > max_lines then - dbg( - 'skipping hunk %s:%d (%d lines > %d max)', - hunk.filename, - hunk.start_line, - #hunk.lines, - max_lines - ) - use_ts = false - use_vim = false - end - - local apply_syntax = use_ts or use_vim - - ---@type string[] - local code_lines = {} - if apply_syntax then - for _, line in ipairs(hunk.lines) do - table.insert(code_lines, line:sub(2)) - end - end - - local extmark_count = 0 - if use_ts then - extmark_count = highlight_treesitter(bufnr, ns, hunk, code_lines) - elseif use_vim then - extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines) - end - - local syntax_applied = extmark_count > 0 - - for i, line in ipairs(hunk.lines) do - local buf_line = hunk.start_line + i - 1 - local line_len = #line - local prefix = line:sub(1, 1) - - local is_diff_line = prefix == '+' or prefix == '-' - local line_hl = is_diff_line and (prefix == '+' and 'FugitiveTsAdd' or 'FugitiveTsDelete') - or nil - local number_hl = is_diff_line and (prefix == '+' and 'FugitiveTsAddNr' or 'FugitiveTsDeleteNr') - or nil - - if opts.hide_prefix then - local virt_hl = (opts.highlights.background and line_hl) or nil - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - virt_text = { { ' ', virt_hl } }, - virt_text_pos = 'overlay', - }) - end - - if opts.highlights.background and is_diff_line then - local extmark_opts = { - line_hl_group = line_hl, - priority = 198, - } - if opts.highlights.gutter then - extmark_opts.number_hl_group = number_hl - end - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, extmark_opts) - end - - if line_len > 1 and syntax_applied then - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 1, { - end_col = line_len, - hl_group = 'Normal', - priority = 199, - }) - end - end - - dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) -end - -return M diff --git a/lua/fugitive-ts/init.lua b/lua/fugitive-ts/init.lua deleted file mode 100644 index 4dbab38..0000000 --- a/lua/fugitive-ts/init.lua +++ /dev/null @@ -1,354 +0,0 @@ ----@class fugitive-ts.Highlights ----@field background boolean ----@field gutter boolean - ----@class fugitive-ts.TreesitterConfig ----@field enabled boolean ----@field max_lines integer - ----@class fugitive-ts.VimConfig ----@field enabled boolean ----@field max_lines integer - ----@class fugitive-ts.Config ----@field enabled boolean ----@field debug boolean ----@field debounce_ms integer ----@field hide_prefix boolean ----@field treesitter fugitive-ts.TreesitterConfig ----@field vim fugitive-ts.VimConfig ----@field highlights fugitive-ts.Highlights - ----@class fugitive-ts ----@field attach fun(bufnr?: integer) ----@field refresh fun(bufnr?: integer) ----@field setup fun(opts?: fugitive-ts.Config) -local M = {} - -local highlight = require('fugitive-ts.highlight') -local log = require('fugitive-ts.log') -local parser = require('fugitive-ts.parser') - -local ns = vim.api.nvim_create_namespace('fugitive_ts') - ----@param hex integer ----@param bg_hex integer ----@param alpha number ----@return integer -local function blend_color(hex, bg_hex, alpha) - ---@diagnostic disable: undefined-global - local r = bit.band(bit.rshift(hex, 16), 0xFF) - local g = bit.band(bit.rshift(hex, 8), 0xFF) - local b = bit.band(hex, 0xFF) - - local bg_r = bit.band(bit.rshift(bg_hex, 16), 0xFF) - local bg_g = bit.band(bit.rshift(bg_hex, 8), 0xFF) - local bg_b = bit.band(bg_hex, 0xFF) - - local blend_r = math.floor(r * alpha + bg_r * (1 - alpha)) - local blend_g = math.floor(g * alpha + bg_g * (1 - alpha)) - local blend_b = math.floor(b * alpha + bg_b * (1 - alpha)) - - return bit.bor(bit.lshift(blend_r, 16), bit.lshift(blend_g, 8), blend_b) - ---@diagnostic enable: undefined-global -end - ----@param name string ----@return table -local function resolve_hl(name) - local hl = vim.api.nvim_get_hl(0, { name = name }) - while hl.link do - hl = vim.api.nvim_get_hl(0, { name = hl.link }) - end - return hl -end - ----@type fugitive-ts.Config -local default_config = { - enabled = true, - debug = false, - debounce_ms = 0, - hide_prefix = false, - treesitter = { - enabled = true, - max_lines = 500, - }, - vim = { - enabled = false, - max_lines = 200, - }, - highlights = { - background = true, - gutter = true, - }, -} - ----@type fugitive-ts.Config -local config = vim.deepcopy(default_config) - ----@type table -local attached_buffers = {} - ----@type table -local diff_windows = {} - ----@param bufnr integer ----@return boolean -function M.is_fugitive_buffer(bufnr) - return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil -end - -local dbg = log.dbg - ----@param bufnr integer -local function highlight_buffer(bufnr) - if not config.enabled then - return - end - - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - - vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - - local hunks = parser.parse_buffer(bufnr) - dbg('found %d hunks in buffer %d', #hunks, bufnr) - for _, hunk in ipairs(hunks) do - highlight.highlight_hunk(bufnr, ns, hunk, { - hide_prefix = config.hide_prefix, - treesitter = config.treesitter, - vim = config.vim, - highlights = config.highlights, - }) - end -end - ----@param bufnr integer ----@return fun() -local function create_debounced_highlight(bufnr) - local timer = nil ---@type table? - return function() - if timer then - timer:stop() ---@diagnostic disable-line: undefined-field - timer:close() ---@diagnostic disable-line: undefined-field - timer = nil - end - local t = vim.uv.new_timer() - if not t then - highlight_buffer(bufnr) - return - end - timer = t - t:start( - config.debounce_ms, - 0, - vim.schedule_wrap(function() - if timer == t then - timer = nil - t:close() - end - highlight_buffer(bufnr) - end) - ) - end -end - ----@param bufnr? integer -function M.attach(bufnr) - bufnr = bufnr or vim.api.nvim_get_current_buf() - - if attached_buffers[bufnr] then - return - end - attached_buffers[bufnr] = true - - dbg('attaching to buffer %d', bufnr) - - local debounced = create_debounced_highlight(bufnr) - - highlight_buffer(bufnr) - - vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { - buffer = bufnr, - callback = debounced, - }) - - vim.api.nvim_create_autocmd('Syntax', { - buffer = bufnr, - callback = function() - dbg('syntax event, re-highlighting buffer %d', bufnr) - highlight_buffer(bufnr) - end, - }) - - vim.api.nvim_create_autocmd('BufReadPost', { - buffer = bufnr, - callback = function() - dbg('BufReadPost event, re-highlighting buffer %d', bufnr) - highlight_buffer(bufnr) - end, - }) - - vim.api.nvim_create_autocmd('BufWipeout', { - buffer = bufnr, - callback = function() - attached_buffers[bufnr] = nil - end, - }) -end - ----@param bufnr? integer -function M.refresh(bufnr) - bufnr = bufnr or vim.api.nvim_get_current_buf() - highlight_buffer(bufnr) -end - -local function compute_highlight_groups() - local normal = vim.api.nvim_get_hl(0, { name = 'Normal' }) - local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' }) - local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' }) - local diff_added = resolve_hl('diffAdded') - local diff_removed = resolve_hl('diffRemoved') - - local bg = normal.bg or 0x1e1e2e - local add_bg = diff_add.bg or 0x2e4a3a - local del_bg = diff_delete.bg or 0x4a2e3a - local add_fg = diff_added.fg or diff_add.fg or 0x80c080 - local del_fg = diff_removed.fg or diff_delete.fg or 0xc08080 - - local blended_add = blend_color(add_bg, bg, 0.4) - local blended_del = blend_color(del_bg, bg, 0.4) - - vim.api.nvim_set_hl(0, 'FugitiveTsAdd', { bg = blended_add }) - vim.api.nvim_set_hl(0, 'FugitiveTsDelete', { bg = blended_del }) - vim.api.nvim_set_hl(0, 'FugitiveTsAddNr', { fg = add_fg, bg = blended_add }) - vim.api.nvim_set_hl(0, 'FugitiveTsDeleteNr', { fg = del_fg, bg = blended_del }) - - local diff_change = resolve_hl('DiffChange') - local diff_text = resolve_hl('DiffText') - - vim.api.nvim_set_hl(0, 'FugitiveTsDiffAdd', { bg = diff_add.bg }) - vim.api.nvim_set_hl(0, 'FugitiveTsDiffDelete', { bg = diff_delete.bg }) - vim.api.nvim_set_hl(0, 'FugitiveTsDiffChange', { bg = diff_change.bg }) - vim.api.nvim_set_hl(0, 'FugitiveTsDiffText', { bg = diff_text.bg }) -end - -local DIFF_WINHIGHLIGHT = table.concat({ - 'DiffAdd:FugitiveTsDiffAdd', - 'DiffDelete:FugitiveTsDiffDelete', - 'DiffChange:FugitiveTsDiffChange', - 'DiffText:FugitiveTsDiffText', -}, ',') - -function M.attach_diff() - if not config.enabled then - return - end - - local tabpage = vim.api.nvim_get_current_tabpage() - local wins = vim.api.nvim_tabpage_list_wins(tabpage) - - local has_fugitive = false - local diff_wins = {} - - for _, win in ipairs(wins) do - if vim.api.nvim_win_is_valid(win) and vim.wo[win].diff then - table.insert(diff_wins, win) - local bufnr = vim.api.nvim_win_get_buf(win) - if M.is_fugitive_buffer(bufnr) then - has_fugitive = true - end - end - end - - if not has_fugitive then - return - end - - for _, win in ipairs(diff_wins) do - vim.api.nvim_set_option_value('winhighlight', DIFF_WINHIGHLIGHT, { win = win }) - diff_windows[win] = true - dbg('applied diff winhighlight to window %d', win) - end -end - -function M.detach_diff() - for win, _ in pairs(diff_windows) do - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_set_option_value('winhighlight', '', { win = win }) - end - diff_windows[win] = nil - end -end - ----@param opts? fugitive-ts.Config -function M.setup(opts) - opts = opts or {} - - vim.validate({ - enabled = { opts.enabled, 'boolean', true }, - debug = { opts.debug, 'boolean', true }, - debounce_ms = { opts.debounce_ms, 'number', true }, - hide_prefix = { opts.hide_prefix, 'boolean', true }, - treesitter = { opts.treesitter, 'table', true }, - vim = { opts.vim, 'table', true }, - highlights = { opts.highlights, 'table', true }, - }) - - if opts.treesitter then - vim.validate({ - ['treesitter.enabled'] = { opts.treesitter.enabled, 'boolean', true }, - ['treesitter.max_lines'] = { opts.treesitter.max_lines, 'number', true }, - }) - end - - if opts.vim then - vim.validate({ - ['vim.enabled'] = { opts.vim.enabled, 'boolean', true }, - ['vim.max_lines'] = { opts.vim.max_lines, 'number', true }, - }) - end - - if opts.highlights then - vim.validate({ - ['highlights.background'] = { opts.highlights.background, 'boolean', true }, - ['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true }, - }) - end - - if opts.debounce_ms and opts.debounce_ms < 0 then - error('fugitive-ts: debounce_ms must be >= 0') - end - if opts.treesitter and opts.treesitter.max_lines and opts.treesitter.max_lines < 1 then - error('fugitive-ts: treesitter.max_lines must be >= 1') - end - if opts.vim and opts.vim.max_lines and opts.vim.max_lines < 1 then - error('fugitive-ts: vim.max_lines must be >= 1') - end - - config = vim.tbl_deep_extend('force', default_config, opts) - log.set_enabled(config.debug) - - compute_highlight_groups() - - vim.api.nvim_create_autocmd('ColorScheme', { - callback = function() - compute_highlight_groups() - for bufnr, _ in pairs(attached_buffers) do - highlight_buffer(bufnr) - end - end, - }) - - vim.api.nvim_create_autocmd('WinClosed', { - callback = function(args) - local win = tonumber(args.match) - if win and diff_windows[win] then - diff_windows[win] = nil - end - end, - }) -end - -return M diff --git a/lua/fugitive-ts/parser.lua b/lua/fugitive-ts/parser.lua deleted file mode 100644 index ab6daed..0000000 --- a/lua/fugitive-ts/parser.lua +++ /dev/null @@ -1,124 +0,0 @@ ----@class fugitive-ts.Hunk ----@field filename string ----@field ft string? ----@field lang string? ----@field start_line integer ----@field header_context string? ----@field header_context_col integer? ----@field lines string[] - -local M = {} - -local dbg = require('fugitive-ts.log').dbg - ----@param filename string ----@return string? -local function get_ft_from_filename(filename) - local ft = vim.filetype.match({ filename = filename }) - if not ft then - dbg('no filetype for: %s', filename) - end - return ft -end - ----@param ft string ----@return string? -local function get_lang_from_ft(ft) - local lang = vim.treesitter.language.get_lang(ft) - if lang then - local ok = pcall(vim.treesitter.language.inspect, lang) - if ok then - return lang - end - dbg('no parser for lang: %s (ft: %s)', lang, ft) - else - dbg('no ts lang for filetype: %s', ft) - end - return nil -end - ----@param bufnr integer ----@return fugitive-ts.Hunk[] -function M.parse_buffer(bufnr) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - ---@type fugitive-ts.Hunk[] - local hunks = {} - - ---@type string? - local current_filename = nil - ---@type string? - local current_ft = nil - ---@type string? - local current_lang = nil - ---@type integer? - local hunk_start = nil - ---@type string? - local hunk_header_context = nil - ---@type integer? - local hunk_header_context_col = nil - ---@type string[] - local hunk_lines = {} - - local function flush_hunk() - if hunk_start and #hunk_lines > 0 and (current_lang or current_ft) then - table.insert(hunks, { - filename = current_filename, - ft = current_ft, - lang = current_lang, - start_line = hunk_start, - header_context = hunk_header_context, - header_context_col = hunk_header_context_col, - lines = hunk_lines, - }) - end - hunk_start = nil - hunk_header_context = nil - hunk_header_context_col = nil - hunk_lines = {} - end - - for i, line in ipairs(lines) do - local filename = line:match('^[MADRC%?!]%s+(.+)$') or line:match('^diff %-%-git a/.+ b/(.+)$') - if filename then - flush_hunk() - current_filename = filename - current_ft = get_ft_from_filename(filename) - current_lang = current_ft and get_lang_from_ft(current_ft) or nil - if current_lang then - dbg('file: %s -> lang: %s', filename, current_lang) - elseif current_ft then - dbg('file: %s -> ft: %s (no ts parser)', filename, current_ft) - end - elseif line:match('^@@.-@@') then - flush_hunk() - hunk_start = i - local prefix, context = line:match('^(@@.-@@%s*)(.*)') - if context and context ~= '' then - hunk_header_context = context - hunk_header_context_col = #prefix - end - elseif hunk_start then - local prefix = line:sub(1, 1) - if prefix == ' ' or prefix == '+' or prefix == '-' then - table.insert(hunk_lines, line) - elseif - line == '' - or line:match('^[MADRC%?!]%s+') - or line:match('^diff ') - or line:match('^index ') - or line:match('^Binary ') - then - flush_hunk() - current_filename = nil - current_ft = nil - current_lang = nil - end - end - end - - flush_hunk() - - return hunks -end - -return M diff --git a/plugin/diffs.lua b/plugin/diffs.lua new file mode 100644 index 0000000..5d3c8b2 --- /dev/null +++ b/plugin/diffs.lua @@ -0,0 +1,84 @@ +if vim.g.loaded_diffs then + return +end +vim.g.loaded_diffs = 1 + +require('diffs.commands').setup() + +vim.api.nvim_create_autocmd('FileType', { + pattern = { 'fugitive', 'git' }, + callback = function(args) + local diffs = require('diffs') + if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then + return + end + diffs.attach(args.buf) + + if args.match == 'fugitive' then + local fugitive_config = diffs.get_fugitive_config() + if fugitive_config.horizontal or fugitive_config.vertical then + require('diffs.fugitive').setup_keymaps(args.buf, fugitive_config) + end + end + end, +}) + +vim.api.nvim_create_autocmd('BufReadCmd', { + pattern = 'diffs://*', + callback = function(args) + require('diffs.commands').read_buffer(args.buf) + end, +}) + +vim.api.nvim_create_autocmd('BufReadPost', { + callback = function(args) + local conflict_config = require('diffs').get_conflict_config() + if conflict_config.enabled then + require('diffs.conflict').attach(args.buf, conflict_config) + end + end, +}) + +vim.api.nvim_create_autocmd('OptionSet', { + pattern = 'diff', + callback = function() + if vim.wo.diff then + require('diffs').attach_diff() + else + require('diffs').detach_diff() + end + end, +}) + +local cmds = require('diffs.commands') +vim.keymap.set('n', '(diffs-gdiff)', function() + cmds.gdiff(nil, false) +end, { desc = 'Unified diff (horizontal)' }) +vim.keymap.set('n', '(diffs-gvdiff)', function() + cmds.gdiff(nil, true) +end, { desc = 'Unified diff (vertical)' }) + +local function conflict_action(fn) + local bufnr = vim.api.nvim_get_current_buf() + local config = require('diffs').get_conflict_config() + fn(bufnr, config) +end + +vim.keymap.set('n', '(diffs-conflict-ours)', function() + conflict_action(require('diffs.conflict').resolve_ours) +end, { desc = 'Accept current (ours) change' }) +vim.keymap.set('n', '(diffs-conflict-theirs)', function() + conflict_action(require('diffs.conflict').resolve_theirs) +end, { desc = 'Accept incoming (theirs) change' }) +vim.keymap.set('n', '(diffs-conflict-both)', function() + conflict_action(require('diffs.conflict').resolve_both) +end, { desc = 'Accept both changes' }) +vim.keymap.set('n', '(diffs-conflict-none)', function() + conflict_action(require('diffs.conflict').resolve_none) +end, { desc = 'Reject both changes' }) +vim.keymap.set('n', '(diffs-conflict-next)', function() + require('diffs.conflict').goto_next(vim.api.nvim_get_current_buf()) +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' }) diff --git a/plugin/fugitive-ts.lua b/plugin/fugitive-ts.lua deleted file mode 100644 index dae4eee..0000000 --- a/plugin/fugitive-ts.lua +++ /dev/null @@ -1,26 +0,0 @@ -if vim.g.loaded_fugitive_ts then - return -end -vim.g.loaded_fugitive_ts = 1 - -vim.api.nvim_create_autocmd('FileType', { - pattern = { 'fugitive', 'git' }, - callback = function(args) - local ft = require('fugitive-ts') - if args.match == 'git' and not ft.is_fugitive_buffer(args.buf) then - return - end - ft.attach(args.buf) - end, -}) - -vim.api.nvim_create_autocmd('OptionSet', { - pattern = 'diff', - callback = function() - if vim.wo.diff then - require('fugitive-ts').attach_diff() - else - require('fugitive-ts').detach_diff() - end - end, -}) diff --git a/scripts/ci.sh b/scripts/ci.sh deleted file mode 100755 index 98d17fb..0000000 --- a/scripts/ci.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -BOLD='\033[1m' -RESET='\033[0m' - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -cd "$ROOT_DIR" - -tmpdir=$(mktemp -d) -trap 'rm -rf "$tmpdir"' EXIT - -run_job() { - local name=$1 - shift - local log="$tmpdir/$name.log" - if "$@" >"$log" 2>&1; then - echo -e "${GREEN}✓${RESET} $name" - return 0 - else - echo -e "${RED}✗${RESET} $name" - cat "$log" - return 1 - fi -} - -echo -e "${BOLD}Running CI jobs in parallel...${RESET}" -echo - -pids=() -jobs_names=() - -run_job "stylua" stylua --check . & -pids+=($!); jobs_names+=("stylua") - -run_job "selene" selene --display-style quiet . & -pids+=($!); jobs_names+=("selene") - -run_job "prettier" prettier --check . & -pids+=($!); jobs_names+=("prettier") - -run_job "busted" env \ - LUA_PATH="/usr/share/lua/5.1/?.lua;/usr/share/lua/5.1/?/init.lua;/usr/lib/lua/5.1/?.lua;/usr/lib/lua/5.1/?/init.lua;;" \ - LUA_CPATH="/usr/lib/lua/5.1/?.so;;" \ - nvim -l /usr/lib/luarocks/rocks-5.1/busted/2.3.0-1/bin/busted --verbose spec/ & -pids+=($!); jobs_names+=("busted") - -failed=0 -for i in "${!pids[@]}"; do - if ! wait "${pids[$i]}"; then - failed=1 - fi -done - -echo -if [ "$failed" -eq 0 ]; then - echo -e "${GREEN}${BOLD}All jobs passed.${RESET}" -else - echo -e "${RED}${BOLD}Some jobs failed.${RESET}" - exit 1 -fi diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua new file mode 100644 index 0000000..daba5d5 --- /dev/null +++ b/spec/commands_spec.lua @@ -0,0 +1,98 @@ +require('spec.helpers') + +local commands = require('diffs.commands') + +describe('commands', function() + describe('setup', function() + it('registers Gdiff, Gvdiff, and Ghdiff commands', function() + commands.setup() + local cmds = vim.api.nvim_get_commands({}) + assert.is_not_nil(cmds.Gdiff) + assert.is_not_nil(cmds.Gvdiff) + assert.is_not_nil(cmds.Ghdiff) + end) + end) + + describe('unified diff generation', function() + local old_lines = { 'local M = {}', 'return M' } + local new_lines = { 'local M = {}', 'local x = 1', 'return M' } + local diff_fn = vim.text and vim.text.diff or vim.diff + + it('generates valid unified diff', function() + local old_content = table.concat(old_lines, '\n') + local new_content = table.concat(new_lines, '\n') + local diff_output = diff_fn(old_content, new_content, { + result_type = 'unified', + ctxlen = 3, + }) + assert.is_not_nil(diff_output) + assert.is_true(diff_output:find('@@ ') ~= nil) + assert.is_true(diff_output:find('+local x = 1') ~= nil) + end) + + it('returns empty for identical content', function() + local content = table.concat(old_lines, '\n') + local diff_output = diff_fn(content, content, { + result_type = 'unified', + ctxlen = 3, + }) + assert.are.equal('', diff_output) + end) + end) + + describe('find_hunk_line', function() + it('finds matching @@ header and returns target line', function() + local diff_lines = { + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + } + local hunk_position = { + hunk_header = '@@ -1,3 +1,4 @@', + offset = 2, + } + local target_line = commands.find_hunk_line(diff_lines, hunk_position) + assert.equals(6, target_line) + end) + + it('returns nil when hunk header not found', function() + local diff_lines = { + 'diff --git a/file.lua b/file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + } + local hunk_position = { + hunk_header = '@@ -99,3 +99,4 @@', + offset = 1, + } + local target_line = commands.find_hunk_line(diff_lines, hunk_position) + assert.is_nil(target_line) + end) + + it('handles multiple hunks and finds correct one', function() + local diff_lines = { + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local x = 1', + ' ', + '@@ -10,3 +11,4 @@', + ' function M.foo()', + '+ print("hello")', + ' end', + } + local hunk_position = { + hunk_header = '@@ -10,3 +11,4 @@', + offset = 2, + } + local target_line = commands.find_hunk_line(diff_lines, hunk_position) + assert.equals(10, target_line) + end) + end) +end) diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua new file mode 100644 index 0000000..75eac23 --- /dev/null +++ b/spec/conflict_spec.lua @@ -0,0 +1,688 @@ +local conflict = require('diffs.conflict') +local helpers = require('spec.helpers') + +local function default_config(overrides) + local cfg = { + enabled = true, + disable_diagnostics = false, + show_virtual_text = true, + keymaps = { + ours = 'doo', + theirs = 'dot', + both = 'dob', + none = 'don', + next = ']x', + prev = '[x', + }, + } + if overrides then + cfg = vim.tbl_deep_extend('force', cfg, overrides) + end + return cfg +end + +local function create_file_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, false) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {}) + return bufnr +end + +local function get_extmarks(bufnr) + return vim.api.nvim_buf_get_extmarks(bufnr, conflict.get_namespace(), 0, -1, { details = true }) +end + +describe('conflict', function() + describe('parse', function() + it('parses a single conflict', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + assert.are.equal(0, regions[1].marker_ours) + assert.are.equal(1, regions[1].ours_start) + assert.are.equal(2, regions[1].ours_end) + assert.are.equal(2, regions[1].marker_sep) + assert.are.equal(3, regions[1].theirs_start) + assert.are.equal(4, regions[1].theirs_end) + assert.are.equal(4, regions[1].marker_theirs) + end) + + it('parses multiple conflicts', function() + local lines = { + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + 'normal line', + '<<<<<<< HEAD', + 'c', + '=======', + 'd', + '>>>>>>> feat', + } + local regions = conflict.parse(lines) + assert.are.equal(2, #regions) + assert.are.equal(0, regions[1].marker_ours) + assert.are.equal(6, regions[2].marker_ours) + end) + + it('parses diff3 format', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '||||||| base', + 'local x = 0', + '=======', + 'local x = 2', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + assert.are.equal(2, regions[1].marker_base) + assert.are.equal(3, regions[1].base_start) + assert.are.equal(4, regions[1].base_end) + end) + + it('handles empty ours section', function() + local lines = { + '<<<<<<< HEAD', + '=======', + 'local x = 2', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + assert.are.equal(1, regions[1].ours_start) + assert.are.equal(1, regions[1].ours_end) + end) + + it('handles empty theirs section', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '=======', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + assert.are.equal(3, regions[1].theirs_start) + assert.are.equal(3, regions[1].theirs_end) + end) + + it('returns empty for no markers', function() + local lines = { 'local x = 1', 'local y = 2' } + local regions = conflict.parse(lines) + assert.are.equal(0, #regions) + end) + + it('discards malformed markers (no separator)', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(0, #regions) + end) + + it('discards malformed markers (no end)', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + } + local regions = conflict.parse(lines) + assert.are.equal(0, #regions) + end) + + it('handles trailing text on marker lines', function() + local lines = { + '<<<<<<< HEAD (some text)', + 'local x = 1', + '======= extra', + 'local x = 2', + '>>>>>>> feature-branch/some-thing', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + end) + + it('handles empty base in diff3', function() + local lines = { + '<<<<<<< HEAD', + 'local x = 1', + '||||||| base', + '=======', + 'local x = 2', + '>>>>>>> feature', + } + local regions = conflict.parse(lines) + assert.are.equal(1, #regions) + assert.are.equal(3, regions[1].base_start) + assert.are.equal(3, regions[1].base_end) + end) + end) + + describe('highlighting', function() + after_each(function() + conflict.detach(vim.api.nvim_get_current_buf()) + end) + + it('applies extmarks for conflict regions', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config()) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + + local has_ours = false + local has_theirs = false + local has_marker = false + for _, mark in ipairs(extmarks) do + local hl = mark[4] and mark[4].hl_group + if hl == 'DiffsConflictOurs' then + has_ours = true + end + if hl == 'DiffsConflictTheirs' then + has_theirs = true + end + if hl == 'DiffsConflictMarker' then + has_marker = true + end + end + assert.is_true(has_ours) + assert.is_true(has_theirs) + assert.is_true(has_marker) + + helpers.delete_buffer(bufnr) + end) + + it('applies virtual text when enabled', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config({ show_virtual_text = true })) + + local extmarks = get_extmarks(bufnr) + local virt_text_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + virt_text_count = virt_text_count + 1 + end + end + assert.are.equal(2, virt_text_count) + + helpers.delete_buffer(bufnr) + end) + + it('does not apply virtual text when disabled', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config({ show_virtual_text = false })) + + local extmarks = get_extmarks(bufnr) + local virt_text_count = 0 + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + virt_text_count = virt_text_count + 1 + end + end + assert.are.equal(0, virt_text_count) + + helpers.delete_buffer(bufnr) + end) + + it('applies number_hl_group to content lines', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config()) + + local extmarks = get_extmarks(bufnr) + local has_ours_nr = false + local has_theirs_nr = false + for _, mark in ipairs(extmarks) do + local nr = mark[4] and mark[4].number_hl_group + if nr == 'DiffsConflictOursNr' then + has_ours_nr = true + end + if nr == 'DiffsConflictTheirsNr' then + has_theirs_nr = true + end + end + assert.is_true(has_ours_nr) + assert.is_true(has_theirs_nr) + + helpers.delete_buffer(bufnr) + end) + + it('highlights base region in diff3', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '||||||| base', + 'local x = 0', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config()) + + local extmarks = get_extmarks(bufnr) + local has_base = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group == 'DiffsConflictBase' then + has_base = true + break + end + end + assert.is_true(has_base) + + helpers.delete_buffer(bufnr) + end) + + it('clears extmarks on detach', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + + conflict.attach(bufnr, default_config()) + assert.is_true(#get_extmarks(bufnr) > 0) + + conflict.detach(bufnr) + assert.are.equal(0, #get_extmarks(bufnr)) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('resolution', function() + local function make_conflict_buffer() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + return bufnr + end + + it('resolve_ours keeps ours content', function() + local bufnr = make_conflict_buffer() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_ours(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 1', lines[1]) + + helpers.delete_buffer(bufnr) + end) + + it('resolve_theirs keeps theirs content', function() + local bufnr = make_conflict_buffer() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_theirs(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 2', lines[1]) + + helpers.delete_buffer(bufnr) + end) + + it('resolve_both keeps ours then theirs', function() + local bufnr = make_conflict_buffer() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_both(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(2, #lines) + assert.are.equal('local x = 1', lines[1]) + assert.are.equal('local x = 2', lines[2]) + + helpers.delete_buffer(bufnr) + end) + + it('resolve_none removes entire block', function() + local bufnr = make_conflict_buffer() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_none(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('', lines[1]) + + helpers.delete_buffer(bufnr) + end) + + it('does nothing when cursor is outside conflict', function() + local bufnr = create_file_buffer({ + 'normal line', + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + conflict.resolve_ours(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(6, #lines) + + helpers.delete_buffer(bufnr) + end) + + it('resolves one conflict among multiple', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + 'middle', + '<<<<<<< HEAD', + 'c', + '=======', + 'd', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_ours(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal('a', lines[1]) + assert.are.equal('middle', lines[2]) + assert.are.equal('<<<<<<< HEAD', lines[3]) + + helpers.delete_buffer(bufnr) + end) + + it('resolve_ours with empty ours section', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + conflict.resolve_ours(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('', lines[1]) + + helpers.delete_buffer(bufnr) + end) + + it('handles diff3 resolution (ignores base)', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '||||||| base', + 'local x = 0', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + conflict.resolve_theirs(bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 2', lines[1]) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('navigation', function() + it('goto_next jumps to next conflict', function() + local bufnr = create_file_buffer({ + 'normal', + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + 'middle', + '<<<<<<< HEAD', + 'c', + '=======', + 'd', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + conflict.goto_next(bufnr) + assert.are.equal(2, vim.api.nvim_win_get_cursor(0)[1]) + + conflict.goto_next(bufnr) + assert.are.equal(8, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(bufnr) + end) + + it('goto_next wraps to first conflict', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + conflict.goto_next(bufnr) + assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(bufnr) + end) + + it('goto_prev jumps to previous conflict', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + 'middle', + '<<<<<<< HEAD', + 'c', + '=======', + 'd', + '>>>>>>> feat', + 'end', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 12, 0 }) + + conflict.goto_prev(bufnr) + assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) + + conflict.goto_prev(bufnr) + assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(bufnr) + end) + + it('goto_prev wraps to last conflict', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + conflict.goto_prev(bufnr) + assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(bufnr) + end) + + it('goto_next does nothing with no conflicts', function() + local bufnr = create_file_buffer({ 'normal line' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + conflict.goto_next(bufnr) + assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('lifecycle', function() + it('attach is idempotent', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + local cfg = default_config() + conflict.attach(bufnr, cfg) + local count1 = #get_extmarks(bufnr) + conflict.attach(bufnr, cfg) + local count2 = #get_extmarks(bufnr) + assert.are.equal(count1, count2) + conflict.detach(bufnr) + helpers.delete_buffer(bufnr) + end) + + it('skips non-file buffers', function() + local bufnr = helpers.create_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr }) + + conflict.attach(bufnr, default_config()) + assert.are.equal(0, #get_extmarks(bufnr)) + + helpers.delete_buffer(bufnr) + end) + + it('skips buffers without conflict markers', function() + local bufnr = create_file_buffer({ 'local x = 1', 'local y = 2' }) + + conflict.attach(bufnr, default_config()) + assert.are.equal(0, #get_extmarks(bufnr)) + + helpers.delete_buffer(bufnr) + end) + + it('re-highlights when markers return after resolution', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + local cfg = default_config() + conflict.attach(bufnr, cfg) + + assert.is_true(#get_extmarks(bufnr) > 0) + + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + conflict.resolve_ours(bufnr, cfg) + assert.are.equal(0, #get_extmarks(bufnr)) + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) + + assert.is_true(#get_extmarks(bufnr) > 0) + + conflict.detach(bufnr) + helpers.delete_buffer(bufnr) + end) + + it('detaches after last conflict resolved', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + conflict.attach(bufnr, default_config()) + + assert.is_true(#get_extmarks(bufnr) > 0) + + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + conflict.resolve_ours(bufnr, default_config()) + + assert.are.equal(0, #get_extmarks(bufnr)) + + helpers.delete_buffer(bufnr) + end) + end) +end) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua new file mode 100644 index 0000000..2cc22ac --- /dev/null +++ b/spec/diff_spec.lua @@ -0,0 +1,163 @@ +require('spec.helpers') +local diff = require('diffs.diff') + +describe('diff', function() + describe('extract_change_groups', function() + it('returns empty for all context lines', function() + local groups = diff.extract_change_groups({ ' line1', ' line2', ' line3' }) + assert.are.equal(0, #groups) + end) + + it('returns empty for pure additions', function() + local groups = diff.extract_change_groups({ '+line1', '+line2' }) + assert.are.equal(0, #groups) + end) + + it('returns empty for pure deletions', function() + local groups = diff.extract_change_groups({ '-line1', '-line2' }) + assert.are.equal(0, #groups) + end) + + it('extracts single change group', function() + local groups = diff.extract_change_groups({ + ' context', + '-old line', + '+new line', + ' context', + }) + assert.are.equal(1, #groups) + assert.are.equal(1, #groups[1].del_lines) + assert.are.equal(1, #groups[1].add_lines) + assert.are.equal('old line', groups[1].del_lines[1].text) + assert.are.equal('new line', groups[1].add_lines[1].text) + end) + + it('extracts multiple change groups separated by context', function() + local groups = diff.extract_change_groups({ + '-old1', + '+new1', + ' context', + '-old2', + '+new2', + }) + assert.are.equal(2, #groups) + assert.are.equal('old1', groups[1].del_lines[1].text) + assert.are.equal('new1', groups[1].add_lines[1].text) + assert.are.equal('old2', groups[2].del_lines[1].text) + assert.are.equal('new2', groups[2].add_lines[1].text) + end) + + it('tracks correct line indices', function() + local groups = diff.extract_change_groups({ + ' context', + '-deleted', + '+added', + }) + assert.are.equal(2, groups[1].del_lines[1].idx) + assert.are.equal(3, groups[1].add_lines[1].idx) + end) + + it('handles multiple del lines followed by multiple add lines', function() + local groups = diff.extract_change_groups({ + '-del1', + '-del2', + '+add1', + '+add2', + '+add3', + }) + assert.are.equal(1, #groups) + assert.are.equal(2, #groups[1].del_lines) + assert.are.equal(3, #groups[1].add_lines) + end) + end) + + describe('compute_intra_hunks', function() + it('returns nil for all-addition hunks', function() + local result = diff.compute_intra_hunks({ '+line1', '+line2' }, 'default') + assert.is_nil(result) + end) + + it('returns nil for all-deletion hunks', function() + local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'default') + assert.is_nil(result) + end) + + it('returns nil for context-only hunks', function() + local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'default') + assert.is_nil(result) + end) + + it('returns spans for single word change', function() + local result = diff.compute_intra_hunks({ + '-local x = 1', + '+local x = 2', + }, 'default') + assert.is_not_nil(result) + assert.is_true(#result.del_spans > 0) + assert.is_true(#result.add_spans > 0) + end) + + it('identifies correct byte offsets for word change', function() + local result = diff.compute_intra_hunks({ + '-local x = 1', + '+local x = 2', + }, 'default') + assert.is_not_nil(result) + + assert.are.equal(1, #result.del_spans) + assert.are.equal(1, #result.add_spans) + local del_span = result.del_spans[1] + local add_span = result.add_spans[1] + local del_text = ('local x = 1'):sub(del_span.col_start, del_span.col_end - 1) + local add_text = ('local x = 2'):sub(add_span.col_start, add_span.col_end - 1) + assert.are.equal('1', del_text) + assert.are.equal('2', add_text) + end) + + it('handles multiple change groups separated by context', function() + local result = diff.compute_intra_hunks({ + '-local a = 1', + '+local a = 2', + ' local b = 3', + '-local c = 4', + '+local c = 5', + }, 'default') + assert.is_not_nil(result) + assert.is_true(#result.del_spans >= 2) + assert.is_true(#result.add_spans >= 2) + end) + + it('handles uneven line counts (2 old, 1 new)', function() + local result = diff.compute_intra_hunks({ + '-line one', + '-line two', + '+line combined', + }, 'default') + assert.is_not_nil(result) + end) + + it('handles multi-byte UTF-8 content', function() + local result = diff.compute_intra_hunks({ + '-local x = "héllo"', + '+local x = "wörld"', + }, 'default') + assert.is_not_nil(result) + assert.is_true(#result.del_spans > 0) + assert.is_true(#result.add_spans > 0) + end) + + it('returns nil when del and add are identical', function() + local result = diff.compute_intra_hunks({ + '-local x = 1', + '+local x = 1', + }, 'default') + assert.is_nil(result) + end) + end) + + describe('has_vscode', function() + it('returns false in test environment', function() + assert.is_false(diff.has_vscode()) + end) + end) +end) diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua new file mode 100644 index 0000000..244e1b7 --- /dev/null +++ b/spec/fugitive_spec.lua @@ -0,0 +1,480 @@ +require('spec.helpers') + +local fugitive = require('diffs.fugitive') + +describe('fugitive', function() + describe('get_section_at_line', function() + local function create_status_buffer(lines) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + return buf + end + + it('returns staged for lines in Staged section', function() + local buf = create_status_buffer({ + 'Head: main', + '', + 'Staged (2)', + 'M file1.lua', + 'A file2.lua', + '', + 'Unstaged (1)', + 'M file3.lua', + }) + assert.equals('staged', fugitive.get_section_at_line(buf, 4)) + assert.equals('staged', fugitive.get_section_at_line(buf, 5)) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns unstaged for lines in Unstaged section', function() + local buf = create_status_buffer({ + 'Head: main', + '', + 'Staged (1)', + 'M file1.lua', + '', + 'Unstaged (2)', + 'M file2.lua', + 'M file3.lua', + }) + assert.equals('unstaged', fugitive.get_section_at_line(buf, 7)) + assert.equals('unstaged', fugitive.get_section_at_line(buf, 8)) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns untracked for lines in Untracked section', function() + local buf = create_status_buffer({ + 'Head: main', + '', + 'Untracked (2)', + '? newfile.lua', + '? another.lua', + }) + assert.equals('untracked', fugitive.get_section_at_line(buf, 4)) + assert.equals('untracked', fugitive.get_section_at_line(buf, 5)) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns nil for lines before any section', function() + local buf = create_status_buffer({ + 'Head: main', + 'Push: origin/main', + '', + 'Staged (1)', + 'M file1.lua', + }) + assert.is_nil(fugitive.get_section_at_line(buf, 1)) + assert.is_nil(fugitive.get_section_at_line(buf, 2)) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + end) + + describe('get_file_at_line', function() + local function create_status_buffer(lines) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + return buf + end + + it('parses simple modified file', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M src/foo.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('src/foo.lua', filename) + assert.equals('unstaged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses added file', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'A newfile.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('newfile.lua', filename) + assert.equals('staged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses deleted file', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'D oldfile.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('oldfile.lua', filename) + assert.equals('staged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses renamed file and returns both names', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R oldname.lua -> newname.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('newname.lua', filename) + assert.equals('staged', section) + assert.is_false(is_header) + assert.equals('oldname.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses renamed file with similarity index', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R100 old.lua -> new.lua', + }) + local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('new.lua', filename) + assert.equals('staged', section) + assert.equals('old.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns nil old_filename for non-renames', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'M modified.lua', + }) + local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('modified.lua', filename) + assert.equals('staged', section) + assert.is_nil(old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed file with spaces in name', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R old file.lua -> new file.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('new file.lua', filename) + assert.equals('old file.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed file in subdirectory', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R src/old.lua -> src/new.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('src/new.lua', filename) + assert.equals('src/old.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed file moved to different directory', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R old/file.lua -> new/file.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('new/file.lua', filename) + assert.equals('old/file.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('KNOWN LIMITATION: filename containing arrow parsed incorrectly', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R a -> b.lua -> c.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('b.lua -> c.lua', filename) + assert.equals('a', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles double extensions', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'M test.spec.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('test.spec.lua', filename) + assert.is_nil(old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles hyphenated filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M my-component-test.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('my-component-test.lua', filename) + assert.equals('unstaged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles underscores and numbers', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'A test_file_123.lua', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('test_file_123.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles dotfiles', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M .gitignore', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('.gitignore', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed with complex names', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R src/old-file.spec.lua -> src/new-file.spec.lua', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('src/new-file.spec.lua', filename) + assert.equals('src/old-file.spec.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles deeply nested paths', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M lua/diffs/ui/components/diff-view.lua', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('lua/diffs/ui/components/diff-view.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses untracked file', function() + local buf = create_status_buffer({ + 'Untracked (1)', + '? untracked.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('untracked.lua', filename) + assert.equals('untracked', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns nil for section header', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + }) + local filename = fugitive.get_file_at_line(buf, 1) + assert.is_nil(filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('walks back from hunk line to find file', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + local filename, section = fugitive.get_file_at_line(buf, 5) + assert.equals('file.lua', filename) + assert.equals('unstaged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles file with both staged and unstaged indicator', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'M both.lua', + '', + 'Unstaged (1)', + 'M both.lua', + }) + local filename1, section1 = fugitive.get_file_at_line(buf, 2) + assert.equals('both.lua', filename1) + assert.equals('staged', section1) + + local filename2, section2 = fugitive.get_file_at_line(buf, 5) + assert.equals('both.lua', filename2) + assert.equals('unstaged', section2) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('detects section header for Staged', function() + local buf = create_status_buffer({ + 'Head: main', + '', + 'Staged (2)', + 'M file1.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 3) + assert.is_nil(filename) + assert.equals('staged', section) + assert.is_true(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('detects section header for Unstaged', function() + local buf = create_status_buffer({ + 'Unstaged (3)', + 'M file1.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 1) + assert.is_nil(filename) + assert.equals('unstaged', section) + assert.is_true(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('detects section header for Untracked', function() + local buf = create_status_buffer({ + 'Untracked (1)', + '? newfile.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 1) + assert.is_nil(filename) + assert.equals('untracked', section) + assert.is_true(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns is_header=false for file lines', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'M file.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 2) + assert.equals('file.lua', filename) + assert.equals('staged', section) + assert.is_false(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + end) + + describe('get_hunk_position', function() + local function create_status_buffer(lines) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + return buf + end + + it('returns nil when on file header line', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + }) + local pos = fugitive.get_hunk_position(buf, 2) + assert.is_nil(pos) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns nil when on @@ header line', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + }) + local pos = fugitive.get_hunk_position(buf, 3) + assert.is_nil(pos) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns hunk header and offset for + line', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + local pos = fugitive.get_hunk_position(buf, 5) + assert.is_not_nil(pos) + assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header) + assert.equals(2, pos.offset) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns hunk header and offset for - line', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local old = false', + ' return M', + }) + local pos = fugitive.get_hunk_position(buf, 5) + assert.is_not_nil(pos) + assert.equals('@@ -1,3 +1,3 @@', pos.hunk_header) + assert.equals(2, pos.offset) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns hunk header and offset for context line', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + local pos = fugitive.get_hunk_position(buf, 6) + assert.is_not_nil(pos) + assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header) + assert.equals(3, pos.offset) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns correct offset for first line after @@', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + }) + local pos = fugitive.get_hunk_position(buf, 4) + assert.is_not_nil(pos) + assert.equals(1, pos.offset) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles @@ header with context text', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -10,3 +10,4 @@ function M.hello()', + ' print("hi")', + '+ print("world")', + }) + local pos = fugitive.get_hunk_position(buf, 5) + assert.is_not_nil(pos) + assert.equals('@@ -10,3 +10,4 @@ function M.hello()', pos.hunk_header) + assert.equals(2, pos.offset) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns nil when section header interrupts search', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + ' some orphan line', + }) + local pos = fugitive.get_hunk_position(buf, 3) + assert.is_nil(pos) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + end) +end) diff --git a/spec/git_spec.lua b/spec/git_spec.lua new file mode 100644 index 0000000..45fd730 --- /dev/null +++ b/spec/git_spec.lua @@ -0,0 +1,54 @@ +require('spec.helpers') +local git = require('diffs.git') + +describe('git', function() + describe('get_repo_root', function() + it('returns repo root for current repo', function() + local cwd = vim.fn.getcwd() + local root = git.get_repo_root(cwd .. '/lua/diffs/init.lua') + assert.is_not_nil(root) + assert.are.equal(cwd, root) + end) + + it('returns nil for non-git directory', function() + local root = git.get_repo_root('/tmp') + assert.is_nil(root) + end) + end) + + describe('get_file_content', function() + it('returns file content at HEAD', function() + local cwd = vim.fn.getcwd() + local content, err = git.get_file_content('HEAD', cwd .. '/lua/diffs/init.lua') + assert.is_nil(err) + assert.is_not_nil(content) + assert.is_true(#content > 0) + end) + + it('returns error for non-existent file', function() + local cwd = vim.fn.getcwd() + local content, err = git.get_file_content('HEAD', cwd .. '/does_not_exist.lua') + assert.is_nil(content) + assert.is_not_nil(err) + end) + + it('returns error for non-git directory', function() + local content, err = git.get_file_content('HEAD', '/tmp/some_file.txt') + assert.is_nil(content) + assert.is_not_nil(err) + end) + end) + + describe('get_relative_path', function() + it('returns relative path within repo', function() + local cwd = vim.fn.getcwd() + local rel = git.get_relative_path(cwd .. '/lua/diffs/init.lua') + assert.are.equal('lua/diffs/init.lua', rel) + end) + + it('returns nil for non-git directory', function() + local rel = git.get_relative_path('/tmp/some_file.txt') + assert.is_nil(rel) + end) + end) +end) diff --git a/spec/helpers.lua b/spec/helpers.lua index db9016a..7774cf4 100644 --- a/spec/helpers.lua +++ b/spec/helpers.lua @@ -2,6 +2,8 @@ local plugin_dir = vim.fn.getcwd() vim.opt.runtimepath:prepend(plugin_dir) vim.opt.packpath = {} +vim.cmd('filetype on') + local function ensure_parser(lang) local ok = pcall(vim.treesitter.language.inspect, lang) if not ok then @@ -10,6 +12,7 @@ local function ensure_parser(lang) end ensure_parser('lua') +ensure_parser('vim') local M = {} diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index a0f7816..0b9051e 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -1,16 +1,18 @@ require('spec.helpers') -local highlight = require('fugitive-ts.highlight') +local highlight = require('diffs.highlight') describe('highlight', function() describe('highlight_hunk', function() local ns before_each(function() - ns = vim.api.nvim_create_namespace('fugitive_ts_test') + ns = vim.api.nvim_create_namespace('diffs_test') + 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, 'FugitiveTsAdd', { bg = diff_add.bg }) - vim.api.nvim_set_hl(0, 'FugitiveTsDelete', { bg = diff_delete.bg }) + 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 }) end) local function create_buffer(lines) @@ -32,28 +34,31 @@ describe('highlight', function() local function default_opts(overrides) local opts = { hide_prefix = false, - treesitter = { - enabled = true, - max_lines = 500, - }, - vim = { - enabled = false, - max_lines = 200, - }, highlights = { background = false, 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, + }, }, } if overrides then - for k, v in pairs(overrides) do - if type(v) == 'table' and type(opts[k]) == 'table' then - for sk, sv in pairs(v) do - opts[k][sk] = sv - end - else - opts[k] = v - end + if overrides.highlights then + opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights) + end + if overrides.hide_prefix ~= nil then + opts.hide_prefix = overrides.hide_prefix end end return opts @@ -80,7 +85,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('applies Normal extmarks to clear diff colors', function() + it('applies DiffsClear extmarks to clear diff colors', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' local x = 1', @@ -97,14 +102,46 @@ describe('highlight', function() highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) - local has_normal = false + local has_clear = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'Normal' then - has_normal = true + if mark[4] and mark[4].hl_group == 'DiffsClear' then + has_clear = true break end end - assert.is_true(has_normal) + assert.is_true(has_clear) + delete_buffer(bufnr) + end) + + it('produces treesitter captures on all lines with split parsing', function() + local bufnr = create_buffer({ + '@@ -1,3 +1,3 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + ' return x', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local x = 1', '-local y = 2', '+local y = 3', ' return x' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local lines_with_ts = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.lua$') then + lines_with_ts[mark[2]] = true + end + end + assert.is_true(lines_with_ts[1] ~= nil) + assert.is_true(lines_with_ts[2] ~= nil) + assert.is_true(lines_with_ts[3] ~= nil) + assert.is_true(lines_with_ts[4] ~= nil) delete_buffer(bufnr) end) @@ -183,6 +220,40 @@ describe('highlight', function() delete_buffer(bufnr) end) + it('highlights function keyword in header context', function() + local bufnr = create_buffer({ + '@@ -5,3 +5,4 @@ function M.setup()', + ' local x = 1', + '+local y = 2', + ' return x', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + header_context = 'function M.setup()', + header_context_col = 18, + lines = { ' local x = 1', '+local y = 2', ' return x' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local has_keyword_function = false + for _, mark in ipairs(extmarks) do + if mark[2] == 0 and mark[4] and mark[4].hl_group then + local hl = mark[4].hl_group + if hl == '@keyword.function.lua' or hl == '@keyword.lua' then + has_keyword_function = true + break + end + end + end + assert.is_true(has_keyword_function) + delete_buffer(bufnr) + end) + it('does not highlight header when no header_context', function() local bufnr = create_buffer({ '@@ -10,3 +10,4 @@', @@ -325,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 == 'FugitiveTsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -358,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 == 'FugitiveTsDelete' then + if mark[4] and mark[4].hl_group == 'DiffsDelete' then has_diff_delete = true break end @@ -391,7 +462,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_line_hl = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group then + if mark[4] and (mark[4].hl_group == 'DiffsAdd' or mark[4].hl_group == 'DiffsDelete') then has_line_hl = true break end @@ -484,7 +555,7 @@ describe('highlight', function() bufnr, ns, hunk, - default_opts({ treesitter = { enabled = false }, highlights = { background = true } }) + default_opts({ highlights = { treesitter = { enabled = false }, background = true } }) ) local extmarks = get_extmarks(bufnr) @@ -517,13 +588,13 @@ describe('highlight', function() bufnr, ns, hunk, - default_opts({ treesitter = { enabled = false }, highlights = { background = true } }) + default_opts({ highlights = { treesitter = { enabled = false }, background = true } }) ) 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 == 'FugitiveTsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -533,6 +604,19 @@ describe('highlight', function() 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 + local orig_synIDattr = vim.fn.synIDattr + vim.fn.synID = function(_line, _col, _trans) + return 1 + end + vim.fn.synIDtrans = function(id) + return id + end + vim.fn.synIDattr = function(_id, _what) + return 'Identifier' + end + local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' local x = 1', @@ -541,18 +625,27 @@ describe('highlight', function() local hunk = { filename = 'test.lua', - ft = 'lua', + ft = 'abap', lang = nil, start_line = 1, lines = { ' local x = 1', '+local y = 2' }, } - highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ vim = { enabled = true } })) + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { vim = { enabled = true } } }) + ) + + vim.fn.synID = orig_synID + vim.fn.synIDtrans = orig_synIDtrans + vim.fn.synIDattr = orig_synIDattr 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 ~= 'Normal' then + if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then has_syntax_hl = true break end @@ -570,18 +663,23 @@ describe('highlight', function() local hunk = { filename = 'test.lua', - ft = 'lua', + ft = 'abap', lang = nil, start_line = 1, lines = { ' local x = 1', '+local y = 2' }, } - highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ vim = { enabled = false } })) + 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 ~= 'Normal' then + if mark[4] and mark[4].hl_group and mark[4].hl_group ~= 'DiffsClear' then has_syntax_hl = true break end @@ -601,7 +699,7 @@ describe('highlight', function() local bufnr = create_buffer(lines) local hunk = { filename = 'test.lua', - ft = 'lua', + ft = 'abap', lang = nil, start_line = 1, lines = hunk_lines, @@ -611,7 +709,7 @@ describe('highlight', function() bufnr, ns, hunk, - default_opts({ vim = { enabled = true, max_lines = 200 } }) + default_opts({ highlights = { vim = { enabled = true, max_lines = 200 } } }) ) local extmarks = get_extmarks(bufnr) @@ -628,7 +726,7 @@ describe('highlight', function() local hunk = { filename = 'test.lua', - ft = 'lua', + ft = 'abap', lang = nil, start_line = 1, lines = { ' local x = 1', '+local y = 2' }, @@ -638,13 +736,13 @@ describe('highlight', function() bufnr, ns, hunk, - default_opts({ vim = { enabled = true }, highlights = { background = true } }) + default_opts({ highlights = { vim = { enabled = true }, background = true } }) ) 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 == 'FugitiveTsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -653,7 +751,20 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('applies Normal blanking for vim fallback hunks', function() + it('applies DiffsClear blanking for vim fallback hunks', function() + local orig_synID = vim.fn.synID + local orig_synIDtrans = vim.fn.synIDtrans + local orig_synIDattr = vim.fn.synIDattr + vim.fn.synID = function(_line, _col, _trans) + return 1 + end + vim.fn.synIDtrans = function(id) + return id + end + vim.fn.synIDattr = function(_id, _what) + return 'Identifier' + end + local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' local x = 1', @@ -662,24 +773,900 @@ describe('highlight', function() local hunk = { filename = 'test.lua', - ft = 'lua', + ft = 'abap', lang = nil, start_line = 1, lines = { ' local x = 1', '+local y = 2' }, } - highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ vim = { enabled = true } })) + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { vim = { enabled = true } } }) + ) + + vim.fn.synID = orig_synID + vim.fn.synIDtrans = orig_synIDtrans + vim.fn.synIDattr = orig_synIDattr local extmarks = get_extmarks(bufnr) - local has_normal = false + local has_clear = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].hl_group == 'Normal' then - has_normal = true + if mark[4] and mark[4].hl_group == 'DiffsClear' then + has_clear = true break end end - assert.is_true(has_normal) + assert.is_true(has_clear) + delete_buffer(bufnr) + end) + + it('uses hl_group not line_hl_group for line backgrounds', 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.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 + delete_buffer(bufnr) + end) + + it('number_hl_group does not bleed to adjacent lines', function() + local bufnr = create_buffer({ + '@@ -1,3 +1,3 @@', + ' local a = 0', + '-local x = 1', + '+local y = 2', + ' local b = 3', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 1, + lines = { ' local a = 0', '-local x = 1', '+local y = 2', ' local b = 3' }, + } + + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { background = true, gutter = true } }) + ) + + local extmarks = get_extmarks(bufnr) + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.number_hl_group then + local start_row = mark[2] + local end_row = d.end_row or start_row + assert.are.equal(start_row, end_row) + end + end + 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 }) + + 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 = true, algorithm = 'default', max_lines = 500 } }, + }) + ) + + local extmarks = get_extmarks(bufnr) + local add_text_marks = {} + local del_text_marks = {} + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'DiffsAddText' then + table.insert(add_text_marks, mark) + end + if d and d.hl_group == 'DiffsDeleteText' then + table.insert(del_text_marks, mark) + end + end + assert.is_true(#add_text_marks > 0) + assert.is_true(#del_text_marks > 0) + 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 }) + + local bufnr = create_buffer({ + '@@ -1,0 +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 = { 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('enforces priority order: DiffsClear < syntax < line bg < char 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 priorities = { clear = {}, line_bg = {}, syntax = {}, char_bg = {} } + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d then + if d.hl_group == 'DiffsClear' then + table.insert(priorities.clear, d.priority) + elseif d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete' then + 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) + elseif d.hl_group and d.hl_group:match('^@.*%.lua$') then + table.insert(priorities.syntax, d.priority) + end + end + end + + assert.is_true(#priorities.clear > 0) + assert.is_true(#priorities.line_bg > 0) + assert.is_true(#priorities.syntax > 0) + assert.is_true(#priorities.char_bg > 0) + + local max_clear = math.max(unpack(priorities.clear)) + local min_line_bg = math.min(unpack(priorities.line_bg)) + local min_syntax = math.min(unpack(priorities.syntax)) + local min_char_bg = math.min(unpack(priorities.char_bg)) + + assert.is_true(max_clear < min_syntax) + assert.is_true(min_syntax < min_line_bg) + assert.is_true(min_line_bg < min_char_bg) + 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 @@', + ' 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_lua = false + local has_vim = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group then + if mark[4].hl_group:match('^@.*%.lua$') then + has_lua = true + end + if mark[4].hl_group:match('^@.*%.vim$') then + has_vim = true + end + end + end + assert.is_true(has_lua) + assert.is_true(has_vim) + delete_buffer(bufnr) + end) + + it('filters @spell and @nospell captures from 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) + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group then + assert.is_falsy(mark[4].hl_group:match('@spell')) + assert.is_falsy(mark[4].hl_group:match('@nospell')) + end + end delete_buffer(bufnr) end) end) + + describe('diff header highlighting', function() + local ns + + before_each(function() + ns = vim.api.nvim_create_namespace('diffs_test_header') + 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() + return { + hide_prefix = false, + highlights = { + background = false, + gutter = false, + context = { enabled = false, lines = 0 }, + treesitter = { enabled = true, max_lines = 500 }, + vim = { enabled = false, max_lines = 200 }, + }, + } + end + + it('applies treesitter extmarks to diff header lines', function() + local bufnr = create_buffer({ + 'diff --git a/parser.lua b/parser.lua', + 'index 3e8afa0..018159c 100644', + '--- a/parser.lua', + '+++ b/parser.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + }) + + local hunk = { + filename = 'parser.lua', + lang = 'lua', + start_line = 5, + lines = { ' local M = {}', '+local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --git a/parser.lua b/parser.lua', + 'index 3e8afa0..018159c 100644', + '--- a/parser.lua', + '+++ b/parser.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local header_extmarks = {} + for _, mark in ipairs(extmarks) do + if mark[2] < 4 and mark[4] and mark[4].hl_group then + table.insert(header_extmarks, mark) + end + end + + assert.is_true(#header_extmarks > 0) + + local has_function_hl = false + local has_keyword_hl = false + for _, mark in ipairs(header_extmarks) do + local hl = mark[4].hl_group + if hl == '@function' or hl == '@function.diff' then + has_function_hl = true + end + if hl == '@keyword' or hl == '@keyword.diff' then + has_keyword_hl = true + end + end + assert.is_true(has_function_hl or has_keyword_hl) + delete_buffer(bufnr) + end) + + it('does not apply header highlights when header_lines missing', function() + local bufnr = create_buffer({ + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + }) + + local hunk = { + filename = 'parser.lua', + lang = 'lua', + start_line = 1, + lines = { ' local M = {}', '+local x = 1' }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local header_extmarks = 0 + for _, mark in ipairs(extmarks) do + if mark[2] < 0 and mark[4] and mark[4].hl_group then + header_extmarks = header_extmarks + 1 + end + end + assert.are.equal(0, header_extmarks) + delete_buffer(bufnr) + end) + + 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', + '--- a/parser.lua', + '+++ b/parser.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + }) + + local hunk = { + filename = 'parser.lua', + lang = 'lua', + start_line = 5, + lines = { ' local M = {}', '+local x = 1' }, + header_start_line = 1, + header_lines = { + 'diff --git a/parser.lua b/parser.lua', + 'index 3e8afa0..018159c 100644', + '--- a/parser.lua', + '+++ b/parser.lua', + }, + } + + 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 + 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 + assert.are.equal(0, header_extmarks) + delete_buffer(bufnr) + end) + end) + + describe('extmark priority', function() + local ns + + before_each(function() + ns = vim.api.nvim_create_namespace('diffs_test_priority') + 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() + return { + hide_prefix = false, + highlights = { + background = false, + gutter = false, + context = { enabled = false, lines = 0 }, + treesitter = { enabled = true, max_lines = 500 }, + vim = { enabled = false, max_lines = 200 }, + }, + } + 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', + '--- a/test.lua', + '+++ b/test.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + + local hunk = { + filename = 'test.lua', + lang = 'lua', + start_line = 5, + lines = { ' local x = 1', '+local y = 2' }, + header_start_line = 1, + header_lines = { + 'diff --git a/test.lua b/test.lua', + '--- a/test.lua', + '+++ b/test.lua', + }, + } + + highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) + + local extmarks = get_extmarks(bufnr) + local diff_extmark_priorities = {} + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].hl_group and mark[4].hl_group:match('^@.*%.diff$') then + table.insert(diff_extmark_priorities, mark[4].priority) + end + end + assert.is_true(#diff_extmark_priorities > 0) + for _, priority in ipairs(diff_extmark_priorities) do + assert.is_true(priority < 199) + end + delete_buffer(bufnr) + end) + end) + + describe('coalesce_syntax_spans', function() + it('coalesces adjacent chars with same hl group', function() + local function query_fn(_line, _col) + return 1, 'Keyword' + end + local spans = highlight.coalesce_syntax_spans(query_fn, { 'hello' }) + assert.are.equal(1, #spans) + assert.are.equal(1, spans[1].col_start) + assert.are.equal(6, spans[1].col_end) + assert.are.equal('Keyword', spans[1].hl_name) + end) + + it('splits spans at hl group boundaries', function() + local function query_fn(_line, col) + if col <= 3 then + return 1, 'Keyword' + end + return 2, 'String' + end + local spans = highlight.coalesce_syntax_spans(query_fn, { 'abcdef' }) + assert.are.equal(2, #spans) + assert.are.equal('Keyword', spans[1].hl_name) + assert.are.equal(1, spans[1].col_start) + assert.are.equal(4, spans[1].col_end) + assert.are.equal('String', spans[2].hl_name) + assert.are.equal(4, spans[2].col_start) + assert.are.equal(7, spans[2].col_end) + end) + + it('skips syn_id 0 gaps', function() + local function query_fn(_line, col) + if col == 2 or col == 3 then + return 0, '' + end + return 1, 'Identifier' + end + local spans = highlight.coalesce_syntax_spans(query_fn, { 'abcd' }) + assert.are.equal(2, #spans) + assert.are.equal(1, spans[1].col_start) + assert.are.equal(2, spans[1].col_end) + assert.are.equal(4, spans[2].col_start) + assert.are.equal(5, spans[2].col_end) + end) + + it('skips empty hl_name spans', function() + local function query_fn(_line, _col) + return 1, '' + end + local spans = highlight.coalesce_syntax_spans(query_fn, { 'abc' }) + assert.are.equal(0, #spans) + end) + end) end) diff --git a/spec/init_spec.lua b/spec/init_spec.lua index 5268b4a..1bf71d1 100644 --- a/spec/init_spec.lua +++ b/spec/init_spec.lua @@ -1,27 +1,34 @@ require('spec.helpers') -local fugitive_ts = require('fugitive-ts') +local diffs = require('diffs') -describe('fugitive-ts', function() - describe('setup', function() - it('accepts empty config', function() - assert.has_no.errors(function() - fugitive_ts.setup({}) - end) +describe('diffs', function() + describe('vim.g.diffs config', function() + after_each(function() + vim.g.diffs = nil end) it('accepts nil config', function() + vim.g.diffs = nil assert.has_no.errors(function() - fugitive_ts.setup() + diffs.attach() + end) + end) + + it('accepts empty config', function() + vim.g.diffs = {} + assert.has_no.errors(function() + diffs.attach() end) end) it('accepts full config', function() - assert.has_no.errors(function() - fugitive_ts.setup({ - enabled = false, - debug = true, - debounce_ms = 100, - hide_prefix = false, + vim.g.diffs = { + debug = true, + debounce_ms = 100, + hide_prefix = false, + highlights = { + background = true, + gutter = true, treesitter = { enabled = true, max_lines = 1000, @@ -30,19 +37,19 @@ describe('fugitive-ts', function() enabled = false, max_lines = 200, }, - highlights = { - background = true, - gutter = true, - }, - }) + }, + } + assert.has_no.errors(function() + diffs.attach() end) end) it('accepts partial config', function() + vim.g.diffs = { + debounce_ms = 25, + } assert.has_no.errors(function() - fugitive_ts.setup({ - debounce_ms = 25, - }) + diffs.attach() end) end) end) @@ -60,14 +67,10 @@ describe('fugitive-ts', function() end end - before_each(function() - fugitive_ts.setup({ enabled = true }) - end) - it('does not error on empty buffer', function() local bufnr = create_buffer({}) assert.has_no.errors(function() - fugitive_ts.attach(bufnr) + diffs.attach(bufnr) end) delete_buffer(bufnr) end) @@ -80,7 +83,7 @@ describe('fugitive-ts', function() '+local y = 2', }) assert.has_no.errors(function() - fugitive_ts.attach(bufnr) + diffs.attach(bufnr) end) delete_buffer(bufnr) end) @@ -88,9 +91,9 @@ describe('fugitive-ts', function() it('is idempotent', function() local bufnr = create_buffer({}) assert.has_no.errors(function() - fugitive_ts.attach(bufnr) - fugitive_ts.attach(bufnr) - fugitive_ts.attach(bufnr) + diffs.attach(bufnr) + diffs.attach(bufnr) + diffs.attach(bufnr) end) delete_buffer(bufnr) end) @@ -109,45 +112,161 @@ describe('fugitive-ts', function() end end - before_each(function() - fugitive_ts.setup({ enabled = true }) - end) - it('does not error on unattached buffer', function() local bufnr = create_buffer({}) assert.has_no.errors(function() - fugitive_ts.refresh(bufnr) + diffs.refresh(bufnr) end) delete_buffer(bufnr) end) it('does not error on attached buffer', function() local bufnr = create_buffer({}) - fugitive_ts.attach(bufnr) + diffs.attach(bufnr) assert.has_no.errors(function() - fugitive_ts.refresh(bufnr) + diffs.refresh(bufnr) end) delete_buffer(bufnr) end) end) - describe('config options', function() - it('enabled=false prevents highlighting', function() - fugitive_ts.setup({ enabled = false }) + describe('is_fugitive_buffer', function() + it('returns true for fugitive:// URLs', function() local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { - 'M test.lua', - '@@ -1,1 +1,2 @@', - ' local x = 1', - '+local y = 2', - }) - fugitive_ts.attach(bufnr) + vim.api.nvim_buf_set_name(bufnr, 'fugitive:///path/to/repo/.git//abc123:file.lua') + assert.is_true(diffs.is_fugitive_buffer(bufnr)) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) - local ns = vim.api.nvim_create_namespace('fugitive_ts') - local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, {}) - assert.are.equal(0, #extmarks) + it('returns false for normal paths', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, '/home/user/project/file.lua') + assert.is_false(diffs.is_fugitive_buffer(bufnr)) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + it('returns false for empty buffer names', function() + local bufnr = vim.api.nvim_create_buf(false, true) + assert.is_false(diffs.is_fugitive_buffer(bufnr)) vim.api.nvim_buf_delete(bufnr, { force = true }) end) end) + + describe('diff mode', function() + local function create_diff_window() + vim.cmd('new') + local win = vim.api.nvim_get_current_win() + local buf = vim.api.nvim_get_current_buf() + vim.wo[win].diff = true + return win, buf + end + + local function close_window(win) + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end + + describe('attach_diff', function() + it('applies winhighlight to diff windows', function() + local win, _ = create_diff_window() + diffs.attach_diff() + + local whl = vim.api.nvim_get_option_value('winhighlight', { win = win }) + assert.is_not_nil(whl:match('DiffAdd:DiffsDiffAdd')) + assert.is_not_nil(whl:match('DiffDelete:DiffsDiffDelete')) + + close_window(win) + end) + + it('is idempotent', function() + local win, _ = create_diff_window() + assert.has_no.errors(function() + diffs.attach_diff() + diffs.attach_diff() + diffs.attach_diff() + end) + + local whl = vim.api.nvim_get_option_value('winhighlight', { win = win }) + assert.is_not_nil(whl:match('DiffAdd:DiffsDiffAdd')) + + close_window(win) + end) + + it('applies to multiple diff windows', function() + local win1, _ = create_diff_window() + local win2, _ = create_diff_window() + diffs.attach_diff() + + local whl1 = vim.api.nvim_get_option_value('winhighlight', { win = win1 }) + local whl2 = vim.api.nvim_get_option_value('winhighlight', { win = win2 }) + assert.is_not_nil(whl1:match('DiffAdd:DiffsDiffAdd')) + assert.is_not_nil(whl2:match('DiffAdd:DiffsDiffAdd')) + + close_window(win1) + close_window(win2) + end) + + it('ignores non-diff windows', function() + vim.cmd('new') + local non_diff_win = vim.api.nvim_get_current_win() + + local diff_win, _ = create_diff_window() + diffs.attach_diff() + + local non_diff_whl = vim.api.nvim_get_option_value('winhighlight', { win = non_diff_win }) + local diff_whl = vim.api.nvim_get_option_value('winhighlight', { win = diff_win }) + + assert.are.equal('', non_diff_whl) + assert.is_not_nil(diff_whl:match('DiffAdd:DiffsDiffAdd')) + + close_window(non_diff_win) + close_window(diff_win) + end) + end) + + describe('detach_diff', function() + it('clears winhighlight from tracked windows', function() + local win, _ = create_diff_window() + diffs.attach_diff() + diffs.detach_diff() + + local whl = vim.api.nvim_get_option_value('winhighlight', { win = win }) + assert.are.equal('', whl) + + close_window(win) + end) + + it('does not error when no windows are tracked', function() + assert.has_no.errors(function() + diffs.detach_diff() + end) + end) + + it('handles already-closed windows gracefully', function() + local win, _ = create_diff_window() + diffs.attach_diff() + close_window(win) + + assert.has_no.errors(function() + diffs.detach_diff() + end) + end) + + it('clears all tracked windows', function() + local win1, _ = create_diff_window() + local win2, _ = create_diff_window() + diffs.attach_diff() + diffs.detach_diff() + + local whl1 = vim.api.nvim_get_option_value('winhighlight', { win = win1 }) + local whl2 = vim.api.nvim_get_option_value('winhighlight', { win = win2 }) + assert.are.equal('', whl1) + assert.are.equal('', whl2) + + close_window(win1) + close_window(win2) + end) + end) + end) end) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 6532b25..11ac3be 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -1,5 +1,5 @@ require('spec.helpers') -local parser = require('fugitive-ts.parser') +local parser = require('diffs.parser') describe('parser', function() describe('parse_buffer', function() @@ -76,6 +76,25 @@ describe('parser', function() end) it('detects hunks across multiple files', function() + local orig_get_lang = vim.treesitter.language.get_lang + local orig_inspect = vim.treesitter.language.inspect + vim.treesitter.language.get_lang = function(ft) + local result = orig_get_lang(ft) + if result then + return result + end + if ft == 'python' then + return 'python' + end + return nil + end + vim.treesitter.language.inspect = function(lang) + if lang == 'python' then + return {} + end + return orig_inspect(lang) + end + local bufnr = create_buffer({ 'M lua/foo.lua', '@@ -1,1 +1,2 @@', @@ -88,6 +107,9 @@ describe('parser', function() }) local hunks = parser.parse_buffer(bufnr) + vim.treesitter.language.get_lang = orig_get_lang + vim.treesitter.language.inspect = orig_inspect + assert.are.equal(2, #hunks) assert.are.equal('lua/foo.lua', hunks[1].filename) assert.are.equal('lua', hunks[1].lang) @@ -193,5 +215,290 @@ describe('parser', function() assert.are.equal(1, #hunks[2].lines) delete_buffer(bufnr) end) + + it('attaches header_lines to first hunk only', function() + local bufnr = create_buffer({ + 'diff --git a/parser.lua b/parser.lua', + 'index 3e8afa0..018159c 100644', + '--- a/parser.lua', + '+++ b/parser.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + '@@ -10,2 +11,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(2, #hunks) + assert.is_not_nil(hunks[1].header_start_line) + assert.is_not_nil(hunks[1].header_lines) + assert.are.equal(1, hunks[1].header_start_line) + assert.is_nil(hunks[2].header_start_line) + assert.is_nil(hunks[2].header_lines) + delete_buffer(bufnr) + end) + + it('header_lines contains only diff metadata, not hunk content', function() + local bufnr = create_buffer({ + 'diff --git a/parser.lua b/parser.lua', + 'index 3e8afa0..018159c 100644', + '--- a/parser.lua', + '+++ b/parser.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(4, #hunks[1].header_lines) + assert.are.equal('diff --git a/parser.lua b/parser.lua', hunks[1].header_lines[1]) + assert.are.equal('index 3e8afa0..018159c 100644', hunks[1].header_lines[2]) + assert.are.equal('--- a/parser.lua', hunks[1].header_lines[3]) + assert.are.equal('+++ b/parser.lua', hunks[1].header_lines[4]) + delete_buffer(bufnr) + end) + + it('handles fugitive status format with diff headers', function() + local bufnr = create_buffer({ + 'Head: main', + 'Push: origin/main', + '', + 'Unstaged (1)', + 'M parser.lua', + 'diff --git a/parser.lua b/parser.lua', + 'index 3e8afa0..018159c 100644', + '--- a/parser.lua', + '+++ b/parser.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local x = 1', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(6, hunks[1].header_start_line) + assert.are.equal(4, #hunks[1].header_lines) + assert.are.equal('diff --git a/parser.lua b/parser.lua', hunks[1].header_lines[1]) + delete_buffer(bufnr) + end) + + it('emits hunk for files with unknown filetype', function() + local bufnr = create_buffer({ + 'M config.obscuretype', + '@@ -1,2 +1,3 @@', + ' setting1 = value1', + '-setting2 = value2', + '+setting2 = MODIFIED', + '+setting4 = newvalue', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('config.obscuretype', hunks[1].filename) + assert.is_nil(hunks[1].ft) + assert.is_nil(hunks[1].lang) + assert.are.equal(4, #hunks[1].lines) + delete_buffer(bufnr) + end) + + it('uses filetype from existing buffer when available', function() + local repo_root = '/tmp/test-repo' + local file_path = repo_root .. '/build' + + local file_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(file_buf, file_path) + vim.api.nvim_set_option_value('filetype', 'bash', { buf = file_buf }) + + local diff_buf = create_buffer({ + 'M build', + '@@ -1,2 +1,3 @@', + ' echo "hello"', + '+set -e', + ' echo "done"', + }) + vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) + + local hunks = parser.parse_buffer(diff_buf) + + assert.are.equal(1, #hunks) + assert.are.equal('build', hunks[1].filename) + assert.are.equal('bash', hunks[1].ft) + + delete_buffer(file_buf) + delete_buffer(diff_buf) + end) + + it('uses filetype from existing buffer via git_dir', function() + local git_dir = '/tmp/test-repo/.git' + local repo_root = '/tmp/test-repo' + local file_path = repo_root .. '/script' + + local file_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(file_buf, file_path) + vim.api.nvim_set_option_value('filetype', 'python', { buf = file_buf }) + + local diff_buf = create_buffer({ + 'M script', + '@@ -1,2 +1,3 @@', + ' def main():', + '+ print("hi")', + ' pass', + }) + vim.api.nvim_buf_set_var(diff_buf, 'git_dir', git_dir) + + local hunks = parser.parse_buffer(diff_buf) + + assert.are.equal(1, #hunks) + assert.are.equal('script', hunks[1].filename) + assert.are.equal('python', hunks[1].ft) + + delete_buffer(file_buf) + delete_buffer(diff_buf) + end) + + it('detects filetype from file content shebang without open buffer', function() + local repo_root = '/tmp/diffs-test-shebang' + vim.fn.mkdir(repo_root, 'p') + + local file_path = repo_root .. '/build' + local f = io.open(file_path, 'w') + f:write('#!/bin/bash\n') + f:write('set -e\n') + f:write('echo "hello"\n') + f:close() + + local diff_buf = create_buffer({ + 'M build', + '@@ -1,2 +1,3 @@', + ' #!/bin/bash', + '+set -e', + ' echo "hello"', + }) + vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) + + local hunks = parser.parse_buffer(diff_buf) + + assert.are.equal(1, #hunks) + assert.are.equal('build', hunks[1].filename) + assert.are.equal('sh', hunks[1].ft) + + delete_buffer(diff_buf) + os.remove(file_path) + vim.fn.delete(repo_root, 'rf') + end) + + it('detects python from shebang without open buffer', function() + local repo_root = '/tmp/diffs-test-shebang-py' + vim.fn.mkdir(repo_root, 'p') + + local file_path = repo_root .. '/deploy' + local f = io.open(file_path, 'w') + f:write('#!/usr/bin/env python3\n') + f:write('import sys\n') + f:write('print("hi")\n') + f:close() + + local diff_buf = create_buffer({ + 'M deploy', + '@@ -1,2 +1,3 @@', + ' #!/usr/bin/env python3', + '+import sys', + ' print("hi")', + }) + vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) + + local hunks = parser.parse_buffer(diff_buf) + + assert.are.equal(1, #hunks) + assert.are.equal('deploy', hunks[1].filename) + assert.are.equal('python', hunks[1].ft) + + delete_buffer(diff_buf) + os.remove(file_path) + vim.fn.delete(repo_root, 'rf') + end) + + it('extracts file line numbers from @@ header', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(1, hunks[1].file_old_start) + assert.are.equal(3, hunks[1].file_old_count) + assert.are.equal(1, hunks[1].file_new_start) + assert.are.equal(4, hunks[1].file_new_count) + delete_buffer(bufnr) + end) + + it('extracts large line numbers from @@ header', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -100,20 +200,30 @@', + ' local M = {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(100, hunks[1].file_old_start) + assert.are.equal(20, hunks[1].file_old_count) + assert.are.equal(200, hunks[1].file_new_start) + assert.are.equal(30, hunks[1].file_new_count) + delete_buffer(bufnr) + end) + + it('defaults count to 1 when omitted in @@ header', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -1 +1 @@', + ' local M = {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal(1, hunks[1].file_old_start) + assert.are.equal(1, hunks[1].file_old_count) + assert.are.equal(1, hunks[1].file_new_start) + assert.are.equal(1, hunks[1].file_new_count) + delete_buffer(bufnr) + end) + + it('stores repo_root on hunk when available', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + vim.api.nvim_buf_set_var(bufnr, 'diffs_repo_root', '/tmp/test-repo') + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.are.equal('/tmp/test-repo', hunks[1].repo_root) + delete_buffer(bufnr) + end) + + it('repo_root is nil when not available', function() + local bufnr = create_buffer({ + 'M lua/test.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + }) + local hunks = parser.parse_buffer(bufnr) + + assert.are.equal(1, #hunks) + assert.is_nil(hunks[1].repo_root) + delete_buffer(bufnr) + end) end) end) diff --git a/spec/read_buffer_spec.lua b/spec/read_buffer_spec.lua new file mode 100644 index 0000000..f571d97 --- /dev/null +++ b/spec/read_buffer_spec.lua @@ -0,0 +1,400 @@ +require('spec.helpers') + +local commands = require('diffs.commands') +local diffs = require('diffs') +local git = require('diffs.git') + +local saved_git = {} +local saved_systemlist +local test_buffers = {} + +local function mock_git(overrides) + overrides = overrides or {} + saved_git.get_file_content = git.get_file_content + saved_git.get_index_content = git.get_index_content + saved_git.get_working_content = git.get_working_content + + git.get_file_content = overrides.get_file_content + or function() + return { 'local M = {}', 'return M' } + end + git.get_index_content = overrides.get_index_content + or function() + return { 'local M = {}', 'return M' } + end + git.get_working_content = overrides.get_working_content + or function() + return { 'local M = {}', 'local x = 1', 'return M' } + end +end + +local function mock_systemlist(fn) + saved_systemlist = vim.fn.systemlist + vim.fn.systemlist = function(cmd) + local result = fn(cmd) + saved_systemlist({ 'true' }) + return result + end +end + +local function restore_mocks() + for k, v in pairs(saved_git) do + git[k] = v + end + saved_git = {} + if saved_systemlist then + vim.fn.systemlist = saved_systemlist + saved_systemlist = nil + end +end + +---@param name string +---@param vars? table +---@return integer +local function create_diffs_buffer(name, vars) + local existing = vim.fn.bufnr(name) + if existing ~= -1 then + vim.api.nvim_buf_delete(existing, { force = true }) + end + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, name) + vars = vars or {} + for k, v in pairs(vars) do + vim.api.nvim_buf_set_var(bufnr, k, v) + end + table.insert(test_buffers, bufnr) + return bufnr +end + +local function cleanup_buffers() + for _, bufnr in ipairs(test_buffers) do + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + test_buffers = {} +end + +describe('read_buffer', function() + after_each(function() + restore_mocks() + cleanup_buffers() + end) + + describe('early returns', function() + it('does nothing on non-diffs:// buffer', function() + local bufnr = vim.api.nvim_create_buf(false, true) + table.insert(test_buffers, bufnr) + assert.has_no.errors(function() + commands.read_buffer(bufnr) + end) + assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) + end) + + it('does nothing on malformed url without colon separator', function() + local bufnr = create_diffs_buffer('diffs://nocolonseparator') + vim.api.nvim_buf_set_var(bufnr, 'diffs_repo_root', '/tmp') + local lines_before = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.has_no.errors(function() + commands.read_buffer(bufnr) + end) + assert.are.same(lines_before, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) + end) + + it('does nothing when diffs_repo_root is missing', function() + local bufnr = create_diffs_buffer('diffs://staged:missing_root.lua') + assert.has_no.errors(function() + commands.read_buffer(bufnr) + end) + assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) + end) + end) + + describe('buffer options', function() + it('sets buftype, bufhidden, swapfile, modifiable, filetype', function() + mock_git() + local bufnr = create_diffs_buffer('diffs://staged:options_test.lua', { + diffs_repo_root = '/tmp', + }) + + commands.read_buffer(bufnr) + + assert.are.equal('nowrite', vim.api.nvim_get_option_value('buftype', { buf = bufnr })) + assert.are.equal('delete', vim.api.nvim_get_option_value('bufhidden', { buf = bufnr })) + assert.is_false(vim.api.nvim_get_option_value('swapfile', { buf = bufnr })) + assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = bufnr })) + assert.are.equal('diff', vim.api.nvim_get_option_value('filetype', { buf = bufnr })) + end) + end) + + describe('dispatch', function() + it('calls get_file_content + get_index_content for staged label', function() + local called_get_file = false + local called_get_index = false + mock_git({ + get_file_content = function() + called_get_file = true + return { 'old' } + end, + get_index_content = function() + called_get_index = true + return { 'new' } + end, + }) + + local bufnr = create_diffs_buffer('diffs://staged:dispatch_staged.lua', { + diffs_repo_root = '/tmp', + }) + commands.read_buffer(bufnr) + + assert.is_true(called_get_file) + assert.is_true(called_get_index) + end) + + it('calls get_index_content + get_working_content for unstaged label', function() + local called_get_index = false + local called_get_working = false + mock_git({ + get_index_content = function() + called_get_index = true + return { 'index' } + end, + get_working_content = function() + called_get_working = true + return { 'working' } + end, + }) + + local bufnr = create_diffs_buffer('diffs://unstaged:dispatch_unstaged.lua', { + diffs_repo_root = '/tmp', + }) + commands.read_buffer(bufnr) + + assert.is_true(called_get_index) + assert.is_true(called_get_working) + end) + + it('calls only get_working_content for untracked label', function() + local called_get_file = false + local called_get_working = false + mock_git({ + get_file_content = function() + called_get_file = true + return {} + end, + get_working_content = function() + called_get_working = true + return { 'new file' } + end, + }) + + local bufnr = create_diffs_buffer('diffs://untracked:dispatch_untracked.lua', { + diffs_repo_root = '/tmp', + }) + commands.read_buffer(bufnr) + + assert.is_false(called_get_file) + assert.is_true(called_get_working) + end) + + it('calls get_file_content + get_working_content for revision label', function() + local captured_rev + local called_get_working = false + mock_git({ + get_file_content = function(rev) + captured_rev = rev + return { 'old' } + end, + get_working_content = function() + called_get_working = true + return { 'new' } + end, + }) + + local bufnr = create_diffs_buffer('diffs://HEAD~3:dispatch_rev.lua', { + diffs_repo_root = '/tmp', + }) + commands.read_buffer(bufnr) + + assert.are.equal('HEAD~3', captured_rev) + assert.is_true(called_get_working) + end) + + it('falls back from index to HEAD for unstaged when index returns nil', function() + local call_order = {} + mock_git({ + get_index_content = function() + table.insert(call_order, 'index') + return nil + end, + get_file_content = function() + table.insert(call_order, 'head') + return { 'head content' } + end, + get_working_content = function() + return { 'working content' } + end, + }) + + local bufnr = create_diffs_buffer('diffs://unstaged:dispatch_fallback.lua', { + diffs_repo_root = '/tmp', + }) + commands.read_buffer(bufnr) + + assert.are.same({ 'index', 'head' }, call_order) + end) + + it('runs git diff for section diffs with path=all', function() + local captured_cmd + mock_systemlist(function(cmd) + captured_cmd = cmd + return { + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1 +1 @@', + '-old', + '+new', + } + end) + + local bufnr = create_diffs_buffer('diffs://unstaged:all', { + diffs_repo_root = '/home/test/repo', + }) + commands.read_buffer(bufnr) + + assert.is_not_nil(captured_cmd) + assert.are.equal('git', captured_cmd[1]) + assert.are.equal('/home/test/repo', captured_cmd[3]) + assert.are.equal('diff', captured_cmd[4]) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal('diff --git a/file.lua b/file.lua', lines[1]) + end) + + it('passes --cached for staged section diffs', function() + local captured_cmd + mock_systemlist(function(cmd) + captured_cmd = cmd + return { 'diff --git a/f.lua b/f.lua', '@@ -1 +1 @@', '-a', '+b' } + end) + + local bufnr = create_diffs_buffer('diffs://staged:all', { + diffs_repo_root = '/tmp', + }) + commands.read_buffer(bufnr) + + assert.is_truthy(vim.tbl_contains(captured_cmd, '--cached')) + end) + end) + + describe('content', function() + it('generates valid unified diff header with correct paths', function() + mock_git({ + get_file_content = function() + return { 'old' } + end, + get_working_content = function() + return { 'new' } + end, + }) + + local bufnr = create_diffs_buffer('diffs://HEAD:lua/diffs/init.lua', { + diffs_repo_root = '/tmp', + }) + commands.read_buffer(bufnr) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal('diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua', lines[1]) + assert.are.equal('--- a/lua/diffs/init.lua', lines[2]) + assert.are.equal('+++ b/lua/diffs/init.lua', lines[3]) + end) + + it('uses old_filepath for diff header in renames', function() + mock_git({ + get_file_content = function(_, path) + assert.are.equal('/tmp/old_name.lua', path) + return { 'old content' } + end, + get_index_content = function() + return { 'new content' } + end, + }) + + local bufnr = create_diffs_buffer('diffs://staged:new_name.lua', { + diffs_repo_root = '/tmp', + diffs_old_filepath = 'old_name.lua', + }) + commands.read_buffer(bufnr) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal('diff --git a/old_name.lua b/new_name.lua', lines[1]) + assert.are.equal('--- a/old_name.lua', lines[2]) + assert.are.equal('+++ b/new_name.lua', lines[3]) + end) + + it('produces empty buffer when old and new are identical', function() + mock_git({ + get_file_content = function() + return { 'identical' } + end, + get_working_content = function() + return { 'identical' } + end, + }) + + local bufnr = create_diffs_buffer('diffs://HEAD:nodiff.lua', { + diffs_repo_root = '/tmp', + }) + commands.read_buffer(bufnr) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same({ '' }, lines) + end) + + it('replaces existing buffer content on reload', function() + mock_git({ + get_file_content = function() + return { 'old' } + end, + get_working_content = function() + return { 'new' } + end, + }) + + local bufnr = create_diffs_buffer('diffs://HEAD:replace_test.lua', { + diffs_repo_root = '/tmp', + }) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale', 'content', 'from', 'before' }) + + commands.read_buffer(bufnr) + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.equal('diff --git a/replace_test.lua b/replace_test.lua', lines[1]) + for _, line in ipairs(lines) do + assert.is_not_equal('stale', line) + end + end) + end) + + describe('attach integration', function() + it('calls attach on the buffer', function() + mock_git() + + local attach_called_with + local original_attach = diffs.attach + diffs.attach = function(bufnr) + attach_called_with = bufnr + end + + local bufnr = create_diffs_buffer('diffs://staged:attach_test.lua', { + diffs_repo_root = '/tmp', + }) + commands.read_buffer(bufnr) + + assert.are.equal(bufnr, attach_called_with) + + diffs.attach = original_attach + end) + end) +end) diff --git a/spec/ux_spec.lua b/spec/ux_spec.lua new file mode 100644 index 0000000..9cf0110 --- /dev/null +++ b/spec/ux_spec.lua @@ -0,0 +1,135 @@ +local commands = require('diffs.commands') +local helpers = require('spec.helpers') + +local counter = 0 + +local function create_diffs_buffer(name) + counter = counter + 1 + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,2 @@', + ' local x = 1', + '+local y = 2', + }) + vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr }) + vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = bufnr }) + vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr }) + vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) + vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr }) + vim.api.nvim_buf_set_name(bufnr, name or ('diffs://unstaged:file_' .. counter .. '.lua')) + return bufnr +end + +describe('ux', function() + describe('diagnostics', function() + it('disables diagnostics on diff buffers', function() + local bufnr = create_diffs_buffer() + commands.setup_diff_buf(bufnr) + + assert.is_false(vim.diagnostic.is_enabled({ bufnr = bufnr })) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('does not affect other buffers', function() + local diff_buf = create_diffs_buffer() + local normal_buf = helpers.create_buffer({ 'hello' }) + + commands.setup_diff_buf(diff_buf) + + assert.is_true(vim.diagnostic.is_enabled({ bufnr = normal_buf })) + vim.api.nvim_buf_delete(diff_buf, { force = true }) + helpers.delete_buffer(normal_buf) + end) + end) + + describe('q keymap', function() + it('sets q keymap on diff buffer', function() + local bufnr = create_diffs_buffer() + commands.setup_diff_buf(bufnr) + + local keymaps = vim.api.nvim_buf_get_keymap(bufnr, 'n') + local has_q = false + for _, km in ipairs(keymaps) do + if km.lhs == 'q' then + has_q = true + break + end + end + assert.is_true(has_q) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('q closes the window', function() + local bufnr = create_diffs_buffer() + commands.setup_diff_buf(bufnr) + + vim.cmd('split') + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, bufnr) + + local win_count_before = #vim.api.nvim_tabpage_list_wins(0) + + vim.api.nvim_buf_call(bufnr, function() + vim.cmd('normal q') + end) + + local win_count_after = #vim.api.nvim_tabpage_list_wins(0) + assert.equals(win_count_before - 1, win_count_after) + end) + end) + + describe('window reuse', function() + it('returns nil when no diffs window exists', function() + local win = commands.find_diffs_window() + assert.is_nil(win) + end) + + it('finds existing diffs:// window', function() + local bufnr = create_diffs_buffer() + vim.cmd('split') + local expected_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(expected_win, bufnr) + + local found = commands.find_diffs_window() + assert.equals(expected_win, found) + + vim.api.nvim_win_close(expected_win, true) + end) + + it('ignores non-diffs buffers', function() + local normal_buf = helpers.create_buffer({ 'hello' }) + vim.cmd('split') + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, normal_buf) + + local found = commands.find_diffs_window() + assert.is_nil(found) + + vim.api.nvim_win_close(win, true) + helpers.delete_buffer(normal_buf) + end) + + it('returns first diffs window when multiple exist', function() + local buf1 = create_diffs_buffer() + local buf2 = create_diffs_buffer() + + vim.cmd('split') + local win1 = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win1, buf1) + + vim.cmd('split') + local win2 = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win2, buf2) + + local found = commands.find_diffs_window() + assert.is_not_nil(found) + assert.is_true(found == win1 or found == win2) + + vim.api.nvim_win_close(win1, true) + vim.api.nvim_win_close(win2, true) + end) + end) +end)