From ebcb61b0f9d4d52e1d1b5efb6a5765f4f0be2542 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. (cherry picked from commit 67839a72f7eba1454a1a3755a55bca5e6562e461) --- 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 097ad0ea9a..59ee4158f2 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 87b7c272be..0aa8b9e4df 100644 --- a/test/functional/treesitter/highlight_spec.lua +++ b/test/functional/treesitter/highlight_spec.lua @@ -181,6 +181,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)