summaryrefslogtreecommitdiff
path: root/lua/blink/cmp/lib/window/docs.lua
diff options
context:
space:
mode:
Diffstat (limited to 'lua/blink/cmp/lib/window/docs.lua')
-rw-r--r--lua/blink/cmp/lib/window/docs.lua224
1 files changed, 224 insertions, 0 deletions
diff --git a/lua/blink/cmp/lib/window/docs.lua b/lua/blink/cmp/lib/window/docs.lua
new file mode 100644
index 0000000..f250701
--- /dev/null
+++ b/lua/blink/cmp/lib/window/docs.lua
@@ -0,0 +1,224 @@
+local highlight_ns = require('blink.cmp.config').appearance.highlight_ns
+
+local docs = {}
+
+--- @class blink.cmp.RenderDetailAndDocumentationOpts
+--- @field bufnr number
+--- @field detail? string|string[]
+--- @field documentation? lsp.MarkupContent | string
+--- @field max_width number
+--- @field use_treesitter_highlighting boolean?
+
+--- @class blink.cmp.RenderDetailAndDocumentationOptsPartial
+--- @field bufnr? number
+--- @field detail? string
+--- @field documentation? lsp.MarkupContent | string
+--- @field max_width? number
+--- @field use_treesitter_highlighting boolean?
+
+--- @param opts blink.cmp.RenderDetailAndDocumentationOpts
+function docs.render_detail_and_documentation(opts)
+ local detail_lines = {}
+ local details = type(opts.detail) == 'string' and { opts.detail } or opts.detail or {}
+ --- @cast details string[]
+ details = require('blink.cmp.lib.utils').deduplicate(details)
+ for _, v in ipairs(details) do
+ vim.list_extend(detail_lines, docs.split_lines(v))
+ end
+
+ local doc_lines = {}
+ if opts.documentation ~= nil then
+ local doc = type(opts.documentation) == 'string' and opts.documentation or opts.documentation.value
+ doc_lines = docs.split_lines(doc)
+ end
+
+ detail_lines, doc_lines = docs.extract_detail_from_doc(detail_lines, doc_lines)
+
+ ---@type string[]
+ local combined_lines = vim.list_extend({}, detail_lines)
+
+ -- add a blank line for the --- separator
+ local doc_already_has_separator = #doc_lines > 1 and (doc_lines[1] == '---' or doc_lines[1] == '***')
+ if #detail_lines > 0 and #doc_lines > 0 then table.insert(combined_lines, '') end
+ -- skip original separator in doc_lines, so we can highlight it later
+ vim.list_extend(combined_lines, doc_lines, doc_already_has_separator and 2 or 1)
+
+ vim.api.nvim_buf_set_lines(opts.bufnr, 0, -1, true, combined_lines)
+ vim.api.nvim_set_option_value('modified', false, { buf = opts.bufnr })
+
+ -- Highlight with treesitter
+ vim.api.nvim_buf_clear_namespace(opts.bufnr, highlight_ns, 0, -1)
+
+ if #detail_lines > 0 and opts.use_treesitter_highlighting then
+ docs.highlight_with_treesitter(opts.bufnr, vim.bo.filetype, 0, #detail_lines)
+ end
+
+ -- Only add the separator if there are documentation lines (otherwise only display the detail)
+ if #detail_lines > 0 and #doc_lines > 0 then
+ vim.api.nvim_buf_set_extmark(opts.bufnr, highlight_ns, #detail_lines, 0, {
+ virt_text = { { string.rep('─', opts.max_width), 'BlinkCmpDocSeparator' } },
+ virt_text_pos = 'overlay',
+ })
+ end
+
+ if #doc_lines > 0 and opts.use_treesitter_highlighting then
+ local start = #detail_lines + (#detail_lines > 0 and 1 or 0)
+ docs.highlight_with_treesitter(opts.bufnr, 'markdown', start, start + #doc_lines)
+ end
+end
+
+--- Highlights the given range with treesitter with the given filetype
+--- @param bufnr number
+--- @param filetype string
+--- @param start_line number
+--- @param end_line number
+--- TODO: fallback to regex highlighting if treesitter fails
+--- TODO: only render what's visible
+function docs.highlight_with_treesitter(bufnr, filetype, start_line, end_line)
+ local Range = require('vim.treesitter._range')
+
+ local root_lang = vim.treesitter.language.get_lang(filetype)
+ if root_lang == nil then return end
+
+ local success, trees = pcall(vim.treesitter.get_parser, bufnr, root_lang)
+ if not success or not trees then return end
+
+ trees:parse({ start_line, end_line })
+
+ trees:for_each_tree(function(tree, tstree)
+ local lang = tstree:lang()
+ local highlighter_query = vim.treesitter.query.get(lang, 'highlights')
+ if not highlighter_query then return end
+
+ local root_node = tree:root()
+ local _, _, root_end_row, _ = root_node:range()
+
+ local iter = highlighter_query:iter_captures(tree:root(), bufnr, start_line, end_line)
+ local line = start_line
+ while line < end_line do
+ local capture, node, metadata, _ = iter(line)
+ if capture == nil then break end
+
+ local range = { root_end_row + 1, 0, root_end_row + 1, 0 }
+ if node then range = vim.treesitter.get_range(node, bufnr, metadata and metadata[capture]) end
+ local start_row, start_col, end_row, end_col = Range.unpack4(range)
+
+ if capture then
+ local name = highlighter_query.captures[capture]
+ local hl = 0
+ if not vim.startswith(name, '_') then hl = vim.api.nvim_get_hl_id_by_name('@' .. name .. '.' .. lang) end
+
+ -- 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.highlight.priorities.treesitter
+ )
+
+ -- 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
+
+ if hl and end_row >= line then
+ vim.api.nvim_buf_set_extmark(bufnr, highlight_ns, start_row, start_col, {
+ end_line = end_row,
+ end_col = end_col,
+ hl_group = hl,
+ priority = priority,
+ conceal = conceal,
+ })
+ end
+ end
+
+ if start_row > line then line = start_row end
+ end
+ end)
+end
+
+--- Gets the start and end row of the code block for the given row
+--- Or returns nil if there's no code block
+--- @param lines string[]
+--- @param row number
+--- @return number?, number?
+function docs.get_code_block_range(lines, row)
+ if row < 1 or row > #lines then return end
+ -- get the start of the code block
+ local code_block_start = nil
+ for i = 1, row do
+ local line = lines[i]
+ if line:match('^%s*```') then
+ if code_block_start == nil then
+ code_block_start = i
+ else
+ code_block_start = nil
+ end
+ end
+ end
+ if code_block_start == nil then return end
+
+ -- get the end of the code block
+ local code_block_end = nil
+ for i = row, #lines do
+ local line = lines[i]
+ if line:match('^%s*```') then
+ code_block_end = i
+ break
+ end
+ end
+ if code_block_end == nil then return end
+
+ return code_block_start, code_block_end
+end
+
+--- Avoids showing the detail if it's part of the documentation
+--- or, if the detail is in a code block in the doc,
+--- extracts the code block into the detail
+---@param detail_lines string[]
+---@param doc_lines string[]
+---@return string[], string[]
+--- TODO: Also move the code block into detail if it's at the start of the doc
+--- and we have no detail
+function docs.extract_detail_from_doc(detail_lines, doc_lines)
+ local detail_str = table.concat(detail_lines, '\n')
+ local doc_str = table.concat(doc_lines, '\n')
+ local doc_str_detail_row = doc_str:find(detail_str, 1, true)
+
+ -- didn't find the detail in the doc, so return as is
+ if doc_str_detail_row == nil or #detail_str == 0 or #doc_str == 0 then return detail_lines, doc_lines end
+
+ -- get the line of the match
+ -- hack: surely there's a better way to do this but it's late
+ -- and I can't be bothered
+ local offset = 1
+ local detail_line = 1
+ for line_num, line in ipairs(doc_lines) do
+ if #line + offset > doc_str_detail_row then
+ detail_line = line_num
+ break
+ end
+ offset = offset + #line + 1
+ end
+
+ -- extract the code block, if it exists, and use it as the detail
+ local code_block_start, code_block_end = docs.get_code_block_range(doc_lines, detail_line)
+ if code_block_start ~= nil and code_block_end ~= nil then
+ detail_lines = vim.list_slice(doc_lines, code_block_start + 1, code_block_end - 1)
+
+ local doc_lines_start = vim.list_slice(doc_lines, 1, code_block_start - 1)
+ local doc_lines_end = vim.list_slice(doc_lines, code_block_end + 1, #doc_lines)
+ vim.list_extend(doc_lines_start, doc_lines_end)
+ doc_lines = doc_lines_start
+ else
+ detail_lines = {}
+ end
+
+ return detail_lines, doc_lines
+end
+
+function docs.split_lines(text)
+ local lines = {}
+ for s in text:gmatch('[^\r\n]+') do
+ table.insert(lines, s)
+ end
+ return lines
+end
+
+return docs