Files
neovim/runtime/lua/vim/treesitter/highlighter.lua
bfredl 5119c03be7 fix(treesitter): use subpriorities for tree ordering
This partially reverts 0b8a72b739,
that is unreverts 15e77a56b7

"priority" is an internal neovim concept which does not occur in shared
queries. Ideally a single priority space should eventually be enough
for our needs. But as we don't want to poke at the usages of
priorities right now in the wider ecosystem,
introduce the "subpriorities" so that treesitter code can distinguish
highlights of the same priorities with different tree nesting depth.

This mainly affects `injection.combined` as parent-tree nodes might appear
in the middle of child-tree nodes which otherwise is not possible.
2025-09-09 12:56:49 +02:00

582 lines
17 KiB
Lua

local api = vim.api
local query = vim.treesitter.query
local Range = require('vim.treesitter._range')
local cmp_lt = Range.cmp_pos.lt
local ns = api.nvim_create_namespace('nvim.treesitter.highlighter')
---@alias vim.treesitter.highlighter.Iter fun(end_line: integer|nil, end_col: integer|nil): integer, TSNode, vim.treesitter.query.TSMetadata, TSQueryMatch, TSTree
---@class (private) vim.treesitter.highlighter.Query
---@field private _query vim.treesitter.Query?
---@field private lang string
---@field private hl_cache table<integer,integer>
local TSHighlighterQuery = {}
TSHighlighterQuery.__index = TSHighlighterQuery
---@private
---@param lang string
---@param query_string string?
---@return vim.treesitter.highlighter.Query
function TSHighlighterQuery.new(lang, query_string)
local self = setmetatable({}, TSHighlighterQuery)
self.lang = lang
self.hl_cache = {}
if query_string then
self._query = query.parse(lang, query_string)
else
self._query = query.get(lang, 'highlights')
end
return self
end
---@package
---@param capture integer
---@return integer?
function TSHighlighterQuery:get_hl_from_capture(capture)
if not self.hl_cache[capture] then
local name = self._query.captures[capture]
local id = 0
if not vim.startswith(name, '_') then
id = api.nvim_get_hl_id_by_name('@' .. name .. '.' .. self.lang)
end
self.hl_cache[capture] = id
end
return self.hl_cache[capture]
end
---@nodoc
function TSHighlighterQuery:query()
return self._query
end
---@class (private) vim.treesitter.highlighter.State
---@field tstree TSTree
---@field next_row integer
---@field next_col integer
---@field iter vim.treesitter.highlighter.Iter?
---@field highlighter_query vim.treesitter.highlighter.Query
---@nodoc
---@class vim.treesitter.highlighter
---@field active table<integer,vim.treesitter.highlighter>
---@field bufnr integer
---@field private orig_spelloptions string
--- A map from window ID to highlight states.
--- This state is kept during rendering across each line update.
---@field private _highlight_states table<integer, vim.treesitter.highlighter.State[]>
---@field private _queries table<string,vim.treesitter.highlighter.Query>
---@field _conceal_line boolean?
---@field _conceal_checked table<integer, boolean>
---@field tree vim.treesitter.LanguageTree
---@field private redraw_count integer
--- A map from window ID to whether we are currently parsing that window asynchronously
---@field parsing table<integer, boolean>
local TSHighlighter = {
active = {},
}
TSHighlighter.__index = TSHighlighter
---@nodoc
---
--- Creates a highlighter for `tree`.
---
---@param tree vim.treesitter.LanguageTree parser object to use for highlighting
---@param opts (table|nil) Configuration of the highlighter:
--- - 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
error('TSHighlighter can not be used with a string parser source.')
end
opts = opts or {} ---@type { queries: table<string,string> }
self.tree = tree
tree:register_cbs({
on_detach = function()
self:on_detach()
end,
})
-- Enable conceal_lines if query exists for lang and has conceal_lines metadata.
local function set_conceal_lines(lang)
if not self._conceal_line and self:get_query(lang):query() then
self._conceal_line = self:get_query(lang):query().has_conceal_line
end
end
tree:register_cbs({
on_bytes = function(buf)
-- Clear conceal_lines marks whenever the buffer text changes. Marks are added
-- back as either the _conceal_line or on_win callback comes across them.
local hl = TSHighlighter.active[buf]
if hl and next(hl._conceal_checked) then
api.nvim_buf_clear_namespace(buf, ns, 0, -1)
hl._conceal_checked = {}
end
end,
on_changedtree = function(...)
self:on_changedtree(...)
end,
on_child_removed = function(child)
child:for_each_tree(function(t)
self:on_changedtree(t:included_ranges(true))
end)
end,
on_child_added = function(child)
child:for_each_tree(function(t)
set_conceal_lines(t:lang())
end)
end,
}, true)
local source = tree:source()
assert(type(source) == 'number')
self.bufnr = source
self.redraw_count = 0
self._conceal_checked = {}
self._queries = {}
self._highlight_states = {}
self.parsing = {}
-- Queries for a specific language can be overridden by a custom
-- string query... if one is not provided it will be looked up by file.
if opts.queries then
for lang, query_string in pairs(opts.queries) do
self._queries[lang] = TSHighlighterQuery.new(lang, query_string)
set_conceal_lines(lang)
end
end
set_conceal_lines(tree:lang())
self.orig_spelloptions = vim.bo[self.bufnr].spelloptions
vim.bo[self.bufnr].syntax = ''
vim.b[self.bufnr].ts_highlight = true
TSHighlighter.active[self.bufnr] = self
-- Tricky: if syntax hasn't been enabled, we need to reload color scheme
-- but use synload.vim rather than syntax.vim to not enable
-- syntax FileType autocmds. Later on we should integrate with the
-- `:syntax` and `set syntax=...` machinery properly.
-- Still need to ensure that syntaxset augroup exists, so that calling :destroy()
-- immediately afterwards will not error.
if vim.g.syntax_on ~= 1 then
vim.cmd.runtime({ 'syntax/synload.vim', bang = true })
api.nvim_create_augroup('syntaxset', { clear = false })
end
vim._with({ buf = self.bufnr }, function()
vim.opt_local.spelloptions:append('noplainbuffer')
end)
return self
end
--- @nodoc
--- Removes all internal references to the highlighter
function TSHighlighter:destroy()
TSHighlighter.active[self.bufnr] = nil
if api.nvim_buf_is_loaded(self.bufnr) then
vim.bo[self.bufnr].spelloptions = self.orig_spelloptions
vim.b[self.bufnr].ts_highlight = nil
api.nvim_buf_clear_namespace(self.bufnr, ns, 0, -1)
if vim.g.syntax_on == 1 then
-- FileType autocmds commonly assume curbuf is the target buffer, so nvim_buf_call.
api.nvim_buf_call(self.bufnr, function()
api.nvim_exec_autocmds(
'FileType',
{ group = 'syntaxset', buffer = self.bufnr, modeline = false }
)
end)
end
end
end
---@param win integer
---@param srow integer
---@param erow integer exclusive
---@private
function TSHighlighter:prepare_highlight_states(win, srow, erow)
self._highlight_states[win] = {}
self.tree:for_each_tree(function(tstree, tree)
if not tstree then
return
end
local root_node = tstree:root()
local root_start_row, _, root_end_row, _ = root_node:range()
-- Only consider trees within the visible range
if root_start_row > erow or root_end_row < srow then
return
end
local hl_query = self:get_query(tree:lang())
-- Some injected languages may not have highlight queries.
if not hl_query:query() then
return
end
-- _highlight_states should be a list so that the highlights are added in the same order as
-- for_each_tree traversal. This ensures that parents' highlight don't override children's.
table.insert(self._highlight_states[win], {
tstree = tstree,
next_row = 0,
next_col = 0,
iter = nil,
highlighter_query = hl_query,
})
end)
end
---@param win integer
---@param fn fun(state: vim.treesitter.highlighter.State)
---@package
function TSHighlighter:for_each_highlight_state(win, fn)
for _, state in ipairs(self._highlight_states[win] or {}) do
fn(state)
end
end
---@package
function TSHighlighter:on_detach()
self:destroy()
end
---@package
---@param changes Range6[]
function TSHighlighter:on_changedtree(changes)
for _, ch in ipairs(changes) do
api.nvim__redraw({ buf = self.bufnr, range = { ch[1], ch[4] + 1 }, flush = false })
-- Only invalidate the _conceal_checked range if _conceal_line is set and
-- ch[4] is not UINT32_MAX (empty range on first changedtree).
if ch[4] == 2 ^ 32 - 1 then
self._conceal_checked = {}
end
for i = ch[1], self._conceal_line and ch[4] ~= 2 ^ 32 - 1 and ch[4] or 0 do
self._conceal_checked[i] = false
end
end
end
--- Gets the query used for @param lang
---@nodoc
---@param lang string Language used by the highlighter.
---@return vim.treesitter.highlighter.Query
function TSHighlighter:get_query(lang)
if not self._queries[lang] then
local success, result = pcall(TSHighlighterQuery.new, lang)
if not success then
self:destroy()
error(result)
end
self._queries[lang] = result
end
return self._queries[lang]
end
--- @param match TSQueryMatch
--- @param bufnr integer
--- @param capture integer
--- @param metadata vim.treesitter.query.TSMetadata
--- @return string?
local function get_url(match, bufnr, capture, metadata)
---@type string|number|nil
local url = metadata[capture] and metadata[capture].url
if not url or type(url) == 'string' then
return url
end
local captures = match:captures()
if not captures[url] then
return
end
-- Assume there is only one matching node. If there is more than one, take the URL
-- from the first.
local other_node = captures[url][1]
return vim.treesitter.get_node_text(other_node, bufnr, {
metadata = metadata[url],
})
end
--- @param capture_name string
--- @return boolean?, integer
local function get_spell(capture_name)
if capture_name == 'spell' then
return true, 0
elseif capture_name == 'nospell' then
-- Give nospell a higher priority so it always overrides spell captures.
return false, 1
end
return nil, 0
end
---@param self vim.treesitter.highlighter
---@param win integer
---@param buf integer
---@param range_start_row integer
---@param range_start_col integer
---@param range_end_row integer
---@param range_end_col integer
---@param on_spell boolean
---@param on_conceal boolean
local function on_range_impl(
self,
win,
buf,
range_start_row,
range_start_col,
range_end_row,
range_end_col,
on_spell,
on_conceal
)
if self._conceal_line then
range_start_col = 0
if range_end_col ~= 0 then
range_end_row = range_end_row + 1
range_end_col = 0
end
end
for i = range_start_row, range_end_row - 1 do
self._conceal_checked[i] = self._conceal_line or nil
end
local MAX_ROW = 2147483647 -- sentinel for skipping to the end of file
local skip_until_row = MAX_ROW
local skip_until_col = 0
local subtree_counter = 0
self:for_each_highlight_state(win, function(state)
subtree_counter = subtree_counter + 1
local root_node = state.tstree:root()
---@type { [1]: integer, [2]: integer, [3]: integer, [4]: integer }
local root_range = { root_node:range() }
if
not Range.intercepts(
root_range,
{ range_start_row, range_start_col, range_end_row, range_end_col }
)
then
if cmp_lt(root_range[1], root_range[2], skip_until_row, skip_until_col) then
skip_until_row = root_range[1]
skip_until_col = root_range[2]
end
return
end
local tree_region = state.tstree:included_ranges(true)
local next_row = state.next_row
local next_col = state.next_col
if state.iter == nil or cmp_lt(next_row, next_col, range_start_row, range_start_col) then
-- Mainly used to skip over folds
-- TODO(lewis6991): Creating a new iterator loses the cached predicate results for query
-- matches. Move this logic inside iter_captures() so we can maintain the cache.
state.iter = state.highlighter_query:query():iter_captures(
root_node,
self.bufnr,
range_start_row,
root_range[3],
{ start_col = range_start_col, end_col = root_range[4] }
)
end
local captures = state.highlighter_query:query().captures
while cmp_lt(next_row, next_col, range_end_row, range_end_col) do
local capture, node, metadata, match = state.iter(range_end_row, range_end_col)
if not node then
next_row = math.huge
next_col = math.huge
break
end
local outer_range = vim.treesitter.get_range(node, buf, metadata and metadata[capture])
if cmp_lt(next_row, next_col, outer_range[1], outer_range[2]) then
next_row = outer_range[1]
next_col = outer_range[2]
end
if not capture then
break
end
for _, range in ipairs(tree_region) do
local intersection = Range.intersection(range, outer_range)
if intersection then
local start_row, start_col, end_row, end_col = Range.unpack4(intersection)
local hl = state.highlighter_query:get_hl_from_capture(capture)
local capture_name = captures[capture]
local spell, spell_pri_offset = get_spell(capture_name)
-- The "priority" attribute can be set at the pattern level or on a particular capture
local priority = (
tonumber(metadata.priority or metadata[capture] and metadata[capture].priority)
or vim.hl.priorities.treesitter
) + spell_pri_offset
-- The "conceal" attribute can be set at the pattern level or on a particular capture
local conceal = metadata.conceal or metadata[capture] and metadata[capture].conceal
local url = get_url(match, buf, capture, metadata)
if hl and not on_conceal and (not on_spell or spell ~= nil) then
api.nvim_buf_set_extmark(buf, ns, start_row, start_col, {
end_line = end_row,
end_col = end_col,
hl_group = hl,
ephemeral = true,
priority = priority,
conceal = conceal,
spell = spell,
url = url,
_subpriority = subtree_counter,
})
end
if
(metadata.conceal_lines or metadata[capture] and metadata[capture].conceal_lines)
and #api.nvim_buf_get_extmarks(buf, ns, { start_row, 0 }, { start_row, 0 }, {}) == 0
then
api.nvim_buf_set_extmark(buf, ns, start_row, 0, {
end_line = end_row,
conceal_lines = '',
})
end
end
end
end
state.next_row = next_row
state.next_col = next_col
if cmp_lt(next_row, next_col, skip_until_row, skip_until_col) then
skip_until_row = next_row
skip_until_col = next_col
end
end)
return skip_until_row, skip_until_col
end
---@private
---@param win integer
---@param buf integer
---@param br integer
---@param bc integer
---@param er integer
---@param ec integer
function TSHighlighter._on_range(_, win, buf, br, bc, er, ec, _)
local self = TSHighlighter.active[buf]
if not self then
return
end
return on_range_impl(self, win, buf, br, bc, er, ec, false, false)
end
---@private
---@param win integer
---@param buf integer
---@param srow integer
---@param erow integer
function TSHighlighter._on_spell_nav(_, win, buf, srow, _, erow, _)
local self = TSHighlighter.active[buf]
if not self then
return
end
-- Do not affect potentially populated highlight state. Here we just want a temporary
-- empty state so the C code can detect whether the region should be spell checked.
local highlight_states = self._highlight_states[win]
self:prepare_highlight_states(win, srow, erow)
on_range_impl(self, win, buf, srow, 0, erow, 0, true, false)
self._highlight_states[win] = highlight_states
end
---@private
---@param win integer
---@param buf integer
---@param row integer
function TSHighlighter._on_conceal_line(_, win, buf, row)
local self = TSHighlighter.active[buf]
if not self or not self._conceal_line or self._conceal_checked[row] then
return
end
-- Do not affect potentially populated highlight state.
local highlight_states = self._highlight_states[win]
self.tree:parse({ row, row })
self:prepare_highlight_states(win, row, row)
on_range_impl(self, win, buf, row, 0, row + 1, 0, false, true)
self._highlight_states[win] = highlight_states
end
---@private
---@param buf integer
---@param topline integer
---@param botline integer
function TSHighlighter._on_win(_, win, buf, topline, botline)
local self = TSHighlighter.active[buf]
if not self then
return false
end
self.parsing[win] = self.parsing[win]
or nil
== self.tree:parse({ topline, botline + 1 }, function(_, trees)
if trees and self.parsing[win] then
self.parsing[win] = false
if api.nvim_win_is_valid(win) then
api.nvim__redraw({ win = win, valid = false, flush = false })
end
end
end)
if not self.parsing[win] then
self.redraw_count = self.redraw_count + 1
self:prepare_highlight_states(win, topline, botline)
else
self:for_each_highlight_state(win, function(state)
-- TODO(ribru17): Inefficient. Eventually all marks should be applied in on_buf, and all
-- non-folded ranges of each open window should be merged, and iterators should only be
-- created over those regions. This would also fix #31777.
--
-- Currently this is not possible because the parser discards previously parsed injection
-- trees upon parsing a different region.
state.iter = nil
state.next_row = 0
end)
end
local hl_states = self._highlight_states[win] or {}
return #hl_states > 0
end
api.nvim_set_decoration_provider(ns, {
on_win = TSHighlighter._on_win,
on_range = TSHighlighter._on_range,
_on_spell_nav = TSHighlighter._on_spell_nav,
_on_conceal_line = TSHighlighter._on_conceal_line,
})
return TSHighlighter