highlighter.lua 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. local api = vim.api
  2. local query = vim.treesitter.query
  3. local Range = require('vim.treesitter._range')
  4. local ns = api.nvim_create_namespace('nvim.treesitter.highlighter')
  5. ---@alias vim.treesitter.highlighter.Iter fun(end_line: integer|nil): integer, TSNode, vim.treesitter.query.TSMetadata, TSQueryMatch
  6. ---@class (private) vim.treesitter.highlighter.Query
  7. ---@field private _query vim.treesitter.Query?
  8. ---@field private lang string
  9. ---@field private hl_cache table<integer,integer>
  10. local TSHighlighterQuery = {}
  11. TSHighlighterQuery.__index = TSHighlighterQuery
  12. ---@private
  13. ---@param lang string
  14. ---@param query_string string?
  15. ---@return vim.treesitter.highlighter.Query
  16. function TSHighlighterQuery.new(lang, query_string)
  17. local self = setmetatable({}, TSHighlighterQuery)
  18. self.lang = lang
  19. self.hl_cache = {}
  20. if query_string then
  21. self._query = query.parse(lang, query_string)
  22. else
  23. self._query = query.get(lang, 'highlights')
  24. end
  25. return self
  26. end
  27. ---@package
  28. ---@param capture integer
  29. ---@return integer?
  30. function TSHighlighterQuery:get_hl_from_capture(capture)
  31. if not self.hl_cache[capture] then
  32. local name = self._query.captures[capture]
  33. local id = 0
  34. if not vim.startswith(name, '_') then
  35. id = api.nvim_get_hl_id_by_name('@' .. name .. '.' .. self.lang)
  36. end
  37. self.hl_cache[capture] = id
  38. end
  39. return self.hl_cache[capture]
  40. end
  41. ---@nodoc
  42. function TSHighlighterQuery:query()
  43. return self._query
  44. end
  45. ---@class (private) vim.treesitter.highlighter.State
  46. ---@field tstree TSTree
  47. ---@field next_row integer
  48. ---@field iter vim.treesitter.highlighter.Iter?
  49. ---@field highlighter_query vim.treesitter.highlighter.Query
  50. ---@nodoc
  51. ---@class vim.treesitter.highlighter
  52. ---@field active table<integer,vim.treesitter.highlighter>
  53. ---@field bufnr integer
  54. ---@field private orig_spelloptions string
  55. --- A map of highlight states.
  56. --- This state is kept during rendering across each line update.
  57. ---@field private _highlight_states vim.treesitter.highlighter.State[]
  58. ---@field private _queries table<string,vim.treesitter.highlighter.Query>
  59. ---@field tree vim.treesitter.LanguageTree
  60. ---@field private redraw_count integer
  61. ---@field parsing boolean true if we are parsing asynchronously
  62. local TSHighlighter = {
  63. active = {},
  64. }
  65. TSHighlighter.__index = TSHighlighter
  66. ---@nodoc
  67. ---
  68. --- Creates a highlighter for `tree`.
  69. ---
  70. ---@param tree vim.treesitter.LanguageTree parser object to use for highlighting
  71. ---@param opts (table|nil) Configuration of the highlighter:
  72. --- - queries table overwrite queries used by the highlighter
  73. ---@return vim.treesitter.highlighter Created highlighter object
  74. function TSHighlighter.new(tree, opts)
  75. local self = setmetatable({}, TSHighlighter)
  76. if type(tree:source()) ~= 'number' then
  77. error('TSHighlighter can not be used with a string parser source.')
  78. end
  79. opts = opts or {} ---@type { queries: table<string,string> }
  80. self.tree = tree
  81. tree:register_cbs({
  82. on_detach = function()
  83. self:on_detach()
  84. end,
  85. })
  86. tree:register_cbs({
  87. on_changedtree = function(...)
  88. self:on_changedtree(...)
  89. end,
  90. on_child_removed = function(child)
  91. child:for_each_tree(function(t)
  92. self:on_changedtree(t:included_ranges(true))
  93. end)
  94. end,
  95. }, true)
  96. local source = tree:source()
  97. assert(type(source) == 'number')
  98. self.bufnr = source
  99. self.redraw_count = 0
  100. self._highlight_states = {}
  101. self._queries = {}
  102. -- Queries for a specific language can be overridden by a custom
  103. -- string query... if one is not provided it will be looked up by file.
  104. if opts.queries then
  105. for lang, query_string in pairs(opts.queries) do
  106. self._queries[lang] = TSHighlighterQuery.new(lang, query_string)
  107. end
  108. end
  109. self.orig_spelloptions = vim.bo[self.bufnr].spelloptions
  110. vim.bo[self.bufnr].syntax = ''
  111. vim.b[self.bufnr].ts_highlight = true
  112. TSHighlighter.active[self.bufnr] = self
  113. -- Tricky: if syntax hasn't been enabled, we need to reload color scheme
  114. -- but use synload.vim rather than syntax.vim to not enable
  115. -- syntax FileType autocmds. Later on we should integrate with the
  116. -- `:syntax` and `set syntax=...` machinery properly.
  117. -- Still need to ensure that syntaxset augroup exists, so that calling :destroy()
  118. -- immediately afterwards will not error.
  119. if vim.g.syntax_on ~= 1 then
  120. vim.cmd.runtime({ 'syntax/synload.vim', bang = true })
  121. vim.api.nvim_create_augroup('syntaxset', { clear = false })
  122. end
  123. vim._with({ buf = self.bufnr }, function()
  124. vim.opt_local.spelloptions:append('noplainbuffer')
  125. end)
  126. return self
  127. end
  128. --- @nodoc
  129. --- Removes all internal references to the highlighter
  130. function TSHighlighter:destroy()
  131. TSHighlighter.active[self.bufnr] = nil
  132. if api.nvim_buf_is_loaded(self.bufnr) then
  133. vim.bo[self.bufnr].spelloptions = self.orig_spelloptions
  134. vim.b[self.bufnr].ts_highlight = nil
  135. if vim.g.syntax_on == 1 then
  136. api.nvim_exec_autocmds('FileType', { group = 'syntaxset', buffer = self.bufnr })
  137. end
  138. end
  139. end
  140. ---@param srow integer
  141. ---@param erow integer exclusive
  142. ---@private
  143. function TSHighlighter:prepare_highlight_states(srow, erow)
  144. self._highlight_states = {}
  145. self.tree:for_each_tree(function(tstree, tree)
  146. if not tstree then
  147. return
  148. end
  149. local root_node = tstree:root()
  150. local root_start_row, _, root_end_row, _ = root_node:range()
  151. -- Only consider trees within the visible range
  152. if root_start_row > erow or root_end_row < srow then
  153. return
  154. end
  155. local highlighter_query = self:get_query(tree:lang())
  156. -- Some injected languages may not have highlight queries.
  157. if not highlighter_query:query() then
  158. return
  159. end
  160. -- _highlight_states should be a list so that the highlights are added in the same order as
  161. -- for_each_tree traversal. This ensures that parents' highlight don't override children's.
  162. table.insert(self._highlight_states, {
  163. tstree = tstree,
  164. next_row = 0,
  165. iter = nil,
  166. highlighter_query = highlighter_query,
  167. })
  168. end)
  169. end
  170. ---@param fn fun(state: vim.treesitter.highlighter.State)
  171. ---@package
  172. function TSHighlighter:for_each_highlight_state(fn)
  173. for _, state in ipairs(self._highlight_states) do
  174. fn(state)
  175. end
  176. end
  177. ---@package
  178. function TSHighlighter:on_detach()
  179. self:destroy()
  180. end
  181. ---@package
  182. ---@param changes Range6[]
  183. function TSHighlighter:on_changedtree(changes)
  184. for _, ch in ipairs(changes) do
  185. api.nvim__redraw({ buf = self.bufnr, range = { ch[1], ch[4] + 1 }, flush = false })
  186. end
  187. end
  188. --- Gets the query used for @param lang
  189. ---@nodoc
  190. ---@param lang string Language used by the highlighter.
  191. ---@return vim.treesitter.highlighter.Query
  192. function TSHighlighter:get_query(lang)
  193. if not self._queries[lang] then
  194. self._queries[lang] = TSHighlighterQuery.new(lang)
  195. end
  196. return self._queries[lang]
  197. end
  198. --- @param match TSQueryMatch
  199. --- @param bufnr integer
  200. --- @param capture integer
  201. --- @param metadata vim.treesitter.query.TSMetadata
  202. --- @return string?
  203. local function get_url(match, bufnr, capture, metadata)
  204. ---@type string|number|nil
  205. local url = metadata[capture] and metadata[capture].url
  206. if not url or type(url) == 'string' then
  207. return url
  208. end
  209. local captures = match:captures()
  210. if not captures[url] then
  211. return
  212. end
  213. -- Assume there is only one matching node. If there is more than one, take the URL
  214. -- from the first.
  215. local other_node = captures[url][1]
  216. return vim.treesitter.get_node_text(other_node, bufnr, {
  217. metadata = metadata[url],
  218. })
  219. end
  220. --- @param capture_name string
  221. --- @return boolean?, integer
  222. local function get_spell(capture_name)
  223. if capture_name == 'spell' then
  224. return true, 0
  225. elseif capture_name == 'nospell' then
  226. -- Give nospell a higher priority so it always overrides spell captures.
  227. return false, 1
  228. end
  229. return nil, 0
  230. end
  231. ---@param self vim.treesitter.highlighter
  232. ---@param buf integer
  233. ---@param line integer
  234. ---@param is_spell_nav boolean
  235. local function on_line_impl(self, buf, line, is_spell_nav)
  236. self:for_each_highlight_state(function(state)
  237. local root_node = state.tstree:root()
  238. local root_start_row, _, root_end_row, _ = root_node:range()
  239. -- Only consider trees that contain this line
  240. if root_start_row > line or root_end_row < line then
  241. return
  242. end
  243. if state.iter == nil or state.next_row < line then
  244. -- Mainly used to skip over folds
  245. -- TODO(lewis6991): Creating a new iterator loses the cached predicate results for query
  246. -- matches. Move this logic inside iter_captures() so we can maintain the cache.
  247. state.iter =
  248. state.highlighter_query:query():iter_captures(root_node, self.bufnr, line, root_end_row + 1)
  249. end
  250. local captures = state.highlighter_query:query().captures
  251. while line >= state.next_row do
  252. local capture, node, metadata, match = state.iter(line)
  253. local range = { root_end_row + 1, 0, root_end_row + 1, 0 }
  254. if node then
  255. range = vim.treesitter.get_range(node, buf, metadata and metadata[capture])
  256. end
  257. local start_row, start_col, end_row, end_col = Range.unpack4(range)
  258. if capture then
  259. local hl = state.highlighter_query:get_hl_from_capture(capture)
  260. local capture_name = captures[capture]
  261. local spell, spell_pri_offset = get_spell(capture_name)
  262. -- The "priority" attribute can be set at the pattern level or on a particular capture
  263. local priority = (
  264. tonumber(metadata.priority or metadata[capture] and metadata[capture].priority)
  265. or vim.hl.priorities.treesitter
  266. ) + spell_pri_offset
  267. -- The "conceal" attribute can be set at the pattern level or on a particular capture
  268. local conceal = metadata.conceal or metadata[capture] and metadata[capture].conceal
  269. local url = get_url(match, buf, capture, metadata)
  270. if hl and end_row >= line and (not is_spell_nav or spell ~= nil) then
  271. api.nvim_buf_set_extmark(buf, ns, start_row, start_col, {
  272. end_line = end_row,
  273. end_col = end_col,
  274. hl_group = hl,
  275. ephemeral = true,
  276. priority = priority,
  277. conceal = conceal,
  278. spell = spell,
  279. url = url,
  280. })
  281. end
  282. end
  283. if start_row > line then
  284. state.next_row = start_row
  285. end
  286. end
  287. end)
  288. end
  289. ---@private
  290. ---@param _win integer
  291. ---@param buf integer
  292. ---@param line integer
  293. function TSHighlighter._on_line(_, _win, buf, line, _)
  294. local self = TSHighlighter.active[buf]
  295. if not self then
  296. return
  297. end
  298. on_line_impl(self, buf, line, false)
  299. end
  300. ---@private
  301. ---@param buf integer
  302. ---@param srow integer
  303. ---@param erow integer
  304. function TSHighlighter._on_spell_nav(_, _, buf, srow, _, erow, _)
  305. local self = TSHighlighter.active[buf]
  306. if not self then
  307. return
  308. end
  309. -- Do not affect potentially populated highlight state. Here we just want a temporary
  310. -- empty state so the C code can detect whether the region should be spell checked.
  311. local highlight_states = self._highlight_states
  312. self:prepare_highlight_states(srow, erow)
  313. for row = srow, erow do
  314. on_line_impl(self, buf, row, true)
  315. end
  316. self._highlight_states = highlight_states
  317. end
  318. ---@private
  319. ---@param buf integer
  320. ---@param topline integer
  321. ---@param botline integer
  322. function TSHighlighter._on_win(_, _, buf, topline, botline)
  323. local self = TSHighlighter.active[buf]
  324. if not self or self.parsing then
  325. return false
  326. end
  327. self.parsing = self.tree:parse({ topline, botline + 1 }, function(_, trees)
  328. if trees and self.parsing then
  329. self.parsing = false
  330. api.nvim__redraw({ buf = buf, valid = false, flush = false })
  331. end
  332. end) == nil
  333. self.redraw_count = self.redraw_count + 1
  334. self:prepare_highlight_states(topline, botline)
  335. return #self._highlight_states > 0
  336. end
  337. api.nvim_set_decoration_provider(ns, {
  338. on_win = TSHighlighter._on_win,
  339. on_line = TSHighlighter._on_line,
  340. _on_spell_nav = TSHighlighter._on_spell_nav,
  341. })
  342. return TSHighlighter