summaryrefslogtreecommitdiff
path: root/lua/blink/cmp/completion
diff options
context:
space:
mode:
authorMike Vink <mike@pionative.com>2025-01-19 13:52:52 +0100
committerMike Vink <mike@pionative.com>2025-01-19 13:52:52 +0100
commitb77413ff8f59f380612074f0c9bd49093d8db695 (patch)
tree32c39a811ba96ed4ab0a1c81cce9f8d518ed7e31 /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')
-rw-r--r--lua/blink/cmp/completion/accept/init.lua110
-rw-r--r--lua/blink/cmp/completion/accept/prefix.lua58
-rw-r--r--lua/blink/cmp/completion/accept/preview.lua35
-rw-r--r--lua/blink/cmp/completion/brackets/config.lua37
-rw-r--r--lua/blink/cmp/completion/brackets/init.lua6
-rw-r--r--lua/blink/cmp/completion/brackets/kind.lua52
-rw-r--r--lua/blink/cmp/completion/brackets/semantic.lua109
-rw-r--r--lua/blink/cmp/completion/brackets/utils.lua61
-rw-r--r--lua/blink/cmp/completion/init.lua88
-rw-r--r--lua/blink/cmp/completion/list.lua250
-rw-r--r--lua/blink/cmp/completion/prefetch.lua29
-rw-r--r--lua/blink/cmp/completion/trigger/context.lua118
-rw-r--r--lua/blink/cmp/completion/trigger/init.lua241
-rw-r--r--lua/blink/cmp/completion/trigger/utils.lua30
-rw-r--r--lua/blink/cmp/completion/windows/documentation.lua228
-rw-r--r--lua/blink/cmp/completion/windows/ghost_text.lua100
-rw-r--r--lua/blink/cmp/completion/windows/menu.lua136
-rw-r--r--lua/blink/cmp/completion/windows/render/column.lua120
-rw-r--r--lua/blink/cmp/completion/windows/render/context.lua85
-rw-r--r--lua/blink/cmp/completion/windows/render/init.lua146
-rw-r--r--lua/blink/cmp/completion/windows/render/tailwind.lua31
-rw-r--r--lua/blink/cmp/completion/windows/render/text.lua72
-rw-r--r--lua/blink/cmp/completion/windows/render/treesitter.lua70
-rw-r--r--lua/blink/cmp/completion/windows/render/types.lua24
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