diff --git a/README.md b/README.md index da79302..1dbf60b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ highlighting driven by treesitter. [vim-fugitive](https://github.com/tpope/vim-fugitive), [Neogit](https://github.com/NeogitOrg/neogit), builtin `diff` filetype, and more! -- Word and chatacer-level diff highlighting +- Word- and character-level diff highlighting, driven by `'diffopt'` `inline:` - `:Diff` for [pierre-style](https://diffs.com) unified, stacked, or split diffs against any revision - `:Diff review` full-repo review diff with qflist/loclist navigation - `:Diff files` to diff two arbitrary files, or the buffer against its last save diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 7cfb220..c0adb8b 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -19,8 +19,7 @@ All other integrations are opt-in. See |diffs.nvim-integrations|. Features: ~ - Treesitter syntax highlighting in diff hunks -- Character-level intra-line diff highlighting -- Word-level diff highlighting +- Intra-line diff highlighting, character- or word-level per 'diffopt' - Vim syntax fallback for languages without a treesitter parser - Blended diff background colors that preserve syntax visibility - Optional diff prefix (`+`/`-`/` `) concealment @@ -305,11 +304,13 @@ Configuration is done via `vim.g.diffs`. Set options before the plugin loads: *diffs.nvim.IntraConfig* Intra config fields: ~ {enabled} (boolean, default: true) - Enable character-level diff highlighting within - changed lines. When a line changes from `local x = 1` + Enable intra-line 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. + line keeps the softer line-level background. How + much of the line is covered follows 'diffopt' + `inline:`; see |diffs.nvim-inline|. {algorithm} (string, default: 'default') Diff algorithm for character-level analysis. @@ -763,6 +764,41 @@ Whitespace handling: ~ *diffs.nvim-whitespace* computed with |vim.diff()| but not the git-backed surfaces (|:Diff-review| and the staged/unstaged section diffs). +Inline granularity: ~ *diffs.nvim-inline* + + How much of a changed line is emphasized comes from 'diffopt' `inline:`, so + diffs.nvim surfaces match native |:diffsplit| windows. As with whitespace, + there is no separate plugin option. + + inline:none No intra-line emphasis. Changed lines keep only their + line-level background. + inline:simple One span per line, from the first differing byte to + the last. + inline:char A span per differing run of characters. + inline:word Spans grown to whole words, with gaps of five bytes or + less of non-word characters merged into one block. + + Neovim treats a 'diffopt' with no `inline:` value as `inline:simple`, and + diffs.nvim resolves it the same way. To emphasize whole words: >vim + set diffopt+=inline:word +< + The setting takes effect live through the same 'diffopt' repaint described + above, so a plain `:set`, an unimpaired-style toggle, or an autocmd all + work. There is no dedicated mapping. + + `highlights.intra` stays orthogonal: `inline:` chooses the granularity, + `highlights.intra.algorithm` chooses the engine that finds the differences, + and `highlights.intra.enabled = false` turns the feature off regardless of + 'diffopt'. + + Limitations: ~ + - `inline:word` is produced by growing character-level spans to the word + boundaries they touch, not by re-diffing the line word by word. The + result matches Neovim's own `inline:word` rendering on ordinary source + lines, but the two are not guaranteed identical in every case. + - difftastic-rendered diffs keep their structural token spans; `inline:` + does not apply to them (see |diffs.nvim-difftastic|). + ============================================================================== MAPPINGS *diffs.nvim-mappings* diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua index b33d330..1360f1b 100644 --- a/lua/diffs/diff.lua +++ b/lua/diffs/diff.lua @@ -136,6 +136,130 @@ local function drop_whitespace_spans(spans, line, diff_opts) return kept end +--- Gap between two inline:word changes that 'diffopt' folds into a single +--- highlight block, in bytes. +local WORD_GAP_MERGE = 5 + +--- Whether a byte belongs to a |word| (letters, digits, underscore). Bytes of a +--- multi-byte character are excluded on purpose: 'diffopt' counts emoji and CJK +--- characters as individual words, so a word boundary falls either side of one. +---@param byte string +---@return boolean +local function is_word_byte(byte) + return byte:match('[%w_]') ~= nil +end + +--- Grow a span outward until it covers whole words. The spans reaching here come +--- from a character-wise differ, so inline:word is produced by widening them to +--- the word boundaries they already touch rather than by diffing words. +---@param span diffs.CharSpan +---@param line string +---@return diffs.CharSpan +local function extend_span_to_words(span, line) + local col_start, col_end = span.col_start, span.col_end + if is_word_byte(line:sub(col_start, col_start)) then + while col_start > 1 and is_word_byte(line:sub(col_start - 1, col_start - 1)) do + col_start = col_start - 1 + end + end + if is_word_byte(line:sub(col_end - 1, col_end - 1)) then + while col_end <= #line and is_word_byte(line:sub(col_end, col_end)) do + col_end = col_end + 1 + end + end + return { line = span.line, col_start = col_start, col_end = col_end } +end + +--- Whether the half-open byte range holds no word bytes. A gap containing one +--- means an unchanged word sits between the spans, which must not be swallowed. +---@param line string +---@param from integer +---@param to integer +---@return boolean +local function gap_is_wordless(line, from, to) + for i = from, to - 1 do + if is_word_byte(line:sub(i, i)) then + return false + end + end + return true +end + +--- Merge one line's word spans where they overlap or are parted by a short run +--- of non-word bytes, matching how 'diffopt' inline:word blocks read. +---@param spans diffs.CharSpan[] +---@param line string +---@return diffs.CharSpan[] +local function merge_word_spans(spans, line) + table.sort(spans, function(a, b) + return a.col_start < b.col_start + end) + + ---@type diffs.CharSpan[] + local merged = {} + for _, span in ipairs(spans) do + local prev = merged[#merged] + local gap = prev and span.col_start - prev.col_end or nil + if + prev + and gap + and gap <= WORD_GAP_MERGE + and (gap <= 0 or gap_is_wordless(line, prev.col_end, span.col_start)) + then + prev.col_end = math.max(prev.col_end, span.col_end) + else + merged[#merged + 1] = span + end + end + return merged +end + +--- Reshape character-wise spans to the granularity 'diffopt' inline: asks for. +--- `char` is what the differs already produce, so it passes through untouched. +---@param spans diffs.CharSpan[] +---@param texts table +---@param mode diffs.InlineMode +---@return diffs.CharSpan[] +local function apply_inline_mode(spans, texts, mode) + if mode == 'char' or #spans == 0 then + return spans + end + + ---@type table + local by_line = {} + ---@type integer[] + local line_order = {} + for _, span in ipairs(spans) do + if not by_line[span.line] then + by_line[span.line] = {} + line_order[#line_order + 1] = span.line + end + table.insert(by_line[span.line], span) + end + + ---@type diffs.CharSpan[] + local out = {} + for _, line_idx in ipairs(line_order) do + local line = texts[line_idx] or '' + if mode == 'simple' then + local col_start, col_end = math.huge, 0 + for _, span in ipairs(by_line[line_idx]) do + col_start = math.min(col_start, span.col_start) + col_end = math.max(col_end, span.col_end) + end + out[#out + 1] = { line = line_idx, col_start = col_start, col_end = col_end } + else + ---@type diffs.CharSpan[] + local extended = {} + for _, span in ipairs(by_line[line_idx]) do + extended[#extended + 1] = extend_span_to_words(span, line) + end + vim.list_extend(out, merge_word_spans(extended, line)) + end + end + return out +end + ---@param old_line string ---@param new_line string ---@param del_idx integer @@ -339,6 +463,12 @@ end ---@param algorithm? string ---@return diffs.IntraChanges? function M.compute_intra_hunks(hunk_lines, algorithm) + local inline = diffopt.inline() + if inline == 'none' then + dbg('intra skipped: diffopt inline:none') + return nil + end + local groups = M.extract_change_groups(hunk_lines) if #groups == 0 then return nil @@ -407,7 +537,16 @@ function M.compute_intra_hunks(hunk_lines, algorithm) return nil end - return { add_spans = all_add, del_spans = all_del } + ---@type table + local texts = {} + for i, line in ipairs(hunk_lines) do + texts[i] = line:sub(2) + end + + return { + add_spans = apply_inline_mode(all_add, texts, inline), + del_spans = apply_inline_mode(all_del, texts, inline), + } end --- Normalize a line for whitespace-insensitive comparison, following the active diff --git a/lua/diffs/diffopt.lua b/lua/diffs/diffopt.lua index 3ed5d60..ca1e43c 100644 --- a/lua/diffs/diffopt.lua +++ b/lua/diffs/diffopt.lua @@ -1,6 +1,9 @@ +---@alias diffs.InlineMode 'none'|'simple'|'char'|'word' + ---@class diffs.DiffOpts ---@field algorithm? string ---@field linematch? integer +---@field inline? diffs.InlineMode ---@field ignore_whitespace? boolean ---@field ignore_whitespace_change? boolean ---@field ignore_whitespace_change_at_eol? boolean @@ -18,6 +21,17 @@ local WHITESPACE_FLAGS = { iblank = 'ignore_blank_lines', } +--- 'diffopt' inline: granularities. Unknown values are ignored rather than +--- treated as "off", so a value added by a future Neovim does not silently +--- drop intra-line highlighting. +---@type table +local INLINE_MODES = { + none = true, + simple = true, + char = true, + word = true, +} + --- Resolve the effective diff options from Neovim's global 'diffopt'. ---@return diffs.DiffOpts function M.resolve() @@ -29,6 +43,8 @@ function M.resolve() opts.algorithm = val elseif key == 'linematch' then opts.linematch = tonumber(val) + elseif key == 'inline' and INLINE_MODES[val] then + opts.inline = val elseif WHITESPACE_FLAGS[item] then opts[WHITESPACE_FLAGS[item]] = true end @@ -36,14 +52,25 @@ function M.resolve() return opts end ---- Options to merge into a vim.diff()/vim.text.diff() call. +--- Effective intra-line granularity. 'diffopt' treats a missing `inline:` as +--- `simple`, so plugin surfaces resolve it the same way native diff windows do. +---@return diffs.InlineMode +function M.inline() + return M.resolve().inline or 'simple' +end + +--- Options to merge into a vim.diff()/vim.text.diff() call. inline is dropped: +--- it only shapes highlighting, and vim.diff() rejects keys it does not know. ---@return diffs.DiffOpts function M.vim_diff_opts() - return M.resolve() + local opts = M.resolve() + opts.inline = nil + return opts end --- Equivalent git diff flags for the resolved options. linematch has no git ---- counterpart and is omitted. +--- counterpart and is omitted, and inline only shapes highlighting, not the +--- diff git is asked to produce. ---@return string[] function M.git_flags() local opts = M.resolve() diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 2cc22ac..b2fb509 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -155,6 +155,67 @@ describe('diff', function() end) end) + describe('compute_intra_hunks inline granularity', function() + local saved + + before_each(function() + saved = vim.o.diffopt + end) + + after_each(function() + vim.o.diffopt = saved + end) + + --- Spans of the added line, rendered as the text they cover. + ---@param lines string[] + ---@return string[] + local function add_texts(lines) + local result = diff.compute_intra_hunks(lines, 'default') + if not result then + return {} + end + local out = {} + for _, span in ipairs(result.add_spans) do + out[#out + 1] = lines[span.line]:sub(1 + span.col_start, span.col_end) + end + table.sort(out) + return out + end + + local changed = { '-local foo_bar = 1 + qux', '+local foo_baz = 2 + qux' } + + it('skips intra-line work entirely on inline:none', function() + vim.o.diffopt = 'internal,filler,inline:none' + assert.is_nil(diff.compute_intra_hunks(changed, 'default')) + end) + + it('spans first to last difference on inline:simple', function() + vim.o.diffopt = 'internal,filler,inline:simple' + assert.are.same({ 'z = 2' }, add_texts(changed)) + end) + + it('spans each differing run on inline:char', function() + vim.o.diffopt = 'internal,filler,inline:char' + assert.are.same({ '2', 'z' }, add_texts(changed)) + end) + + it('grows spans to whole words on inline:word', function() + vim.o.diffopt = 'internal,filler,inline:word' + assert.are.same({ 'foo_baz = 2' }, add_texts(changed)) + end) + + it('merges word spans only across short non-word gaps', function() + vim.o.diffopt = 'internal,filler,inline:word' + assert.are.same({ 'B', 'F' }, add_texts({ '-a+b+c+d+e+f+g', '+a+B+c+d+e+F+g' })) + assert.are.same({ 'xxx', 'yyy' }, add_texts({ '-aaa bbb ccc ddd', '+aaa xxx ccc yyy' })) + end) + + it('falls back to simple when diffopt sets no inline value', function() + vim.o.diffopt = 'internal,filler' + assert.are.same({ 'z = 2' }, add_texts(changed)) + end) + end) + describe('has_vscode', function() it('returns false in test environment', function() assert.is_false(diff.has_vscode()) diff --git a/spec/diffopt_spec.lua b/spec/diffopt_spec.lua index c1c2d0f..7f65d53 100644 --- a/spec/diffopt_spec.lua +++ b/spec/diffopt_spec.lua @@ -36,6 +36,26 @@ describe('diffopt resolver', function() assert.are.equal(60, opts.linematch) end) + it('resolves every inline granularity', function() + for _, mode in ipairs({ 'none', 'simple', 'char', 'word' }) do + vim.o.diffopt = 'internal,filler,inline:' .. mode + assert.are.equal(mode, diffopt.resolve().inline) + assert.are.equal(mode, diffopt.inline()) + end + end) + + it('treats a missing inline value as simple, like Neovim does', function() + vim.o.diffopt = 'internal,filler' + assert.is_nil(diffopt.resolve().inline) + assert.are.equal('simple', diffopt.inline()) + end) + + it('omits inline from git flags and vim.diff() options', function() + vim.o.diffopt = 'internal,inline:word,iwhiteall' + assert.are.same({ '--ignore-all-space' }, diffopt.git_flags()) + assert.are.same({ ignore_whitespace = true }, diffopt.vim_diff_opts()) + end) + it('maps flags onto the equivalent git diff flags', function() vim.o.diffopt = 'internal,iwhiteall' assert.are.same({ '--ignore-all-space' }, diffopt.git_flags())