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)