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*