fix(treesitter): make TSHighlighter.new() idempotent for an active buffer #41090

Problem:
Calling vim.treesitter.start() a second time on a buffer that already
has an active TSHighlighter creates a brand new instance instead of
reusing it, whether the parser tree is unchanged (e.g. calling start()
twice) or different (e.g. switching languages). Either way the old
instance is silently discarded without calling :destroy() on it, so
its on_bytes/on_changedtree/on_detach callbacks stay registered and
its buffer-local state (spelloptions, decoration namespace) is never
restored, both leaking indefinitely for an orphaned instance that
nothing references anymore.

Solution:
Return the existing instance when TSHighlighter.active[source] is
already set for the same parser tree, instead of unconditionally
constructing a new one. When the tree differs instead (e.g. a language
switch), destroy() the old instance first, matching stop() semantics,
before constructing the new one.
This commit is contained in:
Freddie Haddad
2026-08-02 11:42:47 -07:00
committed by GitHub
parent 2f09090134
commit 67839a72f7
2 changed files with 30 additions and 6 deletions

View File

@@ -90,12 +90,26 @@ TSHighlighter.__index = TSHighlighter
--- - queries table overwrite queries used by the highlighter
---@return vim.treesitter.highlighter Created highlighter object
function TSHighlighter.new(tree, opts)
local self = setmetatable({}, TSHighlighter)
if type(tree:source()) ~= 'number' then
local source = tree:source()
if type(source) ~= 'number' then
error('TSHighlighter can not be used with a string parser source.')
end
-- Calling start() again on an already-highlighted buffer must be a no-op: a second instance
-- would register duplicate on_bytes/on_changedtree/on_detach callbacks that destroy() never
-- unregisters, leaking callbacks that keep firing for the orphaned instance.
local existing = TSHighlighter.active[source]
if existing then
if existing.tree == tree then
return existing
end
-- Different tree (e.g. a language switch): the old instance would otherwise be silently
-- orphaned with the same leaked callbacks, so destroy() it first, matching stop() semantics.
existing:destroy()
end
local self = setmetatable({}, TSHighlighter)
opts = opts or {} ---@type { queries: table<string,string> }
self.tree = tree
tree:register_cbs({
@@ -136,9 +150,6 @@ function TSHighlighter.new(tree, opts)
end,
}, true)
local source = tree:source()
assert(type(source) == 'number')
self.bufnr = source
self.redraw_count = 0
self._conceal_checked = {}