diff --git a/README.md b/README.md index 9ceaf2a..a3ca314 100644 --- a/README.md +++ b/README.md @@ -198,8 +198,11 @@ require("quicker").setup({ -- Keep the cursor to the right of the filename and lnum columns constrain_cursor = true, highlight = { - -- Use treesitter highlighting + -- Attach treesitter parser to qf buffer, highlight text in real time as you edit. treesitter = true, + -- Do not register callbacks when buffer line counts exceed this limit. In other words, + -- highlight won't get updated as you edit. + max_lines = 10000, -- Use LSP semantic token highlighting lsp = true, -- Load the referenced buffers to apply more accurate highlights (may be slow) diff --git a/lua/quicker/config.lua b/lua/quicker/config.lua index f60b978..424b50f 100644 --- a/lua/quicker/config.lua +++ b/lua/quicker/config.lua @@ -3,6 +3,7 @@ local default_config = { opts = { buflisted = false, number = false, + concealcursor = "nvic", relativenumber = false, signcolumn = "auto", winfixheight = true, @@ -26,8 +27,11 @@ local default_config = { -- Keep the cursor to the right of the filename and lnum columns constrain_cursor = true, highlight = { - -- Use treesitter highlighting + -- Attach treesitter parser to qf buffer, highlight text in real time as you edit. treesitter = true, + -- Do not register callbacks when buffer line counts exceed this limit. In other words, + -- highlight won't get updated as you edit. + max_lines = 10000, -- Use LSP semantic token highlighting lsp = true, -- Load the referenced buffers to apply more accurate highlights (may be slow) @@ -155,6 +159,7 @@ end ---@class (exact) quicker.HighlightConfig ---@field treesitter boolean +---@field max_lines integer ---@field lsp boolean ---@field load_buffers boolean diff --git a/lua/quicker/display.lua b/lua/quicker/display.lua index 5c551d3..f603d02 100644 --- a/lua/quicker/display.lua +++ b/lua/quicker/display.lua @@ -5,7 +5,9 @@ local util = require("quicker.util") local M = {} -local EM_QUAD = " " +-- A EM_QUAD and a space, we include an extra space before the real text, because it +-- avoids strange cases such as no highlight when insert at start of qf text. +local EM_QUAD = "  " local EM_QUAD_LEN = EM_QUAD:len() M.EM_QUAD = EM_QUAD M.EM_QUAD_LEN = EM_QUAD_LEN @@ -216,27 +218,6 @@ local function add_item_highlights_from_buf(qfbufnr, item, line, lnum) offset = offset - item_space end - -- Add treesitter highlights - if config.highlight.treesitter then - for _, hl in ipairs(highlight.buf_get_ts_highlights(item.bufnr, item.lnum)) do - local start_col, end_col, hl_group = hl[1], hl[2], hl[3] - if end_col == -1 then - end_col = src_line:len() - end - -- If the highlight starts at the beginning of the source line, then it might be off the - -- buffer in the quickfix because we've removed leading whitespace. If so, clamp the value - -- to 0. Except, for some reason 0 gives incorrect results, but -1 works properly even - -- though -1 should indicate the *end* of the line. Not sure why this work, but it does. - local hl_start = math.max(-1, start_col + offset) - vim.api.nvim_buf_set_extmark(qfbufnr, ns, lnum - 1, hl_start, { - hl_group = hl_group, - end_col = end_col + offset, - priority = 100, - strict = false, - }) - end - end - -- Add LSP semantic token highlights if config.highlight.lsp then for _, hl in ipairs(highlight.buf_get_lsp_highlights(item.bufnr, item.lnum)) do @@ -267,6 +248,10 @@ local function highlight_buffer_when_entered(qfbufnr, info) vim.b[qfbufnr].pending_highlight = nil info.start_idx = 1 info.end_idx = vim.api.nvim_buf_line_count(qfbufnr) + info.regions = {} + info.region_count = 0 + info.empty_regions = {} + info.ft = {} schedule_highlights(info) end, }) @@ -339,23 +324,44 @@ add_qf_highlights = function(info) loaded = true end - if loaded then - add_item_highlights_from_buf(qfbufnr, item, line, i) - elseif config.highlight.treesitter then + local ft = info.ft[item.bufnr] + if ft == nil then + ft = loaded and vim.bo[item.bufnr].filetype or vim.filetype.match({ buf = item.bufnr }) + info.ft[item.bufnr] = ft + end + + if config.highlight.treesitter and ft then + info.regions[ft] = info.regions[ft] or {} + info.region_count = info.region_count + 1 + info.empty_regions[ft] = info.empty_regions[ft] or {} local filename = vim.split(line, EM_QUAD, { plain = true })[1] - local offset = filename:len() + EM_QUAD_LEN - local text = line:sub(offset + 1) - for _, hl in ipairs(highlight.get_heuristic_ts_highlights(item, text)) do - local start_col, end_col, hl_group = hl[1], hl[2], hl[3] - start_col = start_col + offset - end_col = end_col + offset - vim.api.nvim_buf_set_extmark(qfbufnr, ns, i - 1, start_col, { - hl_group = hl_group, - end_col = end_col, - priority = 100, - strict = false, + + -- Note: we must include the new line character, when multiple lines are treated as one + -- range, without "\n" causes all the problems, for example: + -- + -- tests/tmp/expand_1.lua ┃ 2┃-- a comment + -- tests/tmp/expand_1.lua ┃ 3┃local a = 1 + -- + -- The parser will treat this as "-- a commentlocal a = 1". + local region = { + i - 1, + filename:len() + EM_QUAD_LEN - 1, -- skip EM_QUAD but include a space + i, + 0, + } + info.previous_item = info.previous_item or item + if info.previous_item.bufnr == item.bufnr and info.previous_item.lnum == item.lnum - 1 then + -- Merge current range with previous one, parse them together. + table.insert(info.regions[ft][#info.regions[ft]], region) + else + table.insert(info.regions[ft], { + region, }) end + info.previous_item = item + end + if loaded then + add_item_highlights_from_buf(qfbufnr, item, line, i) end end @@ -383,6 +389,12 @@ add_qf_highlights = function(info) return end end + if config.highlight.treesitter then + local register_cbs = info.region_count <= config.highlight.max_lines + -- cleanup previous regions each time we call setqflist. + require("quicker.treesitter").attach(qf_list.qfbufnr, info.empty_regions, register_cbs) + require("quicker.treesitter").attach(qf_list.qfbufnr, info.regions, register_cbs) + end vim.api.nvim_buf_clear_namespace(qf_list.qfbufnr, ns, info.end_idx, -1) end @@ -425,6 +437,11 @@ end ---@field id integer ---@field start_idx integer ---@field end_idx integer +---@field regions table +---@field region_count integer +---@field empty_regions table +---@field ft table +---@field previous_item QuickFixItem ---@field winid integer ---@field quickfix 1|0 ---@field force_bufload? boolean field injected by us to control if we're forcing a bufload for the syntax highlighting @@ -561,6 +578,10 @@ function M.quickfixtextfunc(info) virt_text_pos = "inline", invalidate = true, }) + vim.api.nvim_buf_set_extmark(qf_list.qfbufnr, ns, lnum - 1, end_col, { + end_col = end_col + EM_QUAD_LEN - 1, + conceal = "", + }) idmap[id] = lnum -- Highlight the filename @@ -581,6 +602,10 @@ function M.quickfixtextfunc(info) virt_lines = { header }, virt_lines_above = true, }) + -- vim.api.nvim_buf_set_extmark(qf_list.qfbufnr, ns, lnum - 1, end_col, { + -- end_col = end_col + EM_QUAD_LEN - 1, + -- conceal = "", + -- }) end end end @@ -588,6 +613,10 @@ function M.quickfixtextfunc(info) -- If we just rendered the last item, add highlights if info.end_idx == #items then + info.regions = {} + info.region_count = 0 + info.empty_regions = {} + info.ft = {} schedule_highlights(info) if qf_list.qfbufnr > 0 then diff --git a/lua/quicker/highlight.lua b/lua/quicker/highlight.lua index a7323aa..0b52c2b 100644 --- a/lua/quicker/highlight.lua +++ b/lua/quicker/highlight.lua @@ -19,71 +19,6 @@ local function get_highlight_query(lang) end end ----@param bufnr integer ----@param lnum integer ----@return quicker.TSHighlight[] -function M.buf_get_ts_highlights(bufnr, lnum) - local filetype = vim.bo[bufnr].filetype - if not filetype or filetype == "" then - filetype = vim.filetype.match({ buf = bufnr }) or "" - end - local lang = vim.treesitter.language.get_lang(filetype) or filetype - if lang == "" then - return {} - end - local ok, parser = pcall(vim.treesitter.get_parser, bufnr, lang) - if not ok or not parser then - return {} - end - - local row = lnum - 1 - if not parser:is_valid() then - parser:parse(true) - end - - local highlights = {} - parser: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 worry about trees within the line range - if root_start_row > row or root_end_row < row then - return - end - - local query = get_highlight_query(tree:lang()) - - -- Some injected languages may not have highlight queries. - if not query then - return - end - - for capture, node, metadata in query:iter_captures(root_node, bufnr, row, root_end_row + 1) do - if capture == nil then - break - end - - local range = vim.treesitter.get_range(node, bufnr, metadata[capture]) - local start_row, start_col, _, end_row, end_col, _ = unpack(range) - if start_row > row then - break - end - local capture_name = query.captures[capture] - local hl = string.format("@%s.%s", capture_name, tree:lang()) - if end_row > start_row then - end_col = -1 - end - table.insert(highlights, { start_col, end_col, hl }) - end - end) - - return highlights -end - ---@class quicker.LSPHighlight ---@field [1] integer start_col ---@field [2] integer end_col @@ -157,50 +92,6 @@ function M.buf_get_lsp_highlights(bufnr, lnum) return lsp_highlights end ----@param item QuickFixItem ----@param line string ----@return quicker.TSHighlight[] -M.get_heuristic_ts_highlights = function(item, line) - local filetype = vim.filetype.match({ buf = item.bufnr }) - if not filetype then - return {} - end - - local lang = vim.treesitter.language.get_lang(filetype) - if not lang then - return {} - end - - local has_parser, parser = pcall(vim.treesitter.get_string_parser, line, lang) - if not has_parser then - return {} - end - - local root = parser:parse(true)[1]:root() - local query = vim.treesitter.query.get(lang, "highlights") - if not query then - return {} - end - - local highlights = {} - for capture, node, metadata in query:iter_captures(root, line) do - if capture == nil then - break - end - - local range = vim.treesitter.get_range(node, line, metadata[capture]) - local start_row, start_col, _, end_row, end_col, _ = unpack(range) - local capture_name = query.captures[capture] - local hl = string.format("@%s.%s", capture_name, lang) - if end_row > start_row then - end_col = -1 - end - table.insert(highlights, { start_col, end_col, hl }) - end - - return highlights -end - function M.set_highlight_groups() if vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = "QuickFixHeaderHard" })) then vim.api.nvim_set_hl(0, "QuickFixHeaderHard", { link = "Delimiter", default = true }) diff --git a/lua/quicker/treesitter.lua b/lua/quicker/treesitter.lua new file mode 100644 index 0000000..c041eb8 --- /dev/null +++ b/lua/quicker/treesitter.lua @@ -0,0 +1,103 @@ +local config = require("quicker.config") + +local M = {} + +local cache = {} ---@type table> +local ns = vim.api.nvim_create_namespace("quicker.treesitter") + +local TSHighlighter = vim.treesitter.highlighter + +local function wrap(name) + return function(_, win, buf, ...) + if not cache[buf] then + return false + end + for _, hl in pairs(cache[buf] or {}) do + if hl.enabled then + TSHighlighter.active[buf] = hl.highlighter + TSHighlighter[name](_, win, buf, ...) + end + end + TSHighlighter.active[buf] = nil + end +end + +M.did_setup = false +function M.setup() + if M.did_setup then + return + end + M.did_setup = true + + vim.api.nvim_set_decoration_provider(ns, { + on_win = wrap("_on_win"), + on_line = wrap("_on_line"), + }) + + vim.api.nvim_create_autocmd("BufWipeout", { + group = vim.api.nvim_create_augroup("quicker.treesitter.hl", { clear = true }), + callback = function(ev) + cache[ev.buf] = nil + end, + }) +end + +---@alias quicker.LangRegions table + +---@param buf number +---@param regions quicker.LangRegions +---@param register_cbs boolean +function M.attach(buf, regions, register_cbs) + M.setup() + cache[buf] = cache[buf] or {} + for lang in pairs(cache[buf]) do + cache[buf][lang].enabled = regions[lang] ~= nil + end + + for lang in pairs(regions) do + M._attach_lang(buf, lang, regions[lang], register_cbs) + end +end + +---@param buf number +---@param lang string +---@param regions quicker.LangRegions +---@param register_cbs boolean +function M._attach_lang(buf, lang, regions, register_cbs) + cache[buf] = cache[buf] or {} + + if not cache[buf][lang] then + local ok, parser + if register_cbs then + ok, parser = pcall(vim.treesitter.get_parser, buf, lang) + else + ok, parser = pcall(vim.treesitter.languagetree.new, buf, lang) + end + if not ok then + return + end + parser:set_included_regions(vim.deepcopy(regions)) + cache[buf][lang] = { + parser = parser, + highlighter = TSHighlighter.new(parser), + } + end + cache[buf][lang].enabled = true + local parser = cache[buf][lang].parser + + parser:set_included_regions(vim.deepcopy(regions)) + -- Run a full parse for all included regions. There are two reasons: + -- 1. When we call `vim.treesitter.get_parser`, we have not set any + -- injection ranges. + -- 2. If this is not called, the highlighter will do incremental parsing, + -- which means it only parses visible areas (the on_win and on_line callback), + -- so if we modify the buffer, unvisited area's state get unsynced. + pcall(parser.parse, parser, true) + -- Hack: we need to manually perform an edit, otherwise delete an entire line + -- will cause all highlights to disappear. + vim.api.nvim_buf_call(buf, function() + vim.cmd([[norm! "_xu]]) + end) +end + +return M