Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 41 additions & 5 deletions doc/diffs.nvim.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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*

Expand Down
141 changes: 140 additions & 1 deletion lua/diffs/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<integer, string>
---@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<integer, diffs.CharSpan[]>
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<integer, string>
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
Expand Down
33 changes: 30 additions & 3 deletions lua/diffs/diffopt.lua
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, boolean>
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()
Expand All @@ -29,21 +43,34 @@ 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
end
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()
Expand Down
61 changes: 61 additions & 0 deletions spec/diff_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading
Loading