perf(treesitter): reuse edited tree ranges for callbacks

After an edit, LanguageTree:_edit() updates the current trees. When the
LanguageTree manages explicit regions, _edit() also refreshes _regions
from tree:included_ranges(true), so those regions have the edited byte
offsets.

A later injection pass may call set_included_regions() with a different
number of child regions. That path discards the old trees and emits
changedtree callbacks for them. invalidate(true) does the same when a
buffer is reloaded. Before this change, both discard paths called
tree:included_ranges(true) for every old tree, even if _edit() had just
collected those exact ranges.

That duplicate range extraction is expensive with many injection trees.
Realistic shapes include generated C files with many macro bodies parsed
by the C preproc_arg injection, Markdown documents with many fenced blocks
of the same language, and template files with many embedded-language
islands. The stock highlighter registers recursive changedtree callbacks,
so this is on the normal highlighting edit path.

Track whether _regions currently came from tree:included_ranges(true)
with _regions_from_tree_ranges. _do_changedtree_callbacks() reuses
_regions only in that state; otherwise it falls back to calling
tree:included_ranges(true). Clear the marker when regions are replaced by
injection ranges, when a tree is reparsed, or when trees are discarded.

This avoids keeping a second copy of the ranges while preserving callback
precision: changedtree still receives tree:included_ranges(true) for the
old tree, not the broader managed region.

Benchmark on 100k C macro injections, one-line edit, recursive
changedtree callback:

- HEAD median: edited parse 84.2 ms, child region replacement 58.7 ms
- This change: edited parse 34.6 ms, child region replacement 8.5 ms

That is about 2.4x faster for the edit parse and 6.9x faster for child
region replacement in this workload.

Add a regression test that replacing injection regions still fires
changedtree and still reports the old tree's exact included ranges.

AI-assisted: Codex
This commit is contained in:
Lewis Russell
2026-04-27 17:29:37 +01:00
committed by Lewis Russell
parent b9431b340f
commit 33ea63011c
3 changed files with 59 additions and 6 deletions

View File

@@ -179,6 +179,9 @@ OPTIONS
PERFORMANCE
• |treesitter-highlight| performance on large injection-heavy files improves
by 50% to 100% by reusing edited child-tree ranges.
• Nvim architecture allows pure-Lua implementations of some `vim.fn`
functions, which skips the Vimscript <=> Lua "bridge" (no data
conversion/marshalling) entirely, if the `vim.fn` function is called from

View File

@@ -103,6 +103,7 @@ local TSCallbackNames = {
---@field private _parent? vim.treesitter.LanguageTree Parent LanguageTree
---@field private _source (integer|string) Buffer or string to parse
---@field private _trees table<integer, TSTree> Reference to parsed tree (one for each language).
---@field private _regions_from_tree_ranges boolean Whether _regions were copied from TSTree:included_ranges(true).
---Each key is the index of region, which is synced with _regions and _valid.
---@field private _valid_regions table<integer,true> Set of valid region IDs.
---@field private _num_valid_regions integer Number of valid regions
@@ -144,6 +145,7 @@ function LanguageTree.new(source, lang, opts)
_lang = lang,
_children = {},
_trees = {},
_regions_from_tree_ranges = false,
_opts = opts,
_injection_query = injections[lang] and query.parse(lang, injections[lang])
or query.get(lang, 'injections'),
@@ -264,10 +266,9 @@ function LanguageTree:invalidate(reload)
-- buffer was reloaded, reparse all trees
if reload then
for _, t in pairs(self._trees) do
self:_do_callback('changedtree', t:included_ranges(true), t)
end
self:_do_changedtree_callbacks()
self._trees = {}
self._regions_from_tree_ranges = false
end
for _, child in pairs(self._children) do
@@ -458,6 +459,7 @@ function LanguageTree:_parse_regions(range, thread_state)
self:_do_callback('changedtree', tree_changes, tree)
self._trees[i] = tree
self._regions_from_tree_ranges = false
vim.list_extend(changes, tree_changes)
total_parse_time = total_parse_time + parse_time
@@ -862,10 +864,9 @@ function LanguageTree:set_included_regions(new_regions)
-- outdated_regions is invalidated by _iter_regions in else branch.
if #self:included_regions() ~= #new_regions then
-- TODO(lewis6991): inefficient; invalidate trees incrementally
for _, t in pairs(self._trees) do
self:_do_callback('changedtree', t:included_ranges(true), t)
end
self:_do_changedtree_callbacks()
self._trees = {}
self._regions_from_tree_ranges = false
self:invalidate()
else
self:_iter_regions(function(i, region)
@@ -874,6 +875,7 @@ function LanguageTree:set_included_regions(new_regions)
end
self._regions = new_regions
self._regions_from_tree_ranges = false
self._num_regions = #new_regions
end
@@ -1146,6 +1148,25 @@ function LanguageTree:_get_injections(range, thread_state)
return result
end
---@private
---@param cb_name TSCallbackName
function LanguageTree:_has_callback(cb_name)
return #self._callbacks[cb_name] > 0 or #self._callbacks_rec[cb_name] > 0
end
---@private
function LanguageTree:_do_changedtree_callbacks()
if not self:_has_callback('changedtree') then
return
end
local tree_ranges = self._regions_from_tree_ranges and self._regions or nil
for i, tree in pairs(self._trees) do
local ranges = tree_ranges and tree_ranges[i] or tree:included_ranges(true)
self:_do_callback('changedtree', ranges, tree)
end
end
---@private
---@param cb_name TSCallbackName
function LanguageTree:_do_callback(cb_name, ...)
@@ -1191,6 +1212,7 @@ function LanguageTree:_edit(
regions[i] = tree:included_ranges(true)
end
self._regions = regions
self._regions_from_tree_ranges = true
end
local changed_range = {

View File

@@ -665,6 +665,34 @@ describe('treesitter parser API', function()
{ 6, 15, 6, 18 }, -- VALUE2 123
}, get_ranges())
end)
it('notifies changedtree callbacks when replacing injection regions', function()
exec_lua(function()
_G.parser = vim.treesitter._create_parser(0, 'c', {
injections = {
c = '(preproc_def (preproc_arg) @injection.content (#set! injection.language "c"))',
},
})
_G.parser:parse(true)
local child = _G.parser:children().c
vim.api.nvim_buf_set_lines(0, 3, 4, false, { '#define VALUE changed' })
_G.changedtree = 0
_G.precise_changedtree = true
child:register_cbs({
on_changedtree = function(changes, tree)
_G.changedtree = _G.changedtree + 1
_G.precise_changedtree = _G.precise_changedtree
and vim.deep_equal(changes, tree:included_ranges(true))
end,
})
_G.parser:parse({ 3, 4 })
end)
eq(true, exec_lua('return _G.changedtree > 0 and _G.precise_changedtree'))
end)
end)
describe('when parsing regions combined', function()