From a25f1e9d8423ffd83227724334ebf0da44209ecc Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 14:49:27 -0500 Subject: [PATCH 01/53] feat: treesitter highlighting for diff headers Apply treesitter highlighting to diff metadata lines (diff --git, index, ---, +++) using the diff language parser. Header info is attached only to the first hunk of each file to avoid duplicate highlighting. Based on PR #52 by @phanen with fixes: - header_lines now only contains diff metadata, not hunk content - header info attached only to first hunk per file - removed arbitrary hunk count restriction --- .github/workflows/test.yaml | 8 ++ README.md | 2 + doc/diffs.nvim.txt | 9 +++ lua/diffs/highlight.lua | 25 +++++- lua/diffs/parser.lua | 27 ++++++- spec/highlight_spec.lua | 156 ++++++++++++++++++++++++++++++++++++ spec/parser_spec.lua | 70 ++++++++++++++++ 7 files changed, 292 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6910389..f5f8e03 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -20,3 +20,11 @@ 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/tree-sitter-diff + cd /tmp/tree-sitter-diff && cc -shared -fPIC -o diff.so -I./src src/parser.c + PARSER_DIR=$(nvim --headless -c 'lua print(vim.fn.stdpath("data") .. "/site/parser")' -c 'q' 2>&1 | tail -1) + echo "Installing parser to: $PARSER_DIR" + mkdir -p "$PARSER_DIR" + cp diff.so "$PARSER_DIR/" + nvim --headless -c 'lua print("diff parser available:", pcall(vim.treesitter.language.inspect, "diff"))' -c 'q' diff --git a/README.md b/README.md index c38528d..4683152 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ syntax highlighting. ## Features - Treesitter syntax highlighting in `:Git` diffs and commit views +- Diff header highlighting (`diff --git`, `index`, `---`, `+++`) - `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds - Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`) - Vim syntax fallback for languages without a treesitter parser @@ -66,3 +67,4 @@ luarocks install diffs.nvim - [`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 diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 6b66ee1..ece0bfc 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -12,6 +12,7 @@ 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 - Background-only diff colors for any `&diff` buffer (vimdiff, diffthis, etc.) - Vim syntax fallback for languages without a treesitter parser @@ -296,5 +297,13 @@ Checks performed: - Neovim version >= 0.9.0 - vim-fugitive is installed (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 + ============================================================================== vim:tw=78:ts=8:ft=help:norl: diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index e34931b..e814dc0 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -57,8 +57,9 @@ end ---@param ns integer ---@param hunk diffs.Hunk ---@param code_lines string[] +---@param col_offset integer? ---@return integer -local function highlight_treesitter(bufnr, ns, hunk, code_lines) +local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset) local lang = hunk.lang if not lang then return 0 @@ -101,6 +102,8 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines) end end + col_offset = col_offset or 1 + local extmark_count = 0 for id, node, _ in query:iter_captures(trees[1]:root(), code) do local capture_name = '@' .. query.captures[id] @@ -108,8 +111,8 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines) local buf_sr = hunk.start_line + sr local buf_er = hunk.start_line + er - local buf_sc = sc + 1 - local buf_ec = ec + 1 + local buf_sc = sc + col_offset + local buf_ec = ec + col_offset pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_sr, buf_sc, { end_row = buf_er, @@ -257,6 +260,22 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines) end + if + hunk.header_start_line + and hunk.header_lines + and #hunk.header_lines > 0 + and opts.highlights.treesitter.enabled + then + extmark_count = extmark_count + + highlight_treesitter(bufnr, ns, { + filename = hunk.filename, + start_line = hunk.header_start_line - 1, + lang = 'diff', + lines = hunk.header_lines, + header_lines = {}, + }, hunk.header_lines, 0) + end + local syntax_applied = extmark_count > 0 for i, line in ipairs(hunk.lines) do diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index ca34ec3..bbb8ab5 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -6,6 +6,8 @@ ---@field header_context string? ---@field header_context_col integer? ---@field lines string[] +---@field header_start_line integer? +---@field header_lines string[]? local M = {} @@ -58,10 +60,16 @@ function M.parse_buffer(bufnr) 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 = {} local function flush_hunk() if hunk_start and #hunk_lines > 0 and (current_lang or current_ft) then - table.insert(hunks, { + local hunk = { filename = current_filename, ft = current_ft, lang = current_lang, @@ -69,7 +77,12 @@ function M.parse_buffer(bufnr) header_context = hunk_header_context, header_context_col = hunk_header_context_col, lines = hunk_lines, - }) + } + 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 @@ -89,6 +102,9 @@ function M.parse_buffer(bufnr) 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 @@ -97,6 +113,9 @@ function M.parse_buffer(bufnr) 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 @@ -112,8 +131,12 @@ function M.parse_buffer(bufnr) 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() diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 3d6df08..d73028b 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -729,6 +729,162 @@ describe('highlight', function() 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, + 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('coalesce_syntax_spans', function() it('coalesces adjacent chars with same hl group', function() local function query_fn(_line, _col) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index c2c1c3a..9d2c01b 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -215,5 +215,75 @@ 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) end) end) From f83fb8b4a73b3105e8ddc3eb6bff31047f374d4f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 15:11:45 -0500 Subject: [PATCH 02/53] fix: remove useless ci script --- .github/workflows/test.yaml | 12 +++---- scripts/ci.sh | 65 ------------------------------------- 2 files changed, 5 insertions(+), 72 deletions(-) delete mode 100755 scripts/ci.sh diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f5f8e03..c850e2d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -21,10 +21,8 @@ jobs: with: nvim_version: ${{ matrix.nvim }} before: | - git clone --depth 1 https://github.com/the-mikedavis/tree-sitter-diff /tmp/tree-sitter-diff - cd /tmp/tree-sitter-diff && cc -shared -fPIC -o diff.so -I./src src/parser.c - PARSER_DIR=$(nvim --headless -c 'lua print(vim.fn.stdpath("data") .. "/site/parser")' -c 'q' 2>&1 | tail -1) - echo "Installing parser to: $PARSER_DIR" - mkdir -p "$PARSER_DIR" - cp diff.so "$PARSER_DIR/" - nvim --headless -c 'lua print("diff parser available:", pcall(vim.treesitter.language.inspect, "diff"))' -c 'q' + 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/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 From 045a9044b575ab8462b6ee1d3d4b1dc19c99065b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 18:14:18 -0500 Subject: [PATCH 03/53] feat: add :Gdiff, :Gvdiff, :Ghdiff commands for unified diff view Compares current buffer against any git revision (default HEAD), opens result with full diffs.nvim syntax highlighting. Follows fugitive convention: :Gdiff/:Gvdiff open vertical split, :Ghdiff opens horizontal split. --- README.md | 1 + doc/diffs.nvim.txt | 29 ++++++++++ lua/diffs/commands.lua | 114 ++++++++++++++++++++++++++++++++++++++++ lua/diffs/git.lua | 48 +++++++++++++++++ lua/diffs/highlight.lua | 16 +++--- lua/diffs/log.lua | 2 +- plugin/diffs.lua | 2 + spec/commands_spec.lua | 40 ++++++++++++++ spec/git_spec.lua | 54 +++++++++++++++++++ spec/highlight_spec.lua | 105 ++++++++++++++++++++++++++++++++++++ 10 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 lua/diffs/commands.lua create mode 100644 lua/diffs/git.lua create mode 100644 spec/commands_spec.lua create mode 100644 spec/git_spec.lua diff --git a/README.md b/README.md index 4683152..2be550b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ syntax highlighting. - 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 - 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()`) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index ece0bfc..755b6b5 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -14,6 +14,7 @@ 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 @@ -139,6 +140,34 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: or register treesitter parsers for custom filetypes, use |vim.filetype.add()| and |vim.treesitter.language.register()|. +============================================================================== +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. + + 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. + ============================================================================== API *diffs-api* diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua new file mode 100644 index 0000000..6dbc176 --- /dev/null +++ b/lua/diffs/commands.lua @@ -0,0 +1,114 @@ +local M = {} + +local git = require('diffs.git') +local dbg = require('diffs.log').dbg + +---@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 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', 'nofile', { buf = diff_buf }) + vim.api.nvim_set_option_value('bufhidden', 'wipe', { 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) + + vim.cmd(vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, 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 + +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/git.lua b/lua/diffs/git.lua new file mode 100644 index 0000000..c7632a2 --- /dev/null +++ b/lua/diffs/git.lua @@ -0,0 +1,48 @@ +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 + +return M diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index e814dc0..9f4d882 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -28,8 +28,8 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang) 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] + for id, node, metadata in query:iter_captures(trees[1]:root(), text) do + local capture_name = '@' .. query.captures[id] .. '.' .. lang local sr, sc, er, ec = node:range() local buf_sr = header_line + sr @@ -37,11 +37,13 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang) local buf_sc = col_offset + sc local buf_ec = col_offset + ec + local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200 + 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, + priority = priority, }) extmark_count = extmark_count + 1 end @@ -105,8 +107,8 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset) col_offset = col_offset or 1 local extmark_count = 0 - for id, node, _ in query:iter_captures(trees[1]:root(), code) do - local capture_name = '@' .. query.captures[id] + for id, node, metadata in query:iter_captures(trees[1]:root(), code) do + local capture_name = '@' .. query.captures[id] .. '.' .. lang local sr, sc, er, ec = node:range() local buf_sr = hunk.start_line + sr @@ -114,11 +116,13 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset) local buf_sc = sc + col_offset local buf_ec = ec + col_offset + local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200 + 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, + priority = priority, }) extmark_count = extmark_count + 1 end diff --git a/lua/diffs/log.lua b/lua/diffs/log.lua index b9f1f3b..08abcc6 100644 --- a/lua/diffs/log.lua +++ b/lua/diffs/log.lua @@ -13,7 +13,7 @@ function M.dbg(msg, ...) if not enabled then return end - vim.notify('[diffs] ' .. string.format(msg, ...), vim.log.levels.DEBUG) + vim.notify('[diffs.nvim]: ' .. string.format(msg, ...), vim.log.levels.DEBUG) end return M diff --git a/plugin/diffs.lua b/plugin/diffs.lua index af2bddb..8a11067 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -3,6 +3,8 @@ if vim.g.loaded_diffs then end vim.g.loaded_diffs = 1 +require('diffs.commands').setup() + vim.api.nvim_create_autocmd('FileType', { pattern = { 'fugitive', 'git' }, callback = function(args) diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua new file mode 100644 index 0000000..add7169 --- /dev/null +++ b/spec/commands_spec.lua @@ -0,0 +1,40 @@ +require('spec.helpers') + +describe('commands', function() + describe('setup', function() + it('registers Gdiff, Gvdiff, and Ghdiff commands', function() + require('diffs.commands').setup() + local commands = vim.api.nvim_get_commands({}) + assert.is_not_nil(commands.Gdiff) + assert.is_not_nil(commands.Gvdiff) + assert.is_not_nil(commands.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) +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/highlight_spec.lua b/spec/highlight_spec.lua index d73028b..e870177 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -885,6 +885,111 @@ describe('highlight', function() 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, + treesitter = { enabled = true, max_lines = 500 }, + vim = { enabled = false, max_lines = 200 }, + }, + } + end + + it('uses priority 200 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_200 = 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 == 200 then + has_priority_200 = true + break + end + end + end + assert.is_true(has_priority_200) + 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 < 200) + 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) From 85080514b688bd21801cb1bc83220ff061c4bce1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 22:23:09 -0500 Subject: [PATCH 04/53] feat(git): add index and working tree content retrieval Adds functions for accessing git index content and working tree files: - get_index_content() retrieves file from staging area via :0:path - get_working_content() reads file directly from disk - file_exists_in_index() checks if file is staged - file_exists_at_revision() checks if file exists at given revision --- lua/diffs/git.lua | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua index c7632a2..7695283 100644 --- a/lua/diffs/git.lua +++ b/lua/diffs/git.lua @@ -45,4 +45,69 @@ function M.get_relative_path(filepath) 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 From ea60ab8d015ca583f61acabe1f5e23d694a305a4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 22:23:13 -0500 Subject: [PATCH 05/53] feat(commands): add gdiff_file for diffing arbitrary paths Adds gdiff_file() which can diff any file path (not just current buffer) with support for staged vs unstaged changes: - staged=true diffs index against HEAD - staged=false diffs working tree against index - Falls back to HEAD if file not in index (for untracked comparison) --- lua/diffs/commands.lua | 75 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 6dbc176..e497e4c 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -88,6 +88,81 @@ function M.gdiff(revision, vertical) end) end +---@class diffs.GdiffFileOpts +---@field vertical? boolean +---@field staged? boolean + +---@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_lines, new_lines, err + local diff_label + + if opts.staged then + old_lines, err = git.get_file_content('HEAD', filepath) + if not old_lines then + vim.notify('[diffs.nvim]: ' .. (err or 'file not in HEAD'), vim.log.levels.ERROR) + return + end + new_lines, err = git.get_index_content(filepath) + if not new_lines then + vim.notify('[diffs.nvim]: ' .. (err or 'file not in index'), vim.log.levels.ERROR) + return + end + diff_label = 'staged' + else + old_lines, err = git.get_index_content(filepath) + if not old_lines then + old_lines, err = git.get_file_content('HEAD', 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 + vim.notify('[diffs.nvim]: ' .. (err or 'cannot read file'), vim.log.levels.ERROR) + return + end + end + + local diff_lines = generate_unified_diff(old_lines, new_lines, rel_path, rel_path) + + if #diff_lines == 0 then + vim.notify('[diffs.nvim]: no changes', vim.log.levels.INFO) + return + end + + 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', 'nofile', { buf = diff_buf }) + vim.api.nvim_set_option_value('bufhidden', 'wipe', { 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) + + vim.cmd(opts.vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, 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 + function M.setup() vim.api.nvim_create_user_command('Gdiff', function(opts) M.gdiff(opts.args ~= '' and opts.args or nil, false) From 9289f33639cb432dc2bc677369178cfcf21e94ba Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 22:23:21 -0500 Subject: [PATCH 06/53] feat(fugitive): add status buffer keymaps for unified diffs Adds du/dU keymaps to fugitive's :Git status buffer for opening unified diffs instead of side-by-side diffs: - du opens horizontal split (mirrors dd) - dU opens vertical split (mirrors dv) Parses status buffer lines to extract filename and detect section (staged/unstaged/untracked). For staged files, diffs index vs HEAD. For unstaged files, diffs working tree vs index. Configurable via vim.g.diffs.fugitive.horizontal/vertical (set to false to disable). --- lua/diffs/fugitive.lua | 145 +++++++++++++++++++++++++++++++++++++++++ lua/diffs/init.lua | 34 ++++++++++ plugin/diffs.lua | 7 ++ 3 files changed, 186 insertions(+) create mode 100644 lua/diffs/fugitive.lua diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua new file mode 100644 index 0000000..0a8f297 --- /dev/null +++ b/lua/diffs/fugitive.lua @@ -0,0 +1,145 @@ +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? +local function parse_file_line(line) + local renamed = line:match('^R[%s%d]*[^%s]+%s*->%s*(.+)$') + if renamed then + return vim.trim(renamed) + end + + local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$') + if filename then + return vim.trim(filename) + end + + return nil +end + +---@param bufnr integer +---@param lnum integer +---@return string?, diffs.FugitiveSection +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 + end + + local filename = parse_file_line(current_line) + if filename then + local section = M.get_section_at_line(bufnr, lnum) + return filename, section + 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 = parse_file_line(prev_line) + if filename then + local section = M.get_section_at_line(bufnr, i) + return filename, section + end + if prev_line:match('^%w+ %(') or prev_line == '' then + break + end + end + end + + return nil, 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 = M.get_file_at_line(bufnr, lnum) + + if not filename then + vim.notify('[diffs.nvim]: no file under cursor', vim.log.levels.WARN) + return + end + + 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 + + local filepath = repo_root .. '/' .. filename + + dbg('diff_file_under_cursor: %s (section: %s)', filename, section or 'unknown') + + if section == 'untracked' then + vim.notify('[diffs.nvim]: cannot diff untracked file (no base version)', vim.log.levels.WARN) + return + end + + commands.gdiff_file(filepath, { + vertical = vertical, + staged = section == 'staged', + }) +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/init.lua b/lua/diffs/init.lua index 66a8098..b9944a4 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -12,11 +12,16 @@ ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig +---@class diffs.FugitiveConfig +---@field horizontal string|false +---@field vertical string|false + ---@class diffs.Config ---@field debug boolean ---@field debounce_ms integer ---@field hide_prefix boolean ---@field highlights diffs.Highlights +---@field fugitive diffs.FugitiveConfig ---@class diffs ---@field attach fun(bufnr?: integer) @@ -78,6 +83,10 @@ local default_config = { max_lines = 200, }, }, + fugitive = { + horizontal = 'du', + vertical = 'dU', + }, } ---@type diffs.Config @@ -219,6 +228,25 @@ local function init() 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.debounce_ms and opts.debounce_ms < 0 then error('diffs: debounce_ms must be >= 0') end @@ -354,4 +382,10 @@ function M.detach_diff() end end +---@return diffs.FugitiveConfig +function M.get_fugitive_config() + init() + return config.fugitive +end + return M diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 8a11067..35aa223 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -13,6 +13,13 @@ vim.api.nvim_create_autocmd('FileType', { 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, }) From ce8fe3b89be692056b0454082a94acf36eaec809 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 22:23:25 -0500 Subject: [PATCH 07/53] test(fugitive): add unit tests for line parsing Tests for get_section_at_line() and get_file_at_line() covering: - Section detection (staged, unstaged, untracked) - File parsing (modified, added, deleted, renamed, untracked) - Hunk line walk-back to parent file - Files appearing in multiple sections --- spec/fugitive_spec.lua | 177 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 spec/fugitive_spec.lua diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua new file mode 100644 index 0000000..bab23a0 --- /dev/null +++ b/spec/fugitive_spec.lua @@ -0,0 +1,177 @@ +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 new name', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R oldname.lua -> newname.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('newname.lua', filename) + assert.equals('staged', section) + 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, section = 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) + end) +end) From 6072dd01567f2c2e1eaad38c8bb3c04c9f3337ca Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 22:39:07 -0500 Subject: [PATCH 08/53] feat(fugitive): add section header and untracked file support Section headers (Staged/Unstaged) now show all diffs in that section, matching fugitive's behavior. Untracked files show as all-added diffs. Deleted files show as all-removed diffs. Also handles edge cases: - Empty new/old content for deleted/new files - Section header detection returns is_header flag --- lua/diffs/commands.lua | 64 +++++++++++++++++++++++++++++++++++++----- lua/diffs/fugitive.lua | 59 +++++++++++++++++++++++++++----------- spec/fugitive_spec.lua | 50 +++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 23 deletions(-) diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index e497e4c..63b5fb0 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -91,6 +91,7 @@ end ---@class diffs.GdiffFileOpts ---@field vertical? boolean ---@field staged? boolean +---@field untracked? boolean ---@param filepath string ---@param opts? diffs.GdiffFileOpts @@ -106,16 +107,22 @@ function M.gdiff_file(filepath, opts) local old_lines, new_lines, err local diff_label - if opts.staged then + 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', filepath) if not old_lines then - vim.notify('[diffs.nvim]: ' .. (err or 'file not in HEAD'), vim.log.levels.ERROR) - return + old_lines = {} end new_lines, err = git.get_index_content(filepath) if not new_lines then - vim.notify('[diffs.nvim]: ' .. (err or 'file not in index'), vim.log.levels.ERROR) - return + new_lines = {} end diff_label = 'staged' else @@ -133,8 +140,7 @@ function M.gdiff_file(filepath, opts) end 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 + new_lines = {} end end @@ -163,6 +169,50 @@ function M.gdiff_file(filepath, opts) 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', 'nofile', { buf = diff_buf }) + vim.api.nvim_set_option_value('bufhidden', 'wipe', { 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.cmd(opts.vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, diff_buf) + + dbg('opened section diff buffer %d (%s)', diff_buf, diff_label) + + vim.schedule(function() + require('diffs').attach(diff_buf) + end) +end + function M.setup() vim.api.nvim_create_user_command('Gdiff', function(opts) M.gdiff(opts.args ~= '' and opts.args or nil, false) diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index 0a8f297..47f856b 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -42,21 +42,39 @@ local function parse_file_line(line) return 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 +---@return string?, diffs.FugitiveSection, boolean 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 + return nil, nil, false + end + + local section_header = parse_section_header(current_line) + if section_header then + return nil, section_header, true end local filename = parse_file_line(current_line) if filename then local section = M.get_section_at_line(bufnr, lnum) - return filename, section + return filename, section, false end local prefix = current_line:sub(1, 1) @@ -66,7 +84,7 @@ function M.get_file_at_line(bufnr, lnum) filename = parse_file_line(prev_line) if filename then local section = M.get_section_at_line(bufnr, i) - return filename, section + return filename, section, false end if prev_line:match('^%w+ %(') or prev_line == '' then break @@ -74,7 +92,7 @@ function M.get_file_at_line(bufnr, lnum) end end - return nil, nil + return nil, nil, false end ---@param bufnr integer @@ -96,12 +114,7 @@ function M.diff_file_under_cursor(vertical) local bufnr = vim.api.nvim_get_current_buf() local lnum = vim.api.nvim_win_get_cursor(0)[1] - local filename, section = M.get_file_at_line(bufnr, lnum) - - if not filename then - vim.notify('[diffs.nvim]: no file under cursor', vim.log.levels.WARN) - return - end + local filename, section, is_header = M.get_file_at_line(bufnr, lnum) local repo_root = get_repo_root_from_fugitive(bufnr) if not repo_root then @@ -109,18 +122,32 @@ function M.diff_file_under_cursor(vertical) 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 dbg('diff_file_under_cursor: %s (section: %s)', filename, section or 'unknown') - if section == 'untracked' then - vim.notify('[diffs.nvim]: cannot diff untracked file (no base version)', vim.log.levels.WARN) - return - end - commands.gdiff_file(filepath, { vertical = vertical, staged = section == 'staged', + untracked = section == 'untracked', }) end diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index bab23a0..00ebef1 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -173,5 +173,55 @@ describe('fugitive', function() 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) end) From 9ed0639005cb17a5d68f59e4cd7397325f05b14b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 23:12:30 -0500 Subject: [PATCH 09/53] fix(fugitive): handle renamed files correctly Parse both old and new filenames from rename lines (R old -> new). When diffing staged renames, use old filename as base to correctly show content changes rather than treating the file as entirely new. Also adds comprehensive tests for filename edge cases: - Double extensions, hyphens, underscores, dotfiles - Deep nested paths, complex renames - Documents known limitation with filenames containing ' -> ' --- lua/diffs/commands.lua | 11 ++-- lua/diffs/fugitive.lua | 39 +++++++----- spec/fugitive_spec.lua | 137 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 165 insertions(+), 22 deletions(-) diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 63b5fb0..22d0570 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -92,6 +92,7 @@ end ---@field vertical? boolean ---@field staged? boolean ---@field untracked? boolean +---@field old_filepath? string ---@param filepath string ---@param opts? diffs.GdiffFileOpts @@ -104,6 +105,8 @@ function M.gdiff_file(filepath, opts) 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 @@ -116,7 +119,7 @@ function M.gdiff_file(filepath, opts) end diff_label = 'untracked' elseif opts.staged then - old_lines, err = git.get_file_content('HEAD', filepath) + old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath) if not old_lines then old_lines = {} end @@ -126,9 +129,9 @@ function M.gdiff_file(filepath, opts) end diff_label = 'staged' else - old_lines, err = git.get_index_content(filepath) + 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', filepath) + old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath) if not old_lines then old_lines = {} diff_label = 'untracked' @@ -144,7 +147,7 @@ function M.gdiff_file(filepath, opts) end end - local diff_lines = generate_unified_diff(old_lines, new_lines, rel_path, rel_path) + 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) diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index 47f856b..bdb5214 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -27,19 +27,19 @@ function M.get_section_at_line(bufnr, lnum) end ---@param line string ----@return string? +---@return string?, string? local function parse_file_line(line) - local renamed = line:match('^R[%s%d]*[^%s]+%s*->%s*(.+)$') - if renamed then - return vim.trim(renamed) + 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) + return vim.trim(filename), nil end - return nil + return nil, nil end ---@param line string @@ -57,34 +57,34 @@ end ---@param bufnr integer ---@param lnum integer ----@return string?, diffs.FugitiveSection, boolean +---@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 + return nil, nil, false, nil end local section_header = parse_section_header(current_line) if section_header then - return nil, section_header, true + return nil, section_header, true, nil end - local filename = parse_file_line(current_line) + local filename, old_filename = parse_file_line(current_line) if filename then local section = M.get_section_at_line(bufnr, lnum) - return filename, section, false + 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 = parse_file_line(prev_line) + filename, old_filename = parse_file_line(prev_line) if filename then local section = M.get_section_at_line(bufnr, i) - return filename, section, false + return filename, section, false, old_filename end if prev_line:match('^%w+ %(') or prev_line == '' then break @@ -92,7 +92,7 @@ function M.get_file_at_line(bufnr, lnum) end end - return nil, nil, false + return nil, nil, false, nil end ---@param bufnr integer @@ -114,7 +114,7 @@ function M.diff_file_under_cursor(vertical) local bufnr = vim.api.nvim_get_current_buf() local lnum = vim.api.nvim_win_get_cursor(0)[1] - local filename, section, is_header = M.get_file_at_line(bufnr, lnum) + local filename, section, is_header, old_filename = M.get_file_at_line(bufnr, lnum) local repo_root = get_repo_root_from_fugitive(bufnr) if not repo_root then @@ -141,13 +141,20 @@ function M.diff_file_under_cursor(vertical) end local filepath = repo_root .. '/' .. filename + local old_filepath = old_filename and (repo_root .. '/' .. old_filename) or nil - dbg('diff_file_under_cursor: %s (section: %s)', filename, section or 'unknown') + dbg( + 'diff_file_under_cursor: %s (section: %s, old: %s)', + filename, + section or 'unknown', + old_filename or 'none' + ) commands.gdiff_file(filepath, { vertical = vertical, staged = section == 'staged', untracked = section == 'untracked', + old_filepath = old_filepath, }) end diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index 00ebef1..3d73b0c 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -109,14 +109,147 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('parses renamed file and returns new name', function() + it('parses renamed file and returns both names', function() local buf = create_status_buffer({ 'Staged (1)', 'R oldname.lua -> newname.lua', }) - local filename, section = fugitive.get_file_at_line(buf, 2) + 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, is_header, 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, is_header, 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, section, is_header, 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, section, is_header, 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, section, is_header, 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, section, is_header, 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, section, is_header, 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, is_header, old_filename = 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, section, is_header, old_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, section, is_header, old_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, section, is_header, 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, section, is_header, old_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) From 08e22af1139594f58045010063e8e12fdba82c5d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 23:25:39 -0500 Subject: [PATCH 10/53] fix(tests): suppress unused variable warnings for selene --- spec/fugitive_spec.lua | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index 3d73b0c..e79aed1 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -127,7 +127,7 @@ describe('fugitive', function() 'Staged (1)', 'R100 old.lua -> new.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + 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) @@ -139,7 +139,7 @@ describe('fugitive', function() 'Staged (1)', 'M modified.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + 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) @@ -151,7 +151,7 @@ describe('fugitive', function() 'Staged (1)', 'R old file.lua -> new file.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + 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 }) @@ -162,7 +162,7 @@ describe('fugitive', function() 'Staged (1)', 'R src/old.lua -> src/new.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + 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 }) @@ -173,7 +173,7 @@ describe('fugitive', function() 'Staged (1)', 'R old/file.lua -> new/file.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + 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 }) @@ -184,7 +184,7 @@ describe('fugitive', function() 'Staged (1)', 'R a -> b.lua -> c.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + 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 }) @@ -195,7 +195,7 @@ describe('fugitive', function() 'Staged (1)', 'M test.spec.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + 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 }) @@ -206,7 +206,7 @@ describe('fugitive', function() 'Unstaged (1)', 'M my-component-test.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + 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 }) @@ -217,7 +217,7 @@ describe('fugitive', function() 'Staged (1)', 'A test_file_123.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + 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) @@ -227,7 +227,7 @@ describe('fugitive', function() 'Unstaged (1)', 'M .gitignore', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename = fugitive.get_file_at_line(buf, 2) assert.equals('.gitignore', filename) vim.api.nvim_buf_delete(buf, { force = true }) end) @@ -237,7 +237,7 @@ describe('fugitive', function() 'Staged (1)', 'R src/old-file.spec.lua -> src/new-file.spec.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + 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 }) @@ -248,7 +248,7 @@ describe('fugitive', function() 'Unstaged (1)', 'M lua/diffs/ui/components/diff-view.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + 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) @@ -269,7 +269,7 @@ describe('fugitive', function() 'Unstaged (1)', 'M file.lua', }) - local filename, section = fugitive.get_file_at_line(buf, 1) + local filename = fugitive.get_file_at_line(buf, 1) assert.is_nil(filename) vim.api.nvim_buf_delete(buf, { force = true }) end) From d8332895f3a9465d4ccf31a0fdef646f8edd607d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 23:37:39 -0500 Subject: [PATCH 11/53] docs: add fugitive status buffer keymaps documentation Document the du/dU keymaps for unified diffs in vim-fugitive status buffers, including behavior by file status and configuration options. --- README.md | 1 + doc/diffs.nvim.txt | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/README.md b/README.md index 2be550b..8d0d11c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ syntax highlighting. - 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()`) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 755b6b5..b3240f4 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -65,6 +65,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: max_lines = 200, }, }, + fugitive = { + horizontal = 'du', + vertical = 'dU', + }, } < *diffs.Config* @@ -89,6 +93,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: 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. + *diffs.Highlights* Highlights table fields: ~ {background} (boolean, default: true) @@ -168,6 +176,55 @@ COMMANDS *diffs-commands* :Ghdiff [revision] *:Ghdiff* Like |:Gdiff| but explicitly opens in a horizontal split. +============================================================================== +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. + ============================================================================== API *diffs-api* From 980bedc8a6e2180d60462204d54bbc1d4dd7c613 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 23:51:15 -0500 Subject: [PATCH 12/53] fix(parser): emit hunks for files with unknown filetypes Previously, hunks were discarded entirely if vim.filetype.match() returned nil. This meant files with unrecognized extensions got no highlighting at all - not even the basic green/red backgrounds for added/deleted lines. Remove the (current_lang or current_ft) condition from flush_hunk() so all hunks are collected. highlight_hunk() already handles the case where ft/lang are nil by skipping syntax highlighting but still applying background colors. Co-authored-by: phanen --- lua/diffs/parser.lua | 2 +- spec/parser_spec.lua | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index bbb8ab5..da3b291 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -68,7 +68,7 @@ function M.parse_buffer(bufnr) local header_lines = {} local function flush_hunk() - if hunk_start and #hunk_lines > 0 and (current_lang or current_ft) then + if hunk_start and #hunk_lines > 0 then local hunk = { filename = current_filename, ft = current_ft, diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 9d2c01b..5323d09 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -285,5 +285,24 @@ describe('parser', function() 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) end) end) From c51d625dc68863ad9235b3caf15943bb762b3fe4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Feb 2026 00:13:32 -0500 Subject: [PATCH 13/53] fix(parser): detect filetype from existing buffer Files detected via shebang or modeline (e.g., `build` with `#!/bin/bash`) weren't getting syntax highlighting because `vim.filetype.match()` only does filename-based detection. Now `get_ft_from_filename()` first checks if a buffer already exists for the file and uses its detected filetype. This requires knowing the repo root to construct the full path, so: - `parse_buffer()` reads `b:diffs_repo_root` or `b:git_dir` (fugitive) - `commands.lua` sets `b:diffs_repo_root` on diff buffers it creates Co-authored-by: phanen --- lua/diffs/commands.lua | 11 +++++++++ lua/diffs/parser.lua | 34 ++++++++++++++++++++++++-- spec/parser_spec.lua | 55 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 22d0570..0944759 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -70,6 +70,8 @@ function M.gdiff(revision, vertical) 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', 'nofile', { buf = diff_buf }) @@ -77,6 +79,9 @@ function M.gdiff(revision, vertical) 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 vim.cmd(vertical and 'vsplit' or 'split') vim.api.nvim_win_set_buf(0, diff_buf) @@ -154,6 +159,8 @@ function M.gdiff_file(filepath, opts) 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', 'nofile', { buf = diff_buf }) @@ -161,6 +168,9 @@ function M.gdiff_file(filepath, opts) 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 vim.cmd(opts.vertical and 'vsplit' or 'split') vim.api.nvim_win_set_buf(0, diff_buf) @@ -205,6 +215,7 @@ function M.gdiff_section(repo_root, opts) 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) vim.cmd(opts.vertical and 'vsplit' or 'split') vim.api.nvim_win_set_buf(0, diff_buf) diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index da3b291..528327b 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -14,8 +14,21 @@ local M = {} local dbg = require('diffs.log').dbg ---@param filename string +---@param repo_root string? ---@return string? -local function get_ft_from_filename(filename) +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 not ft then dbg('no filetype for: %s', filename) @@ -39,10 +52,27 @@ local function get_lang_from_ft(ft) 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 = {} @@ -95,7 +125,7 @@ function M.parse_buffer(bufnr) if filename then flush_hunk() current_filename = filename - current_ft = get_ft_from_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) diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 5323d09..e5e1dba 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -304,5 +304,60 @@ describe('parser', function() 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) end) end) From 9e857d4b29639a58ada99b42ffdc85a9b24e7a00 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Feb 2026 00:27:35 -0500 Subject: [PATCH 14/53] feat(fugitive): line position tracking for keymaps When pressing `du`/`dU` from a hunk line in the fugitive status buffer (after expanding with `=`), the unified diff now opens at the corresponding line instead of line 1. Implementation: - `fugitive.get_hunk_position()` returns @@ header and offset when on a hunk line - `commands.find_hunk_line()` finds matching @@ header in diff buffer - `commands.gdiff_file()` accepts optional `hunk_position` and jumps after opening Also updates @phanen's README credit for the previous two fixes. Closes #65 --- README.md | 3 +- lua/diffs/commands.lua | 21 ++++++++ lua/diffs/fugitive.lua | 43 ++++++++++++++- spec/commands_spec.lua | 68 +++++++++++++++++++++-- spec/fugitive_spec.lua | 120 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 247 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8d0d11c..583799e 100644 --- a/README.md +++ b/README.md @@ -69,4 +69,5 @@ luarocks install diffs.nvim - [`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 +- [@phanen](https://github.com/phanen) - diff header highlighting, unknown + filetype fix, shebang/modeline detection diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 0944759..b3f9b42 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -3,6 +3,18 @@ local M = {} local git = require('diffs.git') local dbg = require('diffs.log').dbg +---@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 @@ -98,6 +110,7 @@ end ---@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 @@ -175,6 +188,14 @@ function M.gdiff_file(filepath, opts) vim.cmd(opts.vertical and 'vsplit' or 'split') vim.api.nvim_win_set_buf(0, diff_buf) + 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 + dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) vim.schedule(function() diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index bdb5214..a588a22 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -95,6 +95,42 @@ function M.get_file_at_line(bufnr, lnum) 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) @@ -142,12 +178,14 @@ function M.diff_file_under_cursor(vertical) 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)', + 'diff_file_under_cursor: %s (section: %s, old: %s, hunk_offset: %s)', filename, section or 'unknown', - old_filename or 'none' + old_filename or 'none', + hunk_position and tostring(hunk_position.offset) or 'none' ) commands.gdiff_file(filepath, { @@ -155,6 +193,7 @@ function M.diff_file_under_cursor(vertical) staged = section == 'staged', untracked = section == 'untracked', old_filepath = old_filepath, + hunk_position = hunk_position, }) end diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index add7169..daba5d5 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -1,13 +1,15 @@ require('spec.helpers') +local commands = require('diffs.commands') + describe('commands', function() describe('setup', function() it('registers Gdiff, Gvdiff, and Ghdiff commands', function() - require('diffs.commands').setup() - local commands = vim.api.nvim_get_commands({}) - assert.is_not_nil(commands.Gdiff) - assert.is_not_nil(commands.Gvdiff) - assert.is_not_nil(commands.Ghdiff) + 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) @@ -37,4 +39,60 @@ describe('commands', function() 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/fugitive_spec.lua b/spec/fugitive_spec.lua index e79aed1..244e1b7 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -357,4 +357,124 @@ describe('fugitive', function() 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) From 33c58c749853d3a090aa8f832b71b218032a49e9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 5 Feb 2026 01:04:23 -0500 Subject: [PATCH 15/53] fix(parser): detect filetype from file content (shebang/modeline) When a file has no extension but contains a shebang (e.g., `#!/bin/bash`), filetype detection now reads the first 10 lines from disk and uses `vim.filetype.match({ filename, contents })` for content-based detection. This enables syntax highlighting for files like `build` scripts that rely on shebang detection, even when the file isn't open in a buffer. Detection order: 1. Existing buffer's filetype (already implemented in #69) 2. File content (shebang/modeline) - NEW 3. Filename extension only Also adds `filetype on` to test helpers to ensure `vim.g.ft_ignore_pat` is set, which is required for shell detection. --- lua/diffs/parser.lua | 42 +++++++++++++++++++++++++++--- spec/helpers.lua | 2 ++ spec/parser_spec.lua | 62 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 528327b..52b2864 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -13,12 +13,33 @@ 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 }) @@ -30,10 +51,25 @@ local function get_ft_from_filename(filename, repo_root) end local ft = vim.filetype.match({ filename = filename }) - if not ft then - dbg('no filetype for: %s', filename) + if ft then + dbg('filetype from filename: %s', ft) + return ft end - return ft + + 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 diff --git a/spec/helpers.lua b/spec/helpers.lua index db9016a..34128ac 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 diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index e5e1dba..89d0ac8 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -359,5 +359,67 @@ describe('parser', function() 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) end) end) From 997bc49f8bb6205ce8f8884366c03eaa4bf7bf99 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 13:53:58 -0500 Subject: [PATCH 16/53] feat(highlight): add character-level intra-line diff highlighting Line-level backgrounds (DiffsAdd/DiffsDelete) now get a second tier: changed characters within modified lines receive an intense background overlay (DiffsAddText/DiffsDeleteText at 70% alpha vs 40% for lines). Treesitter foreground colors show through since the extmarks only set bg. diff.lua extracts contiguous -/+ change groups from hunk lines and diffs each group byte-by-byte using vim.diff(). An optional libvscodediff FFI backend (lib.lua) auto-downloads the .so from codediff.nvim releases and falls back to native if unavailable. New config: highlights.intra.{enabled, algorithm, max_lines}. Gated by max_lines (default 200) to avoid stalling on huge hunks. Priority 201 sits above treesitter (200) so the character bg always wins. Closes #60 --- doc/diffs.nvim.txt | 44 ++++++ lua/diffs/diff.lua | 338 ++++++++++++++++++++++++++++++++++++++++ lua/diffs/health.lua | 7 + lua/diffs/highlight.lua | 37 +++++ lua/diffs/init.lua | 39 +++++ lua/diffs/lib.lua | 214 +++++++++++++++++++++++++ spec/diff_spec.lua | 163 +++++++++++++++++++ 7 files changed, 842 insertions(+) create mode 100644 lua/diffs/diff.lua create mode 100644 lua/diffs/lib.lua create mode 100644 spec/diff_spec.lua diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index b3240f4..76e5ea3 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -64,6 +64,11 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: enabled = false, max_lines = 200, }, + intra = { + enabled = true, + algorithm = 'auto', + max_lines = 200, + }, }, fugitive = { horizontal = 'du', @@ -116,6 +121,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: 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. + *diffs.TreesitterConfig* Treesitter config fields: ~ {enabled} (boolean, default: true) @@ -140,6 +149,26 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: 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: 'auto') + Diff algorithm for character-level analysis. + `'auto'`: use libvscodediff if available, else + native `vim.diff()`. `'native'`: always use + `vim.diff()`. `'vscode'`: require libvscodediff + (falls back to native if not available). + + {max_lines} (integer, default: 200) + 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. @@ -259,6 +288,8 @@ Summary / commit detail views: ~ - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 198 - `Normal` extmarks at priority 199 clear underlying diff foreground - 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 @@ -349,6 +380,18 @@ Fugitive unified diff highlights: ~ DiffsDeleteNr Line number for `-` lines. Foreground from `diffRemoved`, background from `DiffsDelete`. + *DiffsAddText* + DiffsAddText Character-level background for changed characters + within `+` lines. Derived by blending `DiffAdd` + background with `Normal` at 70% alpha (brighter + than line-level `DiffsAdd`). Only sets `bg`, so + treesitter foreground colors show through. + + *DiffsDeleteText* + DiffsDeleteText Character-level background for changed characters + within `-` lines. Derived by blending `DiffDelete` + background with `Normal` at 70% alpha. + Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. @@ -382,6 +425,7 @@ 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* diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua new file mode 100644 index 0000000..8dc836b --- /dev/null +++ b/lua/diffs/diff.lua @@ -0,0 +1,338 @@ +---@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}[] + +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 + +---@param old_text string +---@param new_text string +---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[] +local function byte_diff(old_text, new_text) + local ok, result = pcall(vim.diff, old_text, new_text, { result_type = 'indices' }) + 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 +---@return diffs.CharSpan[], diffs.CharSpan[] +local function char_diff_pair(old_line, new_line, del_idx, add_idx) + ---@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_hunks = byte_diff(old_text, new_text) + + 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 +---@return diffs.CharSpan[], diffs.CharSpan[] +local function diff_group_native(group) + ---@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 + ) + 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) + + ---@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 + ) + 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 + ) + 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 'auto' + + local lib = require('diffs.lib') + local vscode_handle = nil + if algorithm ~= 'native' then + vscode_handle = lib.load() + end + + if algorithm == 'vscode' and not vscode_handle then + dbg('vscode algorithm requested but library not available, falling back to native') + end + + ---@type diffs.CharSpan[] + local all_add = {} + ---@type diffs.CharSpan[] + local all_del = {} + + for _, group in ipairs(groups) do + local ds, as + if vscode_handle then + ds, as = diff_group_vscode(group, vscode_handle) + else + ds, as = diff_group_native(group) + 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/health.lua b/lua/diffs/health.lua index c4777ca..54a3189 100644 --- a/lua/diffs/health.lua +++ b/lua/diffs/health.lua @@ -15,6 +15,13 @@ function M.check() 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 index 9f4d882..1eb16d7 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -1,6 +1,7 @@ local M = {} local dbg = require('diffs.log').dbg +local diff = require('diffs.diff') ---@param bufnr integer ---@param ns integer @@ -282,6 +283,30 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) local syntax_applied = extmark_count > 0 + ---@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 + intra = diff.compute_intra_hunks(hunk.lines, intra_cfg.algorithm) + 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 @@ -317,6 +342,18 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) priority = 199, }) 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 + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { + end_col = span.col_end, + hl_group = char_hl, + priority = 201, + }) + extmark_count = extmark_count + 1 + end + end end dbg('hunk %s:%d applied %d extmarks', hunk.filename, hunk.start_line, extmark_count) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index b9944a4..ea33a32 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -6,11 +6,17 @@ ---@field enabled boolean ---@field max_lines integer +---@class diffs.IntraConfig +---@field enabled boolean +---@field algorithm string +---@field max_lines integer + ---@class diffs.Highlights ---@field background boolean ---@field gutter boolean ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig +---@field intra diffs.IntraConfig ---@class diffs.FugitiveConfig ---@field horizontal string|false @@ -82,6 +88,11 @@ local default_config = { enabled = false, max_lines = 200, }, + intra = { + enabled = true, + algorithm = 'auto', + max_lines = 200, + }, }, fugitive = { horizontal = 'du', @@ -172,10 +183,15 @@ local function compute_highlight_groups() local blended_add = blend_color(add_bg, bg, 0.4) local blended_del = blend_color(del_bg, bg, 0.4) + local blended_add_text = blend_color(add_bg, bg, 0.7) + local blended_del_text = blend_color(del_bg, bg, 0.7) + 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 = add_fg, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { default = true, fg = del_fg, 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 }) local diff_change = resolve_hl('DiffChange') local diff_text = resolve_hl('DiffText') @@ -207,6 +223,7 @@ local function init() ['highlights.gutter'] = { opts.highlights.gutter, 'boolean', 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.treesitter then @@ -226,6 +243,20 @@ local function init() ['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 == 'auto' or v == 'native' or v == 'vscode' + end, + "'auto', 'native', or 'vscode'", + }, + ['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true }, + }) + end end if opts.fugitive then @@ -266,6 +297,14 @@ local function init() 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 config = vim.tbl_deep_extend('force', default_config, opts) log.set_enabled(config.debug) 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/spec/diff_spec.lua b/spec/diff_spec.lua new file mode 100644 index 0000000..80ad8a8 --- /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' }, 'native') + assert.is_nil(result) + end) + + it('returns nil for all-deletion hunks', function() + local result = diff.compute_intra_hunks({ '-line1', '-line2' }, 'native') + assert.is_nil(result) + end) + + it('returns nil for context-only hunks', function() + local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'native') + 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', + }, 'native') + 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', + }, 'native') + 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', + }, 'native') + 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', + }, 'native') + 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"', + }, 'native') + 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', + }, 'native') + 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) From 63b6e7d4c63f71f0f9cecc43362f213cef412c0d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 13:58:30 -0500 Subject: [PATCH 17/53] fix(ci): add jit to luarc globals for lua-language-server jit is a standard LuaJIT global (like vim), needed by lib.lua for platform detection via jit.os and jit.arch. --- .luarc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From f1c13966ba4a517f72b9b03761696e1dca3d7529 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 14:43:23 -0500 Subject: [PATCH 18/53] fix(highlight): use diffAdded/diffRemoved fg for char-level backgrounds The previous 70% alpha blend of DiffAdd bg was nearly identical to the 40% line-level blend, making char-level highlights invisible. Now blends the bright diffAdded/diffRemoved foreground color (same base as line number fg) into the char-level bg, matching GitHub/VSCode intensity. Also bumps intra.max_lines default from 200 to 500. --- doc/diffs.nvim.txt | 19 ++++++++++--------- lua/diffs/init.lua | 6 +++--- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 76e5ea3..dacbfa6 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -62,12 +62,12 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: }, vim = { enabled = false, - max_lines = 200, + max_lines = 500, }, intra = { enabled = true, algorithm = 'auto', - max_lines = 200, + max_lines = 500, }, }, fugitive = { @@ -165,7 +165,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: `vim.diff()`. `'vscode'`: require libvscodediff (falls back to native if not available). - {max_lines} (integer, default: 200) + {max_lines} (integer, default: 500) Skip character-level highlighting for hunks larger than this many lines. @@ -382,15 +382,16 @@ Fugitive unified diff highlights: ~ *DiffsAddText* DiffsAddText Character-level background for changed characters - within `+` lines. Derived by blending `DiffAdd` - background with `Normal` at 70% alpha (brighter - than line-level `DiffsAdd`). Only sets `bg`, so - treesitter foreground colors show through. + within `+` lines. Derived by blending `diffAdded` + foreground with `Normal` background at 40% alpha. + Uses the same base color as `DiffsAddNr` foreground, + making changed characters clearly visible. Only sets + `bg`, so treesitter foreground colors show through. *DiffsDeleteText* DiffsDeleteText Character-level background for changed characters - within `-` lines. Derived by blending `DiffDelete` - background with `Normal` at 70% alpha. + within `-` lines. Derived by blending `diffRemoved` + foreground with `Normal` background at 40% alpha. Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index ea33a32..d2097b1 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -91,7 +91,7 @@ local default_config = { intra = { enabled = true, algorithm = 'auto', - max_lines = 200, + max_lines = 500, }, }, fugitive = { @@ -183,8 +183,8 @@ local function compute_highlight_groups() local blended_add = blend_color(add_bg, bg, 0.4) local blended_del = blend_color(del_bg, bg, 0.4) - local blended_add_text = blend_color(add_bg, bg, 0.7) - local blended_del_text = blend_color(del_bg, bg, 0.7) + local blended_add_text = blend_color(add_fg, bg, 0.4) + local blended_del_text = blend_color(del_fg, bg, 0.4) vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del }) From cc947167c36e01c53cd74cd9b5bc9b5cb603ba45 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 18:28:22 -0500 Subject: [PATCH 19/53] fix(highlight): use hl_group instead of line_hl_group for diff backgrounds line_hl_group bg occupies a separate rendering channel from hl_group in Neovim's extmark system, causing character-level bg-only highlights to be invisible regardless of priority. Switching to hl_group + hl_eol ensures all backgrounds compete in the same channel. Also reorders priorities (Normal 198 < line bg 199 < syntax 200 < char bg 201), bumps char-level blend alpha from 0.4 to 0.7 for visibility, and adds debug logging throughout the intra pipeline. --- lua/diffs/debug.lua | 68 +++++++++ lua/diffs/diff.lua | 17 ++- lua/diffs/highlight.lua | 45 ++++-- lua/diffs/init.lua | 13 +- spec/highlight_spec.lua | 304 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 427 insertions(+), 20 deletions(-) create mode 100644 lua/diffs/debug.lua diff --git a/lua/diffs/debug.lua b/lua/diffs/debug.lua new file mode 100644 index 0000000..5be95bc --- /dev/null +++ b/lua/diffs/debug.lua @@ -0,0 +1,68 @@ +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, + line_hl_group = details.line_hl_group, + number_hl_group = details.number_hl_group, + virt_text = details.virt_text, + } + if not by_line[row] then + by_line[row] = { text = lines[row + 1] or '', marks = {} } + end + table.insert(by_line[row].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 index 8dc836b..65d3ac8 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -312,13 +312,28 @@ function M.compute_intra_hunks(hunk_lines, algorithm) ---@type diffs.CharSpan[] local all_del = {} - for _, group in ipairs(groups) do + 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) 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 diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 1eb16d7..311a6f3 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -287,7 +287,17 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) 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 @@ -324,21 +334,20 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) }) 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 = 198, + }) + end + + if opts.highlights.background and is_diff_line then + pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { + end_col = line_len, + hl_group = line_hl, + hl_eol = true, + number_hl_group = opts.highlights.gutter and number_hl or nil, priority = 199, }) end @@ -346,11 +355,23 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) 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 - pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, span.col_start, { + 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 = 201, }) + if not ok then + dbg('char extmark FAILED: %s', err) + end extmark_count = extmark_count + 1 end end diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index d2097b1..d80cd2e 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -183,8 +183,8 @@ local function compute_highlight_groups() local blended_add = blend_color(add_bg, bg, 0.4) local blended_del = blend_color(del_bg, bg, 0.4) - local blended_add_text = blend_color(add_fg, bg, 0.4) - local blended_del_text = blend_color(del_fg, bg, 0.4) + local blended_add_text = blend_color(add_fg, bg, 0.7) + local blended_del_text = blend_color(del_fg, bg, 0.7) vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add }) vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del }) @@ -193,6 +193,15 @@ local function compute_highlight_groups() 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') diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index e870177..9b7e685 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -43,6 +43,11 @@ describe('highlight', function() enabled = false, max_lines = 200, }, + intra = { + enabled = false, + algorithm = 'native', + max_lines = 500, + }, }, } if overrides then @@ -322,7 +327,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -355,7 +360,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_delete = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then + if mark[4] and mark[4].hl_group == 'DiffsDelete' then has_diff_delete = true break end @@ -388,7 +393,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 @@ -520,7 +525,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -668,7 +673,7 @@ describe('highlight', function() local extmarks = get_extmarks(bufnr) local has_diff_add = false for _, mark in ipairs(extmarks) do - if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then + if mark[4] and mark[4].hl_group == 'DiffsAdd' then has_diff_add = true break end @@ -727,6 +732,295 @@ describe('highlight', function() assert.is_true(has_normal) 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('line bg priority > Normal 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 normal_priority = nil + local line_bg_priority = nil + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d and d.hl_group == 'Normal' then + normal_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(normal_priority) + assert.is_not_nil(line_bg_priority) + assert.is_true(line_bg_priority > normal_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 = 'native', 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 = 'native', 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 = 'native', 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 = 'native', 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: Normal < line bg < syntax < 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 = 'native', max_lines = 500 }, + }, + }) + ) + + local extmarks = get_extmarks(bufnr) + local priorities = { normal = {}, line_bg = {}, syntax = {}, char_bg = {} } + for _, mark in ipairs(extmarks) do + local d = mark[4] + if d then + if d.hl_group == 'Normal' then + table.insert(priorities.normal, 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.normal > 0) + assert.is_true(#priorities.line_bg > 0) + assert.is_true(#priorities.syntax > 0) + assert.is_true(#priorities.char_bg > 0) + + local max_normal = math.max(unpack(priorities.normal)) + 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_normal < min_line_bg) + assert.is_true(min_line_bg < min_syntax) + assert.is_true(min_syntax < min_char_bg) + delete_buffer(bufnr) + end) end) describe('diff header highlighting', function() From 10af59a70d09cb5769cf859d70cedef933d1c6eb Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 21:23:40 -0500 Subject: [PATCH 20/53] feat(config): replace algorithm 'auto'/'native' with 'default'/'vscode' 'default' inherits algorithm and linematch from diffopt, 'vscode' uses the FFI library. Removes the need for diffs.nvim to duplicate settings that users already control globally. --- README.md | 1 + doc/diffs.nvim.txt | 25 +++++++-------- lua/diffs/diff.lua | 70 +++++++++++++++++++++++++++++++---------- lua/diffs/init.lua | 6 ++-- spec/diff_spec.lua | 18 +++++------ spec/highlight_spec.lua | 12 +++---- 6 files changed, 85 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 583799e..89254ac 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ syntax highlighting. - Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`) - Vim syntax fallback for languages without a treesitter parser - Hunk header context highlighting (`@@ ... @@ function foo()`) +- Character-level (intra-line) diff highlighting for changed characters - Configurable debouncing, max lines, and diff prefix concealment ## Requirements diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index dacbfa6..9554059 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -66,7 +66,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: }, intra = { enabled = true, - algorithm = 'auto', + algorithm = 'default', max_lines = 500, }, }, @@ -158,12 +158,12 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: an intense background overlay while the rest of the line keeps the softer line-level background. - {algorithm} (string, default: 'auto') + {algorithm} (string, default: 'default') Diff algorithm for character-level analysis. - `'auto'`: use libvscodediff if available, else - native `vim.diff()`. `'native'`: always use - `vim.diff()`. `'vscode'`: require libvscodediff - (falls back to native if not available). + `'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 @@ -285,8 +285,8 @@ Summary / commit detail views: ~ - Code is parsed with |vim.treesitter.get_string_parser()| - If no treesitter parser and `vim.enabled`: vim syntax fallback via scratch buffer and |synID()| - - Background extmarks (`DiffsAdd`/`DiffsDelete`) at priority 198 - - `Normal` extmarks at priority 199 clear underlying diff foreground + - `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 @@ -383,15 +383,14 @@ Fugitive unified diff highlights: ~ *DiffsAddText* DiffsAddText Character-level background for changed characters within `+` lines. Derived by blending `diffAdded` - foreground with `Normal` background at 40% alpha. - Uses the same base color as `DiffsAddNr` foreground, - making changed characters clearly visible. Only sets - `bg`, so treesitter foreground colors show through. + foreground with `Normal` background at 70% 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 40% alpha. + foreground with `Normal` background at 70% alpha. Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua index 65d3ac8..618677a 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -60,11 +60,35 @@ function M.extract_change_groups(hunk_lines) return groups end +---@return {algorithm?: string, linematch?: integer} +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? {algorithm?: string, linematch?: integer} ---@return {old_start: integer, old_count: integer, new_start: integer, new_count: integer}[] -local function byte_diff(old_text, new_text) - local ok, result = pcall(vim.diff, old_text, new_text, { result_type = 'indices' }) +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 @@ -95,8 +119,9 @@ end ---@param new_line string ---@param del_idx integer ---@param add_idx integer +---@param diff_opts? {algorithm?: string, linematch?: integer} ---@return diffs.CharSpan[], diffs.CharSpan[] -local function char_diff_pair(old_line, new_line, del_idx, add_idx) +local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts) ---@type diffs.CharSpan[] local del_spans = {} ---@type diffs.CharSpan[] @@ -108,7 +133,7 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx) local old_text = table.concat(old_bytes, '\n') .. '\n' local new_text = table.concat(new_bytes, '\n') .. '\n' - local char_hunks = byte_diff(old_text, new_text) + local char_hunks = byte_diff(old_text, new_text, diff_opts) for _, ch in ipairs(char_hunks) do if ch.old_count > 0 then @@ -132,8 +157,9 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx) end ---@param group diffs.ChangeGroup +---@param diff_opts? {algorithm?: string, linematch?: integer} ---@return diffs.CharSpan[], diffs.CharSpan[] -local function diff_group_native(group) +local function diff_group_native(group, diff_opts) ---@type diffs.CharSpan[] local all_del = {} ---@type diffs.CharSpan[] @@ -147,7 +173,8 @@ local function diff_group_native(group) group.del_lines[1].text, group.add_lines[1].text, group.del_lines[1].idx, - group.add_lines[1].idx + group.add_lines[1].idx, + diff_opts ) vim.list_extend(all_del, ds) vim.list_extend(all_add, as) @@ -166,7 +193,7 @@ local function diff_group_native(group) 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) + local line_hunks = byte_diff(old_block, new_block, diff_opts) ---@type table local old_to_new = {} @@ -184,7 +211,8 @@ local function diff_group_native(group) group.del_lines[old_i].text, group.add_lines[new_i].text, group.del_lines[old_i].idx, - group.add_lines[new_i].idx + group.add_lines[new_i].idx, + diff_opts ) vim.list_extend(all_del, ds) vim.list_extend(all_add, as) @@ -202,7 +230,8 @@ local function diff_group_native(group) group.del_lines[oi].text, group.add_lines[ni].text, group.del_lines[oi].idx, - group.add_lines[ni].idx + group.add_lines[ni].idx, + diff_opts ) vim.list_extend(all_del, ds) vim.list_extend(all_add, as) @@ -295,16 +324,25 @@ function M.compute_intra_hunks(hunk_lines, algorithm) return nil end - algorithm = algorithm or 'auto' + algorithm = algorithm or 'default' - local lib = require('diffs.lib') local vscode_handle = nil - if algorithm ~= 'native' then - vscode_handle = lib.load() + 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 - if algorithm == 'vscode' and not vscode_handle then - dbg('vscode algorithm requested but library not available, falling back to native') + 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[] @@ -325,7 +363,7 @@ function M.compute_intra_hunks(hunk_lines, algorithm) if vscode_handle then ds, as = diff_group_vscode(group, vscode_handle) else - ds, as = diff_group_native(group) + 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 diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index d80cd2e..ca8aa1a 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -90,7 +90,7 @@ local default_config = { }, intra = { enabled = true, - algorithm = 'auto', + algorithm = 'default', max_lines = 500, }, }, @@ -259,9 +259,9 @@ local function init() ['highlights.intra.algorithm'] = { opts.highlights.intra.algorithm, function(v) - return v == nil or v == 'auto' or v == 'native' or v == 'vscode' + return v == nil or v == 'default' or v == 'vscode' end, - "'auto', 'native', or 'vscode'", + "'default' or 'vscode'", }, ['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true }, }) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 80ad8a8..2cc22ac 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -73,17 +73,17 @@ describe('diff', function() describe('compute_intra_hunks', function() it('returns nil for all-addition hunks', function() - local result = diff.compute_intra_hunks({ '+line1', '+line2' }, 'native') + 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' }, 'native') + 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' }, 'native') + local result = diff.compute_intra_hunks({ ' line1', ' line2' }, 'default') assert.is_nil(result) end) @@ -91,7 +91,7 @@ describe('diff', function() local result = diff.compute_intra_hunks({ '-local x = 1', '+local x = 2', - }, 'native') + }, 'default') assert.is_not_nil(result) assert.is_true(#result.del_spans > 0) assert.is_true(#result.add_spans > 0) @@ -101,7 +101,7 @@ describe('diff', function() local result = diff.compute_intra_hunks({ '-local x = 1', '+local x = 2', - }, 'native') + }, 'default') assert.is_not_nil(result) assert.are.equal(1, #result.del_spans) @@ -121,7 +121,7 @@ describe('diff', function() ' local b = 3', '-local c = 4', '+local c = 5', - }, 'native') + }, 'default') assert.is_not_nil(result) assert.is_true(#result.del_spans >= 2) assert.is_true(#result.add_spans >= 2) @@ -132,7 +132,7 @@ describe('diff', function() '-line one', '-line two', '+line combined', - }, 'native') + }, 'default') assert.is_not_nil(result) end) @@ -140,7 +140,7 @@ describe('diff', function() local result = diff.compute_intra_hunks({ '-local x = "héllo"', '+local x = "wörld"', - }, 'native') + }, 'default') assert.is_not_nil(result) assert.is_true(#result.del_spans > 0) assert.is_true(#result.add_spans > 0) @@ -150,7 +150,7 @@ describe('diff', function() local result = diff.compute_intra_hunks({ '-local x = 1', '+local x = 1', - }, 'native') + }, 'default') assert.is_nil(result) end) end) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 9b7e685..bb125fd 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -45,7 +45,7 @@ describe('highlight', function() }, intra = { enabled = false, - algorithm = 'native', + algorithm = 'default', max_lines = 500, }, }, @@ -828,7 +828,7 @@ describe('highlight', function() default_opts({ highlights = { background = true, - intra = { enabled = true, algorithm = 'native', max_lines = 500 }, + intra = { enabled = true, algorithm = 'default', max_lines = 500 }, }, }) ) @@ -873,7 +873,7 @@ describe('highlight', function() ns, hunk, default_opts({ - highlights = { intra = { enabled = true, algorithm = 'native', max_lines = 500 } }, + highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } }, }) ) @@ -913,7 +913,7 @@ describe('highlight', function() ns, hunk, default_opts({ - highlights = { intra = { enabled = false, algorithm = 'native', max_lines = 500 } }, + highlights = { intra = { enabled = false, algorithm = 'default', max_lines = 500 } }, }) ) @@ -947,7 +947,7 @@ describe('highlight', function() ns, hunk, default_opts({ - highlights = { intra = { enabled = true, algorithm = 'native', max_lines = 500 } }, + highlights = { intra = { enabled = true, algorithm = 'default', max_lines = 500 } }, }) ) @@ -984,7 +984,7 @@ describe('highlight', function() default_opts({ highlights = { background = true, - intra = { enabled = true, algorithm = 'native', max_lines = 500 }, + intra = { enabled = true, algorithm = 'default', max_lines = 500 }, }, }) ) From 5722ccdbb2fbcbb21147a841f4aed6de842e342c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 21:29:53 -0500 Subject: [PATCH 21/53] fix(ci): typing --- lua/diffs/diff.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua index 618677a..0617151 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -334,6 +334,7 @@ function M.compute_intra_hunks(hunk_lines, algorithm) end end + ---@type {algorithm?: string, linematch?: integer}? local diff_opts = nil if not vscode_handle then diff_opts = parse_diffopt() From b79adba5f2b11f5d9f21df90b23a8f55334369fa Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 21:33:55 -0500 Subject: [PATCH 22/53] fix(ci): typing --- lua/diffs/diff.lua | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua index 0617151..5f10f9a 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -11,6 +11,10 @@ ---@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 @@ -60,7 +64,7 @@ function M.extract_change_groups(hunk_lines) return groups end ----@return {algorithm?: string, linematch?: integer} +---@return diffs.DiffOpts local function parse_diffopt() local opts = {} for _, item in ipairs(vim.split(vim.o.diffopt, ',')) do @@ -76,7 +80,7 @@ end ---@param old_text string ---@param new_text string ----@param diff_opts? {algorithm?: string, linematch?: integer} +---@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' } @@ -119,7 +123,7 @@ end ---@param new_line string ---@param del_idx integer ---@param add_idx integer ----@param diff_opts? {algorithm?: string, linematch?: 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[] @@ -157,7 +161,7 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts) end ---@param group diffs.ChangeGroup ----@param diff_opts? {algorithm?: string, linematch?: integer} +---@param diff_opts? diffs.DiffOpts ---@return diffs.CharSpan[], diffs.CharSpan[] local function diff_group_native(group, diff_opts) ---@type diffs.CharSpan[] @@ -334,7 +338,7 @@ function M.compute_intra_hunks(hunk_lines, algorithm) end end - ---@type {algorithm?: string, linematch?: integer}? + ---@type diffs.DiffOpts? local diff_opts = nil if not vscode_handle then diff_opts = parse_diffopt() From f948982848e1bae3941d9eb618ebf31aec9316e4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 22:21:33 -0500 Subject: [PATCH 23/53] fix(commands): handle :e on diffs:// buffers via BufReadCmd Problem: running :e on a :Gdiff buffer cleared all content because diffs:// buffers had no BufReadCmd handler. Neovim tried to read the buffer name as a file path, found nothing on disk, and emptied the buffer. This affected all three buffer creation paths (gdiff, gdiff_file, gdiff_section). Solution: register a BufReadCmd autocmd for diffs://* that parses the URL and regenerates diff content from git. Change buffer options from nofile/wipe to nowrite/delete (matching fugitive's approach) so buffer-local autocmds and variables survive across unload/reload cycles. Store old filepath as buffer variable for rename support. --- lua/diffs/commands.lua | 89 ++++++++- plugin/diffs.lua | 7 + spec/read_buffer_spec.lua | 400 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 490 insertions(+), 6 deletions(-) create mode 100644 spec/read_buffer_spec.lua diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index b3f9b42..4fc795b 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -86,8 +86,9 @@ function M.gdiff(revision, vertical) 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', 'nofile', { buf = diff_buf }) - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf }) + 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) @@ -176,14 +177,18 @@ function M.gdiff_file(filepath, opts) 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', 'nofile', { buf = diff_buf }) - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf }) + 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 vim.cmd(opts.vertical and 'vsplit' or 'split') vim.api.nvim_win_set_buf(0, diff_buf) @@ -231,8 +236,9 @@ function M.gdiff_section(repo_root, opts) 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', 'nofile', { buf = diff_buf }) - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf }) + 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') @@ -248,6 +254,77 @@ function M.gdiff_section(repo_root, opts) 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) diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 35aa223..c51d417 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -23,6 +23,13 @@ vim.api.nvim_create_autocmd('FileType', { 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('OptionSet', { pattern = 'diff', callback = function() 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) From 2d72963f8f7243bfe624a085d7145d27d21170d7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 00:50:02 -0500 Subject: [PATCH 24/53] fix(debug): resolve sparse array crash in json dump Problem: vim.json.encode fails with "excessively sparse array" when extmark row numbers are used as integer table keys, since they create gaps in the array. Solution: use tostring(row) as keys instead. Also add hl_eol to dumped extmark fields for debugging line background extmarks. --- lua/diffs/debug.lua | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lua/diffs/debug.lua b/lua/diffs/debug.lua index 5be95bc..c234c32 100644 --- a/lua/diffs/debug.lua +++ b/lua/diffs/debug.lua @@ -18,14 +18,16 @@ function M.dump() 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, } - if not by_line[row] then - by_line[row] = { text = lines[row + 1] or '', marks = {} } + local key = tostring(row) + if not by_line[key] then + by_line[key] = { text = lines[row + 1] or '', marks = {} } end - table.insert(by_line[row].marks, entry) + table.insert(by_line[key].marks, entry) end local all_ns_marks = vim.api.nvim_buf_get_extmarks(bufnr, -1, 0, -1, { details = true }) From 93f6627dd2b6761e0f7959945d85b3492d845926 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 00:50:08 -0500 Subject: [PATCH 25/53] fix(diff): strip linematch from char-level diff Problem: parse_diffopt() passes linematch from diffopt to byte-level vim.diff() in char_diff_pair, where each "line" is a single byte. linematch is meaningless at this granularity. Solution: copy diff_opts without linematch before passing to byte_diff in char_diff_pair. linematch remains in effect for the line-level diff in diff_group_native where it belongs. --- lua/diffs/diff.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua index 5f10f9a..edf0275 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -137,7 +137,12 @@ local function char_diff_pair(old_line, new_line, del_idx, add_idx, diff_opts) local old_text = table.concat(old_bytes, '\n') .. '\n' local new_text = table.concat(new_bytes, '\n') .. '\n' - local char_hunks = byte_diff(old_text, new_text, diff_opts) + 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 From bbb87b660e711a1dc3fc01fdccbf1c101fae4164 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 00:50:21 -0500 Subject: [PATCH 26/53] fix(highlight): split old/new treesitter parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: highlight_treesitter concatenated all hunk lines (context, -, +) into a single string. Mixed old/new code produced invalid syntax (e.g. two return statements), causing treesitter error recovery to drop captures on lines after the syntax error. Solution: split hunk lines into two versions — new (context + added) and old (context + deleted) — each parsed independently. Use a line_map to resolve treesitter row indices to buffer lines, with the old version only mapping deleted lines to avoid duplicate extmarks on context. Also fixes three related issues exposed by the improved TS coverage: - Replace Normal extmark with DiffsClear (explicit fg from Normal.fg). Normal in extmarks doesn't reliably override vim :syntax foreground. - Reorder priority stack to DiffsClear(198) < syntax(199) < line bg(200) < char bg(201). TS capture groups can carry colorscheme backgrounds that would override diff line backgrounds at higher priority. - Gate DiffsClear on per-line coverage tracking. Only clear fugitive syntax fg on lines where TS/vim actually produced captures, preventing force-clearing on lines where error recovery drops captures. --- lua/diffs/highlight.lua | 171 +++++++++++++++++++++++++--------------- lua/diffs/init.lua | 1 + spec/highlight_spec.lua | 100 +++++++++++++++-------- 3 files changed, 175 insertions(+), 97 deletions(-) diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 311a6f3..42c4868 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -3,6 +3,11 @@ local M = {} local dbg = require('diffs.log').dbg local diff = require('diffs.diff') +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 @@ -38,7 +43,7 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang) local buf_sc = col_offset + sc local buf_ec = col_offset + ec - local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200 + 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, @@ -58,16 +63,21 @@ end ---@param bufnr integer ---@param ns integer ----@param hunk diffs.Hunk ---@param code_lines string[] ----@param col_offset integer? +---@param lang string +---@param line_map table +---@param col_offset integer +---@param covered_lines? table ---@return integer -local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset) - local lang = hunk.lang - if not lang then - return 0 - end - +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 @@ -91,41 +101,31 @@ local function highlight_treesitter(bufnr, ns, hunk, code_lines, col_offset) 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 - - col_offset = col_offset or 1 - local extmark_count = 0 for id, node, metadata in query:iter_captures(trees[1]:root(), code) do local capture_name = '@' .. query.captures[id] .. '.' .. lang 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 + col_offset - local buf_ec = ec + col_offset + local buf_sr = line_map[sr] + if buf_sr then + local buf_er = line_map[er] or buf_sr - local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or 200 + local buf_sc = sc + col_offset + local buf_ec = ec + col_offset - 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 + 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 + if covered_lines then + covered_lines[buf_sr] = true + end + end end return extmark_count @@ -176,8 +176,9 @@ end ---@param ns integer ---@param hunk diffs.Hunk ---@param code_lines string[] +---@param covered_lines? table ---@return integer -local function highlight_vim_syntax(bufnr, ns, hunk, code_lines) +local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines) local ft = hunk.ft if not ft then return 0 @@ -219,9 +220,12 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines) 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 = 200, + priority = PRIORITY_SYNTAX, }) extmark_count = extmark_count + 1 + if covered_lines then + covered_lines[buf_line] = true + end end return extmark_count @@ -248,21 +252,63 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) 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 + ---@type table + local covered_lines = {} local extmark_count = 0 if use_ts then - extmark_count = highlight_treesitter(bufnr, ns, hunk, code_lines) + ---@type string[] + local new_code = {} + ---@type table + local new_map = {} + ---@type string[] + local old_code = {} + ---@type table + local old_map = {} + + 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 + + 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) + 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 - extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines) + ---@type string[] + local code_lines = {} + for _, line in ipairs(hunk.lines) do + table.insert(code_lines, line:sub(2)) + end + extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines) end if @@ -271,18 +317,15 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) 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, { - filename = hunk.filename, - start_line = hunk.header_start_line - 1, - lang = 'diff', - lines = hunk.header_lines, - header_lines = {}, - }, hunk.header_lines, 0) + + highlight_treesitter(bufnr, ns, hunk.header_lines, 'diff', header_map, 0) end - local syntax_applied = extmark_count > 0 - ---@type diffs.IntraChanges? local intra = nil local intra_cfg = opts.highlights.intra @@ -334,11 +377,11 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) }) end - if line_len > 1 and syntax_applied then + 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 = 'Normal', - priority = 198, + hl_group = 'DiffsClear', + priority = PRIORITY_CLEAR, }) end @@ -348,7 +391,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) hl_group = line_hl, hl_eol = true, number_hl_group = opts.highlights.gutter and number_hl or nil, - priority = 199, + priority = PRIORITY_LINE_BG, }) end @@ -367,7 +410,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) 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 = 201, + priority = PRIORITY_CHAR_BG, }) if not ok then dbg('char extmark FAILED: %s', err) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index ca8aa1a..a5dfcf0 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -186,6 +186,7 @@ local function compute_highlight_groups() local blended_add_text = blend_color(add_fg, bg, 0.7) local blended_del_text = blend_color(del_fg, bg, 0.7) + 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 = add_fg, bg = blended_add }) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index bb125fd..1257523 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -7,8 +7,10 @@ describe('highlight', function() before_each(function() 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, '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) @@ -82,7 +84,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', @@ -99,14 +101,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) @@ -576,7 +610,7 @@ describe('highlight', function() 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 @@ -610,7 +644,7 @@ describe('highlight', function() 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 @@ -682,7 +716,7 @@ 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 @@ -722,14 +756,14 @@ describe('highlight', function() 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) @@ -765,7 +799,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('line bg priority > Normal priority', function() + it('line bg priority > DiffsClear priority', function() local bufnr = create_buffer({ '@@ -1,2 +1,1 @@', '-local x = 1', @@ -787,20 +821,20 @@ describe('highlight', function() ) local extmarks = get_extmarks(bufnr) - local normal_priority = nil + 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 == 'Normal' then - normal_priority = d.priority + 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(normal_priority) + assert.is_not_nil(clear_priority) assert.is_not_nil(line_bg_priority) - assert.is_true(line_bg_priority > normal_priority) + assert.is_true(line_bg_priority > clear_priority) delete_buffer(bufnr) end) @@ -960,7 +994,7 @@ describe('highlight', function() delete_buffer(bufnr) end) - it('enforces priority order: Normal < line bg < syntax < char bg', function() + 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 }) @@ -990,12 +1024,12 @@ describe('highlight', function() ) local extmarks = get_extmarks(bufnr) - local priorities = { normal = {}, line_bg = {}, syntax = {}, char_bg = {} } + 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 == 'Normal' then - table.insert(priorities.normal, d.priority) + 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 @@ -1006,19 +1040,19 @@ describe('highlight', function() end end - assert.is_true(#priorities.normal > 0) + 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_normal = math.max(unpack(priorities.normal)) + 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_normal < min_line_bg) - assert.is_true(min_line_bg < min_syntax) - assert.is_true(min_syntax < min_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) end) @@ -1214,7 +1248,7 @@ describe('highlight', function() } end - it('uses priority 200 for code languages', function() + it('uses priority 199 for code languages', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' local x = 1', @@ -1231,16 +1265,16 @@ describe('highlight', function() highlight.highlight_hunk(bufnr, ns, hunk, default_opts()) local extmarks = get_extmarks(bufnr) - local has_priority_200 = false + 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 == 200 then - has_priority_200 = true + if mark[4].priority == 199 then + has_priority_199 = true break end end end - assert.is_true(has_priority_200) + assert.is_true(has_priority_199) delete_buffer(bufnr) end) @@ -1278,7 +1312,7 @@ describe('highlight', function() end assert.is_true(#diff_extmark_priorities > 0) for _, priority in ipairs(diff_extmark_priorities) do - assert.is_true(priority < 200) + assert.is_true(priority < 199) end delete_buffer(bufnr) end) From a046f38796b3c53a10aeb2e23e3da836d55f3b69 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 00:55:37 -0500 Subject: [PATCH 27/53] docs: acknowledge difftastic and conflicting plugins --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 89254ac..2e04b2c 100644 --- a/README.md +++ b/README.md @@ -70,5 +70,9 @@ luarocks install diffs.nvim - [`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 From 2e1ebdee0380f3c507128109d90812ef5ede5756 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 13:05:53 -0500 Subject: [PATCH 28/53] feat(highlight): add treesitter context padding from disk Problem: treesitter parses each diff hunk in isolation, so incomplete syntax constructs at hunk boundaries (e.g., a function definition with no body) produce ERROR nodes and drop captures. Solution: read N lines from the on-disk file before/after each hunk and prepend/append them as unmapped padding lines. The line_map guard in highlight_treesitter skips extmarks for unmapped lines, so padding provides syntax context without visual output. Controlled by highlights.context (default 25, 0 to disable). Also applies to the vim syntax fallback path via a leading_offset filter. --- README.md | 10 ++--- doc/diffs.nvim.txt | 26 ++++++++--- lua/diffs/highlight.lua | 86 +++++++++++++++++++++++++++++++----- lua/diffs/init.lua | 6 +++ lua/diffs/parser.lua | 29 +++++++++++++ spec/highlight_spec.lua | 96 +++++++++++++++++++++++++++++++++++++++++ spec/parser_spec.lua | 79 +++++++++++++++++++++++++++++++++ 7 files changed, 308 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2e04b2c..e334426 100644 --- a/README.md +++ b/README.md @@ -41,12 +41,10 @@ luarocks install diffs.nvim ## Known Limitations -- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation - without surrounding code context. When a hunk shows lines added to an existing - block (e.g., adding a plugin inside `return { ... }`), the parser doesn't see - the `return` statement and may produce incorrect highlighting. This is - inherent to parsing code fragments—no diff tooling solves this without - significant complexity. +- **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 (controlled by `highlights.context`, default 25). + This resolves most boundary issues. Set `highlights.context = 0` to disable. - **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event triggered by `vim-fugitive`, at which point the buffer is preliminarily diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 9554059..22db825 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -56,6 +56,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: highlights = { background = true, gutter = true, + context = 25, treesitter = { enabled = true, max_lines = 500, @@ -113,6 +114,16 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Highlight line numbers with matching colors. Only visible if line numbers are enabled. + {context} (integer, default: 25) + Number of lines to read from the source file + before and after each hunk for syntax parsing + context. Improves accuracy at hunk boundaries + where incomplete constructs (e.g., a function + definition with no body) would otherwise confuse + the parser. Set to 0 to disable. Lines are read + from disk with early exit — cost scales with the + context value, not file size. + {treesitter} (table, default: see below) Treesitter highlighting options. See |diffs.TreesitterConfig| for fields. @@ -305,14 +316,15 @@ KNOWN LIMITATIONS *diffs-limitations* Incomplete Syntax Context ~ *diffs-syntax-context* -Treesitter parses each diff hunk in isolation without surrounding code -context. When a hunk shows lines added to an existing block (e.g., adding a -plugin inside `return { ... }`), the parser doesn't see the `return` -statement and may produce incorrect or unusual highlighting. +Treesitter parses each diff hunk in isolation. To provide surrounding code +context, diffs.nvim reads lines from disk before and after each hunk +(controlled by `highlights.context`, default 25). 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. -This is inherent to parsing code fragments. No diff tooling solves this -problem without significant complexity—the parser simply doesn't have enough -information to understand the full syntactic structure. +Set `highlights.context = 0` to disable context padding and restore the +previous behavior. 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* diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 42c4868..5107893 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -3,6 +3,33 @@ 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 @@ -177,8 +204,9 @@ end ---@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) +local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, leading_offset) local ft = hunk.ft if not ft then return 0 @@ -188,6 +216,8 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines) 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 }) @@ -214,17 +244,21 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines) 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 buf_line = hunk.start_line + span.line - 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 + 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 @@ -255,6 +289,20 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) ---@type table local covered_lines = {} + local context = opts.highlights.context 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[] @@ -266,6 +314,11 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) ---@type table local old_map = {} + for _, pad_line in ipairs(leading) do + table.insert(new_code, pad_line) + table.insert(old_code, pad_line) + end + for i, line in ipairs(hunk.lines) do local prefix = line:sub(1, 1) local stripped = line:sub(2) @@ -284,6 +337,11 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) end end + for _, pad_line in ipairs(trailing) do + table.insert(new_code, pad_line) + table.insert(old_code, pad_line) + 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) @@ -305,10 +363,16 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) elseif use_vim then ---@type string[] local code_lines = {} + for _, pad_line in ipairs(leading) do + table.insert(code_lines, pad_line) + end for _, line in ipairs(hunk.lines) do table.insert(code_lines, line:sub(2)) end - extmark_count = highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines) + 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 diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index a5dfcf0..8d8f670 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -14,6 +14,7 @@ ---@class diffs.Highlights ---@field background boolean ---@field gutter boolean +---@field context integer ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig ---@field intra diffs.IntraConfig @@ -80,6 +81,7 @@ local default_config = { highlights = { background = true, gutter = true, + context = 25, treesitter = { enabled = true, max_lines = 500, @@ -231,6 +233,7 @@ local function init() vim.validate({ ['highlights.background'] = { opts.highlights.background, 'boolean', true }, ['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true }, + ['highlights.context'] = { opts.highlights.context, 'number', true }, ['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true }, ['highlights.vim'] = { opts.highlights.vim, 'table', true }, ['highlights.intra'] = { opts.highlights.intra, 'table', true }, @@ -291,6 +294,9 @@ local function init() 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 < 0 then + error('diffs: highlights.context must be >= 0') + end if opts.highlights and opts.highlights.treesitter diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index 52b2864..43eb1f6 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -8,6 +8,11 @@ ---@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 = {} @@ -132,6 +137,14 @@ function M.parse_buffer(bufnr) 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 @@ -143,6 +156,11 @@ function M.parse_buffer(bufnr) 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 @@ -154,6 +172,10 @@ function M.parse_buffer(bufnr) 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 @@ -174,6 +196,13 @@ function M.parse_buffer(bufnr) 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 diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 1257523..a556228 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -37,6 +37,7 @@ describe('highlight', function() highlights = { background = false, gutter = false, + context = 0, treesitter = { enabled = true, max_lines = 500, @@ -1055,6 +1056,99 @@ describe('highlight', function() 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 = 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 = 0 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 = 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 = 25 } })) + end) + + local extmarks = get_extmarks(bufnr) + assert.is_true(#extmarks > 0) + delete_buffer(bufnr) + end) end) describe('diff header highlighting', function() @@ -1086,6 +1180,7 @@ describe('highlight', function() highlights = { background = false, gutter = false, + context = 0, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, }, @@ -1242,6 +1337,7 @@ describe('highlight', function() highlights = { background = false, gutter = false, + context = 0, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, }, diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua index 89d0ac8..11ac3be 100644 --- a/spec/parser_spec.lua +++ b/spec/parser_spec.lua @@ -421,5 +421,84 @@ describe('parser', function() 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) From 9e32384f185e3b7992e60b47422cc6530ba49bdb Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 13:16:34 -0500 Subject: [PATCH 29/53] refactor: change highlights.context config to table structure Problem: highlights.context was a plain integer, inconsistent with the table structure used by treesitter, vim, and intra sub-configs. Solution: change to { enabled = true, lines = 25 } with full vim.validate() coverage matching the existing pattern. --- README.md | 5 +++-- doc/diffs.nvim.txt | 39 +++++++++++++++++++++++++-------------- lua/diffs/highlight.lua | 3 ++- lua/diffs/init.lua | 29 ++++++++++++++++++++++++----- spec/highlight_spec.lua | 29 ++++++++++++++++++++++------- 5 files changed, 76 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e334426..304e3b8 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,9 @@ luarocks install diffs.nvim - **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 (controlled by `highlights.context`, default 25). - This resolves most boundary issues. Set `highlights.context = 0` to disable. + hunk for parsing context (`highlights.context`, enabled by default with 25 + lines). This resolves most boundary issues. Set + `highlights.context.enabled = false` to disable. - **Syntax flashing**: `diffs.nvim` hooks into the `FileType fugitive` event triggered by `vim-fugitive`, at which point the buffer is preliminarily diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 22db825..e4e750b 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -56,7 +56,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: highlights = { background = true, gutter = true, - context = 25, + context = { + enabled = true, + lines = 25, + }, treesitter = { enabled = true, max_lines = 500, @@ -114,15 +117,9 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Highlight line numbers with matching colors. Only visible if line numbers are enabled. - {context} (integer, default: 25) - Number of lines to read from the source file - before and after each hunk for syntax parsing - context. Improves accuracy at hunk boundaries - where incomplete constructs (e.g., a function - definition with no body) would otherwise confuse - the parser. Set to 0 to disable. Lines are read - from disk with early exit — cost scales with the - context value, not file size. + {context} (table, default: see below) + Syntax parsing context options. + See |diffs.ContextConfig| for fields. {treesitter} (table, default: see below) Treesitter highlighting options. @@ -136,6 +133,20 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: Character-level (intra-line) diff highlighting. See |diffs.IntraConfig| for fields. + *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) @@ -318,13 +329,13 @@ 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 -(controlled by `highlights.context`, default 25). This resolves most boundary +(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 = 0` to disable context padding and restore the -previous behavior. In rare cases, context padding may not help if the -relevant surrounding code is very far from the hunk boundaries. +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* diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 5107893..198231a 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -289,7 +289,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) ---@type table local covered_lines = {} - local context = opts.highlights.context or 0 + 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 diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 8d8f670..4338175 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -11,10 +11,14 @@ ---@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 context integer +---@field context diffs.ContextConfig ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig ---@field intra diffs.IntraConfig @@ -81,7 +85,10 @@ local default_config = { highlights = { background = true, gutter = true, - context = 25, + context = { + enabled = true, + lines = 25, + }, treesitter = { enabled = true, max_lines = 500, @@ -233,12 +240,19 @@ local function init() vim.validate({ ['highlights.background'] = { opts.highlights.background, 'boolean', true }, ['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true }, - ['highlights.context'] = { opts.highlights.context, 'number', 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 }, @@ -294,8 +308,13 @@ local function init() 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 < 0 then - error('diffs: highlights.context must be >= 0') + 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 diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index a556228..eee6d32 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -37,7 +37,7 @@ describe('highlight', function() highlights = { background = false, gutter = false, - context = 0, + context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500, @@ -1087,7 +1087,12 @@ describe('highlight', function() repo_root = repo_root, } - highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ highlights = { context = 25 } })) + 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 @@ -1100,7 +1105,7 @@ describe('highlight', function() vim.fn.delete(repo_root, 'rf') end) - it('context = 0 matches behavior without padding', function() + it('context disabled matches behavior without padding', function() local bufnr = create_buffer({ '@@ -1,1 +1,2 @@', ' local x = 1', @@ -1117,7 +1122,12 @@ describe('highlight', function() repo_root = '/nonexistent', } - highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ highlights = { context = 0 } })) + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { context = { enabled = false, lines = 0 } } }) + ) local extmarks = get_extmarks(bufnr) assert.is_true(#extmarks > 0) @@ -1142,7 +1152,12 @@ describe('highlight', function() } assert.has_no.errors(function() - highlight.highlight_hunk(bufnr, ns, hunk, default_opts({ highlights = { context = 25 } })) + highlight.highlight_hunk( + bufnr, + ns, + hunk, + default_opts({ highlights = { context = { enabled = true, lines = 25 } } }) + ) end) local extmarks = get_extmarks(bufnr) @@ -1180,7 +1195,7 @@ describe('highlight', function() highlights = { background = false, gutter = false, - context = 0, + context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, }, @@ -1337,7 +1352,7 @@ describe('highlight', function() highlights = { background = false, gutter = false, - context = 0, + context = { enabled = false, lines = 0 }, treesitter = { enabled = true, max_lines = 500 }, vim = { enabled = false, max_lines = 200 }, }, From 97a6fb2bd7637b8fca0707539fd449a1046d9565 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 14:12:49 -0500 Subject: [PATCH 30/53] feat: add mappings Problem: users who want keybindings must wrap commands in closures. There is no stable public API for key binding. Solution: define mappings in the plugin file and document them in a new MAPPINGS section in the vimdoc. --- doc/diffs.nvim.txt | 16 ++++++++++++++++ plugin/diffs.lua | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index e4e750b..aa51301 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -227,6 +227,22 @@ COMMANDS *diffs-commands* :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)') +< + ============================================================================== FUGITIVE STATUS KEYMAPS *diffs-fugitive* diff --git a/plugin/diffs.lua b/plugin/diffs.lua index c51d417..031ed60 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -40,3 +40,11 @@ vim.api.nvim_create_autocmd('OptionSet', { 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)' }) From 0cefa00d2708f360c098dbf829da9ed8e1bd77f4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 14:26:11 -0500 Subject: [PATCH 31/53] fix(highlight): include treesitter injections Problem: highlight_treesitter only iterated captures from the base language tree (trees[1]:root()). When code contained injected languages (e.g. VimL inside vim.cmd()), those captures were never read, so injected code got no syntax highlighting in diffs. Solution: pass true to parse() to trigger injection discovery, then use parser_obj:for_each_tree() to iterate all trees including injected ones. Each tree gets its own highlights query looked up by ltree:lang(), and @spell/@nospell captures are filtered out. --- lua/diffs/highlight.lua | 58 +++++++++++++++------------ spec/helpers.lua | 1 + spec/highlight_spec.lua | 88 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 26 deletions(-) diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 198231a..9582910 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -116,44 +116,50 @@ local function highlight_treesitter( return 0 end - local trees = parser_obj:parse() + 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 query = vim.treesitter.query.get(lang, 'highlights') - if not query then - dbg('no highlights query for lang: %s', lang) - return 0 - end - local extmark_count = 0 - for id, node, metadata in query:iter_captures(trees[1]:root(), code) do - local capture_name = '@' .. query.captures[id] .. '.' .. lang - local sr, sc, er, ec = node:range() + 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 - local buf_sr = line_map[sr] - if buf_sr then - local buf_er = line_map[er] or buf_sr + 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_sc = sc + col_offset - local buf_ec = ec + col_offset + local buf_sr = line_map[sr] + if buf_sr then + local buf_er = line_map[er] or buf_sr - local priority = lang == 'diff' and (tonumber(metadata.priority) or 100) or PRIORITY_SYNTAX + local buf_sc = sc + col_offset + local buf_ec = ec + col_offset - 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 + 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 + end) return extmark_count end diff --git a/spec/helpers.lua b/spec/helpers.lua index 34128ac..7774cf4 100644 --- a/spec/helpers.lua +++ b/spec/helpers.lua @@ -12,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 eee6d32..5fd1fa5 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -1164,6 +1164,94 @@ describe('highlight', function() 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() From f6c07383843d086348ab4552df1d33f507c4dcf1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 14:32:04 -0500 Subject: [PATCH 32/53] docs: credit phanen for treesitter injection support --- README.md | 2 +- doc/diffs.nvim.txt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 304e3b8..2b4d845 100644 --- a/README.md +++ b/README.md @@ -74,4 +74,4 @@ luarocks install diffs.nvim - [`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 + filetype fix, shebang/modeline detection, treesitter injection support diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index aa51301..5807736 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -472,7 +472,8 @@ 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 +- @phanen (https://github.com/phanen) - diff header highlighting, + treesitter injection support ============================================================================== vim:tw=78:ts=8:ft=help:norl: From 011db2f8b312a4f4f6570fb212892aae3114fa38 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 14:40:55 -0500 Subject: [PATCH 33/53] fix(highlight): make hl_eol work on background extmarks Problem: hl_eol requires a multiline extmark to extend the background past end-of-line. The extmark used end_col = line_len on the same row, so it was single-line and hl_eol was effectively a no-op. Solution: replace end_col with end_row targeting the next line so the extmark is multiline and hl_eol takes effect. Split number_hl_group into a separate extmark to prevent it bleeding to adjacent lines. --- lua/diffs/highlight.lua | 9 ++++-- spec/highlight_spec.lua | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 9582910..bf9063d 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -458,12 +458,17 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) if opts.highlights.background and is_diff_line then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, { - end_col = line_len, + end_row = buf_line + 1, hl_group = line_hl, hl_eol = true, - number_hl_group = opts.highlights.gutter and number_hl or nil, 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 diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 5fd1fa5..2f95d02 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -800,6 +800,72 @@ describe('highlight', function() 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 @@', From 38220ab368f11da12ae0dd4f5ca25fb3da4e03e5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 15:10:07 -0500 Subject: [PATCH 34/53] fix(highlight): use hunk body as context for header treesitter parsing Problem: the header context string (e.g. "function M.setup()") was parsed in isolation by treesitter, which couldn't recognize "function" as @keyword.function because the snippet is an incomplete definition with no body or "end". Solution: append the already-built new_code lines as trailing context when parsing the header string, giving treesitter a complete function definition. Filter captures to row 0 only so body-line captures don't produce extmarks on the header line. --- lua/diffs/highlight.lua | 52 +++++++++++++++++++++++++++-------------- spec/highlight_spec.lua | 34 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index bf9063d..fed979b 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -41,9 +41,15 @@ local PRIORITY_CHAR_BG = 201 ---@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) - local ok, parser_obj = pcall(vim.treesitter.get_string_parser, text, lang) +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 @@ -61,24 +67,27 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang) local extmark_count = 0 local header_line = hunk.start_line - 1 - for id, node, metadata in query:iter_captures(trees[1]:root(), text) do - local capture_name = '@' .. query.captures[id] .. '.' .. lang + for id, node, metadata in query:iter_captures(trees[1]:root(), parse_text) do local sr, sc, er, ec = node:range() + if sr == 0 then + er = 0 + local capture_name = '@' .. query.captures[id] .. '.' .. lang - local buf_sr = header_line + sr - local buf_er = header_line + er - local buf_sc = col_offset + sc - local buf_ec = col_offset + ec + 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 + 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 + 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 @@ -360,8 +369,15 @@ function M.highlight_hunk(bufnr, ns, hunk, opts) hl_group = 'DiffsClear', priority = PRIORITY_CLEAR, }) - local header_extmarks = - highlight_text(bufnr, ns, hunk, hunk.header_context_col, hunk.header_context, hunk.lang) + 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 diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua index 2f95d02..0b9051e 100644 --- a/spec/highlight_spec.lua +++ b/spec/highlight_spec.lua @@ -220,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 @@', From 825012daeb6c3a95c114f161090e262a2a0794dd Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 15:12:44 -0500 Subject: [PATCH 35/53] fix(ci): remove unused variable --- lua/diffs/highlight.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index fed979b..306b0e5 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -68,9 +68,8 @@ local function highlight_text(bufnr, ns, hunk, col_offset, text, lang, context_l local header_line = hunk.start_line - 1 for id, node, metadata in query:iter_captures(trees[1]:root(), parse_text) do - local sr, sc, er, ec = node:range() + local sr, sc, _, ec = node:range() if sr == 0 then - er = 0 local capture_name = '@' .. query.captures[id] .. '.' .. lang local buf_sr = header_line From d10eaed6acdc1730ef412c96fc937d1d1cf0330e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 15:23:14 -0500 Subject: [PATCH 36/53] fix(highlight): reduce word-level blend alpha and match line number bg Problem: word-level diff highlights were too intense at 70% alpha, and line number backgrounds used the line-level blend instead of matching the word-level highlights. Solution: reduce DiffsAddText/DiffsDeleteText blend alpha from 0.7 to 0.6 and use the same blended background for DiffsAddNr/DiffsDeleteNr. --- doc/diffs.nvim.txt | 12 ++++++++---- lua/diffs/init.lua | 8 ++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 5807736..6443063 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -402,6 +402,10 @@ diffs.nvim defines custom highlight groups. Fugitive unified diff groups use `default = true`, so colorschemes can override them. Diff mode groups are always derived from the corresponding `Diff*` groups. +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 and line-number groups blend at 60% for more contrast. + Fugitive unified diff highlights: ~ *DiffsAdd* DiffsAdd Background for `+` lines. Derived by blending @@ -413,23 +417,23 @@ Fugitive unified diff highlights: ~ *DiffsAddNr* DiffsAddNr Line number for `+` lines. Foreground from - `diffAdded`, background from `DiffsAdd`. + `diffAdded`, background from `DiffsAddText`. *DiffsDeleteNr* DiffsDeleteNr Line number for `-` lines. Foreground from - `diffRemoved`, background from `DiffsDelete`. + `diffRemoved`, background from `DiffsDeleteText`. *DiffsAddText* DiffsAddText Character-level background for changed characters within `+` lines. Derived by blending `diffAdded` - foreground with `Normal` background at 70% alpha. + 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 70% alpha. + foreground with `Normal` background at 60% alpha. Diff mode window highlights: ~ These are used for |winhighlight| remapping in `&diff` windows. diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 4338175..76c17d6 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -192,14 +192,14 @@ local function compute_highlight_groups() local blended_add = blend_color(add_bg, bg, 0.4) local blended_del = blend_color(del_bg, bg, 0.4) - local blended_add_text = blend_color(add_fg, bg, 0.7) - local blended_del_text = blend_color(del_fg, bg, 0.7) + local blended_add_text = blend_color(add_fg, bg, 0.6) + local blended_del_text = blend_color(del_fg, bg, 0.6) 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 = add_fg, bg = blended_add }) - vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { default = true, fg = del_fg, bg = blended_del }) + vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = add_fg, bg = blended_add_text }) + vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { default = true, fg = del_fg, bg = blended_del_text }) 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 }) From 5cfa91039b6b6430c55b03609005185b625e9490 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 15:29:19 -0500 Subject: [PATCH 37/53] fix(highlight): use correct line number gutter colors Problem: DiffsAddNr/DiffsDeleteNr used raw diffAdded/diffRemoved foreground and the word-level blended background, instead of matching the line-level and character-level highlight groups. Solution: set gutter bg to the line-level blend (DiffsAdd/DiffsDelete) and fg to the character-level blend (DiffsAddText/DiffsDeleteText). --- doc/diffs.nvim.txt | 8 +++++--- lua/diffs/init.lua | 8 ++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 6443063..42009e9 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -404,7 +404,9 @@ always derived from the corresponding `Diff*` groups. 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 and line-number groups blend at 60% for more contrast. +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* @@ -417,11 +419,11 @@ Fugitive unified diff highlights: ~ *DiffsAddNr* DiffsAddNr Line number for `+` lines. Foreground from - `diffAdded`, background from `DiffsAddText`. + `DiffsAddText`, background from `DiffsAdd`. *DiffsDeleteNr* DiffsDeleteNr Line number for `-` lines. Foreground from - `diffRemoved`, background from `DiffsDeleteText`. + `DiffsDeleteText`, background from `DiffsDelete`. *DiffsAddText* DiffsAddText Character-level background for changed characters diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 76c17d6..cfdfb4b 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -198,8 +198,12 @@ local function compute_highlight_groups() 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 = add_fg, bg = blended_add_text }) - vim.api.nvim_set_hl(0, 'DiffsDeleteNr', { default = true, fg = del_fg, bg = blended_del_text }) + 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 }) From bcc70280fbe68ef97e9cb42d6a130d75b936427d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 15:44:56 -0500 Subject: [PATCH 38/53] docs: fix vim.max_lines default in config example Problem: the vimdoc config example showed max_lines = 500 under vim, but the actual default in init.lua is 200. Solution: change the example to match the real default. --- doc/diffs.nvim.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 42009e9..3db6db6 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -66,7 +66,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: }, vim = { enabled = false, - max_lines = 500, + max_lines = 200, }, intra = { enabled = true, From 8e0c41bf6b5903e61b40900506f95e23359328e9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 15:45:34 -0500 Subject: [PATCH 39/53] fix(highlight): add default flag to DiffsDiff* groups Problem: DiffsDiff* highlight groups lacked default = true, making them impossible for colorschemes to override, inconsistent with the fugitive unified diff groups which already had it. Solution: add default = true to all four DiffsDiffAdd, DiffsDiffDelete, DiffsDiffChange, and DiffsDiffText nvim_set_hl calls. --- doc/diffs.nvim.txt | 6 +++--- lua/diffs/init.lua | 12 ++++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 3db6db6..7704b5b 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -398,9 +398,9 @@ diff-related features. ============================================================================== HIGHLIGHT GROUPS *diffs-highlights* -diffs.nvim defines custom highlight groups. Fugitive unified diff groups use -`default = true`, so colorschemes can override them. Diff mode groups are -always derived from the corresponding `Diff*` groups. +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; diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index cfdfb4b..14a4c13 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -219,10 +219,14 @@ local function compute_highlight_groups() local diff_change = resolve_hl('DiffChange') local diff_text = resolve_hl('DiffText') - vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { bg = diff_add.bg }) - vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { fg = diff_delete.fg, bg = diff_delete.bg }) - vim.api.nvim_set_hl(0, 'DiffsDiffChange', { bg = diff_change.bg }) - vim.api.nvim_set_hl(0, 'DiffsDiffText', { bg = diff_text.bg }) + 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 }) end local function init() From b7477e3af24ec499cfedf5cfcf4b3db2dda24653 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 15:46:47 -0500 Subject: [PATCH 40/53] feat(highlight): add configurable blend alpha Problem: the character-level blend intensity was hardcoded to 0.6, giving users no way to tune how strongly changed characters stand out from the line-level background. Solution: add highlights.blend_alpha config option (number, 0-1, default 0.6) with type validation and range check. --- doc/diffs.nvim.txt | 8 ++++++++ lua/diffs/init.lua | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 7704b5b..cf03a6f 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -56,6 +56,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: highlights = { background = true, gutter = true, + blend_alpha = 0.6, context = { enabled = true, lines = 25, @@ -117,6 +118,13 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: 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. diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 14a4c13..3397bc1 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -18,6 +18,7 @@ ---@class diffs.Highlights ---@field background boolean ---@field gutter boolean +---@field blend_alpha? number ---@field context diffs.ContextConfig ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig @@ -192,8 +193,9 @@ local function compute_highlight_groups() local blended_add = blend_color(add_bg, bg, 0.4) local blended_del = blend_color(del_bg, bg, 0.4) - local blended_add_text = blend_color(add_fg, bg, 0.6) - local blended_del_text = blend_color(del_fg, bg, 0.6) + 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 }) @@ -248,6 +250,7 @@ local function init() 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.context'] = { opts.highlights.context, 'table', true }, ['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true }, ['highlights.vim'] = { opts.highlights.vim, 'table', true }, @@ -348,6 +351,13 @@ local function init() 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) From a0870a789239d571856883dfea11d6854da5aa98 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 15:47:36 -0500 Subject: [PATCH 41/53] feat(highlight): add highlights.overrides config table Problem: users had no config-level way to override computed highlight groups and had to call nvim_set_hl externally. Solution: add highlights.overrides table that maps group names to highlight definitions. Overrides are applied after all computed groups without default = true, so they always win over both computed defaults and colorscheme definitions. --- doc/diffs.nvim.txt | 18 ++++++++++++++++++ lua/diffs/init.lua | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index cf03a6f..391ad05 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -74,6 +74,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: algorithm = 'default', max_lines = 500, }, + overrides = {}, }, fugitive = { horizontal = 'du', @@ -141,6 +142,13 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: 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) @@ -469,6 +477,16 @@ 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* diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 3397bc1..7fb4e6d 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -19,6 +19,7 @@ ---@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 @@ -229,6 +230,12 @@ local function compute_highlight_groups() ) 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 }) + + 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() @@ -251,6 +258,7 @@ local function init() ['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 }, From ae65c50f9261707269d34b5db479c884d7e61250 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 15:53:08 -0500 Subject: [PATCH 42/53] docs(readme): mention blend alpha and highlight overrides --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b4d845..ca3d72e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ syntax highlighting. - Vim syntax fallback for languages without a treesitter parser - Hunk header context highlighting (`@@ ... @@ function foo()`) - Character-level (intra-line) diff highlighting for changed characters -- Configurable debouncing, max lines, and diff prefix concealment +- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and + highlight overrides ## Requirements From c72efec77d7987a0feb0a4be3992b7e9f3ac568b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 16:48:31 -0500 Subject: [PATCH 43/53] fix(commands): add diff buffer UX improvements Problem: diffs:// buffers could trigger spurious LSP diagnostics, opening multiple diffs from fugitive created redundant splits, and there was no quick way to close diff windows. Solution: disable diagnostics on diff buffers, reuse existing diffs:// windows in the tabpage instead of creating new splits, and add a buffer-local q keymap to close diff windows. --- lua/diffs/commands.lua | 54 +++++++++++++++-- spec/ux_spec.lua | 135 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 spec/ux_spec.lua diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 4fc795b..dc6cfe2 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -3,6 +3,27 @@ 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? @@ -96,9 +117,16 @@ function M.gdiff(revision, vertical) vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) end - vim.cmd(vertical and 'vsplit' or 'split') - vim.api.nvim_win_set_buf(0, diff_buf) + 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() @@ -190,8 +218,14 @@ function M.gdiff_file(filepath, opts) vim.api.nvim_buf_set_var(diff_buf, 'diffs_old_filepath', old_rel_path) end - vim.cmd(opts.vertical and 'vsplit' or 'split') - vim.api.nvim_win_set_buf(0, diff_buf) + 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) @@ -201,6 +235,7 @@ function M.gdiff_file(filepath, opts) 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() @@ -244,9 +279,16 @@ function M.gdiff_section(repo_root, opts) 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) - vim.cmd(opts.vertical and 'vsplit' or 'split') - vim.api.nvim_win_set_buf(0, diff_buf) + 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() 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) From 6b4953bf41014a5492a9e45e7291b8e56226da67 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 16:54:54 -0500 Subject: [PATCH 44/53] docs: document q keymap and window reuse behavior --- doc/diffs.nvim.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 391ad05..d0298b9 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -226,6 +226,9 @@ COMMANDS *diffs-commands* 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. @@ -259,6 +262,12 @@ Example configuration: >lua vim.keymap.set('n', 'gD', '(diffs-gvdiff)') < +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* From 731222d027f16716d802db824d8e65c283bdaf04 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 17:38:34 -0500 Subject: [PATCH 45/53] feat(conflict): detect and resolve inline merge conflict markers Problem: when git hits a merge conflict, users stare at raw <<<<<<< markers with broken treesitter and noisy LSP diagnostics. Existing solutions (git-conflict.nvim) use their own highlighting rather than integrating with diffs.nvim's color blending pipeline. Solution: add conflict.lua module that detects <<<<<<>>>>>> markers (with diff3 ||||||| support), highlights ours/theirs/base regions with blended DiffsConflict* highlight groups, provides resolution keymaps (doo/dot/dob/don) and navigation (]x/[x), suppresses diagnostics while markers are present, and auto-detaches when all conflicts are resolved. Fires DiffsConflictResolved user event on last resolution. --- lua/diffs/conflict.lua | 457 ++++++++++++++++++++++++++++ lua/diffs/init.lua | 89 ++++++ plugin/diffs.lua | 9 + spec/conflict_spec.lua | 655 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1210 insertions(+) create mode 100644 lua/diffs/conflict.lua create mode 100644 spec/conflict_spec.lua diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua new file mode 100644 index 0000000..b4d94ef --- /dev/null +++ b/lua/diffs/conflict.lua @@ -0,0 +1,457 @@ +---@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.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' + 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 + M.detach(bufnr) + vim.api.nvim_exec_autocmds('User', { pattern = 'DiffsConflictResolved' }) + return + end + apply_highlights(bufnr, regions, config) +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 + + if km.ours then + vim.keymap.set('n', km.ours, function() + M.resolve_ours(bufnr, config) + end, { buffer = bufnr }) + end + if km.theirs then + vim.keymap.set('n', km.theirs, function() + M.resolve_theirs(bufnr, config) + end, { buffer = bufnr }) + end + if km.both then + vim.keymap.set('n', km.both, function() + M.resolve_both(bufnr, config) + end, { buffer = bufnr }) + end + if km.none then + vim.keymap.set('n', km.none, function() + M.resolve_none(bufnr, config) + end, { buffer = bufnr }) + end + if km.next then + vim.keymap.set('n', km.next, function() + M.goto_next(bufnr) + end, { buffer = bufnr }) + end + if km.prev then + vim.keymap.set('n', km.prev, function() + M.goto_prev(bufnr) + end, { buffer = bufnr }) + 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.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/init.lua b/lua/diffs/init.lua index 7fb4e6d..cdc29d1 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -29,12 +29,27 @@ ---@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) @@ -109,6 +124,19 @@ local default_config = { 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 @@ -231,6 +259,37 @@ local function compute_highlight_groups() 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) @@ -324,6 +383,30 @@ local function init() }) 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 @@ -488,4 +571,10 @@ function M.get_fugitive_config() return config.fugitive end +---@return diffs.ConflictConfig +function M.get_conflict_config() + init() + return config.conflict +end + return M diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 031ed60..e4fc690 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -30,6 +30,15 @@ vim.api.nvim_create_autocmd('BufReadCmd', { 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() diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua new file mode 100644 index 0000000..128c567 --- /dev/null +++ b/spec/conflict_spec.lua @@ -0,0 +1,655 @@ +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('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) From 74c2dd4c7a0535cbb6611b08b81c64efde79bc4d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 17:39:35 -0500 Subject: [PATCH 46/53] docs: document conflict resolution config and highlight groups --- doc/diffs.nvim.txt | 142 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index d0298b9..028d8ba 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -20,6 +20,7 @@ Features: ~ - 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* @@ -80,6 +81,19 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: 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* @@ -108,6 +122,10 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: 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) @@ -317,6 +335,94 @@ Configuration: ~ 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* @@ -414,8 +520,9 @@ conflict: modifying line highlights may produce unexpected results. - git-conflict.nvim (akinsho/git-conflict.nvim) - Provides conflict marker highlighting that may overlap with - diffs.nvim's highlighting in conflict scenarios. + 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. @@ -462,6 +569,37 @@ Fugitive unified diff highlights: ~ 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. From 7ae867c413aee71ffb123f486294bad712e0fc93 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 17:45:23 -0500 Subject: [PATCH 47/53] fix(conflict): resolve LuaLS duplicate-doc-field and inject-field errors Problem: lua-language-server reports duplicate @class definitions for ConflictKeymaps and ConflictConfig (defined in both init.lua and conflict.lua), and inject-field errors for the untyped parser table. Solution: remove duplicate @class annotations from conflict.lua (init.lua is the canonical source), and annotate the parser's current variable as diffs.ConflictRegion? so LuaLS knows its shape. --- lua/diffs/conflict.lua | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index b4d94ef..6e88c1c 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -1,17 +1,3 @@ ----@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.ConflictRegion ---@field marker_ours integer ---@field ours_start integer @@ -41,6 +27,7 @@ local PRIORITY_LINE_BG = 200 function M.parse(lines) local regions = {} local state = 'idle' + ---@type diffs.ConflictRegion? local current = nil for i, line in ipairs(lines) do From 98a1a4028b369686dd01ee6e01580e1c0e7abab3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 17:51:47 -0500 Subject: [PATCH 48/53] fix(conflict): resolve LuaLS missing-fields diagnostics Problem: LuaLS reports missing-fields errors because the parser builds ConflictRegion tables incrementally, but the variable is typed as diffs.ConflictRegion? which expects all required fields at construction. Solution: type the work-in-progress variable as table? and cast to diffs.ConflictRegion on insertion into the results array. --- lua/diffs/conflict.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 6e88c1c..6e66d39 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -27,7 +27,7 @@ local PRIORITY_LINE_BG = 200 function M.parse(lines) local regions = {} local state = 'idle' - ---@type diffs.ConflictRegion? + ---@type table? local current = nil for i, line in ipairs(lines) do @@ -72,7 +72,7 @@ function M.parse(lines) if line:match('^>>>>>>>') then current.theirs_end = idx current.marker_theirs = idx - table.insert(regions, current) + table.insert(regions, current --[[@as diffs.ConflictRegion]]) current = nil state = 'idle' elseif line:match('^<<<<<<<') then From 1108c3352672802acba1f3df299d06de5be0953c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 17:52:35 -0500 Subject: [PATCH 49/53] refactor(conflict): drop unnecessary @as cast in parser --- lua/diffs/conflict.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 6e66d39..52397fe 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -72,7 +72,7 @@ function M.parse(lines) if line:match('^>>>>>>>') then current.theirs_end = idx current.marker_theirs = idx - table.insert(regions, current --[[@as diffs.ConflictRegion]]) + table.insert(regions, current) current = nil state = 'idle' elseif line:match('^<<<<<<<') then From bae86c5fd9ef148b1ba4b8f13a22dc9ba12c3bda Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 17:57:53 -0500 Subject: [PATCH 50/53] feat(conflict): show branch names in virtual text labels Problem: virtual text showed generic "current"/"incoming" labels with no indication of which branch each side came from. Solution: extract the branch name from the marker line itself (e.g. <<<<<<< HEAD, >>>>>>> feature) and display as "HEAD (current)" / "feature (incoming)". --- lua/diffs/conflict.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 52397fe..6522026 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -108,7 +108,7 @@ local function apply_highlights(bufnr, regions, config) 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 = { { ' (current)', 'DiffsConflictMarker' } }, virt_text_pos = 'eol', }) end @@ -177,7 +177,7 @@ local function apply_highlights(bufnr, regions, config) 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 = { { ' (incoming)', 'DiffsConflictMarker' } }, virt_text_pos = 'eol', }) end From 35cb13419ce4d092ec65ba4782c8fa363d09c6af Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 19:42:29 -0500 Subject: [PATCH 51/53] fix(conflict): keep TextChanged autocmd alive after resolution Problem: resolving the last conflict called M.detach(), which cleared attached_buffers[bufnr]. The TextChanged callback then returned true, permanently deleting the autocmd. Undo restored conflict markers but nothing re-highlighted or re-suppressed diagnostics. Solution: inline the cleanup in refresh() instead of calling detach(). Keep attached_buffers set so the autocmd survives. Re-suppress diagnostics when conflicts reappear after undo. --- lua/diffs/conflict.lua | 10 +++++++++- spec/conflict_spec.lua | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 6522026..f0e47c6 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -214,11 +214,19 @@ end local function refresh(bufnr, config) local regions = parse_buffer(bufnr) if #regions == 0 then - M.detach(bufnr) + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + if diagnostics_suppressed[bufnr] then + 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 diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua index 128c567..75eac23 100644 --- a/spec/conflict_spec.lua +++ b/spec/conflict_spec.lua @@ -631,6 +631,39 @@ describe('conflict', function() 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', From a192830d8c886ba65758fd092daf58733b6213fa Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 19:47:32 -0500 Subject: [PATCH 52/53] fix(conflict): clear stale diagnostics before re-enabling Problem: after resolving all conflicts, vim.diagnostic.enable(true) restored diagnostics that were cached while markers were present, showing errors like "unexpected token end" on clean code. Solution: call vim.diagnostic.reset() before re-enabling to flush stale results and let the LSP re-analyze the resolved buffer. --- README.md | 4 +++- lua/diffs/conflict.lua | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ca3d72e..f0a04a8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ syntax highlighting. - Vim syntax fallback for languages without a treesitter parser - Hunk header context highlighting (`@@ ... @@ function foo()`) - Character-level (intra-line) diff highlighting for changed characters +- Inline merge conflict detection, highlighting, and resolution keymaps - Configurable debouncing, max lines, diff prefix concealment, blend alpha, and highlight overrides @@ -63,7 +64,8 @@ luarocks install diffs.nvim compatible, but both plugins modifying line highlights may produce unexpected results - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - - conflict marker highlighting may overlap with `diffs.nvim` + `diffs.nvim` now includes built-in conflict resolution; disable one or the + other to avoid overlap # Acknowledgements diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index f0e47c6..f4468d2 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -216,6 +216,7 @@ local function refresh(bufnr, config) 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 @@ -385,6 +386,7 @@ function M.detach(bufnr) 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 From f3a72926d288aaf03c4e212e9f82a006fb300320 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Feb 2026 16:28:18 -0500 Subject: [PATCH 53/53] doc: add plug mappings for merge conflict resolution --- doc/diffs.nvim.txt | 35 +++++++++++++++++++++++++++++++++++ lua/diffs/conflict.lua | 42 +++++++++++++----------------------------- plugin/diffs.lua | 25 +++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 29 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 028d8ba..7663150 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -280,6 +280,41 @@ Example configuration: >lua 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://` diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index f4468d2..9e62a15 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -348,35 +348,19 @@ end local function setup_keymaps(bufnr, config) local km = config.keymaps - if km.ours then - vim.keymap.set('n', km.ours, function() - M.resolve_ours(bufnr, config) - end, { buffer = bufnr }) - end - if km.theirs then - vim.keymap.set('n', km.theirs, function() - M.resolve_theirs(bufnr, config) - end, { buffer = bufnr }) - end - if km.both then - vim.keymap.set('n', km.both, function() - M.resolve_both(bufnr, config) - end, { buffer = bufnr }) - end - if km.none then - vim.keymap.set('n', km.none, function() - M.resolve_none(bufnr, config) - end, { buffer = bufnr }) - end - if km.next then - vim.keymap.set('n', km.next, function() - M.goto_next(bufnr) - end, { buffer = bufnr }) - end - if km.prev then - vim.keymap.set('n', km.prev, function() - M.goto_prev(bufnr) - end, { buffer = bufnr }) + 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 diff --git a/plugin/diffs.lua b/plugin/diffs.lua index e4fc690..5d3c8b2 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -57,3 +57,28 @@ 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' })