diff options
| author | Mike Vink <mike@pionative.com> | 2025-01-19 13:52:52 +0100 |
|---|---|---|
| committer | Mike Vink <mike@pionative.com> | 2025-01-19 13:52:52 +0100 |
| commit | b77413ff8f59f380612074f0c9bd49093d8db695 (patch) | |
| tree | 32c39a811ba96ed4ab0a1c81cce9f8d518ed7e31 /lua/blink/cmp/completion | |
Squashed 'mut/neovim/pack/plugins/start/blink.cmp/' content from commit 1cc3b1a
git-subtree-dir: mut/neovim/pack/plugins/start/blink.cmp
git-subtree-split: 1cc3b1a908fbcfd15451c4772759549724f38524
Diffstat (limited to 'lua/blink/cmp/completion')
24 files changed, 2236 insertions, 0 deletions
diff --git a/lua/blink/cmp/completion/accept/init.lua b/lua/blink/cmp/completion/accept/init.lua new file mode 100644 index 0000000..c4041d3 --- /dev/null +++ b/lua/blink/cmp/completion/accept/init.lua @@ -0,0 +1,110 @@ +local text_edits_lib = require('blink.cmp.lib.text_edits') +local brackets_lib = require('blink.cmp.completion.brackets') + +--- Applies a completion item to the current buffer +--- @param ctx blink.cmp.Context +--- @param item blink.cmp.CompletionItem +--- @param callback fun() +local function accept(ctx, item, callback) + local sources = require('blink.cmp.sources.lib') + require('blink.cmp.completion.trigger').hide() + + -- Start the resolve immediately since text changes can invalidate the item + -- with some LSPs (i.e. rust-analyzer) causing them to return the item as-is + -- without i.e. auto-imports + sources + .resolve(ctx, item) + :map(function(item) + item = vim.deepcopy(item) + + -- Get additional text edits, converted to utf-8 + local all_text_edits = vim.deepcopy(item.additionalTextEdits or {}) + all_text_edits = vim.tbl_map( + function(text_edit) return text_edits_lib.to_utf_8(text_edit, text_edits_lib.offset_encoding_from_item(item)) end, + all_text_edits + ) + + -- TODO: it's not obvious that this is converting to utf-8 + item.textEdit = text_edits_lib.get_from_item(item) + + -- Create an undo point, if it's not a snippet, since the snippet engine should handle undo + if + ctx.mode == 'default' + and require('blink.cmp.config').completion.accept.create_undo_point + and item.insertTextFormat ~= vim.lsp.protocol.InsertTextFormat.Snippet + -- HACK: We check the kind here because the Luasnip source returns PlainText and handles + -- expansion itself. Otherwise, Luasnip will fail to enter select mode + -- https://github.com/Saghen/blink.cmp/commit/284dd37f9bbc632f8281d6361e877db5b45e6ff0#r150498482 + and item.kind ~= require('blink.cmp.types').CompletionItemKind.Snippet + then + -- setting the undolevels forces neovim to create an undo point + vim.o.undolevels = vim.o.undolevels + end + + -- Ignore snippets that only contain text + -- FIXME: doesn't handle escaped snippet placeholders "\\$1" should output "$1", not "\$1" + if + item.insertTextFormat == vim.lsp.protocol.InsertTextFormat.Snippet + and item.kind ~= require('blink.cmp.types').CompletionItemKind.Snippet + then + local parsed_snippet = require('blink.cmp.sources.snippets.utils').safe_parse(item.textEdit.newText) + if + parsed_snippet ~= nil + and #parsed_snippet.data.children == 1 + and parsed_snippet.data.children[1].type == vim.lsp._snippet_grammar.NodeType.Text + then + item.insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText + end + end + + -- Add brackets to the text edit if needed + local brackets_status, text_edit_with_brackets, offset = brackets_lib.add_brackets(ctx, vim.bo.filetype, item) + item.textEdit = text_edit_with_brackets + + -- Snippet + if item.insertTextFormat == vim.lsp.protocol.InsertTextFormat.Snippet then + assert(ctx.mode == 'default', 'Snippets are only supported in default mode') + + -- We want to handle offset_encoding and the text edit api can do this for us + -- so we empty the newText and apply + local temp_text_edit = vim.deepcopy(item.textEdit) + temp_text_edit.newText = '' + table.insert(all_text_edits, temp_text_edit) + text_edits_lib.apply(all_text_edits) + + -- Expand the snippet + require('blink.cmp.config').snippets.expand(item.textEdit.newText) + + -- OR Normal: Apply the text edit and move the cursor + else + table.insert(all_text_edits, item.textEdit) + text_edits_lib.apply(all_text_edits) + -- TODO: should move the cursor only by the offset since text edit handles everything else? + ctx.set_cursor({ ctx.get_cursor()[1], item.textEdit.range.start.character + #item.textEdit.newText + offset }) + end + + -- Let the source execute the item itself + sources.execute(ctx, item):map(function() + -- Check semantic tokens for brackets, if needed, and apply additional text edits + if brackets_status == 'check_semantic_token' then + -- TODO: since we apply the additional text edits after, auto imported functions will not + -- get auto brackets. If we apply them before, we have to modify the textEdit to compensate + brackets_lib.add_brackets_via_semantic_token(vim.bo.filetype, item, function() + require('blink.cmp.completion.trigger').show_if_on_trigger_character({ is_accept = true }) + require('blink.cmp.signature.trigger').show_if_on_trigger_character() + callback() + end) + else + require('blink.cmp.completion.trigger').show_if_on_trigger_character({ is_accept = true }) + require('blink.cmp.signature.trigger').show_if_on_trigger_character() + callback() + end + + -- Notify the rust module that the item was accessed + require('blink.cmp.fuzzy').access(item) + end) + end) + :catch(function(err) vim.notify(err, vim.log.levels.ERROR, { title = 'blink.cmp' }) end) +end + +return accept diff --git a/lua/blink/cmp/completion/accept/prefix.lua b/lua/blink/cmp/completion/accept/prefix.lua new file mode 100644 index 0000000..3c51715 --- /dev/null +++ b/lua/blink/cmp/completion/accept/prefix.lua @@ -0,0 +1,58 @@ +local PAIRS_AND_INVALID_CHARS = {} +string.gsub('\'"=$()[]<>{} \t\n\r', '.', function(char) PAIRS_AND_INVALID_CHARS[string.byte(char)] = true end) + +local CLOSING_PAIR = { + [string.byte('<')] = string.byte('>'), + [string.byte('[')] = string.byte(']'), + [string.byte('(')] = string.byte(')'), + [string.byte('{')] = string.byte('}'), + [string.byte('"')] = string.byte('"'), + [string.byte("'")] = string.byte("'"), +} + +local ALPHANUMERIC = {} +string.gsub( + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + '.', + function(char) ALPHANUMERIC[string.byte(char)] = true end +) + +--- Gets the prefix of the given text, stopping at brackets and quotes +--- @param text string +--- @return string +local function get_prefix_before_brackets_and_quotes(text) + local closing_pairs_stack = {} + local word = '' + + local add = function(char) + word = word .. string.char(char) + + -- if we've seen the opening pair, and we've just received the closing pair, + -- remove it from the closing pairs stack + if closing_pairs_stack[#closing_pairs_stack] == char then + table.remove(closing_pairs_stack, #closing_pairs_stack) + -- if the character is an opening pair, add it to the closing pairs stack + elseif CLOSING_PAIR[char] ~= nil then + table.insert(closing_pairs_stack, CLOSING_PAIR[char]) + end + end + + local has_alphanumeric = false + for i = 1, #text do + local char = string.byte(text, i) + if PAIRS_AND_INVALID_CHARS[char] == nil then + add(char) + has_alphanumeric = has_alphanumeric or ALPHANUMERIC[char] + elseif not has_alphanumeric or #closing_pairs_stack ~= 0 then + add(char) + -- if we had an alphanumeric, and the closing pairs stack *just* emptied, + -- because the current character is a closing pair, we exit + if has_alphanumeric and #closing_pairs_stack == 0 then break end + else + break + end + end + return word +end + +return get_prefix_before_brackets_and_quotes diff --git a/lua/blink/cmp/completion/accept/preview.lua b/lua/blink/cmp/completion/accept/preview.lua new file mode 100644 index 0000000..88b46e2 --- /dev/null +++ b/lua/blink/cmp/completion/accept/preview.lua @@ -0,0 +1,35 @@ +--- @param item blink.cmp.CompletionItem +--- @return lsp.TextEdit undo_text_edit, integer[]? undo_cursor_pos The text edit to apply and the original cursor +--- position to move to when undoing the preview, +local function preview(item) + local text_edits_lib = require('blink.cmp.lib.text_edits') + local text_edit = text_edits_lib.get_from_item(item) + + if item.insertTextFormat == vim.lsp.protocol.InsertTextFormat.Snippet then + local expanded_snippet = require('blink.cmp.sources.snippets.utils').safe_parse(text_edit.newText) + local snippet = expanded_snippet and tostring(expanded_snippet) or text_edit.newText + local get_prefix_before_brackets_and_quotes = require('blink.cmp.completion.accept.prefix') + text_edit.newText = get_prefix_before_brackets_and_quotes(snippet) + end + + local undo_text_edit = text_edits_lib.get_undo_text_edit(text_edit) + local cursor_pos = { + text_edit.range.start.line + 1, + text_edit.range.start.character + #text_edit.newText, + } + + text_edits_lib.apply({ text_edit }) + + local original_cursor = vim.api.nvim_win_get_cursor(0) + local cursor_moved = false + + -- TODO: remove when text_edits_lib.apply begins setting cursor position + if vim.api.nvim_get_mode().mode ~= 'c' then + vim.api.nvim_win_set_cursor(0, cursor_pos) + cursor_moved = true + end + + return undo_text_edit, cursor_moved and original_cursor or nil +end + +return preview diff --git a/lua/blink/cmp/completion/brackets/config.lua b/lua/blink/cmp/completion/brackets/config.lua new file mode 100644 index 0000000..9201a08 --- /dev/null +++ b/lua/blink/cmp/completion/brackets/config.lua @@ -0,0 +1,37 @@ +return { + -- stylua: ignore + blocked_filetypes = { + 'sql', 'ruby', 'perl', 'lisp', 'scheme', 'clojure', + 'prolog', 'vb', 'elixir', 'smalltalk', 'applescript' + }, + per_filetype = { + -- languages with a space + haskell = { ' ', '' }, + fsharp = { ' ', '' }, + ocaml = { ' ', '' }, + erlang = { ' ', '' }, + tcl = { ' ', '' }, + nix = { ' ', '' }, + helm = { ' ', '' }, + + shell = { ' ', '' }, + sh = { ' ', '' }, + bash = { ' ', '' }, + fish = { ' ', '' }, + zsh = { ' ', '' }, + powershell = { ' ', '' }, + + make = { ' ', '' }, + + -- languages with square brackets + wl = { '[', ']' }, + wolfram = { '[', ']' }, + mma = { '[', ']' }, + mathematica = { '[', ']' }, + context = { '[', ']' }, + + -- languages with curly brackets + tex = { '{', '}' }, + plaintex = { '{', '}' }, + }, +} diff --git a/lua/blink/cmp/completion/brackets/init.lua b/lua/blink/cmp/completion/brackets/init.lua new file mode 100644 index 0000000..511b42b --- /dev/null +++ b/lua/blink/cmp/completion/brackets/init.lua @@ -0,0 +1,6 @@ +local brackets = {} + +brackets.add_brackets = require('blink.cmp.completion.brackets.kind') +brackets.add_brackets_via_semantic_token = require('blink.cmp.completion.brackets.semantic') + +return brackets diff --git a/lua/blink/cmp/completion/brackets/kind.lua b/lua/blink/cmp/completion/brackets/kind.lua new file mode 100644 index 0000000..f09f180 --- /dev/null +++ b/lua/blink/cmp/completion/brackets/kind.lua @@ -0,0 +1,52 @@ +local utils = require('blink.cmp.completion.brackets.utils') + +--- @param ctx blink.cmp.Context +--- @param filetype string +--- @param item blink.cmp.CompletionItem +--- @return 'added' | 'check_semantic_token' | 'skipped', lsp.TextEdit | lsp.InsertReplaceEdit, number +local function add_brackets(ctx, filetype, item) + local text_edit = item.textEdit + assert(text_edit ~= nil, 'Got nil text edit while adding brackets via kind') + local brackets_for_filetype = utils.get_for_filetype(filetype, item) + + -- skip if we're not in default mode + if ctx.mode ~= 'default' then return 'skipped', text_edit, 0 end + + -- if there's already the correct brackets in front, skip but indicate the cursor should move in front of the bracket + -- TODO: what if the brackets_for_filetype[1] == '' or ' ' (haskell/ocaml)? + -- TODO: should this check semantic tokens and still move the cursor in that case? + if utils.has_brackets_in_front(text_edit, brackets_for_filetype[1]) then + local offset = utils.can_have_brackets(item, brackets_for_filetype) and #brackets_for_filetype[1] or 0 + return 'skipped', text_edit, offset + end + + -- if the item already contains the brackets, conservatively skip adding brackets + -- todo: won't work for snippets when the brackets_for_filetype is { '{', '}' } + -- I've never seen a language like that though + if brackets_for_filetype[1] ~= ' ' and text_edit.newText:match('[\\' .. brackets_for_filetype[1] .. ']') ~= nil then + return 'skipped', text_edit, 0 + end + + -- check if configuration incidates we should skip + if not utils.should_run_resolution(filetype, 'kind') then return 'check_semantic_token', text_edit, 0 end + -- cannot have brackets, skip + if not utils.can_have_brackets(item, brackets_for_filetype) then return 'check_semantic_token', text_edit, 0 end + + text_edit = vim.deepcopy(text_edit) + -- For snippets, we add the cursor position between the brackets as the last placeholder + if item.insertTextFormat == vim.lsp.protocol.InsertTextFormat.Snippet then + local placeholders = utils.snippets_extract_placeholders(text_edit.newText) + local last_placeholder_index = math.max(0, unpack(placeholders)) + text_edit.newText = text_edit.newText + .. brackets_for_filetype[1] + .. '$' + .. tostring(last_placeholder_index + 1) + .. brackets_for_filetype[2] + -- Otherwise, we add as usual + else + text_edit.newText = text_edit.newText .. brackets_for_filetype[1] .. brackets_for_filetype[2] + end + return 'added', text_edit, -#brackets_for_filetype[2] +end + +return add_brackets diff --git a/lua/blink/cmp/completion/brackets/semantic.lua b/lua/blink/cmp/completion/brackets/semantic.lua new file mode 100644 index 0000000..c64afbd --- /dev/null +++ b/lua/blink/cmp/completion/brackets/semantic.lua @@ -0,0 +1,109 @@ +local config = require('blink.cmp.config').completion.accept.auto_brackets +local utils = require('blink.cmp.completion.brackets.utils') + +local semantic = {} + +--- Asynchronously use semantic tokens to determine if brackets should be added +--- @param filetype string +--- @param item blink.cmp.CompletionItem +--- @param callback fun() +function semantic.add_brackets_via_semantic_token(filetype, item, callback) + if not utils.should_run_resolution(filetype, 'semantic_token') then return callback() end + + local text_edit = item.textEdit + assert(text_edit ~= nil, 'Got nil text edit while adding brackets via semantic tokens') + local client = vim.lsp.get_client_by_id(item.client_id) + if client == nil then return callback() end + + local capabilities = client.server_capabilities.semanticTokensProvider + if not capabilities or not capabilities.legend or (not capabilities.range and not capabilities.full) then + return callback() + end + + local token_types = client.server_capabilities.semanticTokensProvider.legend.tokenTypes + local params = { + textDocument = vim.lsp.util.make_text_document_params(), + range = capabilities.range and { + start = { line = text_edit.range.start.line, character = text_edit.range.start.character }, + ['end'] = { line = text_edit.range.start.line + 1, character = 0 }, + } or nil, + } + + local cursor_before_call = vim.api.nvim_win_get_cursor(0) + + local start_time = vim.uv.hrtime() + client.request( + capabilities.range and 'textDocument/semanticTokens/range' or 'textDocument/semanticTokens/full', + params, + function(err, result) + if err ~= nil or result == nil or #result.data == 0 then return callback() end + + -- cancel if it's been too long, or if the cursor moved + local ms_since_call = (vim.uv.hrtime() - start_time) / 1000000 + local cursor_after_call = vim.api.nvim_win_get_cursor(0) + if + ms_since_call > config.semantic_token_resolution.timeout_ms + or cursor_before_call[1] ~= cursor_after_call[1] + or cursor_before_call[2] ~= cursor_after_call[2] + then + return callback() + end + + for _, token in ipairs(semantic.process_semantic_token_data(result.data, token_types)) do + if + cursor_after_call[1] == token.line + and cursor_after_call[2] >= token.start_col + and cursor_after_call[2] <= token.end_col + and (token.type == 'function' or token.type == 'method') + then + -- add the brackets + local brackets_for_filetype = utils.get_for_filetype(filetype, item) + local line = vim.api.nvim_get_current_line() + local start_col = text_edit.range.start.character + #text_edit.newText + local new_line = line:sub(1, start_col) + .. brackets_for_filetype[1] + .. brackets_for_filetype[2] + .. line:sub(start_col + 1) + vim.api.nvim_set_current_line(new_line) + vim.api.nvim_win_set_cursor(0, { cursor_after_call[1], start_col + #brackets_for_filetype[1] }) + callback() + return + end + end + + callback() + end + ) +end + +function semantic.process_semantic_token_data(data, token_types) + local tokens = {} + local idx = 0 + local token_line = 0 + local token_start_col = 0 + + while (idx + 1) * 5 <= #data do + local delta_token_line = data[idx * 5 + 1] + local delta_token_start_col = data[idx * 5 + 2] + local delta_token_length = data[idx * 5 + 3] + local type = token_types[data[idx * 5 + 4] + 1] + + if delta_token_line > 0 then token_start_col = 0 end + token_line = token_line + delta_token_line + token_start_col = token_start_col + delta_token_start_col + + table.insert(tokens, { + line = token_line + 1, + start_col = token_start_col, + end_col = token_start_col + delta_token_length, + type = type, + }) + + token_start_col = token_start_col + delta_token_length + idx = idx + 1 + end + + return tokens +end + +return semantic.add_brackets_via_semantic_token diff --git a/lua/blink/cmp/completion/brackets/utils.lua b/lua/blink/cmp/completion/brackets/utils.lua new file mode 100644 index 0000000..cf84f11 --- /dev/null +++ b/lua/blink/cmp/completion/brackets/utils.lua @@ -0,0 +1,61 @@ +local config = require('blink.cmp.config').completion.accept.auto_brackets +local CompletionItemKind = require('blink.cmp.types').CompletionItemKind +local brackets = require('blink.cmp.completion.brackets.config') +local utils = {} + +--- @param snippet string +function utils.snippets_extract_placeholders(snippet) + local placeholders = {} + local pattern = [=[(\$\{(\d+)(:([^}\\]|\\.)*?)?\})]=] + + for _, number, _, _ in snippet:gmatch(pattern) do + table.insert(placeholders, tonumber(number)) + end + + return placeholders +end + +--- @param filetype string +--- @param item blink.cmp.CompletionItem +--- @return string[] +function utils.get_for_filetype(filetype, item) + local default = config.default_brackets + local per_filetype = config.override_brackets_for_filetypes[filetype] or brackets.per_filetype[filetype] + + if type(per_filetype) == 'function' then return per_filetype(item) or default end + return per_filetype or default +end + +--- @param filetype string +--- @param resolution_method 'kind' | 'semantic_token' +--- @return boolean +function utils.should_run_resolution(filetype, resolution_method) + -- resolution method specific + if not config[resolution_method .. '_resolution'].enabled then return false end + local resolution_blocked_filetypes = config[resolution_method .. '_resolution'].blocked_filetypes + if vim.tbl_contains(resolution_blocked_filetypes, filetype) then return false end + + -- global + if not config.enabled then return false end + if vim.tbl_contains(config.force_allow_filetypes, filetype) then return true end + return not vim.tbl_contains(config.blocked_filetypes, filetype) + and not vim.tbl_contains(brackets.blocked_filetypes, filetype) +end + +--- @param text_edit lsp.TextEdit | lsp.InsertReplaceEdit +--- @param bracket string +--- @return boolean +function utils.has_brackets_in_front(text_edit, bracket) + local line = vim.api.nvim_get_current_line() + local col = text_edit.range['end'].character + 1 + return line:sub(col, col) == bracket +end + +--- @param item blink.cmp.CompletionItem +--- @param _ string[] +-- TODO: for edge cases, we should probably also take brackets themselves into consideration +function utils.can_have_brackets(item, _) + return item.kind == CompletionItemKind.Function or item.kind == CompletionItemKind.Method +end + +return utils diff --git a/lua/blink/cmp/completion/init.lua b/lua/blink/cmp/completion/init.lua new file mode 100644 index 0000000..49c6fc2 --- /dev/null +++ b/lua/blink/cmp/completion/init.lua @@ -0,0 +1,88 @@ +local config = require('blink.cmp.config') +local completion = {} + +function completion.setup() + -- trigger controls when to show the window and the current context for caching + local trigger = require('blink.cmp.completion.trigger') + trigger.activate() + + -- sources fetch completion items and documentation + local sources = require('blink.cmp.sources.lib') + + -- manages the completion list state: + -- fuzzy matching items + -- when to show/hide the windows + -- selection + -- accepting and previewing items + local list = require('blink.cmp.completion.list') + + -- trigger -> sources: request completion items from the sources on show + trigger.show_emitter:on(function(event) sources.request_completions(event.context) end) + trigger.hide_emitter:on(function() + sources.cancel_completions() + list.hide() + end) + + -- sources -> list + sources.completions_emitter:on(function(event) + -- schedule for later to avoid adding 0.5-4ms to insertion latency + vim.schedule(function() + -- since this was performed asynchronously, we check if the context has changed + if trigger.context == nil or event.context.id ~= trigger.context.id then return end + -- don't show the list if prefetching results + if event.context.trigger.kind == 'prefetch' then return end + + -- don't show if all the sources that defined the trigger character returned no items + if event.context.trigger.character ~= nil then + local triggering_source_returned_items = false + for _, source in pairs(event.context.providers) do + local trigger_characters = sources.get_provider_by_id(source):get_trigger_characters() + if + event.items[source] + and #event.items[source] > 0 + and vim.tbl_contains(trigger_characters, trigger.context.trigger.character) + then + triggering_source_returned_items = true + break + end + end + + if not triggering_source_returned_items then return list.hide() end + end + + list.show(event.context, event.items) + end) + end) + + --- list -> windows: ghost text and completion menu + -- setup completion menu + if config.completion.menu.enabled then + list.show_emitter:on( + function(event) require('blink.cmp.completion.windows.menu').open_with_items(event.context, event.items) end + ) + list.hide_emitter:on(function() require('blink.cmp.completion.windows.menu').close() end) + list.select_emitter:on(function(event) + require('blink.cmp.completion.windows.menu').set_selected_item_idx(event.idx) + require('blink.cmp.completion.windows.documentation').auto_show_item(event.context, event.item) + end) + end + + -- setup ghost text + if config.completion.ghost_text.enabled then + list.select_emitter:on( + function(event) require('blink.cmp.completion.windows.ghost_text').show_preview(event.item) end + ) + list.hide_emitter:on(function() require('blink.cmp.completion.windows.ghost_text').clear_preview() end) + end + + -- run 'resolve' on the item ahead of time to avoid delays + -- when accepting the item or showing documentation + list.select_emitter:on(function(event) + -- when selection.preselect == false, we still want to prefetch the first item + local item = event.item or list.items[1] + if item == nil then return end + require('blink.cmp.completion.prefetch')(event.context, event.item) + end) +end + +return completion diff --git a/lua/blink/cmp/completion/list.lua b/lua/blink/cmp/completion/list.lua new file mode 100644 index 0000000..61fa2c3 --- /dev/null +++ b/lua/blink/cmp/completion/list.lua @@ -0,0 +1,250 @@ +--- Manages most of the state for the completion list such that downstream consumers can be mostly stateless +--- @class (exact) blink.cmp.CompletionList +--- @field config blink.cmp.CompletionListConfig +--- @field show_emitter blink.cmp.EventEmitter<blink.cmp.CompletionListShowEvent> +--- @field hide_emitter blink.cmp.EventEmitter<blink.cmp.CompletionListHideEvent> +--- @field select_emitter blink.cmp.EventEmitter<blink.cmp.CompletionListSelectEvent> +--- @field accept_emitter blink.cmp.EventEmitter<blink.cmp.CompletionListAcceptEvent> +--- +--- @field context? blink.cmp.Context +--- @field items blink.cmp.CompletionItem[] +--- @field selected_item_idx? number +--- @field preview_undo? { text_edit: lsp.TextEdit, cursor: integer[]?} +--- +--- @field show fun(context: blink.cmp.Context, items: table<string, blink.cmp.CompletionItem[]>) +--- @field fuzzy fun(context: blink.cmp.Context, items: table<string, blink.cmp.CompletionItem[]>): blink.cmp.CompletionItem[] +--- @field hide fun() +--- +--- @field get_selected_item fun(): blink.cmp.CompletionItem? +--- @field get_selection_mode fun(context: blink.cmp.Context): { preselect: boolean, auto_insert: boolean } +--- @field get_item_idx_in_list fun(item?: blink.cmp.CompletionItem): number? +--- @field select fun(idx?: number, opts?: { auto_insert?: boolean, undo_preview?: boolean, is_explicit_selection?: boolean }) +--- @field select_next fun(opts?: blink.cmp.CompletionListSelectOpts) +--- @field select_prev fun(opts?: blink.cmp.CompletionListSelectOpts) +--- +--- @field undo_preview fun() +--- @field apply_preview fun(item: blink.cmp.CompletionItem) +--- @field accept fun(opts?: blink.cmp.CompletionListAcceptOpts): boolean Applies the currently selected item, returning true if it succeeded + +--- @class blink.cmp.CompletionListSelectOpts +--- @field auto_insert? boolean When `true`, inserts the completion item automatically when selecting it + +--- @class blink.cmp.CompletionListSelectAndAcceptOpts +--- @field callback? fun() Called after the item is accepted + +--- @class blink.cmp.CompletionListAcceptOpts : blink.cmp.CompletionListSelectAndAcceptOpts +--- @field index? number The index of the item to accept, if not provided, the currently selected item will be accepted + +--- @class blink.cmp.CompletionListShowEvent +--- @field items blink.cmp.CompletionItem[] +--- @field context blink.cmp.Context + +--- @class blink.cmp.CompletionListHideEvent +--- @field context blink.cmp.Context + +--- @class blink.cmp.CompletionListSelectEvent +--- @field idx? number +--- @field item? blink.cmp.CompletionItem +--- @field items blink.cmp.CompletionItem[] +--- @field context blink.cmp.Context + +--- @class blink.cmp.CompletionListAcceptEvent +--- @field item blink.cmp.CompletionItem +--- @field context blink.cmp.Context + +--- @type blink.cmp.CompletionList +--- @diagnostic disable-next-line: missing-fields +local list = { + select_emitter = require('blink.cmp.lib.event_emitter').new('select', 'BlinkCmpListSelect'), + accept_emitter = require('blink.cmp.lib.event_emitter').new('accept', 'BlinkCmpAccept'), + show_emitter = require('blink.cmp.lib.event_emitter').new('show', 'BlinkCmpShow'), + hide_emitter = require('blink.cmp.lib.event_emitter').new('hide', 'BlinkCmpHide'), + config = require('blink.cmp.config').completion.list, + context = nil, + items = {}, + is_explicitly_selected = false, + preview_undo = nil, +} + +---------- State ---------- + +function list.show(context, items_by_source) + -- reset state for new context + local is_new_context = not list.context or list.context.id ~= context.id + if is_new_context then + list.preview_undo = nil + list.is_explicitly_selected = false + end + + -- if the keyword changed, the list is no longer explicitly selected + local bounds_equal = list.context ~= nil + and list.context.bounds.start_col == context.bounds.start_col + and list.context.bounds.length == context.bounds.length + if not bounds_equal then list.is_explicitly_selected = false end + + local previous_selected_item = list.get_selected_item() + + -- update the context/list and emit + list.context = context + list.items = list.fuzzy(context, items_by_source) + + if #list.items == 0 then + list.hide_emitter:emit({ context = context }) + else + list.show_emitter:emit({ items = list.items, context = context }) + end + + -- maintain the selection if the user selected an item + local previous_item_idx = list.get_item_idx_in_list(previous_selected_item) + if list.is_explicitly_selected and previous_item_idx ~= nil and previous_item_idx <= 10 then + list.select(previous_item_idx, { auto_insert = false, undo_preview = false }) + + -- otherwise, use the default selection + else + list.select( + list.get_selection_mode(list.context).preselect and 1 or nil, + { auto_insert = false, undo_preview = false, is_explicit_selection = false } + ) + end +end + +function list.fuzzy(context, items_by_source) + local fuzzy = require('blink.cmp.fuzzy') + local filtered_items = fuzzy.fuzzy( + context.get_line(), + context.get_cursor()[2], + items_by_source, + require('blink.cmp.config').completion.keyword.range + ) + + -- apply the per source max_items + filtered_items = require('blink.cmp.sources.lib').apply_max_items_for_completions(context, filtered_items) + + -- apply the global max_items + return require('blink.cmp.lib.utils').slice(filtered_items, 1, list.config.max_items) +end + +function list.hide() list.hide_emitter:emit({ context = list.context }) end + +---------- Selection ---------- + +function list.get_selected_item() return list.items[list.selected_item_idx] end + +function list.get_selection_mode(context) + assert(context ~= nil, 'Context must be set before getting selection mode') + + local preselect = list.config.selection.preselect + if type(preselect) == 'function' then preselect = preselect(context) end + --- @cast preselect boolean + + local auto_insert = list.config.selection.auto_insert + if type(auto_insert) == 'function' then auto_insert = auto_insert(context) end + --- @cast auto_insert boolean + + return { preselect = preselect, auto_insert = auto_insert } +end + +function list.get_item_idx_in_list(item) + if item == nil then return end + return require('blink.cmp.lib.utils').find_idx(list.items, function(i) return i.label == item.label end) +end + +function list.select(idx, opts) + opts = opts or {} + local item = list.items[idx] + + local auto_insert = opts.auto_insert + if auto_insert == nil then auto_insert = list.get_selection_mode(list.context).auto_insert end + + require('blink.cmp.completion.trigger').suppress_events_for_callback(function() + if opts.undo_preview ~= false then list.undo_preview() end + if auto_insert and item ~= nil then list.apply_preview(item) end + end) + + --- @diagnostic disable-next-line: assign-type-mismatch + list.is_explicitly_selected = opts.is_explicit_selection == nil and true or opts.is_explicit_selection + list.selected_item_idx = idx + list.select_emitter:emit({ idx = idx, item = item, items = list.items, context = list.context }) +end + +function list.select_next(opts) + if #list.items == 0 or list.context == nil then return end + + -- haven't selected anything yet, select the first item + if list.selected_item_idx == nil then return list.select(1, opts) end + + -- end of the list + if list.selected_item_idx == #list.items then + -- cycling around has been disabled, ignore + if not list.config.cycle.from_bottom then return end + + -- preselect is not enabled, we go back to no selection + if not list.get_selection_mode(list.context).preselect then return list.select(nil, opts) end + + -- otherwise, we cycle around + return list.select(1, opts) + end + + -- typical case, select the next item + list.select(list.selected_item_idx + 1, opts) +end + +function list.select_prev(opts) + if #list.items == 0 or list.context == nil then return end + + -- haven't selected anything yet, select the last item + if list.selected_item_idx == nil then return list.select(#list.items, opts) end + + -- start of the list + if list.selected_item_idx == 1 then + -- cycling around has been disabled, ignore + if not list.config.cycle.from_top then return end + + -- auto_insert is enabled, we go back to no selection + if list.get_selection_mode(list.context).auto_insert then return list.select(nil, opts) end + + -- otherwise, we cycle around + return list.select(#list.items, opts) + end + + -- typical case, select the previous item + list.select(list.selected_item_idx - 1, opts) +end + +---------- Preview ---------- + +function list.undo_preview() + if list.preview_undo == nil then return end + + require('blink.cmp.lib.text_edits').apply({ list.preview_undo.text_edit }) + if list.preview_undo.cursor then + require('blink.cmp.completion.trigger.context').set_cursor(list.preview_undo.cursor) + end + list.preview_undo = nil +end + +function list.apply_preview(item) + -- undo the previous preview if it exists + list.undo_preview() + -- apply the new preview + local undo_text_edit, undo_cursor = require('blink.cmp.completion.accept.preview')(item) + list.preview_undo = { text_edit = undo_text_edit, cursor = undo_cursor } +end + +---------- Accept ---------- + +function list.accept(opts) + opts = opts or {} + local item = list.items[opts.index or list.selected_item_idx] + if item == nil then return false end + + list.undo_preview() + local accept = require('blink.cmp.completion.accept') + accept(list.context, item, function() + list.accept_emitter:emit({ item = item, context = list.context }) + if opts.callback then opts.callback() end + end) + return true +end + +return list diff --git a/lua/blink/cmp/completion/prefetch.lua b/lua/blink/cmp/completion/prefetch.lua new file mode 100644 index 0000000..c722a30 --- /dev/null +++ b/lua/blink/cmp/completion/prefetch.lua @@ -0,0 +1,29 @@ +-- Run `resolve` on the item ahead of time to avoid delays +-- when accepting the item or showing documentation + +local last_context_id = nil +local last_request = nil +local timer = vim.uv.new_timer() + +--- @param context blink.cmp.Context +--- @param item blink.cmp.CompletionItem +local function prefetch_resolve(context, item) + if not item then return end + + local resolve = vim.schedule_wrap(function() + if last_request ~= nil then last_request:cancel() end + last_request = require('blink.cmp.sources.lib').resolve(context, item) + end) + + -- immediately resolve if the context has changed + if last_context_id ~= context.id then + last_context_id = context.id + resolve() + end + + -- otherwise, wait for the debounce period + timer:stop() + timer:start(50, 0, resolve) +end + +return prefetch_resolve diff --git a/lua/blink/cmp/completion/trigger/context.lua b/lua/blink/cmp/completion/trigger/context.lua new file mode 100644 index 0000000..6eb367d --- /dev/null +++ b/lua/blink/cmp/completion/trigger/context.lua @@ -0,0 +1,118 @@ +-- TODO: remove the end_col field from ContextBounds + +--- @class blink.cmp.ContextBounds +--- @field line string +--- @field line_number number +--- @field start_col number +--- @field length number + +--- @class blink.cmp.Context +--- @field mode blink.cmp.Mode +--- @field id number +--- @field bufnr number +--- @field cursor number[] +--- @field line string +--- @field bounds blink.cmp.ContextBounds +--- @field trigger blink.cmp.ContextTrigger +--- @field providers string[] +--- +--- @field new fun(opts: blink.cmp.ContextOpts): blink.cmp.Context +--- @field get_keyword fun(): string +--- @field within_query_bounds fun(self: blink.cmp.Context, cursor: number[]): boolean +--- +--- @field get_mode fun(): blink.cmp.Mode +--- @field get_cursor fun(): number[] +--- @field set_cursor fun(cursor: number[]) +--- @field get_line fun(num?: number): string +--- @field get_bounds fun(range: blink.cmp.CompletionKeywordRange): blink.cmp.ContextBounds + +--- @class blink.cmp.ContextTrigger +--- @field initial_kind blink.cmp.CompletionTriggerKind The trigger kind when the context was first created +--- @field initial_character? string The trigger character when initial_kind == 'trigger_character' +--- @field kind blink.cmp.CompletionTriggerKind The current trigger kind +--- @field character? string The trigger character when kind == 'trigger_character' + +--- @class blink.cmp.ContextOpts +--- @field id number +--- @field providers string[] +--- @field initial_trigger_kind blink.cmp.CompletionTriggerKind +--- @field initial_trigger_character? string +--- @field trigger_kind blink.cmp.CompletionTriggerKind +--- @field trigger_character? string + +--- @type blink.cmp.Context +--- @diagnostic disable-next-line: missing-fields +local context = {} + +function context.new(opts) + local cursor = context.get_cursor() + local line = context.get_line() + + return setmetatable({ + mode = context.get_mode(), + id = opts.id, + bufnr = vim.api.nvim_get_current_buf(), + cursor = cursor, + line = line, + bounds = context.get_bounds('full'), + trigger = { + initial_kind = opts.initial_trigger_kind, + initial_character = opts.initial_trigger_character, + kind = opts.trigger_kind, + character = opts.trigger_character, + }, + providers = opts.providers, + }, { __index = context }) +end + +function context.get_keyword() + local keyword = require('blink.cmp.config').completion.keyword + local range = context.get_bounds(keyword.range) + return string.sub(context.get_line(), range.start_col, range.start_col + range.length - 1) +end + +--- @param cursor number[] +--- @return boolean +function context:within_query_bounds(cursor) + local row, col = cursor[1], cursor[2] + local bounds = self.bounds + return row == bounds.line_number and col >= bounds.start_col and col < (bounds.start_col + bounds.length) +end + +function context.get_mode() return vim.api.nvim_get_mode().mode == 'c' and 'cmdline' or 'default' end + +function context.get_cursor() + return context.get_mode() == 'cmdline' and { 1, vim.fn.getcmdpos() - 1 } or vim.api.nvim_win_get_cursor(0) +end + +function context.set_cursor(cursor) + local mode = context.get_mode() + if mode == 'default' then return vim.api.nvim_win_set_cursor(0, cursor) end + + assert(mode == 'cmdline', 'Unsupported mode for setting cursor: ' .. mode) + assert(cursor[1] == 1, 'Cursor must be on the first line in cmdline mode') + vim.fn.setcmdpos(cursor[2]) +end + +function context.get_line(num) + if context.get_mode() == 'cmdline' then + assert( + num == nil or num == 0, + 'Cannot get line number ' .. tostring(num) .. ' in cmdline mode. Only 0 is supported' + ) + return vim.fn.getcmdline() + end + + if num == nil then num = context.get_cursor()[1] - 1 end + return vim.api.nvim_buf_get_lines(0, num, num + 1, false)[1] +end + +--- Gets characters around the cursor and returns the range, 0-indexed +function context.get_bounds(range) + local line = context.get_line() + local cursor = context.get_cursor() + local start_col, end_col = require('blink.cmp.fuzzy').get_keyword_range(line, cursor[2], range) + return { line_number = cursor[1], start_col = start_col + 1, length = end_col - start_col } +end + +return context diff --git a/lua/blink/cmp/completion/trigger/init.lua b/lua/blink/cmp/completion/trigger/init.lua new file mode 100644 index 0000000..1bd330f --- /dev/null +++ b/lua/blink/cmp/completion/trigger/init.lua @@ -0,0 +1,241 @@ +--- @alias blink.cmp.CompletionTriggerKind 'manual' | 'prefetch' | 'keyword' | 'trigger_character' +--- +-- Handles hiding and showing the completion window. When a user types a trigger character +-- (provided by the sources) or anything matching the `keyword_regex`, we create a new `context`. +-- This can be used downstream to determine if we should make new requests to the sources or not. +--- @class blink.cmp.CompletionTrigger +--- @field buffer_events blink.cmp.BufferEvents +--- @field cmdline_events blink.cmp.CmdlineEvents +--- @field current_context_id number +--- @field context? blink.cmp.Context +--- @field show_emitter blink.cmp.EventEmitter<{ context: blink.cmp.Context }> +--- @field hide_emitter blink.cmp.EventEmitter<{}> +--- +--- @field activate fun() +--- @field is_keyword_character fun(char: string): boolean +--- @field is_trigger_character fun(char: string, is_show_on_x?: boolean): boolean +--- @field suppress_events_for_callback fun(cb: fun()) +--- @field show_if_on_trigger_character fun(opts?: { is_accept?: boolean }) +--- @field show fun(opts?: { trigger_kind: blink.cmp.CompletionTriggerKind, trigger_character?: string, force?: boolean, send_upstream?: boolean, providers?: string[] }): blink.cmp.Context? +--- @field hide fun() +--- @field within_query_bounds fun(cursor: number[]): boolean +--- @field get_bounds fun(regex: vim.regex, line: string, cursor: number[]): blink.cmp.ContextBounds + +local config = require('blink.cmp.config').completion.trigger +local context = require('blink.cmp.completion.trigger.context') +local utils = require('blink.cmp.completion.trigger.utils') + +--- @type blink.cmp.CompletionTrigger +--- @diagnostic disable-next-line: missing-fields +local trigger = { + current_context_id = -1, + show_emitter = require('blink.cmp.lib.event_emitter').new('show'), + hide_emitter = require('blink.cmp.lib.event_emitter').new('hide'), +} + +function trigger.activate() + trigger.buffer_events = require('blink.cmp.lib.buffer_events').new({ + -- TODO: should this ignore trigger.kind == 'prefetch'? + has_context = function() return trigger.context ~= nil end, + show_in_snippet = config.show_in_snippet, + }) + trigger.cmdline_events = require('blink.cmp.lib.cmdline_events').new() + + local function on_char_added(char, is_ignored) + -- we were told to ignore the text changed event, so we update the context + -- but don't send an on_show event upstream + if is_ignored then + if trigger.context ~= nil then trigger.show({ send_upstream = false, trigger_kind = 'keyword' }) end + + -- character forces a trigger according to the sources, create a fresh context + elseif trigger.is_trigger_character(char) and (config.show_on_trigger_character or trigger.context ~= nil) then + trigger.context = nil + trigger.show({ trigger_kind = 'trigger_character', trigger_character = char }) + + -- character is part of a keyword + elseif trigger.is_keyword_character(char) and (config.show_on_keyword or trigger.context ~= nil) then + trigger.show({ trigger_kind = 'keyword' }) + + -- nothing matches so hide + else + trigger.hide() + end + end + + local function on_cursor_moved(event, is_ignored) + local cursor = context.get_cursor() + local cursor_col = cursor[2] + + local char_under_cursor = utils.get_char_at_cursor() + local is_keyword = trigger.is_keyword_character(char_under_cursor) + + -- we were told to ignore the cursor moved event, so we update the context + -- but don't send an on_show event upstream + if is_ignored and event == 'CursorMoved' then + if trigger.context ~= nil then + -- TODO: If we `auto_insert` with the `path` source, we may end up on a trigger character + -- i.e. `downloads/`. If we naively update the context, we'll show the menu with the + -- existing context. So we clear the context if we're not on a keyword character. + -- Is there a better solution here? + if not is_keyword then trigger.context = nil end + + trigger.show({ send_upstream = false, trigger_kind = 'keyword' }) + end + return + end + + local is_on_trigger_for_show = trigger.is_trigger_character(char_under_cursor) + + -- TODO: doesn't handle `a` where the cursor moves immediately after + -- Reproducable with `example.|a` and pressing `a`, should not show the menu + local insert_enter_on_trigger_character = config.show_on_trigger_character + and config.show_on_insert_on_trigger_character + and event == 'InsertEnter' + and trigger.is_trigger_character(char_under_cursor, true) + + -- check if we're still within the bounds of the query used for the context + if trigger.context ~= nil and trigger.context:within_query_bounds(cursor) then + trigger.show({ trigger_kind = 'keyword' }) + + -- check if we've entered insert mode on a trigger character + -- or if we've moved onto a trigger character while open + elseif + insert_enter_on_trigger_character + or (is_on_trigger_for_show and trigger.context ~= nil and trigger.context.trigger.kind ~= 'prefetch') + then + trigger.context = nil + trigger.show({ trigger_kind = 'trigger_character', trigger_character = char_under_cursor }) + + -- show if we currently have a context, and we've moved outside of it's bounds by 1 char + elseif is_keyword and trigger.context ~= nil and cursor_col == trigger.context.bounds.start_col - 1 then + trigger.context = nil + trigger.show({ trigger_kind = 'keyword' }) + + -- prefetch completions without opening window on InsertEnter + elseif event == 'InsertEnter' and config.prefetch_on_insert then + trigger.show({ trigger_kind = 'prefetch' }) + + -- otherwise hide + else + trigger.hide() + end + end + + trigger.buffer_events:listen({ + on_char_added = on_char_added, + on_cursor_moved = on_cursor_moved, + on_insert_leave = function() trigger.hide() end, + }) + trigger.cmdline_events:listen({ + on_char_added = on_char_added, + on_cursor_moved = on_cursor_moved, + on_leave = function() trigger.hide() end, + }) +end + +function trigger.is_keyword_character(char) + -- special case for hyphen, since we don't consider a lone hyphen to be a keyword + if char == '-' then return true end + + local keyword_start_col, keyword_end_col = require('blink.cmp.fuzzy').get_keyword_range(char, #char, 'prefix') + return keyword_start_col ~= keyword_end_col +end + +function trigger.is_trigger_character(char, is_show_on_x) + local sources = require('blink.cmp.sources.lib') + local is_trigger = vim.tbl_contains(sources.get_trigger_characters(context.get_mode()), char) + + local show_on_blocked_trigger_characters = type(config.show_on_blocked_trigger_characters) == 'function' + and config.show_on_blocked_trigger_characters() + or config.show_on_blocked_trigger_characters + --- @cast show_on_blocked_trigger_characters string[] + local show_on_x_blocked_trigger_characters = type(config.show_on_x_blocked_trigger_characters) == 'function' + and config.show_on_x_blocked_trigger_characters() + or config.show_on_x_blocked_trigger_characters + --- @cast show_on_x_blocked_trigger_characters string[] + + local is_blocked = vim.tbl_contains(show_on_blocked_trigger_characters, char) + or (is_show_on_x and vim.tbl_contains(show_on_x_blocked_trigger_characters, char)) + + return is_trigger and not is_blocked +end + +--- Suppresses on_hide and on_show events for the duration of the callback +function trigger.suppress_events_for_callback(cb) + local mode = vim.api.nvim_get_mode().mode == 'c' and 'cmdline' or 'default' + + local events = mode == 'default' and trigger.buffer_events or trigger.cmdline_events + if not events then return cb() end + + events:suppress_events_for_callback(cb) +end + +function trigger.show_if_on_trigger_character(opts) + if + (opts and opts.is_accept) + and (not config.show_on_trigger_character or not config.show_on_accept_on_trigger_character) + then + return + end + + local cursor_col = context.get_cursor()[2] + local char_under_cursor = context.get_line():sub(cursor_col, cursor_col) + + if trigger.is_trigger_character(char_under_cursor, true) then + trigger.show({ trigger_kind = 'trigger_character', trigger_character = char_under_cursor }) + end +end + +function trigger.show(opts) + if not require('blink.cmp.config').enabled() then return trigger.hide() end + + opts = opts or {} + + -- already triggered at this position, ignore + local mode = context.get_mode() + local cursor = context.get_cursor() + if + not opts.force + and trigger.context ~= nil + and trigger.context.mode == mode + and cursor[1] == trigger.context.cursor[1] + and cursor[2] == trigger.context.cursor[2] + then + return + end + + -- update the context id to indicate a new context, and not an update to an existing context + if trigger.context == nil or opts.providers ~= nil then + trigger.current_context_id = trigger.current_context_id + 1 + end + + local providers = opts.providers + or (trigger.context and trigger.context.providers) + or require('blink.cmp.sources.lib').get_enabled_provider_ids(context.get_mode()) + + local initial_trigger_kind = trigger.context and trigger.context.trigger.initial_kind or opts.trigger_kind + -- if we prefetched, don't keep that as the initial trigger kind + if initial_trigger_kind == 'prefetch' then initial_trigger_kind = opts.trigger_kind end + -- if we're manually triggering, set it as the initial trigger kind + if opts.trigger_kind == 'manual' then initial_trigger_kind = 'manual' end + + trigger.context = context.new({ + id = trigger.current_context_id, + providers = providers, + initial_trigger_kind = initial_trigger_kind, + initial_trigger_character = trigger.context and trigger.context.trigger.initial_character or opts.trigger_character, + trigger_kind = opts.trigger_kind, + trigger_character = opts.trigger_character, + }) + + if opts.send_upstream ~= false then trigger.show_emitter:emit({ context = trigger.context }) end + return trigger.context +end + +function trigger.hide() + if not trigger.context then return end + trigger.context = nil + trigger.hide_emitter:emit() +end + +return trigger diff --git a/lua/blink/cmp/completion/trigger/utils.lua b/lua/blink/cmp/completion/trigger/utils.lua new file mode 100644 index 0000000..b2878c2 --- /dev/null +++ b/lua/blink/cmp/completion/trigger/utils.lua @@ -0,0 +1,30 @@ +local context = require('blink.cmp.completion.trigger.context') +local utils = {} + +--- Gets the full Unicode character at cursor position +--- @return string +function utils.get_char_at_cursor() + local line = context.get_line() + if line == '' then return '' end + local cursor_col = context.get_cursor()[2] + + -- Find the start of the UTF-8 character + local start_col = cursor_col + while start_col > 1 do + local char = string.byte(line:sub(start_col, start_col)) + if char < 0x80 or char > 0xBF then break end + start_col = start_col - 1 + end + + -- Find the end of the UTF-8 character + local end_col = cursor_col + while end_col < #line do + local char = string.byte(line:sub(end_col + 1, end_col + 1)) + if char < 0x80 or char > 0xBF then break end + end_col = end_col + 1 + end + + return line:sub(start_col, end_col) +end + +return utils diff --git a/lua/blink/cmp/completion/windows/documentation.lua b/lua/blink/cmp/completion/windows/documentation.lua new file mode 100644 index 0000000..69a6dd8 --- /dev/null +++ b/lua/blink/cmp/completion/windows/documentation.lua @@ -0,0 +1,228 @@ +--- @class blink.cmp.CompletionDocumentationWindow +--- @field win blink.cmp.Window +--- @field last_context_id? number +--- @field auto_show_timer uv_timer_t +--- @field shown_item? blink.cmp.CompletionItem +--- +--- @field auto_show_item fun(context: blink.cmp.Context, item: blink.cmp.CompletionItem) +--- @field show_item fun(context: blink.cmp.Context, item: blink.cmp.CompletionItem) +--- @field update_position fun() +--- @field scroll_up fun(amount: number) +--- @field scroll_down fun(amount: number) +--- @field close fun() + +local config = require('blink.cmp.config').completion.documentation +local win_config = config.window + +local sources = require('blink.cmp.sources.lib') +local menu = require('blink.cmp.completion.windows.menu') + +--- @type blink.cmp.CompletionDocumentationWindow +--- @diagnostic disable-next-line: missing-fields +local docs = { + win = require('blink.cmp.lib.window').new({ + min_width = win_config.min_width, + max_width = win_config.max_width, + max_height = win_config.max_height, + border = win_config.border, + winblend = win_config.winblend, + winhighlight = win_config.winhighlight, + scrollbar = win_config.scrollbar, + wrap = true, + filetype = 'blink-cmp-documentation', + scrolloff = 0, + }), + last_context_id = nil, + auto_show_timer = vim.uv.new_timer(), +} + +menu.position_update_emitter:on(function() docs.update_position() end) +menu.close_emitter:on(function() docs.close() end) + +function docs.auto_show_item(context, item) + docs.auto_show_timer:stop() + if docs.win:is_open() then + docs.auto_show_timer:start(config.update_delay_ms, 0, function() + vim.schedule(function() docs.show_item(context, item) end) + end) + elseif config.auto_show then + docs.auto_show_timer:start(config.auto_show_delay_ms, 0, function() + vim.schedule(function() docs.show_item(context, item) end) + end) + end +end + +function docs.show_item(context, item) + docs.auto_show_timer:stop() + if item == nil or not menu.win:is_open() then return docs.win:close() end + + -- TODO: cancellation + -- TODO: only resolve if documentation does not exist + sources + .resolve(context, item) + ---@param item blink.cmp.CompletionItem + :map(function(item) + if item.documentation == nil and item.detail == nil then + docs.close() + return + end + + if docs.shown_item ~= item then + --- @type blink.cmp.RenderDetailAndDocumentationOpts + local default_render_opts = { + bufnr = docs.win:get_buf(), + detail = item.detail, + documentation = item.documentation, + max_width = docs.win.config.max_width, + use_treesitter_highlighting = config and config.treesitter_highlighting, + } + local render = require('blink.cmp.lib.window.docs').render_detail_and_documentation + + if item.documentation and item.documentation.render ~= nil then + -- let the provider render the documentation and optionally override + -- the default rendering + item.documentation.render({ + item = item, + window = docs.win, + default_implementation = function(opts) render(vim.tbl_extend('force', default_render_opts, opts)) end, + }) + else + render(default_render_opts) + end + end + docs.shown_item = item + + if menu.win:get_win() then + docs.win:open() + docs.win:set_cursor({ 1, 0 }) -- reset scroll + docs.update_position() + end + end) + :catch(function(err) vim.notify(err, vim.log.levels.ERROR, { title = 'blink.cmp' }) end) +end + +-- TODO: compensate for wrapped lines +function docs.scroll_up(amount) + local winnr = docs.win:get_win() + if winnr == nil then return end + + local top_line = math.max(1, vim.fn.line('w0', winnr)) + local desired_line = math.max(1, top_line - amount) + + docs.win:set_cursor({ desired_line, 0 }) +end + +-- TODO: compensate for wrapped lines +function docs.scroll_down(amount) + local winnr = docs.win:get_win() + if winnr == nil then return end + + local line_count = vim.api.nvim_buf_line_count(docs.win:get_buf()) + local bottom_line = math.max(1, vim.fn.line('w$', winnr)) + local desired_line = math.min(line_count, bottom_line + amount) + + docs.win:set_cursor({ desired_line, 0 }) +end + +function docs.update_position() + if not docs.win:is_open() or not menu.win:is_open() then return end + + docs.win:update_size() + + local menu_winnr = menu.win:get_win() + if not menu_winnr then return end + local menu_win_config = vim.api.nvim_win_get_config(menu_winnr) + local menu_win_height = menu.win:get_height() + local menu_border_size = menu.win:get_border_size() + + local cursor_win_row = vim.fn.winline() + + -- decide direction priority based on the menu window's position + local menu_win_is_up = menu_win_config.row - cursor_win_row < 0 + local direction_priority = menu_win_is_up and win_config.direction_priority.menu_north + or win_config.direction_priority.menu_south + + -- remove the direction priority of the signature window if it's open + local signature = require('blink.cmp.signature.window') + if signature.win and signature.win:is_open() then + direction_priority = vim.tbl_filter( + function(dir) return dir ~= (menu_win_is_up and 's' or 'n') end, + direction_priority + ) + end + + -- decide direction, width and height of window + local win_width = docs.win:get_width() + local win_height = docs.win:get_height() + local pos = docs.win:get_direction_with_window_constraints(menu.win, direction_priority, { + width = math.min(win_width, win_config.desired_min_width), + height = math.min(win_height, win_config.desired_min_height), + }) + + -- couldn't find anywhere to place the window + if not pos then + docs.win:close() + return + end + + -- set width and height based on available space + docs.win:set_height(pos.height) + docs.win:set_width(pos.width) + + -- set position based on provided direction + + local height = docs.win:get_height() + local width = docs.win:get_width() + + local function set_config(opts) + docs.win:set_win_config({ relative = 'win', win = menu_winnr, row = opts.row, col = opts.col }) + end + if pos.direction == 'n' then + if menu_win_is_up then + set_config({ row = -height - menu_border_size.top, col = -menu_border_size.left }) + else + set_config({ row = -1 - height - menu_border_size.top, col = -menu_border_size.left }) + end + elseif pos.direction == 's' then + if menu_win_is_up then + set_config({ + row = 1 + menu_win_height - menu_border_size.top, + col = -menu_border_size.left, + }) + else + set_config({ + row = menu_win_height - menu_border_size.top, + col = -menu_border_size.left, + }) + end + elseif pos.direction == 'e' then + if menu_win_is_up and menu_win_height < height then + set_config({ + row = menu_win_height - menu_border_size.top - height, + col = menu_win_config.width + menu_border_size.right, + }) + else + set_config({ + row = -menu_border_size.top, + col = menu_win_config.width + menu_border_size.right, + }) + end + elseif pos.direction == 'w' then + if menu_win_is_up and menu_win_height < height then + set_config({ + row = menu_win_height - menu_border_size.top - height, + col = -width - menu_border_size.left, + }) + else + set_config({ row = -menu_border_size.top, col = -width - menu_border_size.left }) + end + end +end + +function docs.close() + docs.win:close() + docs.auto_show_timer:stop() + docs.shown_item = nil +end + +return docs diff --git a/lua/blink/cmp/completion/windows/ghost_text.lua b/lua/blink/cmp/completion/windows/ghost_text.lua new file mode 100644 index 0000000..2869e95 --- /dev/null +++ b/lua/blink/cmp/completion/windows/ghost_text.lua @@ -0,0 +1,100 @@ +local config = require('blink.cmp.config').completion.ghost_text +local highlight_ns = require('blink.cmp.config').appearance.highlight_ns +local text_edits_lib = require('blink.cmp.lib.text_edits') +local snippets_utils = require('blink.cmp.sources.snippets.utils') + +--- @class blink.cmp.windows.GhostText +--- @field win integer? +--- @field selected_item blink.cmp.CompletionItem? +--- @field extmark_id integer? +--- +--- @field is_open fun(): boolean +--- @field show_preview fun(item: blink.cmp.CompletionItem) +--- @field clear_preview fun() +--- @field draw_preview fun(bufnr: number) + +--- @type blink.cmp.windows.GhostText +--- @diagnostic disable-next-line: missing-fields +local ghost_text = { + win = nil, + selected_item = nil, + extmark_id = nil, +} + +--- @param textEdit lsp.TextEdit +local function get_still_untyped_text(textEdit) + local type_text_length = textEdit.range['end'].character - textEdit.range.start.character + return textEdit.newText:sub(type_text_length + 1) +end + +-- immediately re-draw the preview when the cursor moves/text changes +vim.api.nvim_create_autocmd({ 'CursorMovedI', 'TextChangedI' }, { + callback = function() + if config.enabled and ghost_text.win then ghost_text.draw_preview(vim.api.nvim_win_get_buf(ghost_text.win)) end + end, +}) + +function ghost_text.is_open() return ghost_text.extmark_id ~= nil end + +--- @param selected_item? blink.cmp.CompletionItem +function ghost_text.show_preview(selected_item) + -- nothing to show, clear the preview + if not selected_item then + ghost_text.clear_preview() + return + end + + -- doesn't work in command mode + -- TODO: integrate with noice.nvim? + if vim.api.nvim_get_mode().mode == 'c' then return end + + -- update state and redraw + local changed = ghost_text.selected_item ~= selected_item + ghost_text.selected_item = selected_item + ghost_text.win = vim.api.nvim_get_current_win() + if changed then ghost_text.draw_preview(vim.api.nvim_win_get_buf(ghost_text.win)) end +end + +function ghost_text.clear_preview() + ghost_text.selected_item = nil + ghost_text.win = nil + if ghost_text.extmark_id ~= nil then + vim.api.nvim_buf_del_extmark(0, highlight_ns, ghost_text.extmark_id) + ghost_text.extmark_id = nil + end +end + +function ghost_text.draw_preview(bufnr) + if not ghost_text.selected_item then return end + + local text_edit = text_edits_lib.get_from_item(ghost_text.selected_item) + + if ghost_text.selected_item.insertTextFormat == vim.lsp.protocol.InsertTextFormat.Snippet then + local expanded_snippet = snippets_utils.safe_parse(text_edit.newText) + text_edit.newText = expanded_snippet and tostring(expanded_snippet) or text_edit.newText + end + + local display_lines = vim.split(get_still_untyped_text(text_edit), '\n', { plain = true }) or {} + + local virt_lines = {} + if #display_lines > 1 then + for i = 2, #display_lines do + virt_lines[i - 1] = { { display_lines[i], 'BlinkCmpGhostText' } } + end + end + + local cursor_pos = { + text_edit.range.start.line, + text_edit.range['end'].character, + } + + ghost_text.extmark_id = vim.api.nvim_buf_set_extmark(bufnr, highlight_ns, cursor_pos[1], cursor_pos[2], { + id = ghost_text.extmark_id, + virt_text_pos = 'inline', + virt_text = { { display_lines[1], 'BlinkCmpGhostText' } }, + virt_lines = virt_lines, + hl_mode = 'combine', + }) +end + +return ghost_text diff --git a/lua/blink/cmp/completion/windows/menu.lua b/lua/blink/cmp/completion/windows/menu.lua new file mode 100644 index 0000000..a749ddd --- /dev/null +++ b/lua/blink/cmp/completion/windows/menu.lua @@ -0,0 +1,136 @@ +--- @class blink.cmp.CompletionMenu +--- @field win blink.cmp.Window +--- @field items blink.cmp.CompletionItem[] +--- @field renderer blink.cmp.Renderer +--- @field selected_item_idx? number +--- @field context blink.cmp.Context? +--- @field open_emitter blink.cmp.EventEmitter<{}> +--- @field close_emitter blink.cmp.EventEmitter<{}> +--- @field position_update_emitter blink.cmp.EventEmitter<{}> +--- +--- @field open_with_items fun(context: blink.cmp.Context, items: blink.cmp.CompletionItem[]) +--- @field open fun() +--- @field close fun() +--- @field set_selected_item_idx fun(idx?: number) +--- @field update_position fun() +--- @field redraw_if_needed fun() + +local config = require('blink.cmp.config').completion.menu + +--- @type blink.cmp.CompletionMenu +--- @diagnostic disable-next-line: missing-fields +local menu = { + win = require('blink.cmp.lib.window').new({ + min_width = config.min_width, + max_height = config.max_height, + border = config.border, + winblend = config.winblend, + winhighlight = config.winhighlight, + cursorline = false, + scrolloff = config.scrolloff, + scrollbar = config.scrollbar, + filetype = 'blink-cmp-menu', + }), + items = {}, + context = nil, + auto_show = config.auto_show, + open_emitter = require('blink.cmp.lib.event_emitter').new('completion_menu_open', 'BlinkCmpMenuOpen'), + close_emitter = require('blink.cmp.lib.event_emitter').new('completion_menu_close', 'BlinkCmpMenuClose'), + position_update_emitter = require('blink.cmp.lib.event_emitter').new( + 'completion_menu_position_update', + 'BlinkCmpMenuPositionUpdate' + ), +} + +vim.api.nvim_create_autocmd({ 'CursorMovedI', 'WinScrolled', 'WinResized' }, { + callback = function() menu.update_position() end, +}) + +function menu.open_with_items(context, items) + menu.context = context + menu.items = items + menu.selected_item_idx = menu.selected_item_idx ~= nil and math.min(menu.selected_item_idx, #items) or nil + + if not menu.renderer then menu.renderer = require('blink.cmp.completion.windows.render').new(config.draw) end + menu.renderer:draw(context, menu.win:get_buf(), items) + + local auto_show = menu.auto_show + if type(auto_show) == 'function' then auto_show = auto_show(context, items) end + if auto_show then + menu.open() + menu.update_position() + end +end + +function menu.open() + if menu.win:is_open() then return end + + menu.win:open() + if menu.selected_item_idx ~= nil then + vim.api.nvim_win_set_cursor(menu.win:get_win(), { menu.selected_item_idx, 0 }) + end + + menu.open_emitter:emit() +end + +function menu.close() + menu.auto_show = config.auto_show + if not menu.win:is_open() then return end + + menu.win:close() + menu.close_emitter:emit() +end + +function menu.set_selected_item_idx(idx) + menu.win:set_option_value('cursorline', idx ~= nil) + menu.selected_item_idx = idx + if menu.win:is_open() then menu.win:set_cursor({ idx or 1, 0 }) end +end + +--- TODO: Don't switch directions if the context is the same +function menu.update_position() + local context = menu.context + if context == nil then return end + + local win = menu.win + if not win:is_open() then return end + + win:update_size() + + local border_size = win:get_border_size() + local pos = win:get_vertical_direction_and_height(config.direction_priority) + + -- couldn't find anywhere to place the window + if not pos then + win:close() + return + end + + local alignment_start_col = menu.renderer:get_alignment_start_col() + + -- place the window at the start col of the current text we're fuzzy matching against + -- so the window doesnt move around as we type + local row = pos.direction == 's' and 1 or -pos.height - border_size.vertical + + if vim.api.nvim_get_mode().mode == 'c' then + local cmdline_position = config.cmdline_position() + win:set_win_config({ + relative = 'editor', + row = cmdline_position[1] + row, + col = math.max(cmdline_position[2] + context.bounds.start_col - alignment_start_col, 0), + }) + else + local cursor_col = context.get_cursor()[2] + + local col = context.bounds.start_col - alignment_start_col - cursor_col - 1 - border_size.left + if config.draw.align_to == 'cursor' then col = 0 end + + win:set_win_config({ relative = 'cursor', row = row, col = col }) + end + + win:set_height(pos.height) + + menu.position_update_emitter:emit() +end + +return menu diff --git a/lua/blink/cmp/completion/windows/render/column.lua b/lua/blink/cmp/completion/windows/render/column.lua new file mode 100644 index 0000000..b9a75d0 --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/column.lua @@ -0,0 +1,120 @@ +--- @class blink.cmp.DrawColumn +--- @field components blink.cmp.DrawComponent[] +--- @field gap number +--- @field lines string[][] +--- @field width number +--- @field ctxs blink.cmp.DrawItemContext[] +--- +--- @field new fun(components: blink.cmp.DrawComponent[], gap: number): blink.cmp.DrawColumn +--- @field render fun(self: blink.cmp.DrawColumn, ctxs: blink.cmp.DrawItemContext[]) +--- @field get_line_text fun(self: blink.cmp.DrawColumn, line_idx: number): string +--- @field get_line_highlights fun(self: blink.cmp.DrawColumn, line_idx: number): blink.cmp.DrawHighlight[] + +local text_lib = require('blink.cmp.completion.windows.render.text') + +--- @type blink.cmp.DrawColumn +--- @diagnostic disable-next-line: missing-fields +local column = {} + +function column.new(components, gap) + local self = setmetatable({}, { __index = column }) + self.components = components + self.gap = gap + self.lines = {} + self.width = 0 + self.ctxs = {} + return self +end + +function column:render(ctxs) + --- render text and get the max widths of each component + --- @type string[][] + local lines = {} + local max_component_widths = {} + for _, ctx in ipairs(ctxs) do + --- @type string[] + local line = {} + for component_idx, component in ipairs(self.components) do + local text = text_lib.apply_component_width(component.text(ctx) or '', component) + table.insert(line, text) + max_component_widths[component_idx] = + math.max(max_component_widths[component_idx] or 0, vim.api.nvim_strwidth(text)) + end + table.insert(lines, line) + end + + --- get the total width of the column + local column_width = 0 + for _, max_component_width in ipairs(max_component_widths) do + if max_component_width > 0 then column_width = column_width + max_component_width + self.gap end + end + column_width = math.max(column_width - self.gap, 0) + + --- find the component that will fill the empty space + local fill_idx = -1 + for component_idx, component in ipairs(self.components) do + if component.width and component.width.fill then + fill_idx = component_idx + break + end + end + if fill_idx == -1 then fill_idx = #self.components end + + --- and add extra spaces until we reach the column width + for _, line in ipairs(lines) do + local line_width = 0 + for _, component_text in ipairs(line) do + if #component_text > 0 then line_width = line_width + vim.api.nvim_strwidth(component_text) + self.gap end + end + line_width = line_width - self.gap + local remaining_width = column_width - line_width + line[fill_idx] = text_lib.pad(line[fill_idx], vim.api.nvim_strwidth(line[fill_idx]) + remaining_width) + end + + -- store results for later + self.width = column_width + self.lines = lines + self.ctxs = ctxs +end + +function column:get_line_text(line_idx) + local text = '' + local line = self.lines[line_idx] + for _, component in ipairs(line) do + if #component > 0 then text = text .. component .. string.rep(' ', self.gap) end + end + return text:sub(1, -self.gap - 1) +end + +function column:get_line_highlights(line_idx) + local ctx = self.ctxs[line_idx] + local offset = 0 + local highlights = {} + + for component_idx, component in ipairs(self.components) do + local text = self.lines[line_idx][component_idx] + if #text > 0 then + local column_highlights = type(component.highlight) == 'function' and component.highlight(ctx, text) + or component.highlight + + if type(column_highlights) == 'string' then + table.insert(highlights, { offset, offset + #text, group = column_highlights }) + elseif type(column_highlights) == 'table' then + for _, highlight in ipairs(column_highlights) do + table.insert(highlights, { + offset + highlight[1], + offset + highlight[2], + group = highlight.group, + params = highlight.params, + }) + end + end + + offset = offset + #text + self.gap + end + end + + return highlights +end + +return column diff --git a/lua/blink/cmp/completion/windows/render/context.lua b/lua/blink/cmp/completion/windows/render/context.lua new file mode 100644 index 0000000..301825f --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/context.lua @@ -0,0 +1,85 @@ +--- @class blink.cmp.DrawItemContext +--- @field self blink.cmp.Draw +--- @field item blink.cmp.CompletionItem +--- @field idx number +--- @field label string +--- @field label_detail string +--- @field label_description string +--- @field label_matched_indices number[] +--- @field kind string +--- @field kind_icon string +--- @field icon_gap string +--- @field deprecated boolean +--- @field source_id string +--- @field source_name string + +local draw_context = {} + +--- @param context blink.cmp.Context +--- @param draw blink.cmp.Draw +--- @param items blink.cmp.CompletionItem[] +--- @return blink.cmp.DrawItemContext[] +function draw_context.get_from_items(context, draw, items) + local matched_indices = require('blink.cmp.fuzzy').fuzzy_matched_indices( + context.get_line(), + context.get_cursor()[2], + vim.tbl_map(function(item) return item.label end, items), + require('blink.cmp.config').completion.keyword.range + ) + + local ctxs = {} + for idx, item in ipairs(items) do + ctxs[idx] = draw_context.new(draw, idx, item, matched_indices[idx]) + end + return ctxs +end + +local config = require('blink.cmp.config').appearance +local kinds = require('blink.cmp.types').CompletionItemKind + +--- @param draw blink.cmp.Draw +--- @param item_idx number +--- @param item blink.cmp.CompletionItem +--- @param matched_indices number[] +--- @return blink.cmp.DrawItemContext +function draw_context.new(draw, item_idx, item, matched_indices) + local kind = kinds[item.kind] or 'Unknown' + local kind_icon = require('blink.cmp.completion.windows.render.tailwind').get_kind_icon(item) + or config.kind_icons[kind] + or config.kind_icons.Field + local icon_spacing = config.nerd_font_variant == 'mono' and '' or ' ' + + -- Some LSPs can return labels with newlines + -- Escape them to avoid errors in nvim_buf_set_lines when rendering the completion menu + local newline_char = '↲' .. icon_spacing + + local label = item.label:gsub('\n', newline_char) .. (kind == 'Snippet' and '~' or '') + if config.nerd_font_variant == 'normal' then label = label:gsub('…', '… ') end + + local label_detail = (item.labelDetails and item.labelDetails.detail or ''):gsub('\n', newline_char) + if config.nerd_font_variant == 'normal' then label_detail = label_detail:gsub('…', '… ') end + + local label_description = (item.labelDetails and item.labelDetails.description or ''):gsub('\n', newline_char) + if config.nerd_font_variant == 'normal' then label_description = label_description:gsub('…', '… ') end + + local source_id = item.source_id + local source_name = item.source_name + + return { + self = draw, + item = item, + idx = item_idx, + label = label, + label_detail = label_detail, + label_description = label_description, + label_matched_indices = matched_indices, + kind = kind, + kind_icon = kind_icon, + icon_gap = config.nerd_font_variant == 'mono' and '' or ' ', + deprecated = item.deprecated or (item.tags and vim.tbl_contains(item.tags, 1)) or false, + source_id = source_id, + source_name = source_name, + } +end + +return draw_context diff --git a/lua/blink/cmp/completion/windows/render/init.lua b/lua/blink/cmp/completion/windows/render/init.lua new file mode 100644 index 0000000..1422ec2 --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/init.lua @@ -0,0 +1,146 @@ +--- @class blink.cmp.Renderer +--- @field def blink.cmp.Draw +--- @field padding number[] +--- @field gap number +--- @field columns blink.cmp.DrawColumn[] +--- +--- @field new fun(draw: blink.cmp.Draw): blink.cmp.Renderer +--- @field draw fun(self: blink.cmp.Renderer, context: blink.cmp.Context, bufnr: number, items: blink.cmp.CompletionItem[]) +--- @field get_component_column_location fun(self: blink.cmp.Renderer, component_name: string): { column_idx: number, component_idx: number } +--- @field get_component_start_col fun(self: blink.cmp.Renderer, component_name: string): number +--- @field get_alignment_start_col fun(self: blink.cmp.Renderer): number + +local ns = vim.api.nvim_create_namespace('blink_cmp_renderer') + +--- @type blink.cmp.Renderer +--- @diagnostic disable-next-line: missing-fields +local renderer = {} + +function renderer.new(draw) + --- Convert the component names in the columns to the component definitions + --- @type blink.cmp.DrawComponent[][] + local columns_definitions = vim.tbl_map(function(column) + local components = {} + for _, component_name in ipairs(column) do + local component = draw.components[component_name] + assert(component ~= nil, 'No component definition found for component: "' .. component_name .. '"') + table.insert(components, draw.components[component_name]) + end + + return { + components = components, + gap = column.gap or 0, + } + end, draw.columns) + + local padding = type(draw.padding) == 'number' and { draw.padding, draw.padding } or draw.padding + --- @cast padding number[] + + local self = setmetatable({}, { __index = renderer }) + self.padding = padding + self.gap = draw.gap + self.def = draw + self.columns = vim.tbl_map( + function(column_definition) + return require('blink.cmp.completion.windows.render.column').new( + column_definition.components, + column_definition.gap + ) + end, + columns_definitions + ) + return self +end + +function renderer:draw(context, bufnr, items) + -- gather contexts + local draw_contexts = require('blink.cmp.completion.windows.render.context').get_from_items(context, self.def, items) + + -- render the columns + for _, column in ipairs(self.columns) do + column:render(draw_contexts) + end + + -- apply to the buffer + local lines = {} + for idx, _ in ipairs(draw_contexts) do + local line = '' + if self.padding[1] > 0 then line = string.rep(' ', self.padding[1]) end + + for _, column in ipairs(self.columns) do + local text = column:get_line_text(idx) + if #text > 0 then line = line .. text .. string.rep(' ', self.gap) end + end + line = line:sub(1, -self.gap - 1) + + if self.padding[2] > 0 then line = line .. string.rep(' ', self.padding[2]) end + + table.insert(lines, line) + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.api.nvim_set_option_value('modified', false, { buf = bufnr }) + + -- Setting highlights is slow and we update on every keystroke so we instead use a decoration provider + -- which will only render highlights of the visible lines. This also avoids having to do virtual scroll + -- like nvim-cmp does, which breaks on UIs like neovide + vim.api.nvim_set_decoration_provider(ns, { + on_win = function(_, _, win_bufnr) return bufnr == win_bufnr end, + on_line = function(_, _, _, line) + local offset = self.padding[1] + for _, column in ipairs(self.columns) do + local text = column:get_line_text(line + 1) + if #text > 0 then + local highlights = column:get_line_highlights(line + 1) + for _, highlight in ipairs(highlights) do + local col = offset + highlight[1] + local end_col = offset + highlight[2] + vim.api.nvim_buf_set_extmark(bufnr, ns, line, col, { + end_col = end_col, + hl_group = highlight.group, + hl_mode = 'combine', + hl_eol = true, + ephemeral = true, + }) + end + offset = offset + #text + self.gap + end + end + end, + }) +end + +function renderer:get_component_column_location(component_name) + for column_idx, column in ipairs(self.def.columns) do + for component_idx, other_component_name in ipairs(column) do + if other_component_name == component_name then return { column_idx, component_idx } end + end + end + error('No component found with name: ' .. component_name) +end + +function renderer:get_component_start_col(component_name) + local column_idx, component_idx = unpack(self:get_component_column_location(component_name)) + + -- add previous columns + local start_col = self.padding[1] + for i = 1, column_idx - 1 do + start_col = start_col + self.columns[i].width + self.gap + end + + -- add previous components + local line = self.columns[column_idx].lines[1] + if not line then return start_col end + for i = 1, component_idx - 1 do + start_col = start_col + #line[i] + end + + return start_col +end + +function renderer:get_alignment_start_col() + local component_name = self.def.align_to + if component_name == nil or component_name == 'none' or component_name == 'cursor' then return 0 end + return self:get_component_start_col(component_name) +end + +return renderer diff --git a/lua/blink/cmp/completion/windows/render/tailwind.lua b/lua/blink/cmp/completion/windows/render/tailwind.lua new file mode 100644 index 0000000..0bff6b2 --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/tailwind.lua @@ -0,0 +1,31 @@ +local tailwind = {} + +local kinds = require('blink.cmp.types').CompletionItemKind + +--- @param item blink.cmp.CompletionItem +--- @return string|nil +function tailwind.get_hex_color(item) + local doc = item.documentation + if item.kind ~= kinds.Color or not doc then return end + local content = type(doc) == 'string' and doc or doc.value + if content and content:match('^#%x%x%x%x%x%x$') then return content end +end + +--- @param item blink.cmp.CompletionItem +--- @return string? +function tailwind.get_kind_icon(item) + if tailwind.get_hex_color(item) then return '██' end +end + +--- @param ctx blink.cmp.DrawItemContext +--- @return string|nil +function tailwind.get_hl(ctx) + local hex_color = tailwind.get_hex_color(ctx.item) + if not hex_color then return end + + local hl_name = 'HexColor' .. hex_color:sub(2) + if #vim.api.nvim_get_hl(0, { name = hl_name }) == 0 then vim.api.nvim_set_hl(0, hl_name, { fg = hex_color }) end + return hl_name +end + +return tailwind diff --git a/lua/blink/cmp/completion/windows/render/text.lua b/lua/blink/cmp/completion/windows/render/text.lua new file mode 100644 index 0000000..b614edc --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/text.lua @@ -0,0 +1,72 @@ +local config = require('blink.cmp.config') +local text_lib = {} + +--- Applies the component width settings to the text +--- @param text string +--- @param component blink.cmp.DrawComponent +--- @return string text +function text_lib.apply_component_width(text, component) + local width = component.width or {} + if width.fixed ~= nil then return text_lib.set_width(text, width.fixed, component) end + if width.min ~= nil then text = text_lib.pad(text, width.min) end + if width.max ~= nil then text = text_lib.truncate(text, width.max, component.ellipsis) end + return text +end + +--- Sets the text width to the given width +--- @param text string +--- @param width number +--- @param component blink.cmp.DrawComponent +--- @return string text +function text_lib.set_width(text, width, component) + local length = vim.api.nvim_strwidth(text) + if length > width then + return text_lib.truncate(text, width, component.ellipsis) + elseif length < width then + return text_lib.pad(text, width) + else + return text + end +end + +--- Truncates the text to the given width +--- @param text string +--- @param target_width number +--- @param ellipsis? boolean +--- @return string truncated_text +function text_lib.truncate(text, target_width, ellipsis) + local ellipsis_str = ellipsis ~= false and '…' or '' + if ellipsis ~= false and config.nerd_font_variant == 'normal' then ellipsis_str = ellipsis_str .. ' ' end + + local text_width = vim.api.nvim_strwidth(text) + local ellipsis_width = vim.api.nvim_strwidth(ellipsis_str) + if text_width > target_width then + return vim.fn.strcharpart(text, 0, target_width - ellipsis_width) .. ellipsis_str + end + return text +end + +--- Pads the text to the given width +--- @param text string +--- @param target_width number +--- @return string padded_text The amount of padding added to the left and the padded text +function text_lib.pad(text, target_width) + local text_width = vim.api.nvim_strwidth(text) + if text_width >= target_width then return text end + return text .. string.rep(' ', target_width - text_width) + + -- if alignment == 'left' then + -- return 0, text .. string.rep(' ', target_width - text_width) + -- elseif alignment == 'center' then + -- local extra_space = target_width - text_width + -- local half_width_start = math.floor(extra_space / 2) + -- local half_width_end = math.ceil(extra_space / 2) + -- return half_width_start, string.rep(' ', half_width_start) .. text .. string.rep(' ', half_width_end) + -- elseif alignment == 'right' then + -- return target_width - text_width, string.rep(' ', target_width - text_width) .. text + -- else + -- error('Invalid alignment: ' .. alignment) + -- end +end + +return text_lib diff --git a/lua/blink/cmp/completion/windows/render/treesitter.lua b/lua/blink/cmp/completion/windows/render/treesitter.lua new file mode 100644 index 0000000..901c46a --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/treesitter.lua @@ -0,0 +1,70 @@ +local treesitter = {} + +---@type table<string, blink.cmp.DrawHighlight[]> +local cache = {} +local cache_size = 0 +local MAX_CACHE_SIZE = 1000 + +--- @param ctx blink.cmp.DrawItemContext +--- @param opts? {offset?: number} +function treesitter.highlight(ctx, opts) + local ret = cache[ctx.label] + if not ret then + -- cleanup cache if it's too big + cache_size = cache_size + 1 + if cache_size > MAX_CACHE_SIZE then + cache = {} + cache_size = 0 + end + ret = treesitter._highlight(ctx) + cache[ctx.label] = ret + end + + -- offset highlights if needed + if opts and opts.offset then + ret = vim.deepcopy(ret) + for _, hl in ipairs(ret) do + hl[1] = hl[1] + opts.offset + hl[2] = hl[2] + opts.offset + end + end + return ret +end + +--- @param ctx blink.cmp.DrawItemContext +function treesitter._highlight(ctx) + local ret = {} ---@type blink.cmp.DrawHighlight[] + + local source = ctx.label + local lang = vim.treesitter.language.get_lang(vim.bo.filetype) + if not lang then return ret end + + local ok, parser = pcall(vim.treesitter.get_string_parser, source, lang) + if not ok then return ret end + + parser:parse(true) + + parser:for_each_tree(function(tstree, tree) + if not tstree then return end + local query = vim.treesitter.query.get(tree:lang(), 'highlights') + -- Some injected languages may not have highlight queries. + if not query then return end + + for capture, node in query:iter_captures(tstree:root(), source) do + local _, start_col, _, end_col = node:range() + + ---@type string + local name = query.captures[capture] + if name ~= 'spell' then + ret[#ret + 1] = { + start_col, + end_col, + group = '@' .. name .. '.' .. lang, + } + end + end + end) + return ret +end + +return treesitter diff --git a/lua/blink/cmp/completion/windows/render/types.lua b/lua/blink/cmp/completion/windows/render/types.lua new file mode 100644 index 0000000..186b3dc --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/types.lua @@ -0,0 +1,24 @@ +--- @class blink.cmp.Draw +--- @field align_to? string | 'none' | 'cursor' Align the window to the component with the given name, or to the cursor +--- @field padding? number | number[] Padding on the left and right of the grid +--- @field gap? number Gap between columns +--- @field columns? { [number]: string, gap?: number }[] Components to render, grouped by column +--- @field components? table<string, blink.cmp.DrawComponent> Component definitions +--- @field treesitter? string[] Use treesitter to highlight the label text of completions from these sources +--- +--- @class blink.cmp.DrawHighlight +--- @field [number] number Start and end index of the highlight +--- @field group? string Highlight group +--- @field params? table Additional parameters passed as the `params` field of the highlight +--- +--- @class blink.cmp.DrawWidth +--- @field fixed? number Fixed width +--- @field fill? boolean Fill the remaining space +--- @field min? number Minimum width +--- @field max? number Maximum width +--- +--- @class blink.cmp.DrawComponent +--- @field width? blink.cmp.DrawWidth +--- @field ellipsis? boolean Whether to add an ellipsis when truncating the text +--- @field text? fun(ctx: blink.cmp.DrawItemContext): string? Renders the text of the component +--- @field highlight? string | fun(ctx: blink.cmp.DrawItemContext, text: string): string | blink.cmp.DrawHighlight[] Renders the highlights of the component |
