highlighter.lua 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  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(
  137. 'FileType',
  138. { group = 'syntaxset', buffer = self.bufnr, modeline = false }
  139. )
  140. end
  141. end
  142. end
  143. ---@param srow integer
  144. ---@param erow integer exclusive
  145. ---@private
  146. function TSHighlighter:prepare_highlight_states(srow, erow)
  147. self._highlight_states = {}
  148. self.tree:for_each_tree(function(tstree, tree)
  149. if not tstree then
  150. return
  151. end
  152. local root_node = tstree:root()
  153. local root_start_row, _, root_end_row, _ = root_node:range()
  154. -- Only consider trees within the visible range
  155. if root_start_row > erow or root_end_row < srow then
  156. return
  157. end
  158. local highlighter_query = self:get_query(tree:lang())
  159. -- Some injected languages may not have highlight queries.
  160. if not highlighter_query:query() then
  161. return
  162. end
  163. -- _highlight_states should be a list so that the highlights are added in the same order as
  164. -- for_each_tree traversal. This ensures that parents' highlight don't override children's.
  165. table.insert(self._highlight_states, {
  166. tstree = tstree,
  167. next_row = 0,
  168. iter = nil,
  169. highlighter_query = highlighter_query,
  170. })
  171. end)
  172. end
  173. ---@param fn fun(state: vim.treesitter.highlighter.State)
  174. ---@package
  175. function TSHighlighter:for_each_highlight_state(fn)
  176. for _, state in ipairs(self._highlight_states) do
  177. fn(state)
  178. end
  179. end
  180. ---@package
  181. function TSHighlighter:on_detach()
  182. self:destroy()
  183. end
  184. ---@package
  185. ---@param changes Range6[]
  186. function TSHighlighter:on_changedtree(changes)
  187. for _, ch in ipairs(changes) do
  188. api.nvim__redraw({ buf = self.bufnr, range = { ch[1], ch[4] + 1 }, flush = false })
  189. end
  190. end
  191. --- Gets the query used for @param lang
  192. ---@nodoc
  193. ---@param lang string Language used by the highlighter.
  194. ---@return vim.treesitter.highlighter.Query
  195. function TSHighlighter:get_query(lang)
  196. if not self._queries[lang] then
  197. self._queries[lang] = TSHighlighterQuery.new(lang)
  198. end
  199. return self._queries[lang]
  200. end
  201. --- @param match TSQueryMatch
  202. --- @param bufnr integer
  203. --- @param capture integer
  204. --- @param metadata vim.treesitter.query.TSMetadata
  205. --- @return string?
  206. local function get_url(match, bufnr, capture, metadata)
  207. ---@type string|number|nil
  208. local url = metadata[capture] and metadata[capture].url
  209. if not url or type(url) == 'string' then
  210. return url
  211. end
  212. local captures = match:captures()
  213. if not captures[url] then
  214. return
  215. end
  216. -- Assume there is only one matching node. If there is more than one, take the URL
  217. -- from the first.
  218. local other_node = captures[url][1]
  219. return vim.treesitter.get_node_text(other_node, bufnr, {
  220. metadata = metadata[url],
  221. })
  222. end
  223. --- @param capture_name string
  224. --- @return boolean?, integer
  225. local function get_spell(capture_name)
  226. if capture_name == 'spell' then
  227. return true, 0
  228. elseif capture_name == 'nospell' then
  229. -- Give nospell a higher priority so it always overrides spell captures.
  230. return false, 1
  231. end
  232. return nil, 0
  233. end
  234. ---@param self vim.treesitter.highlighter
  235. ---@param buf integer
  236. ---@param line integer
  237. ---@param is_spell_nav boolean
  238. local function on_line_impl(self, buf, line, is_spell_nav)
  239. self:for_each_highlight_state(function(state)
  240. local root_node = state.tstree:root()
  241. local root_start_row, _, root_end_row, _ = root_node:range()
  242. -- Only consider trees that contain this line
  243. if root_start_row > line or root_end_row < line then
  244. return
  245. end
  246. if state.iter == nil or state.next_row < line then
  247. -- Mainly used to skip over folds
  248. -- TODO(lewis6991): Creating a new iterator loses the cached predicate results for query
  249. -- matches. Move this logic inside iter_captures() so we can maintain the cache.
  250. state.iter =
  251. state.highlighter_query:query():iter_captures(root_node, self.bufnr, line, root_end_row + 1)
  252. end
  253. local captures = state.highlighter_query:query().captures
  254. while line >= state.next_row do
  255. local capture, node, metadata, match = state.iter(line)
  256. local range = { root_end_row + 1, 0, root_end_row + 1, 0 }
  257. if node then
  258. range = vim.treesitter.get_range(node, buf, metadata and metadata[capture])
  259. end
  260. local start_row, start_col, end_row, end_col = Range.unpack4(range)
  261. if capture then
  262. local hl = state.highlighter_query:get_hl_from_capture(capture)
  263. local capture_name = captures[capture]
  264. local spell, spell_pri_offset = get_spell(capture_name)
  265. -- The "priority" attribute can be set at the pattern level or on a particular capture
  266. local priority = (
  267. tonumber(metadata.priority or metadata[capture] and metadata[capture].priority)
  268. or vim.hl.priorities.treesitter
  269. ) + spell_pri_offset
  270. -- The "conceal" attribute can be set at the pattern level or on a particular capture
  271. local conceal = metadata.conceal or metadata[capture] and metadata[capture].conceal
  272. local url = get_url(match, buf, capture, metadata)
  273. if hl and end_row >= line and (not is_spell_nav or spell ~= nil) then
  274. api.nvim_buf_set_extmark(buf, ns, start_row, start_col, {
  275. end_line = end_row,
  276. end_col = end_col,
  277. hl_group = hl,
  278. ephemeral = true,
  279. priority = priority,
  280. conceal = conceal,
  281. spell = spell,
  282. url = url,
  283. })
  284. end
  285. end
  286. if start_row > line then
  287. state.next_row = start_row
  288. end
  289. end
  290. end)
  291. end
  292. ---@private
  293. ---@param _win integer
  294. ---@param buf integer
  295. ---@param line integer
  296. function TSHighlighter._on_line(_, _win, buf, line, _)
  297. local self = TSHighlighter.active[buf]
  298. if not self then
  299. return
  300. end
  301. on_line_impl(self, buf, line, false)
  302. end
  303. ---@private
  304. ---@param buf integer
  305. ---@param srow integer
  306. ---@param erow integer
  307. function TSHighlighter._on_spell_nav(_, _, buf, srow, _, erow, _)
  308. local self = TSHighlighter.active[buf]
  309. if not self then
  310. return
  311. end
  312. -- Do not affect potentially populated highlight state. Here we just want a temporary
  313. -- empty state so the C code can detect whether the region should be spell checked.
  314. local highlight_states = self._highlight_states
  315. self:prepare_highlight_states(srow, erow)
  316. for row = srow, erow do
  317. on_line_impl(self, buf, row, true)
  318. end
  319. self._highlight_states = highlight_states
  320. end
  321. ---@private
  322. ---@param buf integer
  323. ---@param topline integer
  324. ---@param botline integer
  325. function TSHighlighter._on_win(_, _, buf, topline, botline)
  326. local self = TSHighlighter.active[buf]
  327. if not self or self.parsing then
  328. return false
  329. end
  330. self.parsing = self.tree:parse({ topline, botline + 1 }, function(_, trees)
  331. if trees and self.parsing then
  332. self.parsing = false
  333. api.nvim__redraw({ buf = buf, valid = false, flush = false })
  334. end
  335. end) == nil
  336. self.redraw_count = self.redraw_count + 1
  337. self:prepare_highlight_states(topline, botline)
  338. return #self._highlight_states > 0
  339. end
  340. api.nvim_set_decoration_provider(ns, {
  341. on_win = TSHighlighter._on_win,
  342. on_line = TSHighlighter._on_line,
  343. _on_spell_nav = TSHighlighter._on_spell_nav,
  344. })
  345. return TSHighlighter