From 67839a72f7eba1454a1a3755a55bca5e6562e461 Mon Sep 17 00:00:00 2001 From: Freddie Haddad <6127369+freddiehaddad@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:42:47 -0700 Subject: [PATCH] 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. --- runtime/lua/vim/treesitter/highlighter.lua | 23 ++++++++++++++----- test/functional/treesitter/highlight_spec.lua | 13 +++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/runtime/lua/vim/treesitter/highlighter.lua b/runtime/lua/vim/treesitter/highlighter.lua index 60f7465727..b5ddd28aa2 100644 --- a/runtime/lua/vim/treesitter/highlighter.lua +++ b/runtime/lua/vim/treesitter/highlighter.lua @@ -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 } 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 = {} diff --git a/test/functional/treesitter/highlight_spec.lua b/test/functional/treesitter/highlight_spec.lua index 6d70b4718b..a4ddf40e77 100644 --- a/test/functional/treesitter/highlight_spec.lua +++ b/test/functional/treesitter/highlight_spec.lua @@ -182,6 +182,19 @@ describe('treesitter highlighting (C)', function() -- treesitter highlighting is used screen:expect(hl_grid_ts_c) + -- A second start() on an already-highlighted buffer is a no-op: it returns the existing + -- highlighter instead of replacing it (replacing it would leak the old instance's + -- on_bytes/on_changedtree callbacks, since destroy() is never called on it). + eq( + true, + exec_lua(function() + local buf = vim.api.nvim_get_current_buf() + local first = vim.treesitter.highlighter.active[buf] + vim.treesitter.start() + return first == vim.treesitter.highlighter.active[buf] + end) + ) + exec_lua(function() vim.treesitter.stop() end)