From c9ad60aae27998a0bd7848ed0ddbc59a27c1965e Mon Sep 17 00:00:00 2001 From: HiPhish Date: Fri, 9 Aug 2024 07:58:30 +0200 Subject: [PATCH 1/6] Enable skipped test --- test/e2e/strategy/global.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/e2e/strategy/global.lua b/test/e2e/strategy/global.lua index bb4e80c..a131f74 100644 --- a/test/e2e/strategy/global.lua +++ b/test/e2e/strategy/global.lua @@ -104,8 +104,7 @@ return foo]] assert.nvim(nvim).has_extmarks_at(3, 11, 'lua') end) - it('Preserves nested highlighting when entering insert mode #skip', function() - -- This is broken on Neovim 0.10+, so we skip it for the time being. + it('Preserves nested highlighting when entering insert mode', function() -- See https://github.com/HiPhish/rainbow-delimiters.nvim/pull/121 local content = [[local tmp = { From 30d3bdf38eed4ca4b3b4f8311a039c67ad103f80 Mon Sep 17 00:00:00 2001 From: HiPhish Date: Tue, 3 Sep 2024 23:01:40 +0200 Subject: [PATCH 2/6] Add test for pasting lines with delimiters The test currently fails. --- test/e2e/buffer-manipulation.lua | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/e2e/buffer-manipulation.lua b/test/e2e/buffer-manipulation.lua index e30f679..90eb2df 100644 --- a/test/e2e/buffer-manipulation.lua +++ b/test/e2e/buffer-manipulation.lua @@ -72,4 +72,31 @@ describe('Buffer Manipulation', function() assert.nvim(nvim).has_extmarks_at(3, 5, 'lua') end) + + describe('Pasting lines containing delimiters', function() + local content = [[print { + {a = 1, b = 2}, +}]] + before_each(function() + nvim:buf_set_lines(0, 0, -2, true, vim.fn.split(content, '\n')) + nvim:buf_set_option(0, 'filetype', 'lua') + nvim:exec_lua('vim.treesitter.start()', {}) + assert.nvim(nvim).has_extmarks_at(1, 01, 'lua', 'RainbowDelimiterYellow') + assert.nvim(nvim).has_extmarks_at(1, 14, 'lua', 'RainbowDelimiterYellow') + end) + + it('Properly highlights after inserting one line', function() + nvim:feedkeys('ggjyyp', 'n', false) + + assert.nvim(nvim).has_extmarks_at(2, 01, 'lua', 'RainbowDelimiterYellow') + assert.nvim(nvim).has_extmarks_at(2, 14, 'lua', 'RainbowDelimiterYellow') + end) + + it('Properly highlights after inserting two lines', function() + nvim:feedkeys('ggjyy2p', 'n', false) + + assert.nvim(nvim).has_extmarks_at(2, 01, 'lua', 'RainbowDelimiterYellow') + assert.nvim(nvim).has_extmarks_at(2, 14, 'lua', 'RainbowDelimiterYellow') + end) + end) end) From 5da744d1deaa4cdaa54c12a9f8c0f4d0db54addb Mon Sep 17 00:00:00 2001 From: HiPhish Date: Sat, 10 Aug 2024 12:34:03 +0200 Subject: [PATCH 3/6] Add set data structure definition Previously I was abusing stacks as sets, but this was hard to read because it was unclear whether the stacks were actually used for their stack properties or just as a makeshift set. In addition to that stacks do not have the idempotence of sets, which as not an issue in practice, but it could have caused bugs. --- lua/rainbow-delimiters/set.lua | 99 ++++++++++++++++++++++++++++++++++ test/unit/set_spec.lua | 90 +++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 lua/rainbow-delimiters/set.lua create mode 100644 test/unit/set_spec.lua diff --git a/lua/rainbow-delimiters/set.lua b/lua/rainbow-delimiters/set.lua new file mode 100644 index 0000000..4503577 --- /dev/null +++ b/lua/rainbow-delimiters/set.lua @@ -0,0 +1,99 @@ +--[[ + Copyright 2024 Alejandro "HiPhish" Sanchez + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--]] + +---Helper library for set-like tables. +local M = {} + +---A set-like structure which holds any number of items, but only one of each. +---@generic T +---@class rainbow_delimiters.Set +--- +---Add an item to the set; this function is idempotent: adding an item more +---than once produces the same result +---@field public add fun(self: rainbow_delimiters.Set, item: `T`): nil +--- +---Predicate whether the set currently contains a given item +---@field public contains fun(self: rainbow_delimiters.Set, item: `T`): boolean +--- +---Returns the current size of the set +---@field public size fun(self: rainbow_delimiters.Set): integer +---@field package content `T`[] +--- +---Iterator which returns the contents one at a time in an arbitrary order. +---@field public items fun(self: rainbow_delimiters.Set): ((fun(content: table<`T`, true>, key: `T`): `T`), table<`T`, true>) + + +local function size(self) + local result = 0 + for _, _ in pairs(self.content) do + result = result + 1 + end + return result +end + +local function add(self, item) + self.content[item] = true +end + +local function contains(self, item) + return self.content[item] == true +end + +---Wrapper around the built-in `next`, except that it only returns the key. +local function iter(t, k) + local result = next(t, k) + return result +end + +local function items(self) + return iter, self.content +end + +local function pick_key(key, _value) + return key +end + +local mt = { + ---A human-readable representation of a set like `'Set{1, 2, "a", "b"}'` + ---@param self rainbow_delimiters.Set + ---@return string + __tostring = function(self) + local keys = vim.iter(self.content) + :map(pick_key) + :map(tostring) + :join(', ') + return string.format('Set{%s}', keys) + end +} + +---@return rainbow_delimiters.Set set The new set instance +function M.new(...) + ---@type rainbow_delimiters.Set + local result = { + content = {}, + size = size, + add = add, + contains = contains, + items = items, + } + for _, item in ipairs({...}) do + result.content[item] = true + end + setmetatable(result, mt) + return result +end + +return M diff --git a/test/unit/set_spec.lua b/test/unit/set_spec.lua new file mode 100644 index 0000000..65a7572 --- /dev/null +++ b/test/unit/set_spec.lua @@ -0,0 +1,90 @@ +local Set = require 'rainbow-delimiters.set' + +describe('The set data structure', function() + describe('The empty set', function() + ---@type rainbow_delimiters.Set + local set + + before_each(function() set = Set.new() end) + + it('Can instantiate the empty set', function() + assert.is_not._nil(set) + end) + + it('Is empty', function() + assert.is_equal(0, set:size()) + end) + + it('Can add items to the set', function() + set:add(1) + set:add(2) + assert.is_equal(2, set:size()) + end) + + it('Adds items idempotently', function() + set:add(1) + set:add(1) + assert.is_equal(1, set:size()) + end) + + it('Produces nothing when iterated over', function() + local count = 0 + for _ in set:items() do + count = count + 1 + end + assert.are_equal(0, count) + end) + end) + + describe('Set with contents', function() + ---@type rainbow_delimiters.Set + local set + + before_each(function() + set = Set.new(1, 2, 3, 4) + end) + + it('Can instantiate set with contents', function() + assert.is_not._nil(set) + end) + + it('Holds the correct amount of items', function() + assert.is_equal(4, set:size()) + end) + + it('Tests positively for existing items', function() + assert.is_true(set:contains(1)) + end) + + it('Tests negatively for missing items', function() + assert.is_false(set:contains(0)) + end) + end) + + describe('Set traversal', function() + ---@type rainbow_delimiters.Set + local set + + before_each(function() + set = Set.new(1, 2, 3, 4) + end) + + it('Returns all contents one at a time', function() + local items = {} + for item in set:items() do + items[item] = 1 + (items[item] or 0) + end + + assert.are_equal(1, items[1]) + assert.are_equal(1, items[2]) + assert.are_equal(1, items[3]) + assert.are_equal(1, items[4]) + + local n_items = 0 + for _, _ in pairs(items) do + n_items = n_items + 1 + end + assert.are_equal(4, n_items) + end) + end) +end) From 9e51b8401fd70f603f4f29d68730e91db7476030 Mon Sep 17 00:00:00 2001 From: HiPhish Date: Mon, 12 Aug 2024 22:02:35 +0200 Subject: [PATCH 4/6] Mark Elm query as broken by design There is nothing I can do until this issue is resolved: https://github.com/elm-tooling/tree-sitter-elm/issues/159 The problem is that because the parenthesized pattern does not have a container of its own the nesting cannot be determined properly. --- queries/elm/rainbow-delimiters.scm | 2 ++ test/highlight/spec/elm/rainbow-delimiters/Regular.elm.lua | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/queries/elm/rainbow-delimiters.scm b/queries/elm/rainbow-delimiters.scm index 220de9d..0298816 100644 --- a/queries/elm/rainbow-delimiters.scm +++ b/queries/elm/rainbow-delimiters.scm @@ -10,6 +10,7 @@ "(" @delimiter ")" @delimiter @sentinel) @container +;;; Broken by design, see https://github.com/elm-tooling/tree-sitter-elm/issues/159 (_ "(" @delimiter . @@ -18,6 +19,7 @@ ")" @delimiter @sentinel ) @container +;;; Broken by design, see https://github.com/elm-tooling/tree-sitter-elm/issues/159 (_ "(" @delimiter . diff --git a/test/highlight/spec/elm/rainbow-delimiters/Regular.elm.lua b/test/highlight/spec/elm/rainbow-delimiters/Regular.elm.lua index 3434791..95861e2 100644 --- a/test/highlight/spec/elm/rainbow-delimiters/Regular.elm.lua +++ b/test/highlight/spec/elm/rainbow-delimiters/Regular.elm.lua @@ -374,13 +374,13 @@ return { }, { end_col = 18, end_row = 28, - hl_group = "RainbowDelimiterRed", + hl_group = "RainbowDelimiterYellow", start_col = 17, start_row = 28 }, { end_col = 22, end_row = 28, - hl_group = "RainbowDelimiterRed", + hl_group = "RainbowDelimiterYellow", start_col = 21, start_row = 28 }, { @@ -816,4 +816,4 @@ return { start_col = 24, start_row = 44 } } -} \ No newline at end of file +} From b334c62766382ca5a627996d17682d8616f80eba Mon Sep 17 00:00:00 2001 From: HiPhish Date: Thu, 8 Aug 2024 22:52:42 +0200 Subject: [PATCH 5/6] Implement new global strategy There are now two global strategies and the correct one gets picked at runtime based on the version of Neovim. --- lua/rainbow-delimiters/config.lua | 1 + lua/rainbow-delimiters/match-tree.lua | 120 ++++++++ lua/rainbow-delimiters/strategy/global.lua | 294 +------------------ lua/rainbow-delimiters/strategy/global1.lua | 300 ++++++++++++++++++++ lua/rainbow-delimiters/strategy/global2.lua | 202 +++++++++++++ test/unit/match-tree_spec.lua | 137 +++++++++ 6 files changed, 773 insertions(+), 281 deletions(-) create mode 100644 lua/rainbow-delimiters/match-tree.lua create mode 100644 lua/rainbow-delimiters/strategy/global1.lua create mode 100644 lua/rainbow-delimiters/strategy/global2.lua create mode 100644 test/unit/match-tree_spec.lua diff --git a/lua/rainbow-delimiters/config.lua b/lua/rainbow-delimiters/config.lua index 6bb3759..409e9d8 100644 --- a/lua/rainbow-delimiters/config.lua +++ b/lua/rainbow-delimiters/config.lua @@ -61,6 +61,7 @@ local M = { end }), enabled_for = function(lang) + if not lang then return false end local conf = vim.g.rainbow_delimiters if not conf then return true end diff --git a/lua/rainbow-delimiters/match-tree.lua b/lua/rainbow-delimiters/match-tree.lua new file mode 100644 index 0000000..9565b89 --- /dev/null +++ b/lua/rainbow-delimiters/match-tree.lua @@ -0,0 +1,120 @@ +---Functions for dealing with match trees. This library is only relevant to +---strategy authors. A match tree is the tree-like structure we use to +---organize a subset of the buffer's node tree for highlighting. +local M = {} + +local lib = require 'rainbow-delimiters.lib' +local Set = require 'rainbow-delimiters.set' + +---A single match from the query. All matches contain the same fields, which +---correspond to the captures from the query. Matches are hierarchical and can +---be arranged in a tree where the container of a parent match contains all the +---nodes of the descendant matches. +---@class rainbow_delimiters.Match +---The container node. +---@field container vim.treesitter.TSNode +---Sentinel node, marks the last delimiter of the match. +---@field sentinel vim.treesitter.TSNode +---The actual delimiters we want to highlight, there can be any number of them. +---@field delimiters rainbow_delimiters.Set + +---A hierarchical structure of nested matches. Each node of the tree consists +---of exactly one match and a set of any number of child matches. Terminal +---matches have no children. +--- +---Match trees have a strict partial ordering: for two matches `m1` and `m2` we +---say that `m1` < `m2` if and only if the container of `m1` contains the +---container of `m2`, i.e. `m1` is an ancestor of `m2`. The root node will +---have the lowest value. +---@class rainbow_delimiters.MatchTree +---The match object +---@field public match rainbow_delimiters.Match +---The children of the match +---@field public children rainbow_delimiters.Set + +local match_mt = { + __tostring = function(self) + return string.format( + '{container = %s, delimiters = %s}', + tostring(self.container), + tostring(self.delimiters) + ) + end +} + +local tree_mt = { + ---@param m1 rainbow_delimiters.MatchTree + ---@param m2 rainbow_delimiters.MatchTree + ---@return boolean + __lt = function(m1, m2) + local c1 = m1.match.container + local r2 = {m2.match.container:range()} + return vim.treesitter.node_contains(c1, r2) + end, + ---Appends the given match tree `m2` to this match tree. Will traverse + ---through the descendants until it finds the most appropriate one. + ---@return boolean success Whether appending was successfull + __call = function(self, other) + if not (self < other) then return false end + for child in self.children:items() do + if child < other then + return child(other) + end + end + self.children:add(other) + return true + end, + __tostring = function(self) + return string.format( + '{match = %s, children = %s}', + tostring(self.match), + tostring(self.children) + ) + end +} + +---Instantiate a new match tree node without children based on the results of +---the `iter_matches` method of a query. +---@param query vim.treesitter.Query +---@param match Table +---@return rainbow_delimiters.MatchTree +function M.assemble(query, match) + local result = {delimiters = Set.new()} + for id, nodes in pairs(match) do + local capture = query.captures[id] + if capture == 'delimiter' then + -- It is expected for a match to contain any number of delimiters + for _, node in ipairs(nodes) do + result.delimiters:add(node) + end + else + -- We assume that there is only ever exactly one node per + -- non-delimiter capture + result[capture] = nodes[1] + end + end + + ---@type rainbow_delimiters.MatchTree + local matchtree = { + match = setmetatable(result, match_mt), + children = Set.new(), + } + return setmetatable(matchtree, tree_mt) +end + +---Apply highlighting to a given match tree at a given level +---@param bufnr integer +---@param lang string +---@param tree rainbow_delimiters.MatchTree +---@param level integer Highlight level of this tree +function M.highlight(tree, bufnr, lang, level) + local hlgroup = lib.hlgroup_at(level) + for delimiter in tree.match.delimiters:items() do + lib.highlight(bufnr, lang, delimiter, hlgroup) + end + for child in tree.children:items() do + M.highlight(child, bufnr, lang, level + 1) + end +end + +return M diff --git a/lua/rainbow-delimiters/strategy/global.lua b/lua/rainbow-delimiters/strategy/global.lua index 009f225..8ae0f39 100644 --- a/lua/rainbow-delimiters/strategy/global.lua +++ b/lua/rainbow-delimiters/strategy/global.lua @@ -1,12 +1,11 @@ --[[ - Copyright 2023 Alejandro "HiPhish" Sanchez - Copyright 2020-2022 Chinmay Dalal + Copyright 2024 Alejandro "HiPhish" Sanchez Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -15,286 +14,19 @@ limitations under the License. --]] -local Stack = require 'rainbow-delimiters.stack' -local lib = require 'rainbow-delimiters.lib' -local util = require 'rainbow-delimiters.util' -local log = require 'rainbow-delimiters.log' +-- Neovim 0.10 changed the (undocumented) behaviour of Query:iter_captures(), +-- so we need a different implementation for that version. +-- +-- https://github.com/neovim/neovim/issues/27296 ----Strategy which highlights the entire buffer. -local M = {} +---@type rainbow_delimiters.strategy +local strategy ----Changes are range objects and come in two variants: one with four entries and ----one with six entries. We only want the four-entry variant. See ----`:h TSNode:range()` ----@param change integer[] ----@return integer[] -local function normalize_change(change) - local result - if #change == 4 then - result = change - elseif #change == 6 then - result = {change[1], change[2], change[4], change[5]} - else - result = {} - end - return result +if vim.fn.has 'nvim-0.10' ~= 0 then + strategy = require 'rainbow-delimiters.strategy.global2' +else + strategy = require 'rainbow-delimiters.strategy.global1' end ----@param bufnr integer ----@param lang string ----@param matches Stack ----@param level integer -local function highlight_matches(bufnr, lang, matches, level) - local hlgroup = lib.hlgroup_at(level) - for _, match in matches:iter() do - for _, delimiter in match.delimiter:iter() do lib.highlight(bufnr, lang, delimiter, hlgroup) end - highlight_matches(bufnr, lang, match.children, level + 1) - end -end - - ----Create a new empty match_record ----@return table -local function new_match_record() - return { - delimiter = Stack.new(), - children = Stack.new(), - } -end - ----Update highlights for a range. Called every time text is changed. ----@param bufnr integer Buffer number ----@param changes table List of node ranges in which the changes occurred ----@param tree TSTree TS tree ----@param lang string Language -local function update_range(bufnr, changes, tree, lang) - log.debug('Updated range with changes %s', vim.inspect(changes)) - - if not lib.enabled_for(lang) then return end - if vim.fn.pumvisible() ~= 0 or not lang then return end - - local query = lib.get_query(lang, bufnr) - if not query then return end - - local matches = Stack.new() - - for _, change in ipairs(changes) do - -- This is the match record, it lists all the relevant nodes from - -- each match. - ---@type table? - local match_record - local root_node = tree:root() - local start_row, end_row = change[1], change[3] + 1 - lib.clear_namespace(bufnr, lang, start_row, end_row) - - for qid, node, _ in query:iter_captures(root_node, bufnr, start_row, end_row) do - local name = query.captures[qid] - -- check for 'delimiter' first, since that should be the most - -- common name - if name == 'delimiter' and match_record then - match_record.delimiter:push(node) - elseif name == 'container' and not match_record then - match_record = new_match_record() - elseif name == 'container' then - -- temporarily push the match_record to matches to be retrieved - -- later, since we haven't closed it yet - matches:push(match_record) - match_record = new_match_record() - -- since we didn't close the previous match_record, it must - -- mean that the current match_record has it as an ancestor - match_record.has_ancestor = true - elseif name == 'sentinel' and match_record then - -- if we see the sentinel, then we are done with the current - -- container - if match_record.has_ancestor then - local prev_match_record = matches:pop() - if prev_match_record then - -- since we have an ancestor, it has to be the last - -- element of the stack - prev_match_record.children:push(match_record) - match_record = prev_match_record - else - -- since match_record.has_ancestor was true, we shouldn't - -- be able to get to here unless something went wrong - -- with the queries or treesitter itself - log.error([[You are missing a @container, - which should be impossible! - Please double check the queries.]]) - end - else - -- if match_record doesn't have an ancestor, the sentinel - -- means that we are done with it - matches:push(match_record) - match_record = nil - end - elseif (name == 'delimiter' or name == 'sentinel') and not match_record then - log.error([[You query got the capture name %s. - But it didn't come with a container, which should be impossible! - Please double check your queries.]], name) - end -- do nothing with other capture names - end - if match_record then - -- we might have a dangling match_record, so we push it back into - -- matches - -- this should only happen when the query is on a proper subset - -- of the full tree (usually just one line) - matches:push(match_record) - end - end - - -- when we capture on a row and not the full tree, we get the previous - -- containers (on earlier rows) included in the above, but not the - -- delimiters and sentinels from them, so we push them up as long as - -- we know they have an ancestor - local last_match = matches:pop() - while last_match and last_match.has_ancestor do - local prev_match = matches:pop() - - if prev_match then - prev_match.children:push(last_match) - else - log.error('You are in what should be an unreachable position.') - end - last_match = prev_match - end - matches:push(last_match) - - highlight_matches(bufnr, lang, matches, 1) -end - ----Update highlights for every tree in given buffer. ----@param bufnr integer # Buffer number ----@param parser vim.treesitter.LanguageTree -local function full_update(bufnr, parser) - log.debug('Performing full updated on buffer %d', bufnr) - local function callback(tree, sub_parser) - local changes = {{tree:root():range()}} - update_range(bufnr, changes, tree, sub_parser:lang()) - end - - parser:for_each_tree(callback) -end - - ----Sets up all the callbacks and performs an initial highlighting ----@param bufnr integer # Buffer number ----@param parser vim.treesitter.LanguageTree ----@param start_parent_lang string? # Parent language or nil -local function setup_parser(bufnr, parser, start_parent_lang) - log.debug('Setting up parser for buffer %d', bufnr) - - util.for_each_child(start_parent_lang, parser:lang(), parser, function(p, lang, parent_lang) - log.debug("Setting up parser for '%s' in buffer %d", lang, bufnr) - -- Skip languages which are not supported, otherwise we get a - -- nil-reference error - if not lib.get_query(lang, bufnr) then return end - - p:register_cbs { - ---@param changes table - ---@param tree TSTree - on_changedtree = function(changes, tree) - log.trace('Changed tree in buffer %d with languages %s', bufnr, lang) - -- HACK: As of Neovim v0.9.1 there is no way of unregistering a - -- callback, so we use this check to abort - if not lib.buffers[bufnr] then return end - - -- HACK: changes can accidentally overwrite highlighting in injected code - -- blocks. - if not parent_lang then - -- If we have no parent language, then we use changes, otherwise we use the - -- whole tree's range. - -- Normalize the changes object if we have no parent language (the one we - -- get from on_changedtree) - changes = vim.tbl_map(normalize_change, changes) - elseif parent_lang ~= lang and changes[1] then - -- We have a parent language, so we are in an injected language code - -- block, thus we update all of the current code block - changes = {{tree:root():range()}} - else - -- some languages (like rust) use injections of the language itself for - -- certain functionality (e.g., macros in rust). For these the - -- highlighting will be updated by the non-injected language part of the - -- code. - changes = {} - end - - -- If a line has been moved from another region it will still carry with it - -- the extmarks from the old region. We need to clear all extmarks which - -- do not belong to the current language - for _, change in ipairs(changes) do - for key, nsid in pairs(lib.nsids) do - if key ~= lang then - -- HACK: changes in the main language sometimes need to overwrite - -- highlighting on one more line - local line_end = change[3] + (parent_lang and 0 or 1) - vim.api.nvim_buf_clear_namespace(bufnr, nsid, change[1], line_end) - end - end - end - - -- only update highlighting if we have changes - if changes[1] then - update_range(bufnr, changes, tree, lang) - end - - -- HACK: Since we update the whole tree when we have a parent - -- language, we need to make sure to then update all children - -- too, even if there is no change in them. This shouldn't - -- affect performance, since it only affects code nested at - -- least 2 injection languages deep. - if parent_lang then - local children = p:children() - for child_lang, child in pairs(children) do - if lang == child_lang then return end - child:for_each_tree(function(child_tree, child_p) - local child_changes = {{child_tree:root():range()}} - - -- we don't need to remove old extmarks, since - -- the above code will handle that correctly - -- already, but we might have accidentally - -- removed extmarks that we need to set again - update_range(bufnr, child_changes, child_tree, child_p:lang()) - end) - end - end - end, - -- New languages can be added into the text at some later time, e.g. - -- code snippets in Markdown - ---@param child vim.treesitter.LanguageTree - on_child_added = function(child) - setup_parser(bufnr, child, lang) - end, - } - log.trace("Done with setting up parser for '%s' in buffer %d", lang, bufnr) - end) - - full_update(bufnr, parser) -end - - ----on_attach implementation for the global strategy ----@param bufnr integer ----@param settings rainbow_delimiters.buffer_settings -function M.on_attach(bufnr, settings) - log.trace('global strategy on_attach') - local parser = settings.parser - setup_parser(bufnr, parser, nil) -end - ----on_detach implementation for the global strategy ----@param _bufnr integer -function M.on_detach(_bufnr) -end - ----on_reset implementation for the global strategy ----@param bufnr integer ----@param settings rainbow_delimiters.buffer_settings -function M.on_reset(bufnr, settings) - log.trace('global strategy on_reset') - full_update(bufnr, settings.parser) -end - -return M --[[@as rainbow_delimiters.strategy]] - --- vim:tw=79:ts=4:sw=4:noet: +return strategy diff --git a/lua/rainbow-delimiters/strategy/global1.lua b/lua/rainbow-delimiters/strategy/global1.lua new file mode 100644 index 0000000..009f225 --- /dev/null +++ b/lua/rainbow-delimiters/strategy/global1.lua @@ -0,0 +1,300 @@ +--[[ + Copyright 2023 Alejandro "HiPhish" Sanchez + Copyright 2020-2022 Chinmay Dalal + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--]] + +local Stack = require 'rainbow-delimiters.stack' +local lib = require 'rainbow-delimiters.lib' +local util = require 'rainbow-delimiters.util' +local log = require 'rainbow-delimiters.log' + + +---Strategy which highlights the entire buffer. +local M = {} + +---Changes are range objects and come in two variants: one with four entries and +---one with six entries. We only want the four-entry variant. See +---`:h TSNode:range()` +---@param change integer[] +---@return integer[] +local function normalize_change(change) + local result + if #change == 4 then + result = change + elseif #change == 6 then + result = {change[1], change[2], change[4], change[5]} + else + result = {} + end + return result +end + +---@param bufnr integer +---@param lang string +---@param matches Stack +---@param level integer +local function highlight_matches(bufnr, lang, matches, level) + local hlgroup = lib.hlgroup_at(level) + for _, match in matches:iter() do + for _, delimiter in match.delimiter:iter() do lib.highlight(bufnr, lang, delimiter, hlgroup) end + highlight_matches(bufnr, lang, match.children, level + 1) + end +end + + +---Create a new empty match_record +---@return table +local function new_match_record() + return { + delimiter = Stack.new(), + children = Stack.new(), + } +end + +---Update highlights for a range. Called every time text is changed. +---@param bufnr integer Buffer number +---@param changes table List of node ranges in which the changes occurred +---@param tree TSTree TS tree +---@param lang string Language +local function update_range(bufnr, changes, tree, lang) + log.debug('Updated range with changes %s', vim.inspect(changes)) + + if not lib.enabled_for(lang) then return end + if vim.fn.pumvisible() ~= 0 or not lang then return end + + local query = lib.get_query(lang, bufnr) + if not query then return end + + local matches = Stack.new() + + for _, change in ipairs(changes) do + -- This is the match record, it lists all the relevant nodes from + -- each match. + ---@type table? + local match_record + local root_node = tree:root() + local start_row, end_row = change[1], change[3] + 1 + lib.clear_namespace(bufnr, lang, start_row, end_row) + + for qid, node, _ in query:iter_captures(root_node, bufnr, start_row, end_row) do + local name = query.captures[qid] + -- check for 'delimiter' first, since that should be the most + -- common name + if name == 'delimiter' and match_record then + match_record.delimiter:push(node) + elseif name == 'container' and not match_record then + match_record = new_match_record() + elseif name == 'container' then + -- temporarily push the match_record to matches to be retrieved + -- later, since we haven't closed it yet + matches:push(match_record) + match_record = new_match_record() + -- since we didn't close the previous match_record, it must + -- mean that the current match_record has it as an ancestor + match_record.has_ancestor = true + elseif name == 'sentinel' and match_record then + -- if we see the sentinel, then we are done with the current + -- container + if match_record.has_ancestor then + local prev_match_record = matches:pop() + if prev_match_record then + -- since we have an ancestor, it has to be the last + -- element of the stack + prev_match_record.children:push(match_record) + match_record = prev_match_record + else + -- since match_record.has_ancestor was true, we shouldn't + -- be able to get to here unless something went wrong + -- with the queries or treesitter itself + log.error([[You are missing a @container, + which should be impossible! + Please double check the queries.]]) + end + else + -- if match_record doesn't have an ancestor, the sentinel + -- means that we are done with it + matches:push(match_record) + match_record = nil + end + elseif (name == 'delimiter' or name == 'sentinel') and not match_record then + log.error([[You query got the capture name %s. + But it didn't come with a container, which should be impossible! + Please double check your queries.]], name) + end -- do nothing with other capture names + end + if match_record then + -- we might have a dangling match_record, so we push it back into + -- matches + -- this should only happen when the query is on a proper subset + -- of the full tree (usually just one line) + matches:push(match_record) + end + end + + -- when we capture on a row and not the full tree, we get the previous + -- containers (on earlier rows) included in the above, but not the + -- delimiters and sentinels from them, so we push them up as long as + -- we know they have an ancestor + local last_match = matches:pop() + while last_match and last_match.has_ancestor do + local prev_match = matches:pop() + + if prev_match then + prev_match.children:push(last_match) + else + log.error('You are in what should be an unreachable position.') + end + last_match = prev_match + end + matches:push(last_match) + + highlight_matches(bufnr, lang, matches, 1) +end + +---Update highlights for every tree in given buffer. +---@param bufnr integer # Buffer number +---@param parser vim.treesitter.LanguageTree +local function full_update(bufnr, parser) + log.debug('Performing full updated on buffer %d', bufnr) + local function callback(tree, sub_parser) + local changes = {{tree:root():range()}} + update_range(bufnr, changes, tree, sub_parser:lang()) + end + + parser:for_each_tree(callback) +end + + +---Sets up all the callbacks and performs an initial highlighting +---@param bufnr integer # Buffer number +---@param parser vim.treesitter.LanguageTree +---@param start_parent_lang string? # Parent language or nil +local function setup_parser(bufnr, parser, start_parent_lang) + log.debug('Setting up parser for buffer %d', bufnr) + + util.for_each_child(start_parent_lang, parser:lang(), parser, function(p, lang, parent_lang) + log.debug("Setting up parser for '%s' in buffer %d", lang, bufnr) + -- Skip languages which are not supported, otherwise we get a + -- nil-reference error + if not lib.get_query(lang, bufnr) then return end + + p:register_cbs { + ---@param changes table + ---@param tree TSTree + on_changedtree = function(changes, tree) + log.trace('Changed tree in buffer %d with languages %s', bufnr, lang) + -- HACK: As of Neovim v0.9.1 there is no way of unregistering a + -- callback, so we use this check to abort + if not lib.buffers[bufnr] then return end + + -- HACK: changes can accidentally overwrite highlighting in injected code + -- blocks. + if not parent_lang then + -- If we have no parent language, then we use changes, otherwise we use the + -- whole tree's range. + -- Normalize the changes object if we have no parent language (the one we + -- get from on_changedtree) + changes = vim.tbl_map(normalize_change, changes) + elseif parent_lang ~= lang and changes[1] then + -- We have a parent language, so we are in an injected language code + -- block, thus we update all of the current code block + changes = {{tree:root():range()}} + else + -- some languages (like rust) use injections of the language itself for + -- certain functionality (e.g., macros in rust). For these the + -- highlighting will be updated by the non-injected language part of the + -- code. + changes = {} + end + + -- If a line has been moved from another region it will still carry with it + -- the extmarks from the old region. We need to clear all extmarks which + -- do not belong to the current language + for _, change in ipairs(changes) do + for key, nsid in pairs(lib.nsids) do + if key ~= lang then + -- HACK: changes in the main language sometimes need to overwrite + -- highlighting on one more line + local line_end = change[3] + (parent_lang and 0 or 1) + vim.api.nvim_buf_clear_namespace(bufnr, nsid, change[1], line_end) + end + end + end + + -- only update highlighting if we have changes + if changes[1] then + update_range(bufnr, changes, tree, lang) + end + + -- HACK: Since we update the whole tree when we have a parent + -- language, we need to make sure to then update all children + -- too, even if there is no change in them. This shouldn't + -- affect performance, since it only affects code nested at + -- least 2 injection languages deep. + if parent_lang then + local children = p:children() + for child_lang, child in pairs(children) do + if lang == child_lang then return end + child:for_each_tree(function(child_tree, child_p) + local child_changes = {{child_tree:root():range()}} + + -- we don't need to remove old extmarks, since + -- the above code will handle that correctly + -- already, but we might have accidentally + -- removed extmarks that we need to set again + update_range(bufnr, child_changes, child_tree, child_p:lang()) + end) + end + end + end, + -- New languages can be added into the text at some later time, e.g. + -- code snippets in Markdown + ---@param child vim.treesitter.LanguageTree + on_child_added = function(child) + setup_parser(bufnr, child, lang) + end, + } + log.trace("Done with setting up parser for '%s' in buffer %d", lang, bufnr) + end) + + full_update(bufnr, parser) +end + + +---on_attach implementation for the global strategy +---@param bufnr integer +---@param settings rainbow_delimiters.buffer_settings +function M.on_attach(bufnr, settings) + log.trace('global strategy on_attach') + local parser = settings.parser + setup_parser(bufnr, parser, nil) +end + +---on_detach implementation for the global strategy +---@param _bufnr integer +function M.on_detach(_bufnr) +end + +---on_reset implementation for the global strategy +---@param bufnr integer +---@param settings rainbow_delimiters.buffer_settings +function M.on_reset(bufnr, settings) + log.trace('global strategy on_reset') + full_update(bufnr, settings.parser) +end + +return M --[[@as rainbow_delimiters.strategy]] + +-- vim:tw=79:ts=4:sw=4:noet: diff --git a/lua/rainbow-delimiters/strategy/global2.lua b/lua/rainbow-delimiters/strategy/global2.lua new file mode 100644 index 0000000..9c076bc --- /dev/null +++ b/lua/rainbow-delimiters/strategy/global2.lua @@ -0,0 +1,202 @@ +--[[ + Copyright 2024 Alejandro "HiPhish" Sanchez + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--]] + + +local lib = require 'rainbow-delimiters.lib' +local util = require 'rainbow-delimiters.util' +local log = require 'rainbow-delimiters.log' + +local Stack = require 'rainbow-delimiters.stack' +local MatchTree = require 'rainbow-delimiters.match-tree' + + +---Changes are range objects and come in two variants: one with four entries and +---one with six entries. We only want the four-entry variant. See +---`:h TSNode:range()` +---@param change integer[] +---@return integer[] +local function normalize_change(change) + local result + if #change == 4 then + result = change + elseif #change == 6 then + result = {change[1], change[2], change[4], change[5]} + else + result = {} + end + return result +end + + +---Update highlights for a range. Called every time text is changed. +---@param bufnr integer Buffer number +---@param changes table List of node ranges in which the changes occurred +---@param tree vim.treesitter.TSTree TS tree +---@param lang string Language +local function update_range(bufnr, changes, tree, lang) + log.debug('Updated range with changes %s', vim.inspect(changes)) + + if not lib.enabled_for(lang) or vim.fn.pumvisible() ~= 0 then + return + end + + local query = lib.get_query(lang, bufnr) + if not query then return end + + ---Temporary stack of partial match trees; used to build the final match trees + local root_node = tree:root() + + -- Build the match tree + for _, change in ipairs(changes) do + local match_trees = Stack.new() + local start_row, end_row = change[1], change[3] + 1 + lib.clear_namespace(bufnr, lang, start_row, end_row) + + for _, match in query:iter_matches(root_node, bufnr, start_row, end_row, {all=true}) do + ---@type rainbow_delimiters.MatchTree + local this = MatchTree.assemble(query, match) + while match_trees:size() > 0 do + local other = match_trees:pop() + if this < other then + this(other) + else + match_trees:push(other) + break + end + end + match_trees:push(this) + end + for _, match_tree in match_trees:iter() do + MatchTree.highlight(match_tree, bufnr, lang, 1) + end + end +end + +---Update highlights for every tree in given buffer. +---@param bufnr integer # Buffer number +---@param parser vim.treesitter.LanguageTree +local function full_update(bufnr, parser) + log.debug('Performing full updated on buffer %d', bufnr) + local function callback(tree, sub_parser) + local changes = {{tree:root():range()}} + update_range(bufnr, changes, tree, sub_parser:lang()) + end + + parser:for_each_tree(callback) +end + +---Sets up all the callbacks and performs an initial highlighting +---@param bufnr integer # Buffer number +---@param parser vim.treesitter.LanguageTree +---@param start_parent_lang string? # Parent language or nil +local function setup_parser(bufnr, parser, start_parent_lang) + log.debug('Setting up parser for buffer %d', bufnr) + + ---Sets up an individual parser for a particular language + ---@param p vim.treesitter.LanguageTree Parser for that language + ---@param lang string The language + local function f(p, lang, parent_lang) + log.debug("Setting up parser for '%s' in buffer %d", lang, bufnr) + -- Skip languages which are not supported, otherwise we get a + -- nil-reference error + if not lib.get_query(lang, bufnr) then return end + + local function on_changedtree(changes, tree) + log.trace('Changed tree in buffer %d with languages %s', bufnr, lang) + -- HACK: As of Neovim v0.9.1 there is no way of unregistering a + -- callback, so we use this check to abort + if not lib.buffers[bufnr] then return end + + -- Collect changes to pass on to the next step; might have to treat + -- injected languages differently. + if not parent_lang then + -- If we have no parent language, then we use changes, otherwise we use the + -- whole tree's range. + -- Normalize the changes object if we have no parent language (the one we + -- get from on_changedtree) + changes = vim.tbl_map(normalize_change, changes) + elseif parent_lang ~= lang and changes[1] then + -- We have a parent language, so we are in an injected language code + -- block, thus we update all of the current code block + changes = {{tree:root():range()}} + else + -- some languages (like rust) use injections of the language itself for + -- certain functionality (e.g., macros in rust). For these the + -- highlighting will be updated by the non-injected language part of the + -- code. + changes = {} + end + -- TODO + -- Clear extmarks if a line has been moved across languages + -- + -- TODO + -- Update the range + -- only update highlighting if we have changes + if changes[1] then + update_range(bufnr, changes, tree, lang) + end + end + + ---New languages can be added into the text at some later time, e.g. + ---code snippets in Markdown + ---@param child vim.treesitter.LanguageTree + local function on_child_added(child) + setup_parser(bufnr, child, lang) + end + + p:register_cbs { + on_changedtree = on_changedtree, + on_child_added = on_child_added, + } + log.trace("Done with setting up parser for '%s' in buffer %d", lang, bufnr) + end + + -- A buffer has one primary language and potentially many child languages + -- which may have child languages of their own. We need to set up the + -- parser for each of them. + util.for_each_child(start_parent_lang, parser:lang(), parser, f) + + full_update(bufnr, parser) +end + + +---@param bufnr integer +---@param settings rainbow_delimiters.buffer_settings +local function on_attach(bufnr, settings) + log.trace('global strategy on_attach for buffer %d', bufnr) + local parser = settings.parser + setup_parser(bufnr, parser, nil) +end + +---@param bufnr integer +local function on_detach(bufnr) + log.trace('global strategy on_detach for buffer %d', bufnr) +end + +---@param bufnr integer +---@param settings rainbow_delimiters.buffer_settings +local function on_reset(bufnr, settings) + log.trace('global strategy on_reset for buffer %d', bufnr) +end + + +---Strategy which highlights all delimiters in the current buffer. +---@type rainbow_delimiters.strategy +return { + on_attach = on_attach, + on_detach = on_detach, + on_reset = on_reset, +} diff --git a/test/unit/match-tree_spec.lua b/test/unit/match-tree_spec.lua new file mode 100644 index 0000000..bab371c --- /dev/null +++ b/test/unit/match-tree_spec.lua @@ -0,0 +1,137 @@ +local MatchTree = require 'rainbow-delimiters.match-tree' + +---Constructor for fake TSNode objects which implement the subset of the read +---node interface which we need. +local function make_node(start_row, start_col, end_row, end_col) + return { + range = function(_self) + return start_row, start_col, end_row, end_col + end + } +end + +local fake_query = { + captures = {'delimiter', 'container', 'sentinel'} +} + +local function make_matchtree(match) + return MatchTree.assemble(fake_query, match) +end + +describe('The match tree data structure', function() + describe('Relationship comparison', function() + it('is is less for an ancestor', function() + local ancestor = make_matchtree { + [2] = {make_node(0, 0, 10, 10)} + } + local descendant = make_matchtree { + [2] = {make_node(1, 1, 9, 9)} + } + assert.is_true(ancestor < descendant) + end) + + it('is is not greater for an ancestor', function() + local ancestor = make_matchtree { + [2] = {make_node(0, 0, 10, 10)} + } + local descendant = make_matchtree { + [2] = {make_node(1, 1, 9, 9)} + } + assert.is_false(ancestor > descendant) + end) + + it('is is greater for a descendant', function() + local ancestor = make_matchtree { + [2] = {make_node(0, 0, 10, 10)} + } + local descendant = make_matchtree { + [2] = {make_node(1, 1, 9, 9)} + } + assert.is_true(descendant > ancestor) + end) + + it('is is not less for a descendant', function() + local ancestor = make_matchtree { + [2] = {make_node(0, 0, 10, 10)} + } + local descendant = make_matchtree { + [2] = {make_node(1, 1, 9, 9)} + } + assert.is_false(descendant < ancestor) + end) + + it('is is neither greater nor less for cousins', function() + local tree1 = make_matchtree { + [2] = {make_node(0, 0, 10, 10)} + } + local tree2 = make_matchtree { + [2] = {make_node(11, 11, 19, 19)} + } + assert.is_false(tree1 < tree2) + assert.is_false(tree1 > tree2) + end) + end) + + describe('Appending trees', function() + it('appends a child directly', function() + local parent = make_matchtree { + [2] = {make_node(0, 0, 10, 10)} + } + local child = make_matchtree { + [2] = {make_node(1, 1, 9, 9)} + } + + assert.is_true(parent(child)) + assert.is_true(parent.children:contains(child)) + end) + + it('does not append to a cousin', function() + local tree1 = make_matchtree { + [2] = {make_node(0, 0, 10, 10)} + } + local tree2 = make_matchtree { + [2] = {make_node(11, 11, 19, 19)} + } + assert.is_false(tree1(tree2)) + assert.is_false(tree1.children:contains(tree2)) + end) + + it('appends a grandchild transitively', function() + local parent = make_matchtree { + [2] = {make_node(0, 0, 10, 10)} + } + local child = make_matchtree { + [2] = {make_node(1, 1, 9, 9)} + } + local grandchild = make_matchtree { + [2] = {make_node(2, 2, 8, 8)} + } + + parent(child) + parent(grandchild) + assert.is_false(parent.children:contains(grandchild)) + assert.is_true(child.children:contains(grandchild)) + end) + + it('does not attach to transitive cousins', function() + local parent = make_matchtree { + [2] = {make_node(0, 0, 10, 10)} + } + local child1 = make_matchtree { + [2] = {make_node(1, 1, 5, 5)} + } + local child2 = make_matchtree { + [2] = {make_node(6, 6, 9, 9)} + } + local grandchild = make_matchtree { + [2] = {make_node(2, 2, 4, 4)} + } + + parent(child1) + parent(child2) + parent(grandchild) + assert.is_false(child2.children:contains(grandchild)) + assert.is_true(child1.children:contains(grandchild)) + end) + end) +end) From a026d4af819a4fd8d8498185559f1fdf6277cfc2 Mon Sep 17 00:00:00 2001 From: HiPhish Date: Thu, 26 Sep 2024 23:30:03 +0200 Subject: [PATCH 6/6] Updated changelog --- CHANGELOG.rst | 15 +++++++++++++++ doc/rainbow-delimiters.txt | 18 ++++++------------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index eed0c66..6a7cb78 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,21 @@ is based on `Keep a Changelog`_ and this project adheres to `Semantic Versioning`_. +Unreleased +########## + +Fixed +===== + +- Highlighting wrong in global strategy after making changes inside a nested + node + +Changed +======= + +- Queries no longer need the `@sentinel` capture group + + [0.6.2] - 2024-09-26 #################### diff --git a/doc/rainbow-delimiters.txt b/doc/rainbow-delimiters.txt index 1465801..74449e4 100644 --- a/doc/rainbow-delimiters.txt +++ b/doc/rainbow-delimiters.txt @@ -569,16 +569,12 @@ The queries need to define the following capture groups: The entire delimited node. - `@delimiter` Any delimiter you want to highlight in the current `@container`. -- `@sentinel` - A marker used to signal that you are done with the `@container`. This - should almost always be put right after the last `@delimiter` in the given - `@container`. - `@_` Delimiters starting with `_` (underscore) are ignored for highlighting purposes, but you can use them for treesitter predicates like `#eq?`, `#any-eq?`, etc. (These are very rarely needed.) -`@container` and `@sentinel` are mandatory, and `@delimiter` will always be +`@container` is mandatory, and at least one `@delimiter` will always be present as well since `@delimiter` is what is highlighted. The captures starting with underscore will be rarely used, since you only need them for predicates in a few special cases. @@ -632,7 +628,7 @@ the `
` tag a delimiter. The corresponding query is as follows: (start_tag) @delimiter (element (self_closing_tag) @delimiter)? ; Optional! - (end_tag) @delimiter @sentinel) @container + (end_tag) @delimiter) @container < Highlighting the entire tag might be too vibrant though. What if we want to highlight only the opening and closing angle brackets? The query gets @@ -647,10 +643,8 @@ tree. ["<" "/>"] @delimiter))? ;Optional! (end_tag "" @delimiter @sentinel))) @container + ">" @delimiter))) @container < -Note that we don't want to put the `@sentinel` on the second to last `@delimiter` -`"" @delimiter @sentinel)) @container + ">" @delimiter)) @container < Here both opening and closing tag have three delimiters each. @@ -683,7 +677,7 @@ In HTML the terminating slash in a self-closing tag is optional. Instead of (start_tag "<" @delimiter (tag_name) @delimiter @_tag_name - ">" @delimiter @sentinel)) @container + ">" @delimiter)) @container < However, this query also matches the opening tag of regular tags like `
`. This is where the `@_tag_name` capture comes in. The set of self-closing tags @@ -694,7 +688,7 @@ will not match this particular pattern. (start_tag "<" @delimiter (tag_name) @delimiter @_tag_name - ">" @delimiter @sentinel) + ">" @delimiter) ;; List abridged for brevity (#any-of? @_tag_name "br" "hr" "input")) @container <