From 2889a867041f909afcfdf15d64ac8b1e67ba14da Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Sun, 8 Nov 2026 16:29:33 -0600
Subject: [PATCH 1/3] feat: expose public highlighting APIs
---
doc/diffs.nvim.txt | 71 +++++++++++++++++++++++++++--
lua/diffs/highlight.lua | 37 ++++++++-------
lua/diffs/init.lua | 12 ++++-
lua/diffs/parser.lua | 1 +
lua/diffs/runtime/attach.lua | 80 ++++++++++++++++++++++-----------
lua/diffs/runtime/cache.lua | 36 ++++++++++-----
lua/diffs/runtime/decorator.lua | 20 ++++++---
lua/diffs/runtime/init.lua | 25 ++++++++---
spec/integration_spec.lua | 4 +-
9 files changed, 214 insertions(+), 72 deletions(-)
diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt
index ed9a2139..b1e36c2f 100644
--- a/doc/diffs.nvim.txt
+++ b/doc/diffs.nvim.txt
@@ -1174,7 +1174,7 @@ Save and quit after resolving conflicts. Use `:cq` to abort.
==============================================================================
API *diffs.nvim-api*
-attach({bufnr}) *diffs.nvim.attach()*
+attach({bufnr}, {options}) *diffs.nvim.attach()*
Attach highlighting to a buffer. Called automatically when the buffer's
filetype matches `git`, `gitcommit`, an enabled integration filetype,
or any entry in the deprecated `extra_filetypes` option.
@@ -1182,14 +1182,77 @@ attach({bufnr}) *diffs.nvim.attach()*
filetypes, see |diffs.nvim-extra-filetypes|.
Parameters: ~
- {bufnr} (integer, optional) Buffer number. Defaults to current buffer.
+ {bufnr} (integer, optional) Buffer number. Omit or use 0 for current.
+ {options} (table, optional) ~
+ • {hunks} (function, optional) Receives the buffer number.
+ Returns a list of |diffs.Hunk| tables in buffer order.
+ Core copies these hunks instead of parsing the buffer.
+ • {intra} (boolean, optional) Set false to suppress
+ intra-line highlighting for this attachment. When omitted,
+ core retains its automatic Difftastic detection.
+
+ Without {options}, repeated calls do nothing. With {options}, a call
+ replaces the existing attachment. The callback runs on attachment, after
+ buffer edits, and after |diffs.nvim.refresh()| when core redraws the buffer.
+
+ *diffs.Hunk*
+ A supplied hunk describes logical diff lines and their buffer positions: ~
+ {filename} (string) Source filename for diagnostics.
+ {start_line} (integer) One-based hunk header line. Use 0 when the
+ body starts on the first buffer line without a header.
+ {lines} (string[]) Body lines, including diff prefixes.
+ Element i maps to buffer row start_line + i - 1
+ (zero-based). Exclude display columns before prefixes.
+ {column_offset} (integer, default: 0) Extra bytes before each prefix
+ in the displayed buffer.
+ {prefix_width} (integer, default: 1) Diff prefix width in bytes.
+ {quote_width} (integer, default: 0) Quote prefix width in bytes.
+ {ft} (string, optional) Vim syntax filetype.
+ {lang} (string, optional) Treesitter parser language.
+
+ Core does not infer {ft} or {lang} for supplied hunks. Optional header
+ fields are {header_start_line} (one-based), {header_lines} (string[]),
+ {header_context} (string), and {header_context_col} (zero-based byte column,
+ excluding {column_offset}).
+
+detach({bufnr}) *diffs.nvim.detach()*
+ Remove the attachment, its autocmds, and its highlights. Pending syntax
+ callbacks cannot apply highlights after detachment.
+
+ Parameters: ~
+ {bufnr} (integer, optional) Buffer number. Omit or use 0 for current.
refresh({bufnr}) *diffs.nvim.refresh()*
Manually refresh highlighting for a buffer. Useful after external changes
- or for debugging.
+ or for debugging. For supplied hunks, core calls the provider again on
+ redraw, including when the buffer text is unchanged.
Parameters: ~
- {bufnr} (integer, optional) Buffer number. Defaults to current buffer.
+ {bufnr} (integer, optional) Buffer number. Omit or use 0 for current.
+
+highlight_options() *diffs.nvim.highlight_options()*
+ Initialize the highlight groups and return a copy of the effective
+ {hide_prefix} and {highlights} settings. The {highlights.priorities} table
+ contains {clear}, {syntax}, {line_bg}, and {char_bg} extmark priorities.
+
+diff_options() *diffs.nvim.diff_options()*
+ Return options from 'diffopt': {algorithm}, {linematch}, {inline}, and
+ the whitespace flags {ignore_whitespace}, {ignore_whitespace_change},
+ {ignore_whitespace_change_at_eol}, and {ignore_blank_lines}.
+ Unset options are absent. An absent {inline} means "simple".
+
+compute_intra_hunks({lines}) *diffs.nvim.compute_intra_hunks()*
+ Compute intra-line changes with Neovim and the current 'diffopt'.
+ {lines} contains logical body lines with one diff prefix byte per line.
+ Return {add_spans} and {del_spans}, or nil when no spans apply.
+ Each span contains {line}, {col_start}, and {col_end}. Line indexes refer
+ to {lines} and are one-based. Columns are one-based byte positions in
+ the text after the diff prefix. {col_end} is exclusive.
+
+whitespace_only_lines({lines}) *diffs.nvim.whitespace_only_lines()*
+ Return a table of one-based line indexes with value true for changes
+ ignored by the whitespace options in 'diffopt'. {lines} has the same
+ format as |diffs.nvim.compute_intra_hunks()|.
*diffs.ReviewSpec*
A `diffs.ReviewSpec` says what to review, as the arguments to |:Diff-review|
diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua
index f424972f..cc9d9df3 100644
--- a/lua/diffs/highlight.lua
+++ b/lua/diffs/highlight.lua
@@ -169,11 +169,13 @@ end
---@field highlights diffs.HunkHighlights
---@field defer_vim_syntax? boolean
---@field syntax_only? boolean
+---@field intra? boolean
---@class diffs.HunkOptsOverride
---@field highlights? table
---@field defer_vim_syntax? boolean
---@field syntax_only? boolean
+---@field intra? boolean
---@param config diffs.Config
---@param overrides? diffs.HunkOptsOverride
@@ -209,7 +211,8 @@ end
function M.highlight_hunk_prefixes(bufnr, ns, hunk, opts)
local p = opts.highlights.priorities
local pw = hunk.prefix_width or 1
- local qw = hunk.quote_width or 0
+ local offset = hunk.column_offset or 0
+ local qw = (hunk.quote_width or 0) + offset
local extmark_count = 0
for i, line in ipairs(hunk.lines) do
@@ -395,7 +398,7 @@ local function apply_vim_syntax_spans(
leading_offset = leading_offset or 0
local hunk_line_count = #hunk.lines
- local col_off = (hunk.prefix_width or 1) + (hunk.quote_width or 0) - 1
+ local col_off = (hunk.prefix_width or 1) + (hunk.quote_width or 0) + (hunk.column_offset or 0) - 1
local extmark_count = 0
for _, span in ipairs(spans) do
local adj = span.line - leading_offset
@@ -484,7 +487,8 @@ end
function M.highlight_hunk(bufnr, ns, hunk, opts)
local p = opts.highlights.priorities
local pw = hunk.prefix_width or 1
- local qw = hunk.quote_width or 0
+ local offset = hunk.column_offset or 0
+ local qw = (hunk.quote_width or 0) + offset
local use_ts = hunk.lang and opts.highlights.treesitter
local use_vim = not use_ts and hunk.ft and opts.highlights.vim
@@ -559,7 +563,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
bufnr,
ns,
hunk,
- hunk.header_context_col,
+ hunk.header_context_col + offset,
hunk.header_context,
hunk.lang,
new_code,
@@ -609,7 +613,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
local at_raw_line
- if (qw > 0 or pw > 1) and opts.highlights.treesitter then
+ if hunk.start_line > 0 and (qw > 0 or pw > 1) and opts.highlights.treesitter then
local at_buf_line = hunk.start_line - 1
at_raw_line = vim.api.nvim_buf_get_lines(bufnr, at_buf_line, at_buf_line + 1, false)[1]
end
@@ -617,7 +621,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
---@type diffs.IntraChanges?
local intra = nil
local intra_cfg = opts.highlights.intra
- local difft_active = vim.b[bufnr].diffs_difft_active == true
+ local difft_active = opts.intra == false
+ or (opts.intra == nil and vim.b[bufnr].diffs_difft_active == true)
if
not opts.syntax_only
and intra_cfg
@@ -679,7 +684,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
then
for i = 0, #hunk.header_lines - 1 do
local buf_line = hunk.header_start_line - 1 + i
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, {
end_col = #hunk.header_lines[i + 1] + qw,
hl_group = 'DiffsClear',
priority = p.clear,
@@ -712,7 +717,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
if (qw > 0 or pw > 1) and at_raw_line then
local at_buf_line = hunk.start_line - 1
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, offset, {
end_col = #at_raw_line,
hl_group = 'DiffsClear',
priority = p.clear,
@@ -728,8 +733,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
if use_ts and hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
- end_col = hunk.header_context_col + #hunk.header_context,
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col + offset, {
+ end_col = hunk.header_context_col + offset + #hunk.header_context,
hl_group = 'DiffsClear',
priority = p.clear,
})
@@ -768,7 +773,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
if not opts.syntax_only then
if opts.hide_prefix and (not hunk.rail_width or is_diff_line) then
local virt_hl = (opts.highlights.background and bg_hl) or nil
- local rail_width = hunk.rail_width or 0
+ local rail_width = (hunk.rail_width or 0) + offset
local hide_width = math.max(0, pw + qw - rail_width)
if hide_width > 0 then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, rail_width, {
@@ -786,7 +791,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
if raw_len and prefix_end > raw_len then
prefix_end = raw_len
end
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, {
end_col = prefix_end,
hl_group = 'DiffsClear',
priority = p.clear,
@@ -796,7 +801,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
if raw_len and rail_end > raw_len then
rail_end = raw_len
end
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, {
end_col = rail_end,
hl_group = 'DiffsRail',
priority = p.syntax,
@@ -855,7 +860,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
end
elseif opts.highlights.background and is_diff_line then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, {
end_col = 1,
hl_group = prefix_hl,
priority = p.syntax,
@@ -863,7 +868,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
if hunk.rail_width and is_diff_line and opts.highlights.background and not dehl then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, {
virt_text = { { '▏', bar_hl } },
virt_text_pos = 'overlay',
hl_mode = 'replace',
@@ -872,7 +877,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
if opts.highlights.background and is_diff_line then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, {
end_row = buf_line + 1,
end_col = 0,
hl_group = bg_hl,
diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua
index 200df46a..38d3cc5b 100644
--- a/lua/diffs/init.lua
+++ b/lua/diffs/init.lua
@@ -1,5 +1,10 @@
---@class diffs.PublicApi
----@field attach fun(bufnr?: integer)
+---@field attach fun(bufnr?: integer, options?: diffs.AttachOptions)
+---@field detach fun(bufnr?: integer)
+---@field highlight_options fun(): diffs.HunkOpts
+---@field diff_options fun(): diffs.DiffOpts
+---@field compute_intra_hunks fun(lines: string[]): diffs.IntraChanges?
+---@field whitespace_only_lines fun(lines: string[]): table
---@field open_review fun(spec?: diffs.ReviewSpec, opts?: diffs.ReviewOpts): integer?
---@field refresh fun(bufnr?: integer)
---@field review_files fun(bufnr?: integer): diffs.ReviewFile[]?
@@ -21,6 +26,11 @@ end
---@type diffs.PublicApi
return {
attach = runtime.attach,
+ detach = runtime.detach,
+ highlight_options = runtime.get_highlight_opts,
+ diff_options = require('diffs.diffopt').resolve,
+ compute_intra_hunks = require('diffs.diff').compute_intra_hunks,
+ whitespace_only_lines = require('diffs.diff').whitespace_only_lines,
open_review = command('open_review'),
refresh = runtime.refresh,
review_files = command('review_files'),
diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua
index 7d676afa..5a772d49 100644
--- a/lua/diffs/parser.lua
+++ b/lua/diffs/parser.lua
@@ -10,6 +10,7 @@
---@field header_lines string[]?
---@field prefix_width integer
---@field quote_width integer
+---@field column_offset integer?
---@field rail_width integer?
---@field rail_style diffs.RailStyle?
---@field _hl_line_count integer?
diff --git a/lua/diffs/runtime/attach.lua b/lua/diffs/runtime/attach.lua
index d596c5fb..f4131abf 100644
--- a/lua/diffs/runtime/attach.lua
+++ b/lua/diffs/runtime/attach.lua
@@ -3,41 +3,73 @@ local log = require('diffs.log')
local M = {}
----@class diffs.AttachOpts
+---@class diffs.AttachOptions
+---@field hunks? fun(bufnr: integer): diffs.Hunk[]
+---@field intra? boolean
+
+---@class diffs.Attachment: diffs.AttachOptions
+---@field augroup integer
+
+---@class diffs.DetachOpts
---@field cache diffs.Cache
----@field attached_buffers table
+---@field attached_buffers table
+
+---@class diffs.AttachOpts: diffs.DetachOpts
---@field get_config fun(): diffs.Config
---@field refresh fun(bufnr?: integer)
----@param opts diffs.AttachOpts
+---@param opts diffs.DetachOpts
---@param bufnr? integer
-function M.attach(opts, bufnr)
- bufnr = bufnr or vim.api.nvim_get_current_buf()
+function M.detach(opts, bufnr)
+ if not bufnr or bufnr == 0 then
+ bufnr = vim.api.nvim_get_current_buf()
+ end
+ local attachment = opts.attached_buffers[bufnr]
+ if not attachment then
+ return
+ end
+ opts.attached_buffers[bufnr] = nil
+ opts.cache:delete(bufnr)
+ vim.api.nvim_del_augroup_by_id(attachment.augroup)
+ if vim.api.nvim_buf_is_valid(bufnr) then
+ vim.api.nvim_buf_clear_namespace(bufnr, opts.cache.ns, 0, -1)
+ end
+end
+---@param opts diffs.AttachOpts
+---@param bufnr? integer
+---@param options? diffs.AttachOptions
+function M.attach(opts, bufnr, options)
+ if not bufnr or bufnr == 0 then
+ bufnr = vim.api.nvim_get_current_buf()
+ end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
-
if opts.attached_buffers[bufnr] then
- return
+ if not options then
+ return
+ end
+ M.detach(opts, bufnr)
end
- opts.attached_buffers[bufnr] = true
-
+ local attachment = {
+ hunks = options and options.hunks,
+ intra = options and options.intra,
+ augroup = vim.api.nvim_create_augroup('diffs_buffer_' .. bufnr, { clear = true }),
+ }
+ opts.attached_buffers[bufnr] = attachment
local config = opts.get_config()
- local integration_augroup
- for key, event in pairs({ neogit = 'NeogitDiffLoaded', neojj = 'NeojjDiffLoaded' }) do
+ for name, event in pairs({ neogit = 'NeogitDiffLoaded', neojj = 'NeojjDiffLoaded' }) do
if
- integrations.is_enabled(config.integrations[key])
- and integrations.matches_filetype(key, vim.bo[bufnr].filetype)
+ integrations.is_enabled(config.integrations[name])
+ and integrations.matches_filetype(name, vim.bo[bufnr].filetype)
then
- vim.b[bufnr][key .. '_disable_hunk_highlight'] = true
- integration_augroup =
- vim.api.nvim_create_augroup('diffs_' .. key .. '_' .. bufnr, { clear = true })
+ vim.b[bufnr][name .. '_disable_hunk_highlight'] = true
vim.api.nvim_create_autocmd('User', {
pattern = event,
- group = integration_augroup,
+ group = attachment.augroup,
callback = function()
- if vim.api.nvim_buf_is_valid(bufnr) and opts.attached_buffers[bufnr] then
+ if vim.api.nvim_buf_is_valid(bufnr) and opts.attached_buffers[bufnr] == attachment then
opts.refresh(bufnr)
end
end,
@@ -45,21 +77,17 @@ function M.attach(opts, bufnr)
break
end
end
-
log.dbg('attaching to buffer %d', bufnr)
-
- opts.cache:ensure(bufnr)
-
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
+ group = attachment.augroup,
callback = function()
- opts.attached_buffers[bufnr] = nil
- opts.cache:delete(bufnr)
- if integration_augroup then
- pcall(vim.api.nvim_del_augroup_by_id, integration_augroup)
+ if opts.attached_buffers[bufnr] == attachment then
+ M.detach(opts, bufnr)
end
end,
})
+ opts.cache:ensure(bufnr)
end
return M
diff --git a/lua/diffs/runtime/cache.lua b/lua/diffs/runtime/cache.lua
index 903f76c7..0bee6d5d 100644
--- a/lua/diffs/runtime/cache.lua
+++ b/lua/diffs/runtime/cache.lua
@@ -14,13 +14,15 @@ local M = {}
---@class diffs.Cache
---@field ns integer
---@field get_config fun(): diffs.Config
+---@field get_attachment? fun(bufnr: integer): diffs.Attachment?
---@field hunk_cache table
----@field ft_retry_pending table
----@field syntax_jobs table
+---@field ft_retry_pending table
+---@field syntax_jobs table
---@class diffs.CacheOpts
---@field ns integer
---@field get_config fun(): diffs.Config
+---@field get_attachment? fun(bufnr: integer): diffs.Attachment?
local Cache = {}
Cache.__index = Cache
@@ -94,6 +96,7 @@ end
function M.new(opts)
return setmetatable({
ns = opts.ns,
+ get_attachment = opts.get_attachment,
get_config = opts.get_config,
hunk_cache = {},
ft_retry_pending = {},
@@ -102,9 +105,9 @@ function M.new(opts)
end
---@param bufnr integer
----@return integer
+---@return table
function Cache:next_syntax_job(bufnr)
- local job_id = (self.syntax_jobs[bufnr] or 0) + 1
+ local job_id = {}
self.syntax_jobs[bufnr] = job_id
return job_id
end
@@ -140,7 +143,7 @@ end
---@param bufnr integer
---@param tick integer
---@param changedtick integer
----@param job_id integer
+---@param job_id table
---@param deferred_syntax diffs.Hunk[]
---@return boolean
function Cache:run_deferred_syntax(bufnr, tick, changedtick, job_id, deferred_syntax)
@@ -149,9 +152,9 @@ function Cache:run_deferred_syntax(bufnr, tick, changedtick, job_id, deferred_sy
end
if self.syntax_jobs[bufnr] ~= job_id then
log.dbg(
- 'deferred syntax job superseded: cur=%s job=%d',
+ 'deferred syntax job superseded: cur=%s job=%s',
tostring(self.syntax_jobs[bufnr]),
- job_id
+ tostring(job_id)
)
return false
end
@@ -205,7 +208,7 @@ end
---@param bufnr integer
function Cache:invalidate(bufnr)
- self.syntax_jobs[bufnr] = (self.syntax_jobs[bufnr] or 0) + 1
+ self.syntax_jobs[bufnr] = {}
local entry = self.hunk_cache[bufnr]
if entry then
entry.tick = -1
@@ -223,7 +226,9 @@ function Cache:ensure(bufnr)
if entry and entry.tick == tick then
return
end
- local hunks = parser.parse_buffer(bufnr)
+ local attachment = self.get_attachment and self.get_attachment(bufnr)
+ local hunks = attachment and attachment.hunks and vim.deepcopy(attachment.hunks(bufnr))
+ or parser.parse_buffer(bufnr)
log.dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick)
self.hunk_cache[bufnr] = {
hunks = hunks,
@@ -239,9 +244,18 @@ function Cache:ensure(bufnr)
has_nil_ft = true
end
end
- if has_nil_ft and vim.fn.did_filetype() ~= 0 and not self.ft_retry_pending[bufnr] then
- self.ft_retry_pending[bufnr] = true
+ if
+ not (attachment and attachment.hunks)
+ and has_nil_ft
+ and vim.fn.did_filetype() ~= 0
+ and not self.ft_retry_pending[bufnr]
+ then
+ local retry = {}
+ self.ft_retry_pending[bufnr] = retry
vim.schedule(function()
+ if self.ft_retry_pending[bufnr] ~= retry then
+ return
+ end
if vim.api.nvim_buf_is_valid(bufnr) and self.hunk_cache[bufnr] then
log.dbg('retrying filetype detection for buffer %d (was blocked by did_filetype)', bufnr)
self:invalidate(bufnr)
diff --git a/lua/diffs/runtime/decorator.lua b/lua/diffs/runtime/decorator.lua
index 1eea9dab..665be367 100644
--- a/lua/diffs/runtime/decorator.lua
+++ b/lua/diffs/runtime/decorator.lua
@@ -7,7 +7,7 @@ local M = {}
---@class diffs.DecoratorOpts
---@field ns integer
---@field cache diffs.Cache
----@field attached_buffers table
+---@field attached_buffers table
---@field get_config fun(): diffs.Config
---@field get_fast_hl_opts fun(): diffs.HunkOpts
@@ -35,6 +35,11 @@ function M.setup(opts)
return false
end
local entry = cache.hunk_cache[bufnr]
+ if attached_buffers[bufnr].hunks and entry and entry.tick == -1 then
+ cache:ensure(bufnr)
+ cache:process_pending_clear(bufnr)
+ entry = cache.hunk_cache[bufnr]
+ end
if not entry then
return
end
@@ -47,11 +52,13 @@ function M.setup(opts)
local deferred_syntax = {}
local skipped_count = 0
local count = 0
- local fast_hl_opts = opts.get_fast_hl_opts()
+ local attachment = attached_buffers[bufnr]
+ local fast_hl_opts =
+ vim.tbl_extend('force', opts.get_fast_hl_opts(), { intra = attachment.intra })
for i = first, last do
if not entry.highlighted[i] then
local hunk = entry.hunks[i]
- local clear_start = hunk.start_line - 1
+ local clear_start = math.max(0, hunk.start_line - 1)
local clear_end = hunk.start_line + #hunk.lines
if hunk.header_start_line then
clear_start = hunk.header_start_line - 1
@@ -72,6 +79,9 @@ function M.setup(opts)
entry.warned_max_lines = true
local n = skipped_count
vim.schedule(function()
+ if attached_buffers[bufnr] ~= attachment then
+ return
+ end
log.notify(
(
'Syntax highlighting skipped for %d hunk(s) — too large.'
@@ -86,11 +96,11 @@ function M.setup(opts)
local changedtick = vim.api.nvim_buf_get_changedtick(bufnr)
local job_id = cache:next_syntax_job(bufnr)
log.dbg(
- 'deferred syntax scheduled: %d hunks tick=%d changedtick=%d job=%d',
+ 'deferred syntax scheduled: %d hunks tick=%d changedtick=%d job=%s',
#deferred_syntax,
tick,
changedtick,
- job_id
+ tostring(job_id)
)
vim.schedule(function()
cache:run_deferred_syntax(bufnr, tick, changedtick, job_id, deferred_syntax)
diff --git a/lua/diffs/runtime/init.lua b/lua/diffs/runtime/init.lua
index 20839602..878684a8 100644
--- a/lua/diffs/runtime/init.lua
+++ b/lua/diffs/runtime/init.lua
@@ -1,5 +1,5 @@
---@class diffs
----@field attach fun(bufnr?: integer)
+---@field attach fun(bufnr?: integer, options?: diffs.AttachOptions)
---@field refresh fun(bufnr?: integer)
local M = {}
@@ -17,7 +17,13 @@ local ns = vim.api.nvim_create_namespace('diffs')
---@type diffs.Config
local config = config_mod.new()
+---@type table
+local attached_buffers = {}
+
local cache = cache_mod.new({
+ get_attachment = function(bufnr)
+ return attached_buffers[bufnr]
+ end,
ns = ns,
get_config = function()
return config
@@ -31,9 +37,6 @@ local hl_retry_pending = false
---@diagnostic disable-next-line: missing-fields
local fast_hl_opts = {} ---@type diffs.HunkOpts
----@type table
-local attached_buffers = {}
-
---@type table
local diff_windows = {}
@@ -133,7 +136,8 @@ function M.configure(user_config)
end
---@param bufnr? integer
-function M.attach(bufnr)
+---@param options? diffs.AttachOptions
+function M.attach(bufnr, options)
init()
attach_mod.attach({
cache = cache,
@@ -142,12 +146,19 @@ function M.attach(bufnr)
return config
end,
refresh = M.refresh,
- }, bufnr)
+ }, bufnr, options)
+end
+
+---@param bufnr? integer
+function M.detach(bufnr)
+ attach_mod.detach({ cache = cache, attached_buffers = attached_buffers }, bufnr)
end
---@param bufnr? integer
function M.refresh(bufnr)
- bufnr = bufnr or vim.api.nvim_get_current_buf()
+ if not bufnr or bufnr == 0 then
+ bufnr = vim.api.nvim_get_current_buf()
+ end
cache:invalidate(bufnr)
end
diff --git a/spec/integration_spec.lua b/spec/integration_spec.lua
index 1abeaf94..62d1fd01 100644
--- a/spec/integration_spec.lua
+++ b/spec/integration_spec.lua
@@ -164,7 +164,7 @@ describe('integration', function()
local entry = runtime._test.hunk_cache[bufnr]
assert.is_not_nil(entry)
assert.is_nil(entry.hunks[1].ft)
- assert.is_true(runtime._test.ft_retry_pending[bufnr] == true)
+ assert.is_truthy(runtime._test.ft_retry_pending[bufnr])
delete_buffer(bufnr)
end)
@@ -177,7 +177,7 @@ describe('integration', function()
'+ listen 8080;',
})
runtime.attach(bufnr)
- assert.is_true(runtime._test.ft_retry_pending[bufnr] == true)
+ assert.is_truthy(runtime._test.ft_retry_pending[bufnr])
run_scheduled_callbacks()
From bd8f21c0783e64d4dd9ff21ba66b8f75b00844af Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Sun, 8 Nov 2026 16:45:08 -0600
Subject: [PATCH 2/3] fix: include the final hunk body line in visibility
---
lua/diffs/runtime/cache.lua | 2 +-
spec/runtime_spec.lua | 12 +++++++-----
2 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/lua/diffs/runtime/cache.lua b/lua/diffs/runtime/cache.lua
index 0bee6d5d..b21af21f 100644
--- a/lua/diffs/runtime/cache.lua
+++ b/lua/diffs/runtime/cache.lua
@@ -42,7 +42,7 @@ function M.find_visible_hunks(hunks, toprow, botrow)
while lo < hi do
local mid = math.floor((lo + hi) / 2)
local h = hunks[mid]
- local bottom = h.start_line - 1 + #h.lines - 1
+ local bottom = h.start_line + #h.lines - 1
if bottom < toprow then
lo = mid + 1
else
diff --git a/spec/runtime_spec.lua b/spec/runtime_spec.lua
index bffaa10b..0acd46c0 100644
--- a/spec/runtime_spec.lua
+++ b/spec/runtime_spec.lua
@@ -44,11 +44,13 @@ describe('diffs.runtime', function()
assert.are.equal(0, last)
end)
- it('finds single hunk partially visible at top edge', function()
- local h = make_hunk(5, 15)
- local first, last = find_visible_hunks({ h }, 10, 30)
- assert.are.equal(1, first)
- assert.are.equal(1, last)
+ it('finds a hunk when only its last body line is visible', function()
+ for _, start_line in ipairs({ 0, 5 }) do
+ local h = { start_line = start_line, lines = { '+hello' } }
+ local first, last = find_visible_hunks({ h }, start_line, start_line + 1)
+ assert.are.equal(1, first)
+ assert.are.equal(1, last)
+ end
end)
it('finds single hunk partially visible at bottom edge', function()
From 351d309ed7630b0f74d2a9bb718bb898134b3afd Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Sun, 8 Nov 2026 16:50:26 -0600
Subject: [PATCH 3/3] docs: simplify public highlighting API reference
---
doc/diffs.nvim.txt | 67 +++++++++++++++++++++-------------------------
1 file changed, 31 insertions(+), 36 deletions(-)
diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt
index b1e36c2f..a005b8b2 100644
--- a/doc/diffs.nvim.txt
+++ b/doc/diffs.nvim.txt
@@ -1175,7 +1175,7 @@ Save and quit after resolving conflicts. Use `:cq` to abort.
API *diffs.nvim-api*
attach({bufnr}, {options}) *diffs.nvim.attach()*
- Attach highlighting to a buffer. Called automatically when the buffer's
+ Attach highlighting to a buffer. Called automatically when the buffer's
filetype matches `git`, `gitcommit`, an enabled integration filetype,
or any entry in the deprecated `extra_filetypes` option.
Direct calls work with any filetype. For automatic attachment to other
@@ -1185,73 +1185,68 @@ attach({bufnr}, {options}) *diffs.nvim.attach()*
{bufnr} (integer, optional) Buffer number. Omit or use 0 for current.
{options} (table, optional) ~
• {hunks} (function, optional) Receives the buffer number.
- Returns a list of |diffs.Hunk| tables in buffer order.
- Core copies these hunks instead of parsing the buffer.
- • {intra} (boolean, optional) Set false to suppress
- intra-line highlighting for this attachment. When omitted,
- core retains its automatic Difftastic detection.
+ Returns |diffs.Hunk| tables in buffer order.
+ • {intra} (boolean, optional) Set false to disable intra-line
+ highlighting. Omit for automatic Difftastic detection.
- Without {options}, repeated calls do nothing. With {options}, a call
- replaces the existing attachment. The callback runs on attachment, after
- buffer edits, and after |diffs.nvim.refresh()| when core redraws the buffer.
+ Pass {options} to replace an attachment. Without it, repeated calls do
+ nothing. The {hunks} callback runs on attachment and on redraw after edits
+ or |diffs.nvim.refresh()|.
*diffs.Hunk*
- A supplied hunk describes logical diff lines and their buffer positions: ~
- {filename} (string) Source filename for diagnostics.
- {start_line} (integer) One-based hunk header line. Use 0 when the
+ Hunk fields: ~
+ {filename} (string) Source filename.
+ {start_line} (integer) One-based hunk header line. Use 0 when the
body starts on the first buffer line without a header.
- {lines} (string[]) Body lines, including diff prefixes.
- Element i maps to buffer row start_line + i - 1
- (zero-based). Exclude display columns before prefixes.
+ {lines} (string[]) Diff body, including diff prefixes.
+ Element i is buffer line start_line + i (one-based).
+ Exclude display columns before the diff prefixes.
{column_offset} (integer, default: 0) Extra bytes before each prefix
in the displayed buffer.
{prefix_width} (integer, default: 1) Diff prefix width in bytes.
{quote_width} (integer, default: 0) Quote prefix width in bytes.
{ft} (string, optional) Vim syntax filetype.
{lang} (string, optional) Treesitter parser language.
-
- Core does not infer {ft} or {lang} for supplied hunks. Optional header
- fields are {header_start_line} (one-based), {header_lines} (string[]),
- {header_context} (string), and {header_context_col} (zero-based byte column,
- excluding {column_offset}).
+ {header_start_line} (integer, optional) First file header line
+ (one-based).
+ {header_lines} (string[], optional) File header lines.
+ {header_context} (string, optional) Code after the hunk header.
+ {header_context_col} (integer, optional) Zero-based byte column of
+ {header_context}, excluding {column_offset}.
detach({bufnr}) *diffs.nvim.detach()*
- Remove the attachment, its autocmds, and its highlights. Pending syntax
- callbacks cannot apply highlights after detachment.
+ Stop highlighting the buffer and clear its highlights.
Parameters: ~
{bufnr} (integer, optional) Buffer number. Omit or use 0 for current.
refresh({bufnr}) *diffs.nvim.refresh()*
- Manually refresh highlighting for a buffer. Useful after external changes
- or for debugging. For supplied hunks, core calls the provider again on
- redraw, including when the buffer text is unchanged.
+ Refresh highlighting on the next redraw. If supplied, the {hunks}
+ callback runs again even when the buffer text is unchanged.
Parameters: ~
{bufnr} (integer, optional) Buffer number. Omit or use 0 for current.
highlight_options() *diffs.nvim.highlight_options()*
- Initialize the highlight groups and return a copy of the effective
- {hide_prefix} and {highlights} settings. The {highlights.priorities} table
- contains {clear}, {syntax}, {line_bg}, and {char_bg} extmark priorities.
+ Initialize highlight groups and return {hide_prefix} and {highlights}.
+ The {highlights.priorities} fields are {clear}, {syntax}, {line_bg},
+ and {char_bg}.
diff_options() *diffs.nvim.diff_options()*
Return options from 'diffopt': {algorithm}, {linematch}, {inline}, and
the whitespace flags {ignore_whitespace}, {ignore_whitespace_change},
{ignore_whitespace_change_at_eol}, and {ignore_blank_lines}.
- Unset options are absent. An absent {inline} means "simple".
+ Unset options are absent. An absent {inline} means "simple".
compute_intra_hunks({lines}) *diffs.nvim.compute_intra_hunks()*
- Compute intra-line changes with Neovim and the current 'diffopt'.
- {lines} contains logical body lines with one diff prefix byte per line.
- Return {add_spans} and {del_spans}, or nil when no spans apply.
- Each span contains {line}, {col_start}, and {col_end}. Line indexes refer
- to {lines} and are one-based. Columns are one-based byte positions in
- the text after the diff prefix. {col_end} is exclusive.
+ Return {add_spans} and {del_spans} using 'diffopt', or nil when no spans apply.
+ {lines} contains diff body lines with one prefix byte per line.
+ Each span has a one-based {line} index into {lines}, and one-based byte
+ columns {col_start} and {col_end} after the prefix. {col_end} is exclusive.
whitespace_only_lines({lines}) *diffs.nvim.whitespace_only_lines()*
Return a table of one-based line indexes with value true for changes
- ignored by the whitespace options in 'diffopt'. {lines} has the same
+ ignored by the whitespace options in 'diffopt'. {lines} has the same
format as |diffs.nvim.compute_intra_hunks()|.
*diffs.ReviewSpec*