diff --git a/build.rs b/build.rs deleted file mode 100644 index 1744be2df..000000000 --- a/build.rs +++ /dev/null @@ -1,15 +0,0 @@ -fn main() { - // delete existing version file created by downloader - let _ = std::fs::remove_file("target/release/version"); - - // get current sha from git - let output = std::process::Command::new("git") - .args(["rev-parse", "HEAD"]) - .output() - .unwrap(); - let sha = String::from_utf8(output.stdout).unwrap(); - - // write to version - std::fs::create_dir_all("target/release").unwrap(); - std::fs::write("target/release/version", sha.trim()).unwrap(); -} diff --git a/doc/configuration/reference.md b/doc/configuration/reference.md index 6757b0a97..48287ae93 100644 --- a/doc/configuration/reference.md +++ b/doc/configuration/reference.md @@ -76,7 +76,7 @@ completion.trigger = { show_on_insert = false, -- LSPs can indicate when to show the completion window via trigger characters - -- however, some LSPs (i.e. tsserver) return characters that would essentially + -- however, some LSPs (e.g. tsserver) return characters that would essentially -- always show the window. We block these by default. show_on_blocked_trigger_characters = { ' ', '\n', '\t' }, -- You can also block per filetype with a function: @@ -414,7 +414,7 @@ fuzzy = { -- Location of the frecency database path = vim.fn.stdpath('state') .. '/blink/cmp/frecency.dat', -- UNSAFE!! When enabled, disables the lock and fsync when writing to the frecency database. - -- This should only be used on unsupported platforms (i.e. alpine termux) + -- This should only be used on unsupported platforms (e.g. alpine termux) unsafe_no_lock = false, }, use_frecency = true, -- deprecated alias for frecency.enabled, will be removed in v2.0 diff --git a/doc/development/architecture.md b/doc/development/architecture.md index 9904897ec..aa09c2bd9 100644 --- a/doc/development/architecture.md +++ b/doc/development/architecture.md @@ -1,7 +1,7 @@ # Architecture The plugin use a 4 stage pipeline: trigger -> sources -> fuzzy -> render -1. **Trigger:** Controls when to request completion items from the sources and provides a context downstream with the current query (i.e. `hello.wo|`, the query would be `wo`) and the treesitter object under the cursor (i.e. for intelligently enabling/disabling sources). It respects trigger characters passed by the LSP (or any other source) and includes it in the context for sending to the LSP. +1. **Trigger:** Controls when to request completion items from the sources and provides a context downstream with the current query (e.g. `hello.wo|`, the query would be `wo`) and the treesitter object under the cursor (i.e. for intelligently enabling/disabling sources). It respects trigger characters passed by the LSP (or any other source) and includes it in the context for sending to the LSP. 2. **Sources:** Provides a common interface for and merges the results of completion, trigger character, resolution of additional information and cancellation. Some sources are builtin: `LSP`, `buffer`, `path`, `snippets` 3. **Fuzzy:** Rust <-> Lua FFI which performs both filtering and sorting of the items - **Filtering:** The fuzzy matching uses smith-waterman, same as FZF, but implemented in SIMD for ~6x the performance of FZF (TODO: add benchmarks). Due to the SIMD's performance, the prefiltering phase on FZF was dropped to allow for typos. Similar to fzy/fzf, additional points are given to prefix matches, characters with capitals (to promote camelCase/PascalCase first char matching) and matches after delimiters (to promote snake_case first char matching) diff --git a/doc/development/source-boilerplate.md b/doc/development/source-boilerplate.md index 570d25f85..a829f9f75 100644 --- a/doc/development/source-boilerplate.md +++ b/doc/development/source-boilerplate.md @@ -104,12 +104,12 @@ function source:get_completions(ctx, callback) callback({ items = items, -- Whether blink.cmp should request items when deleting characters - -- from the keyword (i.e. "foo|" -> "fo|") + -- from the keyword (e.g. "foo|" -> "fo|") -- Note that any non-alphanumeric characters will always request -- new items (excluding `-` and `_`) is_incomplete_backward = false, -- Whether blink.cmp should request items when adding characters - -- to the keyword (i.e. "fo|" -> "foo|") + -- to the keyword (e.g. "fo|" -> "foo|") -- Note that any non-alphanumeric characters will always request -- new items (excluding `-` and `_`) is_incomplete_forward = false, @@ -121,7 +121,7 @@ function source:get_completions(ctx, callback) end -- (Optional) Before accepting the item or showing documentation, blink.cmp will call this function --- so you may avoid calculating expensive fields (i.e. documentation) for only when they're actually needed +-- so you may avoid calculating expensive fields (e.g. documentation) for only when they're actually needed -- Note only some fields may be resolved lazily. You may check the LSP capabilities for a complete list: -- `textDocument.completion.completionItem.resolveSupport` -- At the time of writing: 'documentation', 'detail', 'additionalTextEdits', 'command', 'data' diff --git a/doc/recipes.md b/doc/recipes.md index c28c8d322..89324f59a 100644 --- a/doc/recipes.md +++ b/doc/recipes.md @@ -31,7 +31,7 @@ vim.b.completion = false ### Disable completion in *only* shell command mode -When inside of git bash or WSL on windows, you may experience a hang with shell commands. The following disables cmdline completions only when running shell commands (i.e. `[':!' , ':%!']`), but still allows completion in other command modes (i.e. `[':' , ':help', '/' , '?', ...]`). +When inside of git bash or WSL on windows, you may experience a hang with shell commands. The following disables cmdline completions only when running shell commands (e.g. `[':!' , ':%!']`), but still allows completion in other command modes (i.e. `[':' , ':help', '/' , '?', ...]`). ```lua sources = { @@ -279,7 +279,7 @@ sources = { ### Buffer completion from all open buffers -The default behavior is to only show completions from **visible** "normal" buffers (i.e. it wouldn't include neo-tree). This will instead show completions from all buffers, even if they're not visible on screen. Note that the performance impact of this has not been tested. +The default behavior is to only show completions from **visible** "normal" buffers (e.g. it wouldn't include neo-tree). This will instead show completions from all buffers, even if they're not visible on screen. Note that the performance impact of this has not been tested. ```lua sources = { diff --git a/layout.lua b/layout.lua new file mode 100644 index 000000000..192e12bea --- /dev/null +++ b/layout.lua @@ -0,0 +1,180 @@ +--[[ +V2 Overall goals: + - Adopt vim.task: https://github.com/lewis6991/async.nvim + - First-class vim help documentation + - Adopt `iskeyword` for keyword regex by default + - Synchronous API when possible + - Remove sources in favor of LSPs + - Include compat layer for existing blink.cmp sources to in-process LSPs + - Promote building in-process LSPs across the ecosystem (include boilerplate) + - Simplify codebase as much as possible + - Consider removing auto brackets, LSPs should support this themselves + - Switch keymap system to `vim.on_key` + - User calls `cmp.download()` to download prebuilt-binaries or `cmp.build()` to build from source. No automatic download + - Drop custom documentation rendering, in favor of vim.lsp.utils.convert_input_to_markdown_lines (we have conceal lines now!) + - Adopt scrollbar when it's merged: https://github.com/neovim/neovim/pull/35729 + +Configuration goals: + - Programmatic over declarative + - Follow neovim patterns as much as possible (refer to vim.lsp.*, vim.lsp.inlay_hint.*, etc.) + - Easily configure per-buffer, per-LSP, and dynamically + - First-class vim.pack support + - All on_* event handlers also emit `User` autocommands +--]] + +--- @alias filter { bufnr: number?, mode: '*' | ('default' | 'cmdline' | 'term')[] | nil } + +local cmp = require('blink.cmp') + +-- seek community adoption +vim.g.completion = true -- or vim.b.completion +vim.g.nerd_font_variant = 'mono' -- for 'normal', icons are double-width +vim.g.lsp_item_kind_icons = { Text = '󰉿', ... } + +-- blink specific, supports `vim.b` too +vim.g.blink_cmp = true -- equivalent to vim.g.completion, but takes precedence + +--- Global --- +cmp.enable(enable?, filter?) -- enabled by default +cmp.is_enabled(filter?) + +cmp.show({ lsps = {}, select_item_idx = nil }) -- vim.Task +cmp.hide() + +cmp.accept({ select = false, index = nil }) -- vim.Task +cmp.select_next({ count = 1, cycle = true, preview = true }) +cmp.select_prev({ count = 1, cycle = true, preview = true }) +cmp.select(idx, { preview = true }) + +--- Keymaps --- +-- now optional, since users can define keymaps themselves and use the (mostly) synchronous `cmp.*` API +-- filter excludes modes +cmp.keymap.preset(mode, preset, filter?) -- cmp.keymap.preset('*', 'tab') +cmp.keymap.set(mode, key, function(cmp) ... end, filter?) +cmp.keymap.del(mode, key, filter?) + +cmp.keymap.enable(enable?, filter?) -- enabled by default, but no keybinds are assigned +cmp.keymap.is_enabled(filter?) + +--- Completion --- +---- Trigger ---- +cmp.trigger.config({ + keyword = { range = 'prefix', regex = nil }, + on_keyword = true, + on_trigger_character = true, + on_accept_on_trigger_character = true, + on_insert_enter_on_trigger_character = true, +}, filter?) +cmp.trigger.enable(enable?, filter?) -- enabled by default +cmp.trigger.is_enabled(filter?) + +cmp.trigger.on_show(function(ctx) end) +cmp.trigger.on_hide(function(ctx) end) + +---- List ---- +cmp.list.config({ + preselect = true, + sorts = { 'score', 'sort_text' }, + filters = { function(item) return item.label == 'foo' end }, + fuzzy = cmp.fuzzy.rust({ + max_typos = function(keyword) return math.floor(#keyword / 4) end, + frecency_path = vim.fn.stdpath('state') .. '/blink/cmp/frecency.dat', + }), +}, filter?) -- ephemeral option? e.g. could set the sort/filter options for a single completion context + +cmp.list.get_items() +cmp.list.get_selected_item() +cmp.list.get_selected_item_idx() + +cmp.list.on_show(function(ctx, items) end) +cmp.list.on_hide(function(ctx) end) +cmp.list.on_update(function(ctx, items) end) +cmp.list.on_select(function(ctx, item, idx) end) +cmp.list.on_accept(function(ctx, item) end) + +---- Menu ---- +cmp.menu.config({ ..., docs = { ... } }, filter?) +cmp.menu.enable(enable?, filter?) -- enabled by default +cmp.menu.is_enabled(filter?) + +cmp.menu.is_visible() +cmp.menu.get_win() + +cmp.menu.on_show(function(ctx) end) +cmp.menu.on_hide(function(ctx) end) + +-- Menu Docs -- +cmp.menu.docs.show() +cmp.menu.docs.hide() +cmp.menu.docs.scroll_up(count) +cmp.menu.docs.scroll_down(count) + +cmp.menu.docs.is_visible() +cmp.menu.docs.get_win() + +cmp.menu.docs.on_show(function(ctx) end) +cmp.menu.docs.on_hide(function(ctx) end) + +---- Ghost text ---- +cmp.ghost_text.enable(enable?, filter?) +cmp.ghost_text.is_enabled(filter?) + +cmp.ghost_text.is_visible() +cmp.ghost_text.get_extmark_id() + +cmp.ghost_text.on_show(function(ctx) end) +cmp.ghost_text.on_hide(function(ctx) end) + +--- LSPs --- +-- sources system is no more, LSPs only, with compat layer for existing sources +cmp.lsp.config(client, options, filter?) +cmp.lsp.config('*', { ... }) -- global +cmp.lsp.config('lua_ls', { min_keyword_length = 2 }) +cmp.lsp.config('buffer', { fallback_for = { "*", "!snippets", "!path" } }) + +-- default configs built-in for some LSPs, for example +cmp.lsp.config('ts_ls', { blocked_trigger_characters = { ' ', '\t', '\n' } }) +cmp.lsp.config('emmet', { blocked_trigger_characters = function(char) return not char:match('[A-z]') end }) +cmp.lsp.config('rust_analyzer', { bonuses = { frecency = false, proximity = false } }) + +-- all LSPs are enabled by default, except built-in "buffer", "snippets", "path" +cmp.lsp.enable('buffer', enable?, filter?) +cmp.lsp.enable({ 'buffer', 'path' }, enable?, filter?) +cmp.lsp.is_enabled('buffer', filter?) + +--- Snippets --- +-- drop support for pulling snippets from luasnip/mini.snippets. encourage them to support in-process LSPs +-- as a result, this only affects how snippets are expanded/navigated +cmp.snippet.preset('luasnip', filter?) -- use a preset +cmp.snippet.config({ ... }, filter?) -- or define yourself + +cmp.snippet.active(filter?) +cmp.snippet.jump(direction) + +cmp.snippet.registry.add({ ... }) +cmp.snippet.registry.remove({ ... }) +cmp.snippet.registry.load({ ...paths }) -- vim.Task +cmp.snippet.registry.load_friendly_snippets() +cmp.snippet.registry.reload() -- vim.Task (reloads from .load() paths only) +cmp.snippet.registry.clear() + +--- Signature --- +cmp.signature.config({ + docs = false, + direction_priority = { 'n', 's' }, + window = { ... } +}, filter?) +cmp.signature.enable(enable?, filter?) -- enabled by default +cmp.signature.is_enabled(filter?) + +cmp.signature.show() -- vim.Task +cmp.signature.hide() +cmp.signature.scroll_up(count) +cmp.signature.scroll_down(count) + +cmp.signature.get_signatures() +cmp.signature.is_visible() +cmp.signature.get_win() + +cmp.signature.on_show(function(ctx, signatures) end) +cmp.signature.on_hide(function(ctx) end) diff --git a/lua/blink/cmp/completion/accept/init.lua b/lua/blink/cmp/completion/accept/init.lua deleted file mode 100644 index 1562734a4..000000000 --- a/lua/blink/cmp/completion/accept/init.lua +++ /dev/null @@ -1,125 +0,0 @@ -local config = require('blink.cmp.config').completion.accept -local text_edits_lib = require('blink.cmp.lib.text_edits') -local brackets_lib = require('blink.cmp.completion.brackets') - ---- @param ctx blink.cmp.Context ---- @param item blink.cmp.CompletionItem -local function apply_item(ctx, 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 - ) - - -- 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 - 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 - -- snippets automatically handle indentation on newlines, while our implementation does not, - -- so ignore for muli-line snippets - and #vim.split(tostring(parsed_snippet), '\n') == 1 - 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 - item.textEdit.newText = tostring(parsed_snippet) - 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 = '' - text_edits_lib.apply(temp_text_edit, 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 - local new_cursor = text_edits_lib.get_apply_end_position(item.textEdit, all_text_edits) - new_cursor[2] = new_cursor[2] - - text_edits_lib.apply(item.textEdit, all_text_edits) - - ctx.set_cursor(new_cursor) - text_edits_lib.move_cursor_in_dot_repeat(offset) - end - - -- Notify the rust module that the item was accessed - require('blink.cmp.fuzzy').access(item) - - -- Check semantic tokens for brackets, if needed, asynchronously - if brackets_status == 'check_semantic_token' then - brackets_lib.add_brackets_via_semantic_token(ctx, vim.bo.filetype, item):map(function(added_brackets) - if added_brackets then - require('blink.cmp.completion.trigger').show_if_on_trigger_character({ is_accept = true }) - require('blink.cmp.signature.trigger').show_if_on_trigger_character() - end - end) - end -end - ---- 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) - -- Some LSPs may take a long time to resolve the item, so we timeout - :timeout(config.resolve_timeout_ms) - -- and use the item as-is - :catch(function() return item end) - :map(function(resolved_item) - -- Updates the text edit based on the cursor position and converts it to utf-8 - resolved_item = vim.deepcopy(resolved_item) - resolved_item.textEdit = text_edits_lib.get_from_item(resolved_item) - - return sources.execute( - ctx, - resolved_item, - function(alternate_ctx, alternate_item) apply_item(alternate_ctx or ctx, alternate_item or resolved_item) end - ) - end) - :map(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) - :catch(function(err) vim.notify(err, vim.log.levels.ERROR, { title = 'blink.cmp' }) end) -end - -return accept diff --git a/lua/blink/cmp/completion/brackets/config.lua b/lua/blink/cmp/completion/brackets/config.lua deleted file mode 100644 index 5fcc59319..000000000 --- a/lua/blink/cmp/completion/brackets/config.lua +++ /dev/null @@ -1,69 +0,0 @@ -local css_exceptions = function(ctx) - local str = string.sub(ctx.line, 1, ctx.cursor[2] or #ctx.line) - return not str:find('[%w_-]*::?[%w-]*$') -end -local typescript_exceptions = function(ctx) return ctx.line:find('^%s*import%s') == nil end - -return { - -- stylua: ignore - blocked_filetypes = { - 'sql', 'ruby', 'perl', 'lisp', 'scheme', 'clojure', - 'prolog', 'vb', 'elixir', 'smalltalk', 'applescript', - 'elm', 'rust', 'nu', 'cpp', 'fennel', 'janet', 'ps1', - 'racket' - }, - per_filetype = { - -- languages with a space - haskell = { ' ', '' }, - fsharp = { ' ', '' }, - ocaml = { ' ', '' }, - erlang = { ' ', '' }, - tcl = { ' ', '' }, - nix = { ' ', '' }, - helm = { ' ', '' }, - lean = { ' ', '' }, - - shell = { ' ', '' }, - sh = { ' ', '' }, - bash = { ' ', '' }, - fish = { ' ', '' }, - zsh = { ' ', '' }, - powershell = { ' ', '' }, - - make = { ' ', '' }, - - -- languages with square brackets - wl = { '[', ']' }, - wolfram = { '[', ']' }, - mma = { '[', ']' }, - mathematica = { '[', ']' }, - context = { '[', ']' }, - - -- languages with curly brackets - tex = { '{', '}' }, - plaintex = { '{', '}' }, - }, - exceptions = { - by_filetype = { - -- ignore `use` imports - rust = function(ctx) return ctx.line:find('^%s*use%s') == nil end, - -- ignore `from`, `import`, and `except` statements - python = function(ctx) - return ctx.line:find('^%s*import%s') == nil - and ctx.line:find('^%s*from%s') == nil - and ctx.line:find('^%s*except%s') == nil - end, - -- ignore pseudo-classes and pseudo-elements - css = css_exceptions, - scss = css_exceptions, - less = css_exceptions, - html = css_exceptions, -- remove after adding treesitter based language detection - -- ignore `import ...` statements - javascript = typescript_exceptions, - javascriptreact = typescript_exceptions, - typescript = typescript_exceptions, - typescriptreact = typescript_exceptions, - svelte = typescript_exceptions, - }, - }, -} diff --git a/lua/blink/cmp/completion/brackets/init.lua b/lua/blink/cmp/completion/brackets/init.lua deleted file mode 100644 index 511b42b10..000000000 --- a/lua/blink/cmp/completion/brackets/init.lua +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index cd5fd23ad..000000000 --- a/lua/blink/cmp/completion/brackets/kind.lua +++ /dev/null @@ -1,52 +0,0 @@ -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 indicates we should skip - if not utils.should_run_resolution(ctx, 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 deleted file mode 100644 index 9cf1769a5..000000000 --- a/lua/blink/cmp/completion/brackets/semantic.lua +++ /dev/null @@ -1,113 +0,0 @@ -local async = require('blink.cmp.lib.async') -local config = require('blink.cmp.config').completion.accept.auto_brackets -local utils = require('blink.cmp.completion.brackets.utils') - ---- @class blink.cmp.SemanticRequest ---- @field cursor integer[] ---- @field item blink.cmp.CompletionItem ---- @field filetype string ---- @field callback fun(added: boolean) - -local semantic = { - --- @type uv_timer_t - timer = assert(vim.uv.new_timer(), 'Failed to create timer for semantic token resolution'), - --- @type blink.cmp.SemanticRequest | nil - request = nil, -} - -vim.api.nvim_create_autocmd('LspTokenUpdate', { - callback = vim.schedule_wrap(function(args) semantic.process_request({ args.data.token }) end), -}) - -function semantic.finish_request() - if semantic.request == nil then return end - semantic.request.callback(true) - semantic.request = nil - semantic.timer:stop() -end - ---- @param tokens STTokenRange[] -function semantic.process_request(tokens) - local request = semantic.request - if request == nil then return end - - local cursor = vim.api.nvim_win_get_cursor(0) - -- cancel if the cursor moved - if request.cursor[1] ~= cursor[1] or request.cursor[2] ~= cursor[2] then return semantic.finish_request() end - - for _, token in ipairs(tokens) do - if - (token.type == 'function' or token.type == 'method') - and cursor[1] - 1 == token.line - and cursor[2] >= token.start_col - -- we do <= to check 1 character before the cursor (`bar|` would check `r`) - and cursor[2] <= token.end_col - then - -- add the brackets - -- TODO: make dot repeatable - local item_text_edit = assert(request.item.textEdit) - local brackets_for_filetype = utils.get_for_filetype(request.filetype, request.item) - local start_col = item_text_edit.range.start.character + #item_text_edit.newText - vim.lsp.util.apply_text_edits({ - { - newText = brackets_for_filetype[1] .. brackets_for_filetype[2], - range = { - start = { line = cursor[1] - 1, character = start_col }, - ['end'] = { line = cursor[1] - 1, character = start_col }, - }, - }, - }, vim.api.nvim_get_current_buf(), 'utf-8') - vim.api.nvim_win_set_cursor(0, { cursor[1], start_col + #brackets_for_filetype[1] }) - return semantic.finish_request() - end - end -end - ---- Asynchronously use semantic tokens to determine if brackets should be added ---- @param ctx blink.cmp.Context ---- @param filetype string ---- @param item blink.cmp.CompletionItem ---- @return blink.cmp.Task -function semantic.add_brackets_via_semantic_token(ctx, filetype, item) - return async.task.new(function(resolve) - if not utils.should_run_resolution(ctx, filetype, 'semantic_token') then return resolve(false) end - - assert(item.textEdit ~= 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 resolve() end - - local capabilities = client.server_capabilities.semanticTokensProvider - if not capabilities or not capabilities.legend or (not capabilities.range and not capabilities.full) then - return resolve(false) - end - - local highlighter = vim.lsp.semantic_tokens.__STHighlighter.active[ctx.bufnr] - if highlighter == nil then return resolve(false) end - - semantic.timer:stop() - local cursor = vim.api.nvim_win_get_cursor(0) - semantic.request = { - cursor = vim.api.nvim_win_get_cursor(0), - filetype = filetype, - item = item, - callback = resolve, - } - - -- semantic tokens debounced, so manually request a refresh to avoid latency - highlighter:send_request() - - -- first check if a semantic token already exists at the current cursor position - -- we get the token 1 character before the cursor (`bar|` would check `r`) - local tokens = vim.lsp.semantic_tokens.get_at_pos(0, cursor[1] - 1, cursor[2] - 1) - if tokens ~= nil then semantic.process_request(tokens) end - if semantic.request == nil then - -- a matching token exists, and brackets were added - return resolve(true) - end - - -- listen for LspTokenUpdate events until timeout - semantic.timer:start(config.semantic_token_resolution.timeout_ms, 0, semantic.finish_request) - end) -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 deleted file mode 100644 index 2c1e70a55..000000000 --- a/lua/blink/cmp/completion/brackets/utils.lua +++ /dev/null @@ -1,69 +0,0 @@ -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 ctx blink.cmp.Context ---- @param filetype string ---- @param resolution_method 'kind' | 'semantic_token' ---- @return boolean -function utils.should_run_resolution(ctx, 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 - - -- filetype specific exceptions - local exceptions = require('blink.cmp.completion.brackets.config').exceptions - if exceptions.by_filetype[filetype] ~= nil then - if not exceptions.by_filetype[filetype](ctx) then return false end - 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/prefetch.lua b/lua/blink/cmp/completion/prefetch.lua deleted file mode 100644 index c722a300f..000000000 --- a/lua/blink/cmp/completion/prefetch.lua +++ /dev/null @@ -1,29 +0,0 @@ --- 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 index 6c52b5019..48f15b58b 100644 --- a/lua/blink/cmp/completion/trigger/context.lua +++ b/lua/blink/cmp/completion/trigger/context.lua @@ -12,11 +12,9 @@ --- @field bufnr number --- @field cursor number[] --- @field line string ---- @field term blink.cmp.ContextTerm --- @field bounds blink.cmp.ContextBounds --- @field trigger blink.cmp.ContextTrigger ---- @field providers string[] ---- @field initial_selected_item_idx? number +--- @field lsps string[] --- @field timestamp number --- --- @field new fun(opts: blink.cmp.ContextOpts): blink.cmp.Context @@ -36,9 +34,6 @@ --- @field kind blink.cmp.CompletionTriggerKind The current trigger kind --- @field character? string The trigger character when kind == 'trigger_character' ---- @class blink.cmp.ContextTerm ---- @field command blink.cmp.ContextTermCommand - --- @class blink.cmp.ContextTermCommand --- @field found_escape_code boolean Whether the FTCS_COMMAND_START escape sequence was found when querying for the command on the current line. This will always be false when the cursor isn't in a prompt, such as when a command is running. --- @field text string The command in the current line, without the shell prompt if found_escape_code = true, up to the cursor. Note that for multiline commands, it will always provide you with the content of the last line. This is because there is no way to distinguish the starting point of a single line command from a multiline one using terminal escape sequences @@ -46,7 +41,7 @@ --- @class blink.cmp.ContextOpts --- @field id number ---- @field providers string[] +--- @field lsps string[] --- @field initial_trigger_kind blink.cmp.CompletionTriggerKind --- @field initial_trigger_character? string --- @field trigger_kind blink.cmp.CompletionTriggerKind @@ -67,7 +62,6 @@ function context.new(opts) bufnr = vim.api.nvim_get_current_buf(), cursor = cursor, line = line, - term = { command = context.get_term_command() }, bounds = context.get_bounds('full'), trigger = { initial_kind = opts.initial_trigger_kind, @@ -75,8 +69,7 @@ function context.new(opts) kind = opts.trigger_kind, character = opts.trigger_character, }, - providers = opts.providers, - initial_selected_item_idx = opts.initial_selected_item_idx, + lsps = opts.lsps, timestamp = vim.uv.now(), }, { __index = context }) --[[@as blink.cmp.Context]] end @@ -89,7 +82,7 @@ end --- @param cursor number[] --- Whether to include the start boundary as inside of the query ---- I.e. start_col = 1 (one indexed), cursor[2] = 0 (zero indexed) would be considered within the query bounds with this flag enabled. +--- e.g. start_col = 1 (one indexed), cursor[2] = 0 (zero indexed) would be considered within the query bounds with this flag enabled. --- @param include_start_bound? boolean --- @return boolean function context:within_query_bounds(cursor, include_start_bound) diff --git a/lua/blink/cmp/completion/trigger/init.lua b/lua/blink/cmp/completion/trigger/init.lua index 43cad3c15..bb120d99b 100644 --- a/lua/blink/cmp/completion/trigger/init.lua +++ b/lua/blink/cmp/completion/trigger/init.lua @@ -1,8 +1,8 @@ --- @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. +--- 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 @@ -44,14 +44,9 @@ local trigger = { hide_emitter = require('blink.cmp.lib.event_emitter').new('hide'), } -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 - +local function on_char_added(char) -- 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 + if 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 }) @@ -68,141 +63,70 @@ local function on_char_added(char, is_ignored) end end -local function on_cursor_moved(event, is_ignored, is_backspace, last_event) - local is_enter_event = event == 'InsertEnter' or event == 'TermEnter' - +local function on_cursor_moved() local cursor = context.get_cursor() - local cursor_col = cursor[2] - local char_under_cursor = utils.get_char_at_cursor() - local is_keyword = fuzzy.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 - -- 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 - -- TODO: is this still needed since we handle this in char added? - if require('blink.cmp.completion.list').preview_undo ~= nil then trigger.context = nil end - - trigger.show({ send_upstream = false, trigger_kind = 'keyword' }) - end - return - end - - -- TODO: doesn't handle `a` where the cursor moves immediately after - -- Reproducible 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 is_enter_event - and trigger.is_trigger_character(char_under_cursor, true) + local on_trigger_character = trigger.is_trigger_character(char_under_cursor) -- check if we're still within the bounds of the query used for the context if trigger.context ~= nil and trigger.context.trigger.kind ~= 'prefetch' - and trigger.context:within_query_bounds(cursor, trigger.is_trigger_character(char_under_cursor)) + and trigger.context:within_query_bounds(cursor, on_trigger_character) then trigger.show({ trigger_kind = 'keyword' }) - -- check if we've entered insert mode on a trigger character - elseif insert_enter_on_trigger_character 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' }) - - -- show after entering insert mode - elseif is_enter_event and config.show_on_insert then - trigger.show({ trigger_kind = 'keyword' }) - - -- prefetch completions without opening window after entering insert mode - elseif is_enter_event and config.prefetch_on_insert then - trigger.show({ trigger_kind = 'prefetch' }) - - -- show after backspacing - elseif config.show_on_backspace and is_backspace then - trigger.show({ trigger_kind = 'keyword' }) - - -- show after backspacing into a keyword - elseif config.show_on_backspace_in_keyword and is_backspace and is_keyword then - trigger.show({ trigger_kind = 'keyword' }) - - -- show after entering insert or term mode and backspacing into a keyword - elseif config.show_on_backspace_after_insert_enter and is_backspace and last_event == 'enter' and is_keyword then - trigger.show({ trigger_kind = 'keyword' }) - - -- show after accepting a completion and then backspacing into a keyword - elseif config.show_on_backspace_after_accept and is_backspace and last_event == 'accept' and is_keyword then - trigger.show({ trigger_kind = 'keyword' }) - -- otherwise hide else trigger.hide() end end -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, - }) +local function on_insert_enter() + -- TODO: doesn't handle `a` where the cursor moves immediately after + -- Reproducible with `example.|a` and pressing `a`, should not show the menu + -- + -- check if we've entered insert mode on a trigger character + if + config.show_on_trigger_character + and config.show_on_insert_on_trigger_character + and trigger.is_trigger_character(utils.get_char_at_cursor(), true) + then + trigger.show({ trigger_kind = 'trigger_character', trigger_character = utils.get_char_at_cursor() }) + elseif config.show_on_insert then + trigger.show({ trigger_kind = 'keyword' }) + end +end - trigger.buffer_events:listen({ +function trigger.activate() + require('blink.cmp.events.buffer').listen({ on_char_added = on_char_added, on_cursor_moved = on_cursor_moved, + on_insert_enter = on_insert_enter, on_insert_leave = function() trigger.hide() end, on_complete_changed = function() if vim.fn.pumvisible() == 1 then trigger.hide() end end, }) - trigger.cmdline_events = require('blink.cmp.lib.cmdline_events').new() - if root_config.cmdline.enabled then - trigger.cmdline_events:listen({ - on_char_added = on_char_added, - on_cursor_moved = on_cursor_moved, - on_leave = function() trigger.hide() end, - }) - end - - trigger.term_events = require('blink.cmp.lib.term_events').new({ - has_context = function() return trigger.context ~= nil end, + require('blink.cmp.events.cmdline').listen({ + on_char_added = on_char_added, + on_cursor_moved = on_cursor_moved, + on_leave = trigger.hide, }) - if root_config.term.enabled then - trigger.term_events:listen({ - on_char_added = on_char_added, - on_term_leave = function() trigger.hide() end, - }) - end -end -function trigger.resubscribe() - ---@diagnostic disable-next-line: missing-fields - trigger.buffer_events:resubscribe({ on_char_added = on_char_added }) + require('blink.cmp.events.term').listen({ + on_char_added = on_char_added, + on_term_leave = trigger.hide, + }) 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) - -- ignore a-z and A-Z characters - if char:match('%a') then return false end - - 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 show_on_blocked_trigger_characters = config.show_on_blocked_trigger_characters() + local show_on_x_blocked_trigger_characters = config.show_on_x_blocked_trigger_characters() 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)) @@ -210,20 +134,6 @@ function trigger.is_trigger_character(char, is_show_on_x) 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 - mode = (vim.api.nvim_get_mode().mode == 'c' and 'cmdline') or (mode == 't' and 'term') or 'default' - - local events = (mode == 'default' and trigger.buffer_events) - or (mode == 'term' and trigger.term_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) @@ -241,7 +151,7 @@ function trigger.show_if_on_trigger_character(opts) end function trigger.show(opts) - if vim.fn.pumvisible() == 1 or not root_config.enabled() then return trigger.hide() end + if vim.fn.pumvisible() == 1 or vim.b.completion == false then return trigger.hide() end opts = opts or {} @@ -287,10 +197,9 @@ function trigger.show(opts) initial_trigger_character = initial_trigger_character, trigger_kind = opts.trigger_kind, trigger_character = opts.trigger_character, - initial_selected_item_idx = opts.initial_selected_item_idx, }) - if opts.send_upstream ~= false then trigger.show_emitter:emit({ context = trigger.context }) end + trigger.show_emitter:emit({ context = trigger.context }) return trigger.context end diff --git a/lua/blink/cmp/completion/windows/render/init.lua b/lua/blink/cmp/completion/windows/render/init.lua index ada8028bc..743e4a744 100644 --- a/lua/blink/cmp/completion/windows/render/init.lua +++ b/lua/blink/cmp/completion/windows/render/init.lua @@ -29,7 +29,6 @@ function renderer.new(draw) -- 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 self.bufnr == win_bufnr end, on_line = function(_, _, _, line) diff --git a/lua/blink/cmp/completion/windows/render/treesitter.lua b/lua/blink/cmp/completion/windows/render/treesitter.lua deleted file mode 100644 index 901c46ae0..000000000 --- a/lua/blink/cmp/completion/windows/render/treesitter.lua +++ /dev/null @@ -1,70 +0,0 @@ -local treesitter = {} - ----@type table -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 index d730c45a2..a974c167c 100644 --- a/lua/blink/cmp/completion/windows/render/types.lua +++ b/lua/blink/cmp/completion/windows/render/types.lua @@ -4,7 +4,6 @@ --- @field gap? number Gap between columns --- @field cursorline_priority? number Priority of the background highlight for the cursorline, defaults to 10000. Setting this to 0 will render it below other highlights --- @field snippet_indicator? string Appends an indicator to snippets label, `'~'` by default ---- @field treesitter? string[] Use treesitter to highlight the label text of completions from these sources --- @field columns? blink.cmp.DrawColumnDefinition[] | fun(context: blink.cmp.Context): blink.cmp.DrawColumnDefinition[] Components to render, grouped by column --- @field components? table Component definitions --- diff --git a/lua/blink/cmp/config/completion/accept.lua b/lua/blink/cmp/config/completion/accept.lua index aa98feefe..a61c19364 100644 --- a/lua/blink/cmp/config/completion/accept.lua +++ b/lua/blink/cmp/config/completion/accept.lua @@ -29,22 +29,6 @@ local accept = { dot_repeat = true, create_undo_point = true, resolve_timeout_ms = 100, - auto_brackets = { - enabled = true, - default_brackets = { '(', ')' }, - override_brackets_for_filetypes = {}, - force_allow_filetypes = {}, - blocked_filetypes = {}, - kind_resolution = { - enabled = true, - blocked_filetypes = { 'typescriptreact', 'javascriptreact', 'vue' }, - }, - semantic_token_resolution = { - enabled = true, - blocked_filetypes = { 'java' }, - timeout_ms = 400, - }, - }, }, } @@ -53,26 +37,7 @@ function accept.validate(config) dot_repeat = { config.dot_repeat, 'boolean' }, create_undo_point = { config.create_undo_point, 'boolean' }, resolve_timeout_ms = { config.resolve_timeout_ms, 'number' }, - auto_brackets = { config.auto_brackets, 'table' }, }, config) - validate('completion.accept.auto_brackets', { - enabled = { config.auto_brackets.enabled, 'boolean' }, - default_brackets = { config.auto_brackets.default_brackets, 'table' }, - override_brackets_for_filetypes = { config.auto_brackets.override_brackets_for_filetypes, 'table' }, - force_allow_filetypes = { config.auto_brackets.force_allow_filetypes, 'table' }, - blocked_filetypes = { config.auto_brackets.blocked_filetypes, 'table' }, - kind_resolution = { config.auto_brackets.kind_resolution, 'table' }, - semantic_token_resolution = { config.auto_brackets.semantic_token_resolution, 'table' }, - }, config.auto_brackets) - validate('completion.accept.auto_brackets.kind_resolution', { - enabled = { config.auto_brackets.kind_resolution.enabled, 'boolean' }, - blocked_filetypes = { config.auto_brackets.kind_resolution.blocked_filetypes, 'table' }, - }, config.auto_brackets.kind_resolution) - validate('completion.accept.auto_brackets.semantic_token_resolution', { - enabled = { config.auto_brackets.semantic_token_resolution.enabled, 'boolean' }, - blocked_filetypes = { config.auto_brackets.semantic_token_resolution.blocked_filetypes, 'table' }, - timeout_ms = { config.auto_brackets.semantic_token_resolution.timeout_ms, 'number' }, - }, config.auto_brackets.semantic_token_resolution) end return accept diff --git a/lua/blink/cmp/config/completion/list.lua b/lua/blink/cmp/config/completion/list.lua index c0c5690c9..975ecc956 100644 --- a/lua/blink/cmp/config/completion/list.lua +++ b/lua/blink/cmp/config/completion/list.lua @@ -15,7 +15,8 @@ local validate = require('blink.cmp.config.utils').validate local list = { --- @type blink.cmp.CompletionListConfig default = { - max_items = 200, + max_items = 200, -- TODO: hard code + -- move to `select_next/prev` args selection = { preselect = true, auto_insert = true, diff --git a/lua/blink/cmp/config/completion/menu.lua b/lua/blink/cmp/config/completion/menu.lua index 4b07304b8..cc8f22bd8 100644 --- a/lua/blink/cmp/config/completion/menu.lua +++ b/lua/blink/cmp/config/completion/menu.lua @@ -67,8 +67,6 @@ local window = { cursorline_priority = 10000, -- Appends an indicator to snippets label, `'~'` by default snippet_indicator = '~', - -- Use treesitter to highlight the label text of completions from these sources - treesitter = {}, -- Components to render, grouped by column columns = { { 'kind_icon' }, { 'label', 'label_description', gap = 1 } }, -- Definitions for possible components to render. Each component defines: @@ -104,11 +102,6 @@ local window = { table.insert(highlights, { #label, #label + #ctx.label_detail, group = 'BlinkCmpLabelDetail' }) end - if vim.list_contains(ctx.self.treesitter, ctx.source_id) and not ctx.deprecated then - -- add treesitter highlights - vim.list_extend(highlights, require('blink.cmp.completion.windows.render.treesitter').highlight(ctx)) - end - -- characters matched on the label by the fuzzy matcher for _, idx in ipairs(ctx.label_matched_indices) do table.insert(highlights, { idx, idx + 1, group = 'BlinkCmpLabelMatch' }) @@ -196,14 +189,12 @@ function window.validate(config) if type(padding[1]) == 'number' and type(padding[2]) == 'number' then return true end return false end, - 'a number or a tuple of 2 numbers (i.e. [1, 2])', + 'a number or a tuple of 2 numbers (e.g. [1, 2])', }, gap = { config.draw.gap, 'number' }, cursorline_priority = { config.draw.cursorline_priority, 'number' }, snippet_indicator = { config.draw.snippet_indicator, 'string' }, - treesitter = { config.draw.treesitter, 'table' }, - columns = { config.draw.columns, function(columns) diff --git a/lua/blink/cmp/config/completion/trigger.lua b/lua/blink/cmp/config/completion/trigger.lua index 355011051..e88dfff6b 100644 --- a/lua/blink/cmp/config/completion/trigger.lua +++ b/lua/blink/cmp/config/completion/trigger.lua @@ -8,7 +8,7 @@ --- @field show_on_backspace_after_insert_enter boolean When true, will show the completion window after entering insert mode and backspacing into keyword --- @field show_on_insert boolean When true, will show the completion window after entering insert mode --- @field show_on_trigger_character boolean When true, will show the completion window after typing a trigger character ---- @field show_on_blocked_trigger_characters string[] | (fun(): string[]) LSPs can indicate when to show the completion window via trigger characters. However, some LSPs (i.e. tsserver) return characters that would essentially always show the window. We block these by default. +--- @field show_on_blocked_trigger_characters string[] | (fun(): string[]) LSPs can indicate when to show the completion window via trigger characters. However, some LSPs (e.g. tsserver) return characters that would essentially always show the window. We block these by default. --- @field show_on_accept_on_trigger_character boolean When both this and show_on_trigger_character are true, will show the completion window when the cursor comes after a trigger character after accepting an item --- @field show_on_insert_on_trigger_character boolean When both this and show_on_trigger_character are true, will show the completion window when the cursor comes after a trigger character when entering insert mode --- @field show_on_x_blocked_trigger_characters string[] | (fun(): string[]) List of trigger characters (on top of `show_on_blocked_trigger_characters`) that won't trigger the completion window when the cursor comes after a trigger character when entering insert mode/accepting an item diff --git a/lua/blink/cmp/config/fuzzy.lua b/lua/blink/cmp/config/fuzzy.lua index 724937d3f..0706f773a 100644 --- a/lua/blink/cmp/config/fuzzy.lua +++ b/lua/blink/cmp/config/fuzzy.lua @@ -1,34 +1,18 @@ --- @class (exact) blink.cmp.FuzzyConfig --- @field implementation blink.cmp.FuzzyImplementationType Controls which implementation to use for the fuzzy matcher. See the documentation for the available values for more information. --- @field max_typos number | fun(keyword: string): number Allows for a number of typos relative to the length of the query. Set this to 0 to match the behavior of fzf. Note, this does not apply when using the Lua implementation. ---- @field use_frecency boolean (deprecated) alias for frecency.enabled, will be removed in v2.0 ---- @field use_unsafe_no_lock boolean (deprecated) alias for frecency.unsafe_no_lock, will be removed in v2.0 --- @field use_proximity boolean Boosts the score of items matching nearby words. Note, this does not apply when using the Lua implementation. --- @field sorts blink.cmp.Sort[] Controls which sorts to use and in which order. --- @field frecency blink.cmp.FuzzyFrecencyConfig Tracks the most recently/frequently used items and boosts the score of the item. Note, this does not apply when using the Lua implementation. ---- @field prebuilt_binaries blink.cmp.PrebuiltBinariesConfig --- @class (exact) blink.cmp.FuzzyFrecencyConfig --- @field enabled boolean Whether to enable the frecency feature --- @field path string Location of the frecency database ---- @field unsafe_no_lock boolean UNSAFE!! When enabled, disables the lock and fsync when writing to the frecency database. This should only be used on unsupported platforms (i.e. alpine termux). - ---- @class (exact) blink.cmp.PrebuiltBinariesConfig ---- @field download boolean Whenther or not to automatically download a prebuilt binary from github. If this is set to `false`, you will need to manually build the fuzzy binary dependencies by running `cargo build --release`. Disabled by default when `fuzzy.implementation = 'lua'` ---- @field ignore_version_mismatch boolean Ignores mismatched version between the built binary and the current git sha, when building locally ---- @field force_version? string When downloading a prebuilt binary, force the downloader to resolve this version. If this is unset then the downloader will attempt to infer the version from the checked out git tag (if any). WARN: Beware that `main` may be incompatible with the version you select ---- @field force_system_triple? string When downloading a prebuilt binary, force the downloader to use this system triple. If this is unset then the downloader will attempt to infer the system triple from `jit.os` and `jit.arch`. Check the latest release for all available system triples. WARN: Beware that `main` may be incompatible with the version you select ---- @field extra_curl_args string[] Extra arguments that will be passed to curl like { 'curl', ..extra_curl_args, ..built_in_args } ---- @field proxy blink.cmp.PrebuiltBinariesProxyConfig - ---- @class (exact) blink.cmp.PrebuiltBinariesProxyConfig ---- @field from_env boolean When downloading a prebuilt binary, use the HTTPS_PROXY environment variable ---- @field url? string When downloading a prebuilt binary, use this proxy URL. This will ignore the HTTPS_PROXY environment variable --- @alias blink.cmp.FuzzyImplementationType ---- | 'prefer_rust_with_warning' (Recommended) If available, use the Rust implementation, automatically downloading prebuilt binaries on supported systems. Fallback to the Lua implementation when not available, emitting a warning message. ---- | 'prefer_rust' If available, use the Rust implementation, automatically downloading prebuilt binaries on supported systems. Fallback to the Lua implementation when not available. ---- | 'rust' Always use the Rust implementation, automatically downloading prebuilt binaries on supported systems. Error if not available. +--- | 'prefer_rust_with_warning' (Recommended) If available, use the Rust implementation. Fallback to the Lua implementation when not available, emitting a warning message. +--- | 'prefer_rust' If available, use the Rust implementation. Fallback to the Lua implementation when not available. +--- | 'rust' Always use the Rust implementation. Error if not available. --- | 'lua' Always use the Lua implementation --- @alias blink.cmp.SortFunction fun(a: blink.cmp.CompletionItem, b: blink.cmp.CompletionItem): boolean | nil @@ -46,35 +30,11 @@ local fuzzy = { frecency = { enabled = true, path = vim.fn.stdpath('state') .. '/blink/cmp/frecency.dat', - unsafe_no_lock = false, - }, - prebuilt_binaries = { - download = true, - ignore_version_mismatch = false, - force_version = nil, - force_system_triple = nil, - extra_curl_args = {}, - proxy = { - from_env = true, - url = nil, - }, }, }, } function fuzzy.validate(config) - -- TODO: Deprecations to be removed in v2.0 - if config.use_frecency ~= nil then - vim.deprecate('fuzzy.use_frecency', 'fuzzy.frecency.enabled', 'v2.0.0', 'blink-cmp') - config.frecency.enabled = config.use_frecency - config.use_frecency = nil - end - if config.use_unsafe_no_lock ~= nil then - vim.deprecate('fuzzy.use_unsafe_no_lock', 'fuzzy.frecency.unsafe_no_lock', 'v2.0.0', 'blink-cmp') - config.frecency.unsafe_no_lock = config.use_unsafe_no_lock - config.use_unsafe_no_lock = nil - end - validate('fuzzy', { implementation = { config.implementation, @@ -101,28 +61,12 @@ function fuzzy.validate(config) 'one of: "label", "sort_text", "kind", "score", "exact" or a function', }, frecency = { config.frecency, 'table' }, - prebuilt_binaries = { config.prebuilt_binaries, 'table' }, }, config) validate('fuzzy.frecency', { enabled = { config.frecency.enabled, 'boolean' }, path = { config.frecency.path, 'string' }, - unsafe_no_lock = { config.frecency.unsafe_no_lock, 'boolean' }, }, config.frecency) - - validate('fuzzy.prebuilt_binaries', { - download = { config.prebuilt_binaries.download, 'boolean' }, - ignore_version_mismatch = { config.prebuilt_binaries.ignore_version_mismatch, 'boolean' }, - force_version = { config.prebuilt_binaries.force_version, { 'string', 'nil' } }, - force_system_triple = { config.prebuilt_binaries.force_system_triple, { 'string', 'nil' } }, - extra_curl_args = { config.prebuilt_binaries.extra_curl_args, { 'table' } }, - proxy = { config.prebuilt_binaries.proxy, 'table' }, - }, config.prebuilt_binaries) - - validate('fuzzy.prebuilt_binaries.proxy', { - from_env = { config.prebuilt_binaries.proxy.from_env, 'boolean' }, - url = { config.prebuilt_binaries.proxy.url, { 'string', 'nil' } }, - }, config.prebuilt_binaries.proxy) end return fuzzy diff --git a/lua/blink/cmp/config/keymap.lua b/lua/blink/cmp/config/keymap.lua index e2c79c806..d4d8e04c4 100644 --- a/lua/blink/cmp/config/keymap.lua +++ b/lua/blink/cmp/config/keymap.lua @@ -7,9 +7,9 @@ --- | 'hide' Hide the completion window --- | 'cancel' Cancel the current completion, undoing the preview from auto_insert --- | 'accept' Accept the current completion item ---- | 'accept_and_enter' Accept the current completion item and feed an enter key to neovim (i.e. to execute the current command in cmdline mode) +--- | 'accept_and_enter' Accept the current completion item and feed an enter key to neovim (e.g. to execute the current command in cmdline mode) --- | 'select_and_accept' Select the first completion item, if there's no selection, and accept ---- | 'select_accept_and_enter' Select the first completion item, if there's no selection, accept and feed an enter key to neovim (i.e. to execute the current command in cmdline mode) +--- | 'select_accept_and_enter' Select the first completion item, if there's no selection, accept and feed an enter key to neovim (e.g. to execute the current command in cmdline mode) --- | 'select_prev' Select the previous completion item --- | 'select_next' Select the next completion item --- | 'insert_prev' Insert the previous completion item (`auto_insert`), cycling to the bottom of the list if at the top, if `completion.list.cycle.from_top == true`. This will trigger completions if none are available, unlike `select_prev` which would fallback to the next keymap in this case. @@ -28,7 +28,7 @@ --- @alias blink.cmp.KeymapPreset --- | 'none' No keymaps ---- | 'inherit' Inherits the keymaps from the top level config. Only applicable to mode specific keymaps (i.e. cmdline, term) +--- | 'inherit' Inherits the keymaps from the top level config. Only applicable to mode specific keymaps (e.g. cmdline, term) --- Mappings similar to the built-in completion: --- ```lua --- { diff --git a/lua/blink/cmp/config/modes/types.lua b/lua/blink/cmp/config/modes/types.lua index 2b383f95b..1530d90a8 100644 --- a/lua/blink/cmp/config/modes/types.lua +++ b/lua/blink/cmp/config/modes/types.lua @@ -18,7 +18,7 @@ --- @field auto_insert? boolean | fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): boolean When `true`, inserts the completion item automatically when selecting it --- @class blink.cmp.ModeCompletionTriggerConfig ---- @field show_on_blocked_trigger_characters? string[] | (fun(): string[]) LSPs can indicate when to show the completion window via trigger characters. However, some LSPs (i.e. tsserver) return characters that would essentially always show the window. We block these by default. +--- @field show_on_blocked_trigger_characters? string[] | (fun(): string[]) LSPs can indicate when to show the completion window via trigger characters. However, some LSPs (e.g. tsserver) return characters that would essentially always show the window. We block these by default. --- @field show_on_x_blocked_trigger_characters? string[] | (fun(): string[]) List of trigger characters (on top of `show_on_blocked_trigger_characters`) that won't trigger the completion window when the cursor comes after a trigger character when entering insert mode/accepting an item --- @class blink.cmp.ModeCompletionMenuConfig diff --git a/lua/blink/cmp/config/signature.lua b/lua/blink/cmp/config/signature.lua index 60ee33e0d..657a95db0 100644 --- a/lua/blink/cmp/config/signature.lua +++ b/lua/blink/cmp/config/signature.lua @@ -12,7 +12,7 @@ --- @field show_on_insert boolean Show the signature help window when entering insert mode --- @field show_on_insert_on_trigger_character boolean Show the signature help window when the cursor comes after a trigger character when entering insert mode --- @field show_on_accept boolean Show the signature help window after accepting a completion item ---- @field show_on_accept_on_trigger_character boolean Show the signature help window when the cursor comes after a trigger character after accepting a completion item (i.e. func(|) where "(" is a trigger character) +--- @field show_on_accept_on_trigger_character boolean Show the signature help window when the cursor comes after a trigger character after accepting a completion item (e.g. func(|) where "(" is a trigger character) --- @class (exact) blink.cmp.SignatureWindowConfig --- @field min_width number @@ -37,9 +37,7 @@ local signature = { blocked_trigger_characters = {}, blocked_retrigger_characters = {}, show_on_trigger_character = true, - show_on_insert = false, show_on_insert_on_trigger_character = true, - show_on_accept = false, show_on_accept_on_trigger_character = true, }, window = { diff --git a/lua/blink/cmp/events/buffer.lua b/lua/blink/cmp/events/buffer.lua new file mode 100644 index 000000000..dca36888e --- /dev/null +++ b/lua/blink/cmp/events/buffer.lua @@ -0,0 +1,98 @@ +--- Exposes three events (cursor moved, char added, insert leave) for triggers to use. +--- Notably, when "char added" is fired, the "cursor moved" event will not be fired. +--- Unlike in regular neovim, ctrl + c and buffer switching will trigger "insert leave" + +--- @class blink.cmp.BufferEventsListener +--- @field on_char_added fun(char: string) +--- @field on_cursor_moved fun() +--- @field on_insert_enter fun() +--- @field on_insert_leave fun() +--- @field on_complete_changed fun() + +--- @class blink.cmp.BufferEvents +local buffer_events = { + --- @type number? + insert_char_pre_id = nil, + --- @type number? + char_added_id = nil, + --- @type number? + cursor_moved_id = nil, +} + +--- @param opts blink.cmp.BufferEventsListener +function buffer_events.listen(opts) + local self = setmetatable({}, { __index = buffer_events }) + + vim.api.nvim_create_autocmd({ 'ModeChanged', 'BufLeave' }, { + callback = function(ev) + local mode = vim.api.nvim_get_mode().mode + if mode:match('i') and ev.event == 'ModeChanged' then + opts.on_insert_enter() + self:subscribe(opts) + elseif self:is_subscribed() then + opts.on_insert_leave() + self:unsubscribe() + end + end, + }) + + if opts.on_complete_changed then + vim.api.nvim_create_autocmd('CompleteChanged', { + callback = function() opts.on_complete_changed() end, + }) + end + + return setmetatable({}, { __index = buffer_events }) +end + +--- @param opts blink.cmp.BufferEventsListener +function buffer_events:subscribe(opts) + self:unsubscribe() + + local last_char = '' + + self.insert_char_pre_id = vim.api.nvim_create_autocmd('InsertCharPre', { + -- FIXME: vim.v.char can be an escape code such as <95> in the case of . This breaks downstream + -- since this isn't a valid utf-8 string. How can we identify and ignore these? + callback = function() last_char = vim.v.char end, + }) + + self.char_added_id = vim.api.nvim_create_autocmd('TextChangedI', { + callback = function() + -- no character added so let cursormoved handle it + if last_char == '' then return end + + opts.on_char_added(last_char) + last_char = '' + end, + }) + + self.cursor_moved_id = vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, { + callback = function() + -- char added so textchanged will handle it + if last_char ~= '' then return end + opts.on_cursor_moved() + end, + }) +end + +function buffer_events:unsubscribe() + if self.insert_char_pre_id ~= nil then + vim.api.nvim_del_autocmd(self.insert_char_pre_id) + self.insert_char_pre_id = nil + end + if self.char_added_id ~= nil then + vim.api.nvim_del_autocmd(self.char_added_id) + self.char_added_id = nil + end + if self.cursor_moved_id ~= nil then + vim.api.nvim_del_autocmd(self.cursor_moved_id) + self.cursor_moved_id = nil + end +end + +function buffer_events:is_subscribed() + return self.insert_char_pre_id ~= nil or self.char_added_id ~= nil or self.cursor_moved_id ~= nil +end + +return buffer_events diff --git a/lua/blink/cmp/events/cmdline.lua b/lua/blink/cmp/events/cmdline.lua new file mode 100644 index 000000000..ae9161f62 --- /dev/null +++ b/lua/blink/cmp/events/cmdline.lua @@ -0,0 +1,62 @@ +--- @class blink.cmp.CmdlineEventsListener +--- @field on_char_added fun(char: string) +--- @field on_cursor_moved fun() +--- @field on_leave fun() + +--- @class blink.cmp.CmdlineEvents +local cmdline_events = {} + +--- @param opts blink.cmp.CmdlineEventsListener +function cmdline_events.listen(opts) + -- Abbreviations and other automated features can cause rapid, repeated cursor movements + -- (a "burst") that are not intentional user actions. To avoid reacting to these artificial + -- movements in CursorMovedC, we detect bursts by measuring the time between moves. + -- If two cursor moves occur within a short threshold (burst_threshold_ms), we treat them + -- as part of a burst and ignore them. + local last_move_time + local burst_threshold_ms = 2 + local function is_burst_move() + local current_time = vim.loop.hrtime() / 1e6 + local is_burst = last_move_time and (current_time - last_move_time) < burst_threshold_ms + last_move_time = current_time + return is_burst or false + end + + -- TextChanged + local pending_key + local is_change_queued = false + vim.on_key(function(raw_key, escaped_key) + if vim.api.nvim_get_mode().mode ~= 'c' then return end + + -- ignore if it's a special key + -- FIXME: odd behavior when escaped_key has multiple keycodes, e.g. by pressing and then "t" + local key = vim.fn.keytrans(escaped_key) + if key:sub(1, 1) == '<' and key:sub(#key, #key) == '>' and raw_key ~= ' ' then return end + if key == '' then return end + + last_move_time = vim.loop.hrtime() / 1e6 + pending_key = raw_key + + if not is_change_queued then + is_change_queued = true + vim.schedule(function() + opts.on_char_added(pending_key) + is_change_queued = false + pending_key = nil + end) + end + end) + + -- CursorMoved + vim.api.nvim_create_autocmd('CursorMovedC', { + callback = function() + if not is_change_queued and not is_burst_move() then opts.on_cursor_moved() end + end, + }) + + vim.api.nvim_create_autocmd('CmdlineLeave', { + callback = function() opts.on_leave() end, + }) +end + +return cmdline_events diff --git a/lua/blink/cmp/events/term.lua b/lua/blink/cmp/events/term.lua new file mode 100644 index 000000000..a8d7b8e17 --- /dev/null +++ b/lua/blink/cmp/events/term.lua @@ -0,0 +1,64 @@ +--- @class blink.cmp.TermEventsListener +--- @field on_char_added fun(char: string) +--- @field on_term_leave fun() + +--- @class blink.cmp.TermEvents +local term_events = {} + +local term_on_key_ns = vim.api.nvim_create_namespace('blink-term-keypress') +local term_command_start_ns = vim.api.nvim_create_namespace('blink-term-command-start') + +--- @param opts blink.cmp.TermEventsListener +function term_events.listen(opts) + local last_char = '' + -- There's no terminal equivalent to 'InsertCharPre', so we need to simulate + -- something similar to this by watching with `vim.on_key()` + vim.api.nvim_create_autocmd('TermEnter', { + callback = function() + vim.on_key(function(k) last_char = k end, term_on_key_ns) + end, + }) + vim.api.nvim_create_autocmd('TermLeave', { + callback = function() + vim.on_key(nil, term_on_key_ns) + last_char = '' + opts.on_term_leave() + end, + }) + + vim.api.nvim_create_autocmd('TextChangedT', { + callback = function() + -- no characters added so let cursormoved handle it + if last_char == '' then return end + + opts.on_char_added(last_char) + last_char = '' + end, + }) + + --- To build proper shell completions we need to know where prompts end and typed commands start. + --- The most reliable way to get this information is to listen for terminal escape sequences. This + --- adds a listener for the terminal escape sequence \027]133;B (called FTCS_COMMAND_START) which + --- marks the start of a command. To enable plugins to access this information later we put an extmark + --- at the position in the buffer. + --- For documentation on FTCS_COMMAND_START see https://iterm2.com/3.0/documentation-escape-codes.html + --- + --- Example: + --- If you type "ls --he" into a terminal buffer in neovim the current line will look something like this: + --- ~/Downloads > ls --he| + --- "~/Downloads > " is your prompt <- an extmark is added after this + --- "ls --he" is the command you typed <- this is what we need to provide proper shell completions + --- "|" marks your cursor position + vim.api.nvim_create_autocmd('TermRequest', { + callback = function(args) + if string.match(args.data.sequence, '^\027]133;B') then + local row, col = table.unpack(args.data.cursor) + vim.api.nvim_buf_set_extmark(args.buf, term_command_start_ns, row - 1, col, {}) + end + end, + }) + + return setmetatable({}, { __index = term_events }) +end + +return term_events diff --git a/lua/blink/cmp/fuzzy/build/init.lua b/lua/blink/cmp/fuzzy/build/init.lua index 22b624e4d..902ba4ea4 100644 --- a/lua/blink/cmp/fuzzy/build/init.lua +++ b/lua/blink/cmp/fuzzy/build/init.lua @@ -61,9 +61,7 @@ local function get_cargo_cmd() if nightly then return { 'cargo', 'build', '--release' } end utils.notify({ - { 'Rust ' }, - { 'nightly', 'DiagnosticInfo' }, - { ' not available via ' }, + { 'Rust nightly not available via ' }, { 'cargo --version', 'DiagnosticInfo' }, { ' and rustup not detected via ' }, { 'cargo +nightly --version', 'DiagnosticInfo' }, @@ -78,7 +76,7 @@ function build.build() utils.notify({ { 'Building fuzzy matching library from source...' } }, vim.log.levels.INFO) local log = log_file.create() - log.write('Working Directory: ' .. get_project_root()) + log.write('Working Directory: ' .. get_project_root() .. '\n') return get_cargo_cmd() --- @param cmd string[] diff --git a/lua/blink/cmp/fuzzy/download/files.lua b/lua/blink/cmp/fuzzy/download/files.lua index a32b3c3b5..0aae1fc96 100644 --- a/lua/blink/cmp/fuzzy/download/files.lua +++ b/lua/blink/cmp/fuzzy/download/files.lua @@ -1,6 +1,5 @@ local async = require('blink.cmp.lib.async') local utils = require('blink.cmp.lib.utils') -local system = require('blink.cmp.fuzzy.download.system') local function get_lib_extension() if jit.os:lower() == 'mac' or jit.os:lower() == 'osx' then return '.dylib' end @@ -14,9 +13,6 @@ local root_dir = table.concat(utils.slice(current_file_dir_parts, 1, #current_fi local lib_folder = root_dir .. '/target/release' local lib_filename = 'libblink_cmp_fuzzy' .. get_lib_extension() local lib_path = lib_folder .. '/' .. lib_filename -local checksum_filename = lib_filename .. '.sha256' -local checksum_path = lib_path .. '.sha256' -local version_path = lib_folder .. '/version' local files = { get_lib_extension = get_lib_extension, @@ -24,146 +20,8 @@ local files = { lib_folder = lib_folder, lib_filename = lib_filename, lib_path = lib_path, - checksum_path = checksum_path, - checksum_filename = checksum_filename, - version_path = version_path, } ---- Checksums --- - -function files.get_checksum() - return files.read_file(files.checksum_path):map(function(checksum) return vim.split(checksum, ' ')[1] end) -end - -function files.get_checksum_for_file(path) - return async.task.new(function(resolve, reject) - local os = system.get_info() - local args - if os == 'linux' then - args = { 'sha256sum', path } - elseif os == 'mac' or os == 'osx' then - args = { 'shasum', '-a', '256', path } - elseif os == 'windows' then - args = { 'certutil', '-hashfile', path, 'SHA256' } - end - - vim.system(args, {}, function(out) - if out.code ~= 0 then return reject('Failed to calculate checksum of pre-built binary: ' .. out.stderr) end - - local stdout = out.stdout or '' - if os == 'windows' then stdout = vim.split(stdout, '\r\n')[2] end - -- We get an output like 'sha256sum filename' on most systems, so we grab just the checksum - return resolve(vim.split(stdout, ' ')[1]) - end) - end) -end - -function files.verify_checksum() - return async.task.all({ files.get_checksum(), files.get_checksum_for_file(files.lib_path) }):map(function(checksums) - assert(#checksums == 2, 'Expected 2 checksums, got ' .. #checksums) - assert(checksums[1] and checksums[2], 'Expected checksums to be non-nil') - assert( - checksums[1] == checksums[2], - 'Checksum of pre-built binary does not match. Expected "' .. checksums[1] .. '", got "' .. checksums[2] .. '"' - ) - end) -end - ---- Prebuilt binary --- - -function files.get_version() - return files - .read_file(files.version_path) - :map(function(version) - if #version == 40 then - return { sha = version } - else - return { tag = version } - end - end) - :catch(function() return { missing = true } end) -end - ---- @param version string ---- @return blink.cmp.Task -function files.set_version(version) - return files - .create_dir(files.root_dir .. '/target') - :map(function() return files.create_dir(files.lib_folder) end) - :map(function() return files.write_file(files.version_path, version) end) -end - ---- Filesystem helpers --- - ---- @param path string ---- @return blink.cmp.Task -function files.read_file(path) - return async.task.new(function(resolve, reject) - vim.uv.fs_open(path, 'r', 438, function(open_err, fd) - if open_err or fd == nil then return reject(open_err or 'Unknown error') end - vim.uv.fs_read(fd, 1024, 0, function(read_err, data) - vim.uv.fs_close(fd, function() end) - if read_err or data == nil then return reject(read_err or 'Unknown error') end - return resolve(data) - end) - end) - end) -end - ---- @param path string ---- @param data string ---- @return blink.cmp.Task -function files.write_file(path, data) - return async.task.new(function(resolve, reject) - vim.uv.fs_open(path, 'w', 438, function(open_err, fd) - if open_err or fd == nil then return reject(open_err or 'Unknown error') end - vim.uv.fs_write(fd, data, 0, function(write_err) - vim.uv.fs_close(fd, function() end) - if write_err then return reject(write_err) end - return resolve() - end) - end) - end) -end - ---- @param path string ---- @return blink.cmp.Task -function files.exists(path) - return async.task.new(function(resolve) - vim.uv.fs_stat(path, function(err) resolve(not err) end) - end) -end - ---- @param path string ---- @return blink.cmp.Task -function files.stat(path) - return async.task.new(function(resolve, reject) - vim.uv.fs_stat(path, function(err, stat) - if err then return reject(err) end - resolve(stat) - end) - end) -end - ---- @param path string ---- @return blink.cmp.Task -function files.create_dir(path) - return files - .stat(path) - :map(function(stat) return stat.type == 'directory' end) - :catch(function() return false end) - :map(function(exists) - if exists then return end - - return async.task.new(function(resolve, reject) - vim.uv.fs_mkdir(path, 511, function(err) - if err then return reject(err) end - resolve() - end) - end) - end) -end - --- Renames a file --- @param old_path string --- @param new_path string diff --git a/lua/blink/cmp/fuzzy/download/git.lua b/lua/blink/cmp/fuzzy/download/git.lua index 1aa9c99ab..c1a5b92b6 100644 --- a/lua/blink/cmp/fuzzy/download/git.lua +++ b/lua/blink/cmp/fuzzy/download/git.lua @@ -2,17 +2,6 @@ local async = require('blink.cmp.lib.async') local files = require('blink.cmp.fuzzy.download.files') local git = {} -function git.get_version() - return async.task.all({ git.get_tag(), git.get_sha() }):map( - function(results) - return { - tag = results[1], - sha = results[2], - } - end - ) -end - function git.get_tag() return async.task.new(function(resolve, reject) -- If repo_dir is nil, no git repository is found, similar to `out.code == 128` @@ -41,30 +30,4 @@ function git.get_tag() end) end -function git.get_sha() - return async.task.new(function(resolve, reject) - -- If repo_dir is nil, no git repository is found, similar to `out.code == 128` - local repo_dir = vim.fs.root(files.root_dir, '.git') - if not repo_dir then resolve() end - - vim.system({ - 'git', - '--git-dir', - vim.fs.joinpath(repo_dir, '.git'), - '--work-tree', - repo_dir, - 'rev-parse', - 'HEAD', - }, { cwd = files.root_dir }, function(out) - if out.code == 128 then return resolve() end - if out.code ~= 0 then - return reject('While getting git sha, git exited with code ' .. out.code .. ': ' .. out.stderr) - end - - local sha = vim.split(out.stdout, '\n')[1] - return resolve(sha) - end) - end) -end - return git diff --git a/lua/blink/cmp/fuzzy/download/init.lua b/lua/blink/cmp/fuzzy/download/init.lua index 27bd11e11..0ea8af5f5 100644 --- a/lua/blink/cmp/fuzzy/download/init.lua +++ b/lua/blink/cmp/fuzzy/download/init.lua @@ -1,253 +1,84 @@ -local fuzzy_config = require('blink.cmp.config').fuzzy -local download_config = fuzzy_config.prebuilt_binaries local async = require('blink.cmp.lib.async') -local git = require('blink.cmp.fuzzy.download.git') local files = require('blink.cmp.fuzzy.download.files') local system = require('blink.cmp.fuzzy.download.system') local utils = require('blink.cmp.lib.utils') +--- @class blink.cmp.Download local download = {} ---- @param callback fun(err: string | nil, fuzzy_implementation?: 'lua' | 'rust') -function download.ensure_downloaded(callback) - callback = vim.schedule_wrap(callback) +--- @class (exact) blink.cmp.DownloadProxy +--- @field from_env? boolean When downloading a prebuilt binary, use the HTTPS_PROXY environment variable. Defaults to true +--- @field url? string When downloading a prebuilt binary, use this proxy URL. This will ignore the HTTPS_PROXY environment variable - if fuzzy_config.implementation == 'lua' then return callback(nil, 'lua') end +--- @class blink.cmp.DownloadOpts +--- @field version? string +--- @field system_triple? string +--- @field proxy? blink.cmp.DownloadProxy +--- @field extra_curl_args? string[] - async.task - .all({ git.get_version(), files.get_version() }) - :map(function(results) - return { - git = results[1], - current = results[2], - } - end) - :map(function(version) - -- no version file found, and found the shared rust library, user manually placed the .so file - if version.current.missing and pcall(require, 'blink.cmp.fuzzy.rust') then return end - - local target_git_tag = download_config.force_version or version.git.tag - - -- built locally - if version.current.sha ~= nil then - -- check version matches (or explicitly ignored) and shared library exists - if version.current.sha == version.git.sha or download_config.ignore_version_mismatch then - local loaded, err = pcall(require, 'blink.cmp.fuzzy.rust') - if loaded then return end - - -- shared library missing despite matching version info (e.g., incomplete build) - utils.notify({ - { 'Incomplete build of the ' }, - { 'fuzzy matching library', 'DiagnosticInfo' }, - { ' detected, please re-run ' }, - { ' cargo build --release ', 'DiagnosticVirtualTextInfo' }, - { ' such as by re-installing. ' }, - { 'Error: ' .. tostring(err), 'DiagnosticError' }, - }) - return false - end - - -- out of date - utils.notify({ - { 'Found an ' }, - { 'outdated version', 'DiagnosticWarn' }, - { ' of the locally built ' }, - { 'fuzzy matching library', 'DiagnosticInfo' }, - }) - - -- downloading is disabled, error - if not download_config.download then - utils.notify({ - { "Couldn't update fuzzy matching library due to github downloads being disabled." }, - { ' Try setting ' }, - { " build = 'cargo build --release' ", 'DiagnosticVirtualTextInfo' }, - { ' in your lazy.nvim spec and re-installing (requires Rust nightly), or enable ' }, - { 'fuzzy.prebuilt_binaries.', 'DiagnosticInfo' }, - { 'ignore_version_mismatch', 'DiagnosticWarn' }, - { ' or set ' }, - { 'fuzzy.prebuilt_binaries.', 'DiagnosticInfo' }, - { 'force_version', 'DiagnosticWarn' }, - }) - return false - - -- downloading enabled but not on a git tag, error - elseif target_git_tag == nil then - utils.notify({ - { "Couldn't download the updated " }, - { 'fuzzy matching library', 'DiagnosticInfo' }, - { ' due to not being on a ' }, - { 'git tag', 'DiagnosticInfo' }, - { '. Try building from source via ' }, - { " build = 'cargo build --release' ", 'DiagnosticVirtualTextInfo' }, - { ' in your lazy.nvim spec and re-installing (requires Rust nightly), or switch to a ' }, - { 'git tag', 'DiagnosticInfo' }, - { ' via ' }, - { " version = '1.*' ", 'DiagnosticVirtualTextInfo' }, - { ' in your lazy.nvim spec. Or ignore this error by enabling ' }, - { 'fuzzy.prebuilt_binaries.', 'DiagnosticInfo' }, - { 'ignore_version_mismatch', 'DiagnosticWarn' }, - { '. Or force a specific version via ' }, - { 'fuzzy.prebuilt_binaries.', 'DiagnosticInfo' }, - { 'force_version', 'DiagnosticWarn' }, - }) - return false - end - end - - -- downloading disabled but not built locally, error - if not download_config.download then - utils.notify({ - { 'No fuzzy matching library found!' }, - { ' Try setting ' }, - { " build = 'cargo build --release' ", 'DiagnosticVirtualTextInfo' }, - { ' in your lazy.nvim spec and re-installing (requires Rust nightly), or enable ' }, - { 'fuzzy.prebuilt_binaries.', 'DiagnosticInfo' }, - { 'download', 'DiagnosticWarn' }, - }) - return false - end +--- @param opts blink.cmp.DownloadOpts? +--- @return blink.cmp.Task +function download.from_github(opts) + opts = opts or {} + local version_task = opts.version and async.task.identity(opts.version) + or async.task.empty():map(function() return require('blink.cmp.fuzzy.download.git').get_tag() end) + local triple_task = opts.system_triple and async.task.identity(system_triple) or system.get_triple() + + return version_task:map(function(version) + if not version then + utils.notify({ { 'Failed to get the current version from git, did you forget to lock your version?' } }) + return + end - -- downloading enabled but not on a git tag, error - if target_git_tag == nil then + triple_task:map(function(triple) + if not triple then utils.notify({ - { 'No fuzzy matching library found!' }, - { ' Try building from source via ' }, - { " build = 'cargo build --release' ", 'DiagnosticVirtualTextInfo' }, - { ' in your lazy.nvim spec and re-installing (requires Rust nightly), or switch to a ' }, - { 'git tag', 'DiagnosticInfo' }, - { ' via ' }, - { " version = '1.*' ", 'DiagnosticVirtualTextInfo' }, - { ' in your lazy.nvim spec, or set ' }, - { 'fuzzy.prebuilt_binaries.', 'DiagnosticInfo' }, - { 'force_version', 'DiagnosticWarn' }, + { 'Your system is not supported by ' }, + { ' pre-built binaries ', 'DiagnosticVirtualTextInfo' }, + { '. Try building from source via ' }, + { ' :BlinkCmp build ', 'DiagnosticVirtualTextInfo' }, }) - return false + return end - -- already downloaded and the correct version, just verify the checksum, and re-download if checksum fails - if version.current.tag == target_git_tag then - return files.verify_checksum():catch(function(err) - utils.notify({ - { 'Pre-built binary checksum verification failed, ' }, - { err, 'DiagnosticError' }, - }, vim.log.levels.ERROR) - return download.download(target_git_tag) - end) - end + local base_url = 'https://github.com/saghen/blink.cmp/releases/download/' .. version .. '/' + local library_url = base_url .. triple .. files.get_lib_extension() - -- download as per usual - utils.notify({ { 'Downloading pre-built binary' } }, vim.log.levels.INFO) return download - .download(target_git_tag) - :map(function() utils.notify({ { 'Downloaded pre-built binary successfully' } }, vim.log.levels.INFO) end) - end) - :catch(function(err) return err end) - :map(function(success_or_err) - if success_or_err == false or type(success_or_err) == 'string' then - -- log error message - if fuzzy_config.implementation ~= 'prefer_rust' then - if type(success_or_err) == 'string' then - utils.notify({ { success_or_err, 'DiagnosticError' } }, vim.log.levels.ERROR) + .download_file(library_url, files.lib_filename .. '.tmp', opts.proxy, opts.extra_curl_args) + -- Mac caches the library in the kernel, so updating in place causes a crash + -- We instead write to a temporary file and rename it, as mentioned in: + -- https://developer.apple.com/documentation/security/updating-mac-software + :map( + function() + return files.rename( + files.lib_folder .. '/' .. files.lib_filename .. '.tmp', + files.lib_folder .. '/' .. files.lib_filename + ) end - end - - -- fallback to lua implementation - if fuzzy_config.implementation == 'prefer_rust' then - callback(nil, 'lua') - - -- fallback to lua implementation and emit warning - elseif fuzzy_config.implementation == 'prefer_rust_with_warning' then - utils.notify({ - { 'Falling back to ' }, - { 'Lua implementation', 'DiagnosticInfo' }, - { ' due to error while downloading pre-built binary, set ' }, - { 'fuzzy.', 'DiagnosticInfo' }, - { 'implementation', 'DiagnosticWarn' }, - { ' to ' }, - { ' "prefer_rust" ', 'DiagnosticVirtualTextInfo' }, - { ' or ' }, - { ' "lua" ', 'DiagnosticVirtualTextInfo' }, - { ' to disable this warning. See ' }, - { ':messages', 'DiagnosticInfo' }, - { ' for details.' }, - }) - callback(nil, 'lua') - else - callback('Failed to setup fuzzy matcher and rust implementation forced. See :messages for details') - end - return - end - - -- clear cached module first since we call it in the pcall above - package.loaded['blink.cmp.fuzzy.rust'] = nil - callback(nil, 'rust') + ) end) -end - -function download.download(version) - -- NOTE: we set the version to 'v0.0.0' to avoid a failure causing the pre-built binary being marked as locally built - return files - .set_version('v0.0.0') - :map(function() return download.from_github(version) end) - :map(function() return files.verify_checksum() end) - :map(function() return files.set_version(version) end) -end - ---- @param tag string ---- @return blink.cmp.Task -function download.from_github(tag) - return system.get_triple():map(function(system_triple) - if not system_triple then - utils.notify({ - { 'Your system is not supported by ' }, - { ' pre-built binaries ', 'DiagnosticVirtualTextInfo' }, - { '. Try building from source via ' }, - { " build = 'cargo build --release' ", 'DiagnosticVirtualTextInfo' }, - { ' in your lazy.nvim spec and re-installing (requires Rust nightly)' }, - }) - return - end - - local base_url = 'https://github.com/saghen/blink.cmp/releases/download/' .. tag .. '/' - local library_url = base_url .. system_triple .. files.get_lib_extension() - local checksum_url = base_url .. system_triple .. files.get_lib_extension() .. '.sha256' - - return async - .task - .all({ - download.download_file(library_url, files.lib_filename .. '.tmp'), - download.download_file(checksum_url, files.checksum_filename), - }) - -- Mac caches the library in the kernel, so updating in place causes a crash - -- We instead write to a temporary file and rename it, as mentioned in: - -- https://developer.apple.com/documentation/security/updating-mac-software - :map( - function() - return files.rename( - files.lib_folder .. '/' .. files.lib_filename .. '.tmp', - files.lib_folder .. '/' .. files.lib_filename - ) - end - ) end) end --- @param url string --- @param filename string +--- @param proxy blink.cmp.DownloadProxy? +--- @param extra_curl_args string[]? --- @return blink.cmp.Task -function download.download_file(url, filename) +function download.download_file(url, filename, proxy, extra_curl_args) return async.task.new(function(resolve, reject) local args = { 'curl' } -- Use https proxy if available - if download_config.proxy.url ~= nil then - vim.list_extend(args, { '--proxy', download_config.proxy.url }) - elseif download_config.proxy.from_env then + if proxy and proxy.url ~= nil then + vim.list_extend(args, { '--proxy', proxy.url }) + elseif not proxy or proxy.from_env == nil or proxy.from_env then local proxy_url = os.getenv('HTTPS_PROXY') if proxy_url ~= nil then vim.list_extend(args, { '--proxy', proxy_url }) end end - vim.list_extend(args, download_config.extra_curl_args) + vim.list_extend(args, extra_curl_args or {}) vim.list_extend(args, { '--fail', -- Fail on 4xx/5xx '--location', -- Follow redirects @@ -261,7 +92,7 @@ function download.download_file(url, filename) vim.system(args, {}, function(out) if out.code ~= 0 then - reject('Failed to download ' .. filename .. 'for pre-built binaries: ' .. out.stderr) + reject('Failed to download ' .. filename .. ': ' .. out.stderr) else resolve() end diff --git a/lua/blink/cmp/fuzzy/download/system.lua b/lua/blink/cmp/fuzzy/download/system.lua index 6427fa720..4979bab70 100644 --- a/lua/blink/cmp/fuzzy/download/system.lua +++ b/lua/blink/cmp/fuzzy/download/system.lua @@ -1,4 +1,3 @@ -local download_config = require('blink.cmp.config').fuzzy.prebuilt_binaries local async = require('blink.cmp.lib.async') local system = {} @@ -27,7 +26,7 @@ function system.get_info() end --- Gets the system target triple from `cc -dumpmachine` ---- I.e. 'gnu' | 'musl' +--- e.g. 'gnu' | 'musl' --- @return blink.cmp.Task function system.get_linux_libc() return async @@ -59,27 +58,11 @@ function system.get_linux_libc() end) end -function system.get_linux_libc_sync() - local _, process = pcall(function() return vim.system({ 'cc', '-dumpmachine' }, { text = true }):wait() end) - if process and process.code == 0 then - -- strip whitespace - local stdout = process.stdout:gsub('%s+', '') - local triple_parts = vim.fn.split(stdout, '-') - if triple_parts[4] ~= nil then return triple_parts[4] end - end - - local _, is_alpine = pcall(function() return vim.uv.fs_stat('/etc/alpine-release') end) - if is_alpine then return 'musl' end - return 'gnu' -end - --- Gets the system triple for the current system ---- I.e. `x86_64-unknown-linux-gnu` or `aarch64-apple-darwin` +--- e.g. `x86_64-unknown-linux-gnu` or `aarch64-apple-darwin` --- @return blink.cmp.Task function system.get_triple() return async.task.new(function(resolve, reject) - if download_config.force_system_triple then return resolve(download_config.force_system_triple) end - local os, arch = system.get_info() local triples = system.triples[os] if triples == nil then return end @@ -97,25 +80,4 @@ function system.get_triple() end) end ---- Same as `system.get_triple` but synchronous ---- @see system.get_triple ---- @return string | nil -function system.get_triple_sync() - if download_config.force_system_triple then return download_config.force_system_triple end - - local os, arch = system.get_info() - local triples = system.triples[os] - if triples == nil then return end - - if os == 'linux' then - if vim.fn.has('android') == 1 then return triples.android end - - local triple = triples[arch] - if type(triple) ~= 'function' then return triple end - return triple(system.get_linux_libc_sync()) - else - return triples[arch] - end -end - return system diff --git a/lua/blink/cmp/fuzzy/init.lua b/lua/blink/cmp/fuzzy/init.lua index 14f3a0f56..66508e741 100644 --- a/lua/blink/cmp/fuzzy/init.lua +++ b/lua/blink/cmp/fuzzy/init.lua @@ -20,7 +20,7 @@ end function fuzzy.init_db() if fuzzy.has_init_db then return end - fuzzy.implementation.init_db(config.fuzzy.frecency.path, config.fuzzy.frecency.unsafe_no_lock) + fuzzy.implementation.init_db(config.fuzzy.frecency.path) vim.api.nvim_create_autocmd('VimLeavePre', { callback = fuzzy.implementation.destroy_db, diff --git a/lua/blink/cmp/fuzzy/types.lua b/lua/blink/cmp/fuzzy/types.lua index ceeeba58a..6df017f2f 100644 --- a/lua/blink/cmp/fuzzy/types.lua +++ b/lua/blink/cmp/fuzzy/types.lua @@ -1,5 +1,5 @@ --- @class blink.cmp.FuzzyImplementation ---- @field init_db fun(path: string, use_unsafe_no_lock: boolean) +--- @field init_db fun(path: string) --- @field destroy_db fun() --- @field access fun(item: blink.cmp.CompletionItem) --- @field get_words fun(text: string): string[] diff --git a/lua/blink/cmp/health.lua b/lua/blink/cmp/health.lua deleted file mode 100644 index 8b7319959..000000000 --- a/lua/blink/cmp/health.lua +++ /dev/null @@ -1,81 +0,0 @@ -local health = {} - -function health.report_system() - vim.health.start('System') - - local required_executables = { 'curl', 'git' } - for _, executable in ipairs(required_executables) do - if vim.fn.executable(executable) == 0 then - vim.health.error(executable .. ' is not installed') - else - vim.health.ok(executable .. ' is installed') - end - end - - -- check if os is supported - local download_system = require('blink.cmp.fuzzy.download.system') - local system_triple = download_system.get_triple_sync() - if system_triple then - vim.health.ok('Your system is supported by pre-built binaries (' .. system_triple .. ')') - else - vim.health.warn( - 'Your system is not supported by pre-built binaries. You must run cargo build --release via your package manager with rust nightly. See the README for more info.' - ) - end - - local download_files = require('blink.cmp.fuzzy.download.files') - local lib_path_without_prefix = string.gsub(download_files.lib_path, 'libblink_cmp_fuzzy', 'blink_cmp_fuzzy') - if vim.uv.fs_stat(download_files.lib_path) or vim.uv.fs_stat(lib_path_without_prefix) then - vim.health.ok('blink_cmp_fuzzy lib is downloaded/built') - else - vim.health.warn('blink_cmp_fuzzy lib is not downloaded/built') - end -end - -function health.report_sources() - vim.health.start('Sources') - - local sources = require('blink.cmp.sources.lib') - - local all_providers = sources.get_all_providers() - local default_providers = sources.get_enabled_provider_ids('default') - local cmdline_providers = sources.get_enabled_provider_ids('cmdline') - - local bufnr = vim.api.nvim_create_buf(false, true) - vim.bo[bufnr].filetype = 'checkhealth' - - vim.health.warn('Some providers may show up as "disabled" but are enabled dynamically (i.e. cmdline)') - - --- @type string[] - local disabled_providers = {} - for provider_id, _ in pairs(all_providers) do - if - not vim.list_contains(default_providers, provider_id) and not vim.list_contains(cmdline_providers, provider_id) - then - table.insert(disabled_providers, provider_id) - end - end - - health.report_sources_list('Default sources', default_providers) - health.report_sources_list('Cmdline sources', cmdline_providers) - health.report_sources_list('Disabled sources', disabled_providers) -end - ---- @param header string ---- @param provider_ids string[] -function health.report_sources_list(header, provider_ids) - if #provider_ids == 0 then return end - - vim.health.start(header) - local all_providers = require('blink.cmp.sources.lib').get_all_providers() - for _, provider_id in ipairs(provider_ids) do - vim.health.info(('%s (%s)'):format(provider_id, all_providers[provider_id].config.module)) - end -end - -function health.check() - health.report_system() - health.report_sources() -end - -return health diff --git a/lua/blink/cmp/highlights.lua b/lua/blink/cmp/highlights.lua deleted file mode 100644 index 553dbe08b..000000000 --- a/lua/blink/cmp/highlights.lua +++ /dev/null @@ -1,46 +0,0 @@ -local highlights = {} - -function highlights.setup() - local use_nvim_cmp = require('blink.cmp.config').appearance.use_nvim_cmp_as_default - - --- @param hl_group string Highlight group name, e.g. 'ErrorMsg' - --- @param opts vim.api.keyset.highlight Highlight definition map - local set_hl = function(hl_group, opts) - opts.default = true -- Prevents overriding existing definitions - vim.api.nvim_set_hl(0, hl_group, opts) - end - - if use_nvim_cmp then - set_hl('BlinkCmpLabel', { link = 'CmpItemAbbr' }) - set_hl('BlinkCmpLabelMatch', { link = 'CmpItemAbbrMatch' }) - end - - set_hl('BlinkCmpLabelDeprecated', { link = use_nvim_cmp and 'CmpItemAbbrDeprecated' or 'PmenuExtra' }) - set_hl('BlinkCmpLabelDetail', { link = use_nvim_cmp and 'CmpItemMenu' or 'PmenuExtra' }) - set_hl('BlinkCmpLabelDescription', { link = use_nvim_cmp and 'CmpItemMenu' or 'PmenuExtra' }) - set_hl('BlinkCmpSource', { link = use_nvim_cmp and 'CmpItemMenu' or 'PmenuExtra' }) - set_hl('BlinkCmpKind', { link = use_nvim_cmp and 'CmpItemKind' or 'PmenuKind' }) - for _, kind in ipairs(require('blink.cmp.types').CompletionItemKind) do - set_hl('BlinkCmpKind' .. kind, { link = use_nvim_cmp and 'CmpItemKind' .. kind or 'BlinkCmpKind' }) - end - - set_hl('BlinkCmpScrollBarThumb', { link = 'PmenuThumb' }) - set_hl('BlinkCmpScrollBarGutter', { link = 'PmenuSbar' }) - - set_hl('BlinkCmpGhostText', { link = 'NonText' }) - - set_hl('BlinkCmpMenu', { link = 'Pmenu' }) - set_hl('BlinkCmpMenuBorder', { link = 'Pmenu' }) - set_hl('BlinkCmpMenuSelection', { link = 'PmenuSel' }) - - set_hl('BlinkCmpDoc', { link = 'NormalFloat' }) - set_hl('BlinkCmpDocBorder', { link = 'NormalFloat' }) - set_hl('BlinkCmpDocSeparator', { link = 'NormalFloat' }) - set_hl('BlinkCmpDocCursorLine', { link = 'Visual' }) - - set_hl('BlinkCmpSignatureHelp', { link = 'NormalFloat' }) - set_hl('BlinkCmpSignatureHelpBorder', { link = 'NormalFloat' }) - set_hl('BlinkCmpSignatureHelpActiveParameter', { link = 'LspSignatureActiveParameter' }) -end - -return highlights diff --git a/lua/blink/cmp/init.lua b/lua/blink/cmp/init.lua index 739f134c2..1b8c04448 100644 --- a/lua/blink/cmp/init.lua +++ b/lua/blink/cmp/init.lua @@ -1,394 +1,32 @@ --- @class blink.cmp.API -local cmp = {} - -local has_setup = false ---- Initializes blink.cmp with the given configuration and initiates the download ---- for the fuzzy matcher's prebuilt binaries, if necessary ---- @param opts? blink.cmp.Config -function cmp.setup(opts) - if has_setup then return end - has_setup = true - - opts = opts or {} - - if vim.fn.has('nvim-0.10') == 0 then - vim.notify('blink.cmp requires nvim 0.10 and newer', vim.log.levels.ERROR, { title = 'blink.cmp' }) - return - end - - local config = require('blink.cmp.config') - config.merge_with(opts) - - require('blink.cmp.fuzzy.download').ensure_downloaded(function(err, fuzzy_implementation) - if err then error(err) end - require('blink.cmp.fuzzy').set_implementation(fuzzy_implementation) - - -- setup highlights, keymap, completion, and signature help - require('blink.cmp.highlights').setup() - require('blink.cmp.keymap').setup() - require('blink.cmp.completion').setup() - if config.signature.enabled then require('blink.cmp.signature').setup() end - end) -end - -------- Public API ------- - ---- Checks if the completion list is active -function cmp.is_active() return require('blink.cmp.completion.list').context ~= nil end - ---- Checks if the completion menu or ghost text is visible ---- @return boolean -function cmp.is_visible() return cmp.is_menu_visible() or cmp.is_ghost_text_visible() end - ---- Checks if the completion menu is visible ---- @return boolean -function cmp.is_menu_visible() return require('blink.cmp.completion.windows.menu').win:is_open() end - ---- Checks if the ghost text is visible ---- @return boolean -function cmp.is_ghost_text_visible() return require('blink.cmp.completion.windows.ghost_text').is_open() end - ---- Checks if the documentation window is visible ---- @return boolean -function cmp.is_documentation_visible() return require('blink.cmp.completion.windows.documentation').win:is_open() end +local cmp = { + list = require('blink.cmp.completion.list'), + trigger = require('blink.cmp.completion.trigger'), + menu = require('blink.cmp.completion.windows.menu'), + ghost_text = require('blink.cmp.completion.windows.ghost_text'), +} + +cmp.accept = cmp.list.accept +cmp.select_prev = cmp.list.select_prev +cmp.select_next = cmp.list.select_next +cmp.hide = cmp.trigger.hide --- Show the completion window ---- @param opts? { providers?: string[], initial_selected_item_idx?: number, callback?: fun() } +--- @param opts? { providers?: string[], initial_selected_item_idx?: number } function cmp.show(opts) - opts = opts or {} - - -- TODO: when passed a list of providers, we should check if we're already showing the menu - -- with that list of providers - if require('blink.cmp.completion.windows.menu').win:is_open() and not (opts and opts.providers) then return end - - vim.schedule(function() - require('blink.cmp.completion.windows.menu').force_auto_show() - - -- HACK: because blink is event based, we don't have an easy way to know when the "show" - -- event completes. So we wait for the list to trigger the show event and check if we're - -- still in the same context - local context - if opts.callback then - vim.api.nvim_create_autocmd('User', { - pattern = 'BlinkCmpShow', - callback = function(event) - if context ~= nil and event.data.context.id == context.id then opts.callback() end - end, - once = true, - }) - end - - context = require('blink.cmp.completion.trigger').show({ - force = true, - providers = opts and opts.providers, - trigger_kind = 'manual', - initial_selected_item_idx = opts.initial_selected_item_idx, - }) - end) - return true -end - --- Show the completion window and select the first item ---- @params opts? { providers?: string[], callback?: fun() } -function cmp.show_and_insert(opts) - opts = opts or {} - opts.initial_selected_item_idx = 1 - return cmp.show(opts) -end - ---- Select the first completion item if there are multiple candidates, or accept it if there is only one, after showing ---- @param opts? blink.cmp.CompletionListSelectAndAcceptOpts -function cmp.show_and_insert_or_accept_single(opts) - local list = require('blink.cmp.completion.list') - - -- If the candidate list has been filtered down to exactly one item, accept it. - if #list.items == 1 then - vim.schedule(function() list.accept({ index = 1, callback = opts and opts.callback }) end) - return true - end - - return cmp.show_and_insert({ - callback = function() - if #list.items == 1 then - list.accept({ index = 1, callback = opts and opts.callback }) - elseif opts and opts.callback then - opts.callback() - end - end, + cmp.menu.config({ auto_show = true }, { ephemeral = true }) + return cmp.trigger.show({ + force = true, + providers = opts and opts.providers, + trigger_kind = 'manual', + initial_selected_item_idx = opts and opts.initial_selected_item_idx, }) end ---- Hide the completion window ---- @param opts? { callback?: fun() } -function cmp.hide(opts) - if not cmp.is_visible() then return end - - vim.schedule(function() - require('blink.cmp.completion.trigger').hide() - if opts and opts.callback then opts.callback() end - end) - return true -end - ---- Cancel the current completion, undoing the preview from auto_insert ---- @param opts? { callback?: fun() } -function cmp.cancel(opts) - if not cmp.is_visible() then return end - vim.schedule(function() - require('blink.cmp.completion.list').undo_preview() - require('blink.cmp.completion.trigger').hide() - if opts and opts.callback then opts.callback() end - end) - return true -end - ---- Accept the current completion item ---- @param opts? blink.cmp.CompletionListAcceptOpts -function cmp.accept(opts) - opts = opts or {} - if not cmp.is_visible() then return end - - local completion_list = require('blink.cmp.completion.list') - local item = opts.index ~= nil and completion_list.items[opts.index] or completion_list.get_selected_item() - if item == nil then return end - - vim.schedule(function() completion_list.accept(opts) end) - return true -end - ---- Select the first completion item, if there's no selection, and accept ---- @param opts? blink.cmp.CompletionListSelectAndAcceptOpts -function cmp.select_and_accept(opts) - if not cmp.is_visible() then return end - - local completion_list = require('blink.cmp.completion.list') - vim.schedule( - function() - completion_list.accept({ - index = completion_list.selected_item_idx or 1, - callback = opts and opts.callback, - }) - end - ) - return true -end - ---- Accept the current completion item and feed an enter key to neovim (i.e. to execute the current command in cmdline mode) ---- @param opts? blink.cmp.CompletionListSelectAndAcceptOpts -function cmp.accept_and_enter(opts) - return cmp.accept({ - callback = function() - if opts and opts.callback then opts.callback() end - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'n', false) - end, - }) -end - ---- Select the first completion item, if there's no selection, accept and feed an enter key to neovim (i.e. to execute the current command in cmdline mode) ---- @param opts? blink.cmp.CompletionListSelectAndAcceptOpts -function cmp.select_accept_and_enter(opts) - return cmp.select_and_accept({ - callback = function() - if opts and opts.callback then opts.callback() end - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'n', false) - end, - }) -end - ---- Select the previous completion item ---- @param opts? blink.cmp.CompletionListSelectOpts -function cmp.select_prev(opts) - if not require('blink.cmp.completion.list').can_select(opts) then return end - vim.schedule(function() require('blink.cmp.completion.list').select_prev(opts) end) - return true -end - ---- Select the next completion item ---- @param opts? blink.cmp.CompletionListSelectOpts -function cmp.select_next(opts) - if not require('blink.cmp.completion.list').can_select(opts) then return end - vim.schedule(function() require('blink.cmp.completion.list').select_next(opts) end) - return true -end - ---- Inserts the next item (`auto_insert`), cycling to the top of the list if at the bottom, if `completion.list.cycle.from_bottom == true`. ---- This will trigger completions if none are available, unlike `select_next` which would fallback to the next keymap in this case. -function cmp.insert_next() - if not cmp.is_active() then return cmp.show_and_insert() end - if not require('blink.cmp.completion.list').can_select({ auto_insert = true }) then return end - - vim.schedule(function() require('blink.cmp.completion.list').select_next({ auto_insert = true }) end) - return true -end - ---- Inserts the previous item (`auto_insert`), cycling to the bottom of the list if at the top, if `completion.list.cycle.from_top == true`. ---- This will trigger completions if none are available, unlike `select_prev` which would fallback to the next keymap in this case. -function cmp.insert_prev() - if not cmp.is_active() then return cmp.show_and_insert() end - if not require('blink.cmp.completion.list').can_select({ auto_insert = true }) then return end - - vim.schedule(function() require('blink.cmp.completion.list').select_prev({ auto_insert = true }) end) - return true -end - ---- Gets the current context ---- @return blink.cmp.Context? -function cmp.get_context() return require('blink.cmp.completion.list').context end - ---- Gets the currently selected completion item ---- @return blink.cmp.CompletionItem? -function cmp.get_selected_item() return require('blink.cmp.completion.list').get_selected_item() end - ---- Gets the currently selected completion item index ---- @return number? -function cmp.get_selected_item_idx() return require('blink.cmp.completion.list').selected_item_idx end - ---- Gets the sorted list of completion items ---- @return blink.cmp.CompletionItem[] -function cmp.get_items() return require('blink.cmp.completion.list').items end - ---- Show the documentation window -function cmp.show_documentation() - local menu = require('blink.cmp.completion.windows.menu') - local documentation = require('blink.cmp.completion.windows.documentation') - if documentation.win:is_open() or not menu.win:is_open() then return end - - local context = require('blink.cmp.completion.list').context - local item = require('blink.cmp.completion.list').get_selected_item() - if not item or not context then return end - - vim.schedule(function() documentation.show_item(context, item) end) - return true -end - ---- Hide the documentation window -function cmp.hide_documentation() - local documentation = require('blink.cmp.completion.windows.documentation') - if not documentation.win:is_open() then return end - - vim.schedule(function() documentation.close() end) - return true -end - ---- Scroll the documentation window up ---- @param count? number -function cmp.scroll_documentation_up(count) - local documentation = require('blink.cmp.completion.windows.documentation') - if not documentation.win:is_open() then return end - - vim.schedule(function() documentation.scroll_up(count or 4) end) - return true -end - ---- Scroll the documentation window down ---- @param count? number -function cmp.scroll_documentation_down(count) - local documentation = require('blink.cmp.completion.windows.documentation') - if not documentation.win:is_open() then return end - - vim.schedule(function() documentation.scroll_down(count or 4) end) - return true -end - ---- Check if the signature help window is visible ---- @return boolean -function cmp.is_signature_visible() return require('blink.cmp.signature.window').win:is_open() end - ---- Show the signature help window -function cmp.show_signature() - local config = require('blink.cmp.config').signature - if not config.enabled or cmp.is_signature_visible() then return end - vim.schedule(function() require('blink.cmp.signature.trigger').show({ force = true }) end) - return true -end - ---- Hide the signature help window -function cmp.hide_signature() - local config = require('blink.cmp.config').signature - if not config.enabled or not cmp.is_signature_visible() then return end - vim.schedule(function() require('blink.cmp.signature.trigger').hide() end) - return true -end - ---- Scroll the documentation window up ---- @param count? number -function cmp.scroll_signature_up(count) - local sig = require('blink.cmp.signature.window') - if not sig.win:is_open() then return end - - vim.schedule(function() sig.scroll_up(count or 4) end) - return true -end - ---- Scroll the documentation window down ---- @param count? number -function cmp.scroll_signature_down(count) - local sig = require('blink.cmp.signature.window') - if not sig.win:is_open() then return end - - vim.schedule(function() sig.scroll_down(count or 4) end) - return true -end - ---- Check if a snippet is active, optionally filtering by direction ---- @param filter? { direction?: number } -function cmp.snippet_active(filter) return require('blink.cmp.config').snippets.active(filter) end - ---- Move the cursor forward to the next snippet placeholder -function cmp.snippet_forward() - local snippets = require('blink.cmp.config').snippets - if not snippets.active({ direction = 1 }) then return end - vim.schedule(function() snippets.jump(1) end) - return true -end - ---- Move the cursor backward to the previous snippet placeholder -function cmp.snippet_backward() - local snippets = require('blink.cmp.config').snippets - if not snippets.active({ direction = -1 }) then return end - vim.schedule(function() snippets.jump(-1) end) - return true -end - ---- Ensures that blink.cmp will be notified last when a user adds a character -function cmp.resubscribe() - local trigger = require('blink.cmp.completion.trigger') - trigger.resubscribe() -end - ---- Tells the sources to reload a specific provider or all providers (when nil) ---- @param provider? string -function cmp.reload(provider) require('blink.cmp.sources.lib').reload(provider) end - ---- Gets the capabilities to pass to the LSP client ---- @param override? lsp.ClientCapabilities Overrides blink.cmp's default capabilities ---- @param include_nvim_defaults? boolean Whether to include nvim's default capabilities -function cmp.get_lsp_capabilities(override, include_nvim_defaults) - return require('blink.cmp.sources.lib').get_lsp_capabilities(override, include_nvim_defaults) -end - ---- Add a new source provider at runtime ---- Equivalent to adding the source via `sources.providers. = ` ---- @param source_id string ---- @param source_config blink.cmp.SourceProviderConfig -function cmp.add_source_provider(source_id, source_config) - local config = require('blink.cmp.config') - - assert(config.sources.providers[source_id] == nil, 'Provider with id ' .. source_id .. ' already exists') - require('blink.cmp.config.sources').validate_provider(source_id, source_config) - - config.sources.providers[source_id] = source_config -end - ---- Adds a source provider to the list of enabled sources for a given filetype ---- ---- Equivalent to adding the source via `sources.per_filetype. = { , inherit_defaults = true }` ---- in the config, appending to the existing list. ---- If the user already has a source defined for the filetype, `inherit_defaults` will default to `false`. ---- @param filetype string ---- @param source_id string -function cmp.add_filetype_source(filetype, source_id) - require('blink.cmp.sources.lib').add_filetype_provider_id(filetype, source_id) +--- Cancel the current completion, undoing the preview +function cmp.cancel() + cmp.list.undo_preview() + cmp.trigger.hide() end return cmp diff --git a/lua/blink/cmp/keymap/apply.lua b/lua/blink/cmp/keymap/apply.lua deleted file mode 100644 index 156ef70f6..000000000 --- a/lua/blink/cmp/keymap/apply.lua +++ /dev/null @@ -1,173 +0,0 @@ -local apply = {} - -local snippet_commands = { 'snippet_forward', 'snippet_backward', 'show_signature', 'hide_signature' } - ---- Applies the keymaps to the current buffer ---- @param keys_to_commands table -function apply.keymap_to_current_buffer(keys_to_commands) - -- skip if we've already applied the keymaps - for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(0, 'i')) do - if mapping.desc == 'blink.cmp' then return end - end - - -- insert mode: uses both snippet and insert commands - for key, commands in pairs(keys_to_commands) do - local fallback = require('blink.cmp.keymap.fallback').wrap('i', key) - apply.set('i', key, function() - if not require('blink.cmp.config').enabled() then return fallback() end - - for _, command in ipairs(commands) do - -- special case for fallback - if command == 'fallback' or command == 'fallback_to_mappings' then - return fallback(command == 'fallback_to_mappings') - - -- run user defined functions - elseif type(command) == 'function' then - local ret = command(require('blink.cmp')) - if type(ret) == 'string' then return ret end - if ret then return end - - -- otherwise, run the built-in command - elseif require('blink.cmp')[command]() then - return - end - end - end) - end - - -- snippet mode: uses only snippet commands - for key, commands in pairs(keys_to_commands) do - if not apply.has_snippet_commands(commands) then goto continue end - - local fallback = require('blink.cmp.keymap.fallback').wrap('s', key) - apply.set('s', key, function() - if not require('blink.cmp.config').enabled() then return fallback() end - - for _, command in ipairs(keys_to_commands[key] or {}) do - -- special case for fallback - if command == 'fallback' or command == 'fallback_to_mappings' then - return fallback(command == 'fallback_to_mappings') - - -- run user defined functions - elseif type(command) == 'function' then - if command(require('blink.cmp')) then return end - - -- only run snippet commands - elseif vim.tbl_contains(snippet_commands, command) then - local did_run = require('blink.cmp')[command]() - if did_run then return end - end - end - end) - - ::continue:: - end -end - -function apply.has_insert_command(commands) - for _, command in ipairs(commands) do - if not vim.tbl_contains(snippet_commands, command) and command ~= 'fallback' then return true end - end - return false -end - -function apply.has_snippet_commands(commands) - for _, command in ipairs(commands) do - if vim.tbl_contains(snippet_commands, command) or type(command) == 'function' then return true end - end - return false -end - -function apply.term_keymaps(keys_to_commands) - -- skip if we've already applied the keymaps - for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(0, 't')) do - if mapping.desc == 'blink.cmp' then return end - end - - -- terminal mode: uses insert commands only - for key, commands in pairs(keys_to_commands) do - if not apply.has_insert_command(commands) then goto continue end - - local fallback = require('blink.cmp.keymap.fallback').wrap('i', key) - apply.set('t', key, function() - for _, command in ipairs(commands) do - -- special case for fallback - if command == 'fallback' or command == 'fallback_to_mappings' then - return fallback(command == 'fallback_to_mappings') - - -- run user defined functions - elseif type(command) == 'function' then - if command(require('blink.cmp')) then return end - - -- otherwise, run the built-in command - elseif require('blink.cmp')[command]() then - return - end - end - end) - - ::continue:: - end -end - -function apply.cmdline_keymaps(keys_to_commands) - -- skip if we've already applied the keymaps - for _, mapping in ipairs(vim.api.nvim_get_keymap('c')) do - if mapping.desc == 'blink.cmp' then return end - end - - -- cmdline mode: uses only insert commands - for key, commands in pairs(keys_to_commands) do - if not apply.has_insert_command(commands) then goto continue end - - local fallback = require('blink.cmp.keymap.fallback').wrap('c', key) - apply.set('c', key, function() - for _, command in ipairs(commands) do - -- special case for fallback - if command == 'fallback' or command == 'fallback_to_mappings' then - return fallback(command == 'fallback_to_mappings') - - -- run user defined functions - elseif type(command) == 'function' then - if command(require('blink.cmp')) then return end - - -- otherwise, run the built-in command - elseif not vim.tbl_contains(snippet_commands, command) then - local did_run = require('blink.cmp')[command]() - if did_run then return end - end - end - end) - - ::continue:: - end -end - ---- @param mode string ---- @param key string ---- @param callback fun(): string | nil -function apply.set(mode, key, callback) - if mode == 'c' or mode == 't' then - vim.api.nvim_set_keymap(mode, key, '', { - callback = callback, - expr = true, - -- silent must be false for fallback to work - -- otherwise, you get very weird behavior - silent = false, - noremap = true, - replace_keycodes = false, - desc = 'blink.cmp', - }) - else - vim.api.nvim_buf_set_keymap(0, mode, key, '', { - callback = callback, - expr = true, - silent = true, - noremap = true, - replace_keycodes = false, - desc = 'blink.cmp', - }) - end -end - -return apply diff --git a/lua/blink/cmp/keymap/fallback.lua b/lua/blink/cmp/keymap/fallback.lua deleted file mode 100644 index 801b22d5b..000000000 --- a/lua/blink/cmp/keymap/fallback.lua +++ /dev/null @@ -1,91 +0,0 @@ -local fallback = {} - ---- Add missing types. Remove when fixed upstream ----@class blink.cmp.Fallback : vim.api.keyset.keymap ----@field lhs string ----@field mode string ----@field rhs? string ----@field lhsraw? string ----@field buffer? number - ---- Gets the non blink.cmp global keymap for the given mode and key ---- @param mode string ---- @param key string ---- @return blink.cmp.Fallback | nil -function fallback.get_non_blink_global_mapping_for_key(mode, key) - local normalized_key = vim.api.nvim_replace_termcodes(key, true, true, true) - - -- get global mappings - local mappings = vim.api.nvim_get_keymap(mode) - - for _, mapping in ipairs(mappings) do - --- @cast mapping blink.cmp.Fallback - local mapping_key = vim.api.nvim_replace_termcodes(mapping.lhs, true, true, true) - if mapping_key == normalized_key and mapping.desc ~= 'blink.cmp' then return mapping end - end -end - ---- Gets the non blink.cmp buffer keymap for the given mode and key ---- @param mode string ---- @param key string ---- @return blink.cmp.Fallback? -function fallback.get_non_blink_buffer_mapping_for_key(mode, key) - local normalized_key = vim.api.nvim_replace_termcodes(key, true, true, true) - - local buffer_mappings = vim.api.nvim_buf_get_keymap(0, mode) - - for _, mapping in ipairs(buffer_mappings) do - --- @cast mapping blink.cmp.Fallback - local mapping_key = vim.api.nvim_replace_termcodes(mapping.lhs, true, true, true) - if mapping_key == normalized_key and mapping.desc ~= 'blink.cmp' then return mapping end - end -end - ---- Returns a function that will run the first non blink.cmp keymap for the given mode and key ---- @param mode string ---- @param key string ---- @return fun(mappings_only?: boolean): string? -function fallback.wrap(mode, key) - -- In default mode, there can't be multiple mappings on a single key for buffer local mappings - -- In cmdline mode, there can't be multiple mappings on a single key for global mappings - local buffer_mapping = mode ~= 'c' and fallback.get_non_blink_buffer_mapping_for_key(mode, key) - or fallback.get_non_blink_global_mapping_for_key(mode, key) - return function(mappings_only) - local mapping = buffer_mapping or fallback.get_non_blink_global_mapping_for_key(mode, key) - if mapping then return fallback.run_non_blink_keymap(mapping, key) end - if not mappings_only then return vim.api.nvim_replace_termcodes(key, true, true, true) end - end -end - ---- Runs the first non blink.cmp keymap for the given mode and key ---- @param mapping blink.cmp.Fallback ---- @param key string ---- @return string | nil -function fallback.run_non_blink_keymap(mapping, key) - -- TODO: there's likely many edge cases here. the nvim-cmp version is lacking documentation - -- and is quite complex. we should look to see if we can simplify their logic - -- https://github.com/hrsh7th/nvim-cmp/blob/ae644feb7b67bf1ce4260c231d1d4300b19c6f30/lua/cmp/utils/keymap.lua - if type(mapping.callback) == 'function' then - -- with expr = true, which we use, we can't modify the buffer without scheduling - -- so if the keymap does not use expr, we must schedule it - if mapping.expr ~= 1 then - vim.schedule(mapping.callback) - return - end - - local expr = mapping.callback() - if type(expr) == 'string' and mapping.replace_keycodes == 1 then - expr = vim.api.nvim_replace_termcodes(expr, true, true, true) - end - return expr - elseif mapping.rhs then - local rhs = vim.api.nvim_replace_termcodes(mapping.rhs, true, true, true) - if mapping.expr == 1 then rhs = vim.api.nvim_eval(rhs) end - return rhs - end - - -- pass the key along as usual - return vim.api.nvim_replace_termcodes(key, true, true, true) -end - -return fallback diff --git a/lua/blink/cmp/keymap/init.lua b/lua/blink/cmp/keymap/init.lua index de635218e..388172a1e 100644 --- a/lua/blink/cmp/keymap/init.lua +++ b/lua/blink/cmp/keymap/init.lua @@ -1,110 +1,38 @@ -local keymap = {} - ---- Lowercases all keys in the mappings table ---- @param existing_mappings table ---- @param new_mappings table ---- @return table -function keymap.merge_mappings(existing_mappings, new_mappings) - local merged_mappings = vim.deepcopy(existing_mappings) - for new_key, new_mapping in pairs(new_mappings) do - -- normalize the keys and replace, since naively merging would not handle == - for existing_key, _ in pairs(existing_mappings) do - if - vim.api.nvim_replace_termcodes(existing_key, true, true, true) - == vim.api.nvim_replace_termcodes(new_key, true, true, true) - then - merged_mappings[existing_key] = new_mapping - goto continue - end - end - - -- key wasn't found, add it as per usual - merged_mappings[new_key] = new_mapping - - ::continue:: - end - return merged_mappings -end - ---- @param keymap_config blink.cmp.KeymapConfig ---- @param mode blink.cmp.Mode ---- @return table -function keymap.get_mappings(keymap_config, mode) - local mappings = vim.deepcopy(keymap_config) - - -- Remove unused keys, but keep keys set to false or empty tables (to disable them) - if mode ~= 'default' then - for key, commands in pairs(mappings) do - if - key ~= 'preset' - and commands ~= false - and #commands ~= 0 - and not require('blink.cmp.keymap.apply').has_insert_command(commands) - then - mappings[key] = nil - end - end - end - - -- Handle preset - if mappings.preset then - local preset_keymap = require('blink.cmp.keymap.presets').get(mappings.preset) - - -- Remove 'preset' key from opts to prevent it from being treated as a keymap - mappings.preset = nil - - -- Merge the preset keymap with the user-defined keymaps - -- User-defined keymaps overwrite the preset keymaps - mappings = keymap.merge_mappings(preset_keymap, mappings) +-- For single-key mappings, use vim.on_key, to avoid complex fallback logic and asynchronous commands +-- For multi-key mappings, we should either error, telling the user to set it themselves with vim.keymap.set, +-- or we can do the vim.keymap.set, but only if the implementation is simple + +--- @class blink.cmp.KeymapOpts +--- @field bufnr? integer +--- @field fallback? boolean + +local keymap = require('blink.cmp.lib.config').new_enable(true) + +--- @param mode blink.cmp.Mode | blink.cmp.Mode[] | '*' +--- @param preset blink.cmp.KeymapPreset +--- @param filter? { bufnr?: integer } +function keymap.preset(mode, preset, filter) + for key, commands in pairs(require('blink.cmp.keymap.presets').get(preset)) do + -- TODO: think through this more. maybe drop the idea of `string[]` commands and just use functions? + local has_fallback = vim.tbl_contains(commands, 'fallback') + keymap.set( + mode, + key, + vim.tbl_filter(function(command) return command ~= 'fallback' end, commands), + { bufnr = filter and filter.bufnr, fallback = has_fallback } + ) end - --- @cast mappings table - - -- Remove keys explicitly disabled by user (set to false or no commands) - for key, commands in pairs(mappings) do - if commands == false or #commands == 0 then mappings[key] = nil end - end - --- @cast mappings table - - return mappings end -function keymap.setup() - local config = require('blink.cmp.config') - local apply = require('blink.cmp.keymap.apply') +--- @param mode blink.cmp.Mode | blink.cmp.Mode[] | '*' +--- @param lhs string +--- @param rhs fun() | blink.cmp.KeymapCommand | blink.cmp.KeymapCommand[] +--- @param opts? blink.cmp.KeymapOpts +function keymap.set(mode, lhs, rhs, opts) end - local mappings = keymap.get_mappings(config.keymap, 'default') - - -- We set on the buffer directly to avoid buffer-local keymaps (such as from autopairs) - -- from overriding our mappings. We also use InsertEnter to avoid conflicts with keymaps - -- applied on other autocmds, such as LspAttach used by nvim-lspconfig and most configs - vim.api.nvim_create_autocmd('InsertEnter', { - callback = function() - if not require('blink.cmp.config').enabled() then return end - apply.keymap_to_current_buffer(mappings) - end, - }) - - -- This is not called when the plugin loads since it first checks if the binary is - -- installed. As a result, when lazy-loaded on InsertEnter, the event may be missed - if vim.api.nvim_get_mode().mode == 'i' and require('blink.cmp.config').enabled() then - apply.keymap_to_current_buffer(mappings) - end - - -- Apply cmdline and term keymaps - for _, mode in ipairs({ 'cmdline', 'term' }) do - local mode_config = config[mode] - if mode_config.enabled then - local mode_keymap = vim.deepcopy(mode_config.keymap) - - if mode_config.keymap.preset == 'inherit' then - mode_keymap = vim.tbl_deep_extend('force', config.keymap, mode_config.keymap) - mode_keymap.preset = config.keymap.preset - end - - local mode_mappings = keymap.get_mappings(mode_keymap, mode) - apply[mode .. '_keymaps'](mode_mappings) - end - end -end +--- @param mode blink.cmp.Mode | blink.cmp.Mode[] | '*' +--- @param lhs string +--- @param opts? blink.cmp.KeymapOpts +function keymap.del(mode, lhs, opts) end return keymap diff --git a/lua/blink/cmp/keymap/presets.lua b/lua/blink/cmp/keymap/presets.lua index efb917b37..6ac687ed5 100644 --- a/lua/blink/cmp/keymap/presets.lua +++ b/lua/blink/cmp/keymap/presets.lua @@ -1,9 +1,5 @@ -local presets = {} - ---- @type table> -local presets_keymaps = { - none = {}, - +--- @type table> +local full_presets = { default = { [''] = { 'show', 'show_documentation', 'hide_documentation' }, [''] = { 'cancel', 'fallback' }, @@ -38,11 +34,13 @@ local presets_keymaps = { [''] = { 'cancel', 'fallback' }, [''] = { 'hide', 'fallback' }, }, +} +--- Merged with the keymaps from the default preset +--- @type table> +local diff_presets = { ['super-tab'] = { - [''] = { 'show', 'show_documentation', 'hide_documentation' }, - [''] = { 'cancel', 'fallback' }, - + [''] = {}, [''] = { function(cmp) if cmp.snippet_active() then @@ -54,46 +52,55 @@ local presets_keymaps = { 'snippet_forward', 'fallback', }, - [''] = { 'snippet_backward', 'fallback' }, - - [''] = { 'select_prev', 'fallback' }, - [''] = { 'select_next', 'fallback' }, - [''] = { 'select_prev', 'fallback_to_mappings' }, - [''] = { 'select_next', 'fallback_to_mappings' }, - - [''] = { 'scroll_documentation_up', 'fallback' }, - [''] = { 'scroll_documentation_down', 'fallback' }, - - [''] = { 'show_signature', 'hide_signature', 'fallback' }, }, enter = { - [''] = { 'show', 'show_documentation', 'hide_documentation' }, - [''] = { 'cancel', 'fallback' }, + [''] = {}, [''] = { 'accept', 'fallback' }, - - [''] = { 'snippet_forward', 'fallback' }, - [''] = { 'snippet_backward', 'fallback' }, - - [''] = { 'select_prev', 'fallback' }, - [''] = { 'select_next', 'fallback' }, - [''] = { 'select_prev', 'fallback_to_mappings' }, - [''] = { 'select_next', 'fallback_to_mappings' }, - - [''] = { 'scroll_documentation_up', 'fallback' }, - [''] = { 'scroll_documentation_down', 'fallback' }, - - [''] = { 'show_signature', 'hide_signature', 'fallback' }, }, } +--- @class blink.cmp.KeymapPresets +local presets = {} + --- Gets the preset keymap for the given preset name --- @param name string ---- @return table +--- @return table function presets.get(name) - local preset = presets_keymaps[name] - if preset == nil then error('Invalid blink.cmp keymap preset: ' .. name) end - return preset + local full_preset = full_presets[name] or full_presets.default + local diff_preset = diff_presets[name] + + if full_preset == nil and diff_preset == nil then error('Invalid blink.cmp keymap preset: ' .. name) end + + if diff_preset == nil then return full_preset end + return presets.merge(full_preset, diff_preset) +end + +--- @param existing_mappings table +--- @param new_mappings table +--- @return table +function presets.merge(existing_mappings, new_mappings) + -- TODO: drop goto, filter out false values + + local merged_mappings = vim.deepcopy(existing_mappings) + for new_key, new_mapping in pairs(new_mappings) do + -- normalize the keys and replace, since naively merging would not handle == + for existing_key, _ in pairs(existing_mappings) do + if + vim.api.nvim_replace_termcodes(existing_key, true, true, true) + == vim.api.nvim_replace_termcodes(new_key, true, true, true) + then + merged_mappings[existing_key] = new_mapping + goto continue + end + end + + -- key wasn't found, add it as per usual + merged_mappings[new_key] = new_mapping + + ::continue:: + end + return merged_mappings end return presets diff --git a/lua/blink/cmp/lib/async.lua b/lua/blink/cmp/lib/async.lua index bbdf27188..e7ff11f69 100644 --- a/lua/blink/cmp/lib/async.lua +++ b/lua/blink/cmp/lib/async.lua @@ -20,7 +20,7 @@ ---Note that lua language server cannot infer the type of the task from the `resolve` call. --- ---You may need to add the type annotation explicitly via an `@return` annotation on a function returning the task, or via the `@cast/@type` annotations on the task variable. ---- @class blink.cmp.Task: { status: blink.cmp.TaskStatus, result: T | nil, error: any | nil, _completion_cbs: fun(result: T)[], _failure_cbs: fun(err: any)[], _cancel_cbs: fun()[], _cancel: fun()?, __task: true } +--- @class blink.cmp.Task: { status: blink.cmp.TaskStatus, result: T, error: any | nil, _completion_cbs: fun(result: T)[], _failure_cbs: fun(err: any)[], _cancel_cbs: fun()[], _cancel: fun()?, __task: true } local task = { __task = true, } diff --git a/lua/blink/cmp/lib/buffer_events.lua b/lua/blink/cmp/lib/buffer_events.lua deleted file mode 100644 index c3309e2eb..000000000 --- a/lua/blink/cmp/lib/buffer_events.lua +++ /dev/null @@ -1,221 +0,0 @@ ---- Exposes three events (cursor moved, char added, insert leave) for triggers to use. ---- Notably, when "char added" is fired, the "cursor moved" event will not be fired. ---- Unlike in regular neovim, ctrl + c and buffer switching will trigger "insert leave" - ---- @class blink.cmp.BufferEvents ---- @field has_context fun(): boolean ---- @field show_in_snippet boolean ---- @field ignore_next_text_changed boolean ---- @field ignore_next_cursor_moved boolean ---- @field last_char string ---- @field textchangedi_id number ---- ---- @field new fun(opts: blink.cmp.BufferEventsOptions): blink.cmp.BufferEvents ---- @field listen fun(self: blink.cmp.BufferEvents, opts: blink.cmp.BufferEventsListener) ---- @field resubscribe fun(self: blink.cmp.BufferEvents, opts: blink.cmp.BufferEventsListener) Effectively ensures that our autocmd listeners run last, after other registered listeners ---- @field suppress_events_for_callback fun(self: blink.cmp.BufferEvents, cb: fun()) - ---- @class blink.cmp.BufferEventsOptions ---- @field has_context fun(): boolean ---- @field show_in_snippet boolean - ---- @class blink.cmp.BufferEventsListener ---- @field on_char_added fun(char: string, is_ignored: boolean) ---- @field on_cursor_moved fun(event: 'CursorMoved' | 'InsertEnter', is_ignored: boolean, is_backspace: boolean, last_event: string) ---- @field on_insert_leave fun() ---- @field on_complete_changed fun() - ---- @type blink.cmp.BufferEvents ---- @diagnostic disable-next-line: missing-fields -local buffer_events = {} - -function buffer_events.new(opts) - return setmetatable({ - has_context = opts.has_context, - show_in_snippet = opts.show_in_snippet, - ignore_next_text_changed = false, - ignore_next_cursor_moved = false, - last_char = '', - textchangedi_id = -1, - }, { __index = buffer_events }) -end - -local function make_char_added(self, snippet, on_char_added) - return function() - if not require('blink.cmp.config').enabled() then return end - if snippet.active() and not self.show_in_snippet and not self.has_context() then return end - - local is_ignored = self.ignore_next_text_changed - self.ignore_next_text_changed = false - - -- no characters added so let cursormoved handle it - if self.last_char == '' then return end - - on_char_added(self.last_char, is_ignored) - - self.last_char = '' - end -end - -local function make_cursor_moved(self, snippet, on_cursor_moved) - --- @type 'accept' | 'enter' | nil - local last_event = nil - - -- track whether the event was triggered by backspacing - local did_backspace = false - vim.on_key(function(key) did_backspace = key == vim.api.nvim_replace_termcodes('', true, true, true) end) - - -- track whether the event was triggered by accepting - local did_accept = false - require('blink.cmp.completion.list').accept_emitter:on(function() did_accept = true end) - - -- clear state on insert leave - vim.api.nvim_create_autocmd('InsertLeave', { - callback = function() - did_backspace = false - did_accept = false - last_event = nil - end, - }) - - return function(ev) - -- only fire a CursorMoved event (notable not CursorMovedI) - -- when jumping between tab stops in a snippet while showing the menu - if - ev.event == 'CursorMoved' - and (vim.api.nvim_get_mode().mode ~= 'v' or not self.has_context() or not snippet.active()) - then - return - end - - local is_cursor_moved = ev.event == 'CursorMoved' or ev.event == 'CursorMovedI' - local is_ignored = is_cursor_moved and self.ignore_next_cursor_moved - if is_cursor_moved then self.ignore_next_cursor_moved = false end - - local is_backspace = did_backspace and is_cursor_moved - did_backspace = false - - -- last event tracking - local tmp_last_event = last_event - -- HACK: accepting will immediately fire a CursorMovedI event, - -- so we ignore the first CursorMovedI event after accepting - if did_accept then - last_event = 'accept' - did_accept = false - elseif ev.event == 'InsertEnter' then - last_event = 'enter' - else - last_event = nil - end - - -- characters added so let textchanged handle it - if self.last_char ~= '' then return end - - if not require('blink.cmp.config').enabled() then return end - if not self.show_in_snippet and not self.has_context() and snippet.active() then return end - - on_cursor_moved(is_cursor_moved and 'CursorMoved' or ev.event, is_ignored, is_backspace, tmp_last_event) - end -end - -local function make_insert_leave(self, on_insert_leave) - return function() - self.last_char = '' - -- HACK: when using vim.snippet.expand, the mode switches from insert -> normal -> visual -> select - -- so we schedule to ignore the intermediary modes - -- TODO: deduplicate requests - vim.schedule(function() - local mode = vim.api.nvim_get_mode().mode - if not mode:match('i') and not mode:match('s') then on_insert_leave() end - end) - end -end - ---- Normalizes the autocmds + ctrl+c into a common api and handles ignored events -function buffer_events:listen(opts) - local snippet = require('blink.cmp.config').snippets - - vim.api.nvim_create_autocmd('InsertCharPre', { - callback = function() - if snippet.active() and not self.show_in_snippet and not self.has_context() then return end - -- FIXME: vim.v.char can be an escape code such as <95> in the case of . This breaks downstream - -- since this isn't a valid utf-8 string. How can we identify and ignore these? - self.last_char = vim.v.char - end, - }) - - self.textchangedi_id = vim.api.nvim_create_autocmd('TextChangedI', { - callback = make_char_added(self, snippet, opts.on_char_added), - }) - - vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI', 'InsertEnter' }, { - callback = make_cursor_moved(self, snippet, opts.on_cursor_moved), - }) - - -- definitely leaving the context - vim.api.nvim_create_autocmd({ 'ModeChanged', 'BufLeave' }, { - callback = make_insert_leave(self, opts.on_insert_leave), - }) - - -- ctrl+c doesn't trigger InsertLeave so handle it separately - local ctrl_c = vim.api.nvim_replace_termcodes('', true, true, true) - vim.on_key(function(key) - if key == ctrl_c then - vim.schedule(function() - local mode = vim.api.nvim_get_mode().mode - if mode ~= 'i' then - self.last_char = '' - opts.on_insert_leave() - end - end) - end - end) - - if opts.on_complete_changed then - vim.api.nvim_create_autocmd('CompleteChanged', { - callback = vim.schedule_wrap(function() opts.on_complete_changed() end), - }) - end -end - ---- Effectively ensures that our autocmd listeners run last, after other registered listeners ---- HACK: Ideally, we would have some way to ensure that we always run after other listeners -function buffer_events:resubscribe(opts) - if self.textchangedi_id == -1 then return end - - local snippet = require('blink.cmp.config').snippets - vim.api.nvim_del_autocmd(self.textchangedi_id) - self.textchangedi_id = vim.api.nvim_create_autocmd('TextChangedI', { - callback = make_char_added(self, snippet, opts.on_char_added), - }) -end - ---- Suppresses autocmd events for the duration of the callback ---- HACK: there's likely edge cases with this since we can't know for sure ---- if the autocmds will fire for cursor_moved afaik -function buffer_events:suppress_events_for_callback(cb) - local cursor_before = vim.api.nvim_win_get_cursor(0) - local changed_tick_before = vim.api.nvim_buf_get_changedtick(0) - - cb() - - local cursor_after = vim.api.nvim_win_get_cursor(0) - local changed_tick_after = vim.api.nvim_buf_get_changedtick(0) - - local is_insert_mode = vim.api.nvim_get_mode().mode:sub(1, 1) == 'i' - - self.ignore_next_text_changed = changed_tick_before ~= changed_tick_after and is_insert_mode - - -- HACK: the cursor may move from position (1, 1) to (1, 0) and back to (1, 1) during the callback - -- This will trigger a CursorMovedI event, but we can't detect it simply by checking the cursor position - -- since they're equal before vs after the callback. So instead, we always mark the cursor as ignored in - -- insert mode, but if the cursor was equal, we undo the ignore after a small delay, which practically guarantees - -- that the CursorMovedI event will fire - -- TODO: It could make sense to override the nvim_win_set_cursor function and mark as ignored if it's called - -- on the current buffer - local cursor_moved = cursor_after[1] ~= cursor_before[1] or cursor_after[2] ~= cursor_before[2] - self.ignore_next_cursor_moved = is_insert_mode - if not cursor_moved then vim.defer_fn(function() self.ignore_next_cursor_moved = false end, 10) end -end - -return buffer_events diff --git a/lua/blink/cmp/lib/cmdline_events.lua b/lua/blink/cmp/lib/cmdline_events.lua deleted file mode 100644 index ecc3189a8..000000000 --- a/lua/blink/cmp/lib/cmdline_events.lua +++ /dev/null @@ -1,142 +0,0 @@ ---- @class blink.cmp.CmdlineEvents ---- @field has_context fun(): boolean ---- @field ignore_next_text_changed boolean ---- @field ignore_next_cursor_moved boolean ---- ---- @field new fun(): blink.cmp.CmdlineEvents ---- @field listen fun(self: blink.cmp.CmdlineEvents, opts: blink.cmp.CmdlineEventsListener) ---- @field suppress_events_for_callback fun(self: blink.cmp.CmdlineEvents, cb: fun()) - ---- @class blink.cmp.CmdlineEventsListener ---- @field on_char_added fun(char: string, is_ignored: boolean) ---- @field on_cursor_moved fun(event: 'CursorMoved' | 'InsertEnter', is_ignored: boolean) ---- @field on_leave fun() - ---- @type blink.cmp.CmdlineEvents ---- @diagnostic disable-next-line: missing-fields -local cmdline_events = {} - -function cmdline_events.new() - return setmetatable({ - ignore_next_text_changed = false, - ignore_next_cursor_moved = false, - }, { __index = cmdline_events }) --[[@as blink.cmp.CmdlineEvents]] -end - -function cmdline_events:listen(opts) - -- TextChanged - local on_changed = function(key) opts.on_char_added(key, false) end - - local last_move_time, pending_key - local is_change_queued = false - vim.on_key(function(raw_key, escaped_key) - if vim.api.nvim_get_mode().mode ~= 'c' then return end - - -- ignore if it's a special key - -- FIXME: odd behavior when escaped_key has multiple keycodes, i.e. by pressing and then "t" - local key = vim.fn.keytrans(escaped_key) - if key:sub(1, 1) == '<' and key:sub(#key, #key) == '>' and raw_key ~= ' ' then return end - if key == '' then return end - - last_move_time = vim.loop.hrtime() / 1e6 - pending_key = raw_key - - if not is_change_queued then - is_change_queued = true - vim.schedule(function() - on_changed(pending_key) - is_change_queued = false - pending_key = nil - end) - end - end) - - -- Abbreviations and other automated features can cause rapid, repeated cursor movements - -- (a "burst") that are not intentional user actions. To avoid reacting to these artificial - -- movements in CursorMovedC, we detect bursts by measuring the time between moves. - -- If two cursor moves occur within a short threshold (burst_threshold_ms), we treat them - -- as part of a burst and ignore them. - local burst_threshold_ms = 2 - local function is_burst_move() - local current_time = vim.loop.hrtime() / 1e6 - local is_burst = last_move_time and (current_time - last_move_time) < burst_threshold_ms - last_move_time = current_time - return is_burst or false - end - - -- CursorMoved - if vim.fn.has('nvim-0.11') == 1 then - vim.api.nvim_create_autocmd('CursorMovedC', { - callback = function() - if vim.api.nvim_get_mode().mode ~= 'c' then return end - - local is_ignored = self.ignore_next_cursor_moved - self.ignore_next_cursor_moved = false - - if is_change_queued then return end - - if not is_burst_move() then opts.on_cursor_moved('CursorMoved', is_ignored) end - end, - }) - - -- TODO: remove when nvim 0.11 is the minimum version - -- HACK: check every 16ms (60 times/second) to see if the cursor moved - -- for neovim < 0.11 - else - local previous_cmdline = '' - local previous_cursor - - local timer = vim.uv.new_timer() - local callback = vim.schedule_wrap(function() - if vim.api.nvim_get_mode().mode ~= 'c' then return end - - local current_cmdline = vim.fn.getcmdline() - local current_cursor = vim.fn.getcmdpos() - local cursor_changed = current_cursor ~= previous_cursor - - -- Fire on_cursor_moved if cursor changed or destructive edits (, or ) - if cursor_changed and #current_cmdline < #previous_cmdline then - local is_ignored = self.ignore_next_cursor_moved - self.ignore_next_cursor_moved = false - if is_change_queued then return end - opts.on_cursor_moved('CursorMoved', is_ignored) - end - - previous_cmdline = current_cmdline - previous_cursor = current_cursor - end) - vim.api.nvim_create_autocmd('CmdlineEnter', { - callback = function() - previous_cmdline = '' - timer:start(16, 16, callback) - end, - }) - vim.api.nvim_create_autocmd('CmdlineLeave', { - callback = function() timer:stop() end, - }) - end - - vim.api.nvim_create_autocmd('CmdlineLeave', { - callback = function() opts.on_leave() end, - }) -end - ---- Suppresses autocmd events for the duration of the callback ---- HACK: there's likely edge cases with this -function cmdline_events:suppress_events_for_callback(cb) - local cursor_before = vim.fn.getcmdpos() - - cb() - - if not vim.api.nvim_get_mode().mode == 'c' then return end - - -- HACK: the cursor may move from position 1 to 0 and back to 1 during the callback - -- This will trigger a CursorMovedC event, but we can't detect it simply by checking the cursor position - -- since they're equal before vs after the callback. So instead, we always mark the cursor as ignored in - -- but if the cursor was equal, we undo the ignore after a small delay - self.ignore_next_cursor_moved = true - local cursor_after = vim.fn.getcmdpos() - if cursor_after == cursor_before then vim.defer_fn(function() self.ignore_next_cursor_moved = false end, 20) end -end - -return cmdline_events diff --git a/lua/blink/cmp/lib/config.lua b/lua/blink/cmp/lib/config.lua new file mode 100644 index 000000000..fb86a0b24 --- /dev/null +++ b/lua/blink/cmp/lib/config.lua @@ -0,0 +1,124 @@ +local M = {} + +--- @class blink.cmp.config: { [string]: T, [integer]: T } + +--- @generic T +--- @param default T +--- @return blink.cmp.config +function M.new_config(default) + return setmetatable({ + _configs = { ['*'] = vim.deepcopy(default) }, + }, { + --- @param mode_or_bufnr blink.cmp.Mode | integer + __index = function(self, mode_or_bufnr) + vim.validate('name', mode_or_bufnr, { 'string', 'number' }) + + local mode = type(mode_or_bufnr) == 'string' and mode_or_bufnr or get_mode() + local bufnr = type(mode_or_bufnr) == 'number' and mode_or_bufnr or vim.api.nvim_get_current_buf() + local include_buffer_config = type(mode_or_bufnr) == 'number' or mode == 'default' or mode == 'term' + + -- TODO: use metatable instead of merging every time + return vim.tbl_deep_extend( + 'force', + self._configs['*'], + self._configs[mode] or {}, + include_buffer_config and self._configs[bufnr] or {} + ) + end, + + --- @param mode_or_bufnr string + __newindex = function(self, mode_or_bufnr, cfg) + vim.validate('name', mode_or_bufnr, { 'string', 'number' }) + vim.validate('cfg', cfg, 'table') + self._configs[mode_or_bufnr] = cfg + end, + + --- @param filter? blink.cmp.Filter + __call = function(self, cfg, filter) + vim.validate('cfg', cfg, 'table') + vim.validate('filter', filter, 'table', true) + local normalized_filter = M.normalize_filter(filter) + + if normalized_filter.bufnr ~= nil then + self._configs[normalized_filter.bufnr] = + vim.tbl_deep_extend('force', self._configs[normalized_filter.bufnr] or {}, cfg) + else + for _, mode in ipairs(normalized_filter.modes) do + self._configs[mode] = vim.tbl_deep_extend('force', self._configs[mode] or {}, cfg) + end + end + end, + }) +end + +--- @param default boolean +function M.new_enable(default) + local per_mode = vim.deepcopy({ + default = default, + cmdline = default, + term = default, + }) + --- @type table + local per_buffer = {} + + return { + --- @param enable? boolean + --- @param filter? blink.cmp.Filter + enable = function(enable, filter) + vim.validate('enable', enable, 'boolean', true) + vim.validate('filter', filter, 'table', true) + + if enable == nil then enable = true end + local normalized_filter = M.normalize_filter(filter) + + if normalized_filter.bufnr ~= nil then + per_buffer[normalized_filter.bufnr] = enable + else + for _, mode in ipairs(normalized_filter.modes) do + per_mode[mode] = enable + end + end + end, + + --- @param filter? blink.cmp.Filter + is_enabled = function(filter) + vim.validate('filter', filter, 'table', true) + local normalized_filter = M.normalize_filter(filter) + + assert(#normalized_filter.modes == 1, 'Cannot call cmp.is_enabled with multiple modes') + local mode = normalized_filter.modes[1] + + if normalized_filter.bufnr ~= nil and per_buffer[normalized_filter.bufnr] ~= nil then + return per_buffer[normalized_filter.bufnr] + end + return per_mode[mode] + end, + } +end + +--- @class blink.cmp.Filter : blink.cmp.BufferFilter +--- @field bufnr? integer +--- @field mode? blink.cmp.Mode | blink.cmp.Mode[] | '*' + +--- @class blink.cmp.NormalizedFilter +--- @field bufnr? integer +--- @field modes blink.cmp.Mode[] + +--- @param filter? blink.cmp.Filter +--- @return blink.cmp.NormalizedFilter +function M.normalize_filter(filter) + if filter == nil then return { modes = { 'default' } } end + + local modes = filter.mode == nil and { 'default' } + or filter.mode == '*' and { 'default', 'cmdline', 'term' } + or type(filter.mode) == 'table' and filter.mode + or { filter.mode } + + if filter.bufnr ~= nil then + local bufnr = filter.bufnr == 0 and vim.api.nvim_get_current_buf() or filter.bufnr + return { bufnr = bufnr, modes = modes } + end + return { modes = modes } +end + +return M diff --git a/lua/blink/cmp/lib/term_events.lua b/lua/blink/cmp/lib/term_events.lua deleted file mode 100644 index 4992b0596..000000000 --- a/lua/blink/cmp/lib/term_events.lua +++ /dev/null @@ -1,116 +0,0 @@ ---- @class blink.cmp.TermEvents ---- @field has_context fun(): boolean ---- @field ignore_next_text_changed boolean ---- @field ignore_next_cursor_moved boolean ---- ---- @field new fun(opts: blink.cmp.TermEventsOptions): blink.cmp.TermEvents ---- @field listen fun(self: blink.cmp.TermEvents, opts: blink.cmp.TermEventsListener) ---- @field suppress_events_for_callback fun(self: blink.cmp.TermEvents, cb: fun()) - ---- @class blink.cmp.TermEventsOptions ---- @field has_context fun(): boolean - ---- @class blink.cmp.TermEventsListener ---- @field on_char_added fun(char: string, is_ignored: boolean) ---- @field on_term_leave fun() - ---- @type blink.cmp.TermEvents ---- @diagnostic disable-next-line: missing-fields -local term_events = {} - -function term_events.new(opts) - return setmetatable({ - has_context = opts.has_context, - ignore_next_text_changed = false, - ignore_next_cursor_moved = false, - }, { __index = term_events }) -end - -local term_on_key_ns = vim.api.nvim_create_namespace('blink-term-keypress') -local term_command_start_ns = vim.api.nvim_create_namespace('blink-term-command-start') - ---- Normalizes the autocmds + ctrl+c into a common api and handles ignored events -function term_events:listen(opts) - local last_char = '' - -- There's no terminal equivalent to 'InsertCharPre', so we need to simulate - -- something similar to this by watching with `vim.on_key()` - vim.api.nvim_create_autocmd('TermEnter', { - callback = function() - vim.on_key(function(k) last_char = k end, term_on_key_ns) - end, - }) - vim.api.nvim_create_autocmd('TermLeave', { - callback = function() - vim.on_key(nil, term_on_key_ns) - last_char = '' - end, - }) - - vim.api.nvim_create_autocmd('TextChangedT', { - callback = function() - if not require('blink.cmp.config').enabled() then return end - - local is_ignored = self.ignore_next_text_changed - self.ignore_next_text_changed = false - - -- no characters added so let cursormoved handle it - if last_char == '' then return end - - opts.on_char_added(last_char, is_ignored) - - last_char = '' - end, - }) - - -- definitely leaving the context - -- HACK: we don't handle mode changed here because the buffer events handles it - vim.api.nvim_create_autocmd('TermLeave', { - callback = function() - last_char = '' - vim.schedule(function() opts.on_term_leave() end) - end, - }) - - --- To build proper shell completions we need to know where prompts end and typed commands start. - --- The most reliable way to get this information is to listen for terminal escape sequences. This - --- adds a listener for the terminal escape sequence \027]133;B (called FTCS_COMMAND_START) which - --- marks the start of a command. To enable plugins to access this information later we put an extmark - --- at the position in the buffer. - --- For documentation on FTCS_COMMAND_START see https://iterm2.com/3.0/documentation-escape-codes.html - --- - --- Example: - --- If you type "ls --he" into a terminal buffer in neovim the current line will look something like this: - --- ~/Downloads > ls --he| - --- "~/Downloads > " is your prompt <- an extmark is added after this - --- "ls --he" is the command you typed <- this is what we need to provide proper shell completions - --- "|" marks your cursor position - vim.api.nvim_create_autocmd('TermRequest', { - callback = function(args) - if string.match(args.data.sequence, '^\027]133;B') then - local row, col = table.unpack(args.data.cursor) - vim.api.nvim_buf_set_extmark(args.buf, term_command_start_ns, row - 1, col, {}) - end - end, - }) -end - ---- Suppresses autocmd events for the duration of the callback ---- HACK: there's likely edge cases with this since we can't know for sure ---- if the autocmds will fire for cursor_moved afaik -function term_events:suppress_events_for_callback(cb) - local cursor_before = vim.api.nvim_win_get_cursor(0) - local changed_tick_before = vim.api.nvim_buf_get_changedtick(0) - - cb() - - local cursor_after = vim.api.nvim_win_get_cursor(0) - local changed_tick_after = vim.api.nvim_buf_get_changedtick(0) - - local is_term_mode = vim.api.nvim_get_mode().mode == 't' - self.ignore_next_text_changed = changed_tick_after ~= changed_tick_before and is_term_mode - -- TODO: does this guarantee that the CursorMovedI event will fire? - self.ignore_next_cursor_moved = (cursor_after[1] ~= cursor_before[1] or cursor_after[2] ~= cursor_before[2]) - and is_term_mode -end - -return term_events diff --git a/lua/blink/cmp/lib/utils.lua b/lua/blink/cmp/lib/utils.lua deleted file mode 100644 index 5d5f85ac8..000000000 --- a/lua/blink/cmp/lib/utils.lua +++ /dev/null @@ -1,231 +0,0 @@ -local utils = {} - ---- Shallow copy table ---- @generic T ---- @param t T ---- @return T -function utils.shallow_copy(t) - local t2 = {} - for k, v in pairs(t) do - t2[k] = v - end - return t2 -end - ---- Returns the union of the keys of two tables ---- @generic T ---- @param t1 T[] ---- @param t2 T[] ---- @return T[] -function utils.union_keys(t1, t2) - local t3 = {} - for k, _ in pairs(t1) do - t3[k] = true - end - for k, _ in pairs(t2) do - t3[k] = true - end - return vim.tbl_keys(t3) -end - ---- Returns a list of unique values from the input array ---- @generic T ---- @param arr T[] ---- @return T[] -function utils.deduplicate(arr) - local seen = {} - local result = {} - for _, v in ipairs(arr) do - if not seen[v] then - seen[v] = true - table.insert(result, v) - end - end - return result -end - -function utils.schedule_if_needed(fn) - if vim.in_fast_event() then - vim.schedule(fn) - else - fn() - end -end - ---- Flattens an arbitrarily deep table into a single level table ---- @param t table ---- @return table -function utils.flatten(t) - if t[1] == nil then return t end - - local flattened = {} - for _, v in ipairs(t) do - if type(v) == 'table' and vim.tbl_isempty(v) then goto continue end - - if v[1] == nil then - table.insert(flattened, v) - else - vim.list_extend(flattened, utils.flatten(v)) - end - - ::continue:: - end - return flattened -end - ---- Returns the index of the first occurrence of the value in the array ---- @generic T ---- @param arr T[] ---- @param val T ---- @return number? -function utils.index_of(arr, val) - for idx, v in ipairs(arr) do - if v == val then return idx end - end - return nil -end - ---- Finds an item in an array using a predicate function ---- @generic T ---- @param arr T[] ---- @param predicate fun(item: T): boolean ---- @return number? -function utils.find_idx(arr, predicate) - for idx, v in ipairs(arr) do - if predicate(v) then return idx end - end - return nil -end - ---- Slices an array ---- @generic T ---- @param arr T[] ---- @param start number? ---- @param finish number? ---- @return T[] -function utils.slice(arr, start, finish) - start = start or 1 - finish = finish or #arr - local sliced = {} - for i = start, finish do - sliced[#sliced + 1] = arr[i] - end - return sliced -end - ---- Gets the full Unicode character at cursor position ---- @return string -function utils.get_char_at_cursor() - local context = require('blink.cmp.completion.trigger.context') - - 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 - ---- Reverses an array ---- @generic T ---- @param arr T[] ---- @return T[] -function utils.reverse(arr) - local reversed = {} - for i = #arr, 1, -1 do - reversed[#reversed + 1] = arr[i] - end - return reversed -end - ---- Disables all autocmds for the duration of the callback ---- @param cb fun() -function utils.with_no_autocmds(cb) - local original_eventignore = vim.opt.eventignore - vim.opt.eventignore = 'all' - - local success, result_or_err = pcall(cb) - - vim.opt.eventignore = original_eventignore - - if not success then error(result_or_err) end - return result_or_err -end - ---- Disable redraw in neovide for the duration of the callback ---- Useful for preventing the cursor from jumping to the top left during `vim.fn.complete` ---- @generic T ---- @param fn fun(): T ---- @return T -function utils.defer_neovide_redraw(fn) - -- don't do anything special when not running inside neovide - if not _G.neovide or not neovide.enable_redraw or not neovide.disable_redraw then return fn() end - - neovide.disable_redraw() - - local success, result = pcall(fn) - - -- make sure that the screen is updated and the mouse cursor returned to the right position before re-enabling redrawing - pcall(vim.api.nvim__redraw, { cursor = true, flush = true }) - - neovide.enable_redraw() - - if not success then error(result) end - return result -end - ----@type boolean Have we passed UIEnter? -local _ui_entered = vim.v.vim_did_enter == 1 -- technically for VimEnter, but should be good enough for when we're lazy loaded ----@type function[] List of notifications. -local _notification_queue = {} - ---- Fancy notification wrapper. ---- @param msg [string, string?][] ---- @param lvl? number -function utils.notify(msg, lvl) - local header_hl = 'DiagnosticVirtualTextWarn' - if lvl == vim.log.levels.ERROR then - header_hl = 'DiagnosticVirtualTextError' - elseif lvl == vim.log.levels.INFO then - header_hl = 'DiagnosticVirtualTextInfo' - end - - table.insert(msg, 1, { ' blink.cmp ', header_hl }) - table.insert(msg, 2, { ' ' }) - - local echo_opts = { verbose = false } - if lvl == vim.log.levels.ERROR and vim.fn.has('nvim-0.11') == 1 then echo_opts.err = true end - if _ui_entered then - vim.schedule(function() vim.api.nvim_echo(msg, true, echo_opts) end) - else - -- Queue notification for the UIEnter event. - table.insert(_notification_queue, function() vim.api.nvim_echo(msg, true, echo_opts) end) - end -end - -vim.api.nvim_create_autocmd('UIEnter', { - callback = function() - _ui_entered = true - - for _, fn in ipairs(_notification_queue) do - pcall(fn) - end - end, -}) - -return utils diff --git a/lua/blink/cmp/lib/window/cursor_line.lua b/lua/blink/cmp/lib/window/cursor_line.lua index 17f3d7a9c..083964a2c 100644 --- a/lua/blink/cmp/lib/window/cursor_line.lua +++ b/lua/blink/cmp/lib/window/cursor_line.lua @@ -1,5 +1,3 @@ -local config = require('blink.cmp.config') - --- By default, the CursorLine highlight will be drawn below all other highlights. --- Unless it contains a foreground color, in which case it will be drawn above --- all other highlights. diff --git a/lua/blink/cmp/lib/window/docs.lua b/lua/blink/cmp/lib/window/docs.lua index 78b0497d5..198c7d0f8 100644 --- a/lua/blink/cmp/lib/window/docs.lua +++ b/lua/blink/cmp/lib/window/docs.lua @@ -27,11 +27,7 @@ function docs.render_detail_and_documentation(opts) end local doc_lines = {} - if opts.documentation ~= nil then - local doc = opts.documentation - if type(opts.documentation) == 'string' then doc = { kind = 'plaintext', value = opts.documentation } end - vim.lsp.util.convert_input_to_markdown_lines(doc, doc_lines) - end + if opts.documentation ~= nil then vim.lsp.util.convert_input_to_markdown_lines(opts.documentation, doc_lines) end ---@type string[] local combined_lines = vim.list_extend({}, detail_lines) diff --git a/lua/blink/cmp/lib/window/init.lua b/lua/blink/cmp/lib/window/init.lua index 75be2d620..a6fd81b2f 100644 --- a/lua/blink/cmp/lib/window/init.lua +++ b/lua/blink/cmp/lib/window/init.lua @@ -1,6 +1,3 @@ --- TODO: The scrollbar and redrawing logic should be done by wrapping the functions that would --- trigger a redraw or update the window - local utils = require('blink.cmp.lib.window.utils') --- @class blink.cmp.WindowOptions @@ -16,46 +13,16 @@ local utils = require('blink.cmp.lib.window.utils') --- @field winblend? number --- @field winhighlight? string --- @field scrolloff? number ---- @field scrollbar? boolean --- @field filetype string ---- @class blink.cmp.Window ---- @field id? number ---- @field buf? number ---- @field config blink.cmp.WindowOptions ---- @field scrollbar? blink.cmp.Scrollbar ---- @field cursor_line blink.cmp.CursorLine ---- @field redraw_queued boolean ---- ---- @field new fun(name: string, config: blink.cmp.WindowOptions): blink.cmp.Window ---- @field get_buf fun(self: blink.cmp.Window): number ---- @field get_win fun(self: blink.cmp.Window): number ---- @field is_open fun(self: blink.cmp.Window): boolean ---- @field open fun(self: blink.cmp.Window) ---- @field close fun(self: blink.cmp.Window) ---- @field set_option_value fun(self: blink.cmp.Window, option: string, value: any) ---- @field update_size fun(self: blink.cmp.Window) ---- @field get_content_height fun(self: blink.cmp.Window): number ---- @field get_border_size fun(self: blink.cmp.Window, border?: 'none' | 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | 'bold' | 'padded' | string[]): { vertical: number, horizontal: number, left: number, right: number, top: number, bottom: number } ---- @field expand_border_chars fun(border: string[]): string[] ---- @field get_height fun(self: blink.cmp.Window): number ---- @field get_content_width fun(self: blink.cmp.Window): number ---- @field get_width fun(self: blink.cmp.Window): number ---- @field get_cursor_screen_position fun(): { distance_from_top: number, distance_from_bottom: number } ---- @field set_cursor fun(self: blink.cmp.Window, cursor: number[]) ---- @field set_height fun(self: blink.cmp.Window, height: number) ---- @field set_width fun(self: blink.cmp.Window, width: number) ---- @field set_win_config fun(self: blink.cmp.Window, config: table) ---- @field get_vertical_direction_and_height fun(self: blink.cmp.Window, direction_priority: blink.cmp.WindowDirectionPriority, max_height: number): { height: number, direction: 'n' | 's' }? ---- @field get_direction_with_window_constraints fun(self: blink.cmp.Window, anchor_win: blink.cmp.Window, direction_priority: ("n" | "s" | "e" | "w")[], desired_min_size?: { width: number, height: number }): { width: number, height: number, direction: 'n' | 's' | 'e' | 'w' }? ---- @field redraw_if_needed fun(self: blink.cmp.Window) - --- @alias blink.cmp.WindowDirectionPriority ("n"|"s")[] | fun(): ("n"|"s")[] ---- @type blink.cmp.Window ---- @diagnostic disable-next-line: missing-fields +--- @class blink.cmp.Window local win = {} +--- @param name string +--- @param config blink.cmp.WindowOptions +--- @return blink.cmp.Window function win.new(name, config) local self = setmetatable({}, { __index = win }) @@ -72,28 +39,15 @@ function win.new(name, config) winblend = config.winblend or 0, winhighlight = config.winhighlight or 'Normal:NormalFloat,FloatBorder:NormalFloat', scrolloff = config.scrolloff or 0, - scrollbar = config.scrollbar, filetype = config.filetype, } self.redraw_queued = false - self.cursor_line = require('blink.cmp.lib.window.cursor_line').new(name, config.cursorline_priority) - if self.config.scrollbar then - -- Enable the gutter if there's no border, or the border is a space - local enable_gutter = self.config.border == 'padded' or self.config.border == 'none' - local border = self.config.border - if type(border) == 'table' then - local resolved_border = self.expand_border_chars(border) - enable_gutter = resolved_border[4] == '' or resolved_border[4] == ' ' - end - - self.scrollbar = require('blink.cmp.lib.window.scrollbar').new({ enable_gutter = enable_gutter }) - end - return self end +--- @return number? function win:get_buf() -- create buffer if it doesn't exist if self.buf == nil or not vim.api.nvim_buf_is_valid(self.buf) then @@ -103,11 +57,13 @@ function win:get_buf() return self.buf end +--- @return number? function win:get_win() if self.id ~= nil and not vim.api.nvim_win_is_valid(self.id) then self.id = nil end return self.id end +--- @return boolean function win:is_open() return self.id ~= nil and vim.api.nvim_win_is_valid(self.id) end function win:open() @@ -138,7 +94,6 @@ function win:open() vim.api.nvim_set_option_value('filetype', self.config.filetype, { buf = self.buf }) self.cursor_line:update(self.id) - if self.scrollbar then self.scrollbar:update(self.id) end self:redraw_if_needed() end @@ -152,7 +107,6 @@ function win:close() vim.api.nvim_win_close(self.id, true) self.id = nil end - if self.scrollbar then self.scrollbar:update() end self:redraw_if_needed() end @@ -175,8 +129,8 @@ function win:update_size() vim.api.nvim_win_set_height(winnr, height) end --- todo: fix nvim_win_text_height --- @return number +--- todo: fix nvim_win_text_height +--- @return number function win:get_content_height() if not self:is_open() then return 0 end return vim.api.nvim_win_text_height(self:get_win(), {}).all @@ -214,13 +168,12 @@ function win:get_border_size() bottom = resolved_border[6] == '' and 0 or 1 end - -- If there's a scrollbar, the border on the right must be atleast 1 - if self.scrollbar and self.scrollbar:is_visible() then right = math.max(1, right) end - return { vertical = top + bottom, horizontal = left + right, left = left, right = right, top = top, bottom = bottom } end --- Gets the characters used for the border, if defined +--- @param border string[] +--- @return string[] function win.expand_border_chars(border) assert(type(border) == 'table', 'Border must be a table') @@ -237,12 +190,14 @@ function win.expand_border_chars(border) end --- Gets the height of the window, taking into account the border +--- @return number function win:get_height() if not self:is_open() then return 0 end return vim.api.nvim_win_get_height(self:get_win()) + self:get_border_size().vertical end --- Gets the width of the longest line in the window +--- @return number function win:get_content_width() if not self:is_open() then return 0 end local max_width = 0 @@ -253,12 +208,14 @@ function win:get_content_width() end --- Gets the width of the window, taking into account the border +--- @return number function win:get_width() if not self:is_open() then return 0 end return vim.api.nvim_win_get_width(self:get_win()) + self:get_border_size().horizontal end --- Gets the cursor's distance from all sides of the screen +--- @return { distance_from_top: number, distance_from_bottom: number, distance_from_left: number, distance_from_right: number } function win.get_cursor_screen_position() local screen_height = vim.o.lines local screen_width = vim.o.columns @@ -289,48 +246,51 @@ function win.get_cursor_screen_position() } end +--- @param cursor number[] function win:set_cursor(cursor) local winnr = self:get_win() assert(winnr ~= nil, 'Window must be open to set cursor') vim.api.nvim_win_set_cursor(winnr, cursor) - if self.scrollbar then self.scrollbar:update(winnr) end self:redraw_if_needed() end +--- @param height number function win:set_height(height) local winnr = self:get_win() assert(winnr ~= nil, 'Window must be open to set height') vim.api.nvim_win_set_height(winnr, height) - if self.scrollbar then self.scrollbar:update(winnr) end self:redraw_if_needed() end +--- @param width number function win:set_width(width) local winnr = self:get_win() assert(winnr ~= nil, 'Window must be open to set width') vim.api.nvim_win_set_width(winnr, width) - if self.scrollbar then self.scrollbar:update(winnr) end self:redraw_if_needed() end +--- @param config vim.api.keyset.win_config function win:set_win_config(config) local winnr = self:get_win() assert(winnr ~= nil, 'Window must be open to set window config') vim.api.nvim_win_set_config(winnr, config) - if self.scrollbar then self.scrollbar:update(winnr) end self:redraw_if_needed() end --- Gets the direction with the most space available, prioritizing the directions in the order of the --- direction_priority list +--- @param direction_priority blink.cmp.WindowDirectionPriority +--- @param max_height number +--- @return { height: number, direction: 'n' | 's' }? function win:get_vertical_direction_and_height(direction_priority, max_height) if type(direction_priority) == 'function' then direction_priority = direction_priority() end local constraints = self.get_cursor_screen_position() @@ -351,6 +311,10 @@ function win:get_vertical_direction_and_height(direction_priority, max_height) return { height = height - border_size.vertical, direction = direction } end +--- @param anchor_win blink.cmp.Window +--- @param direction_priority blink.cmp.WindowDirectionPriority +--- @param desired_min_size { width: number, height: number } +--- @return { width: number, height: number, direction: 'n' | 's' | 'e' | 'w' }? function win:get_direction_with_window_constraints(anchor_win, direction_priority, desired_min_size) local cursor_constraints = self.get_cursor_screen_position() diff --git a/lua/blink/cmp/lib/window/scrollbar/geometry.lua b/lua/blink/cmp/lib/window/scrollbar/geometry.lua deleted file mode 100644 index 800aeea50..000000000 --- a/lua/blink/cmp/lib/window/scrollbar/geometry.lua +++ /dev/null @@ -1,93 +0,0 @@ ---- Helper for calculating placement of the scrollbar thumb and gutter - ---- @class blink.cmp.ScrollbarGeometry ---- @field width number ---- @field height number ---- @field row number ---- @field col number ---- @field zindex number ---- @field relative string ---- @field win number - -local M = {} - ---- @param target_win number ---- @return number -local function get_win_buf_height(target_win) - local buf = vim.api.nvim_win_get_buf(target_win) - - -- not wrapping, so just get the line count - if not vim.wo[target_win].wrap then return vim.api.nvim_buf_line_count(buf) end - - local width = vim.api.nvim_win_get_width(target_win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local height = 0 - for _, l in ipairs(lines) do - if vim.fn.type(l) == vim.v.t_blob then l = vim.fn.string(l) end - height = height + math.max(1, (math.ceil(vim.fn.strwidth(l) / width))) - end - return height -end - ---- @param border string|string[] ---- @return number -local function get_col_offset(border) - -- we only need an extra offset when working with a padded window - if type(border) == 'table' and border[1] == ' ' and border[4] == ' ' and border[7] == ' ' and border[8] == ' ' then - return 1 - end - return 0 -end - ---- Gets the starting line, handling line wrapping if enabled ---- @param target_win number ---- @param width number ---- @return number -local get_content_start_line = function(target_win, width) - local start_line = math.max(1, vim.fn.line('w0', target_win)) - if not vim.wo[target_win].wrap then return start_line end - - local bufnr = vim.api.nvim_win_get_buf(target_win) - local wrapped_start_line = 1 - for _, text in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, start_line - 1, false)) do - -- nvim_buf_get_lines sometimes returns a blob. see hrsh7th/nvim-cmp#2050 - if vim.fn.type(text) == vim.v.t_blob then text = vim.fn.string(text) end - wrapped_start_line = wrapped_start_line + math.max(1, math.ceil(vim.fn.strdisplaywidth(text) / width)) - end - return wrapped_start_line -end - ---- @param target_win number ---- @return { should_hide: boolean, thumb: blink.cmp.ScrollbarGeometry, gutter: blink.cmp.ScrollbarGeometry } -function M.get_geometry(target_win) - local config = vim.api.nvim_win_get_config(target_win) - local width = config.width - local height = config.height - local zindex = config.zindex - - local buf_height = get_win_buf_height(target_win) - local thumb_height = math.max(1, math.floor(height * height / buf_height + 0.5) - 1) - - local start_line = get_content_start_line(target_win, width or 1) - - local pct = (start_line - 1) / (buf_height - height) - local thumb_offset = math.min(math.floor((pct * (height - thumb_height)) + 0.5), height - 1) - thumb_height = thumb_offset + thumb_height > height and height - thumb_offset or thumb_height - thumb_height = math.max(1, thumb_height) - - local common_geometry = { - width = 1, - row = thumb_offset, - col = width + get_col_offset(config.border), - relative = 'win', - win = target_win, - } - - return { - should_hide = height >= buf_height, - thumb = vim.tbl_deep_extend('force', common_geometry, { height = thumb_height, zindex = zindex + 2 }), - gutter = vim.tbl_deep_extend('force', common_geometry, { row = 0, height = height, zindex = zindex + 1 }), - } -end - -return M diff --git a/lua/blink/cmp/lib/window/scrollbar/init.lua b/lua/blink/cmp/lib/window/scrollbar/init.lua deleted file mode 100644 index c72615a08..000000000 --- a/lua/blink/cmp/lib/window/scrollbar/init.lua +++ /dev/null @@ -1,37 +0,0 @@ --- TODO: move the set_config and set_height calls from the menu/documentation/signature files --- to helpers in the window lib, and call scrollbar updates from there. This way, consumers of --- the window lib don't need to worry about scrollbars - ---- @class blink.cmp.ScrollbarConfig ---- @field enable_gutter boolean - ---- @class blink.cmp.Scrollbar ---- @field win blink.cmp.ScrollbarWin ---- ---- @field new fun(opts: blink.cmp.ScrollbarConfig): blink.cmp.Scrollbar ---- @field is_visible fun(self: blink.cmp.Scrollbar): boolean ---- @field update fun(self: blink.cmp.Scrollbar, target_win: number | nil) - ---- @type blink.cmp.Scrollbar ---- @diagnostic disable-next-line: missing-fields -local scrollbar = {} - -function scrollbar.new(opts) - local self = setmetatable({}, { __index = scrollbar }) - self.win = require('blink.cmp.lib.window.scrollbar.win').new(opts) - return self -end - -function scrollbar:is_visible() return self.win:is_visible() end - -function scrollbar:update(target_win) - if target_win == nil or not vim.api.nvim_win_is_valid(target_win) then return self.win:hide() end - - local geometry = require('blink.cmp.lib.window.scrollbar.geometry').get_geometry(target_win) - if geometry.should_hide then return self.win:hide() end - - self.win:show_thumb(geometry.thumb) - self.win:show_gutter(geometry.gutter) -end - -return scrollbar diff --git a/lua/blink/cmp/lib/window/scrollbar/win.lua b/lua/blink/cmp/lib/window/scrollbar/win.lua deleted file mode 100644 index dc6c7fe4f..000000000 --- a/lua/blink/cmp/lib/window/scrollbar/win.lua +++ /dev/null @@ -1,108 +0,0 @@ ---- Manages creating/updating scrollbar gutter and thumb windows - ---- @class blink.cmp.ScrollbarWin ---- @field enable_gutter boolean ---- @field thumb_win? number ---- @field gutter_win? number ---- @field buf? number ---- ---- @field new fun(opts: blink.cmp.ScrollbarConfig): blink.cmp.ScrollbarWin ---- @field is_visible fun(self: blink.cmp.ScrollbarWin): boolean ---- @field show_thumb fun(self: blink.cmp.ScrollbarWin, geometry: blink.cmp.ScrollbarGeometry) ---- @field show_gutter fun(self: blink.cmp.ScrollbarWin, geometry: blink.cmp.ScrollbarGeometry) ---- @field hide_thumb fun(self: blink.cmp.ScrollbarWin) ---- @field hide_gutter fun(self: blink.cmp.ScrollbarWin) ---- @field hide fun(self: blink.cmp.ScrollbarWin) ---- @field _make_win fun(self: blink.cmp.ScrollbarWin, geometry: blink.cmp.ScrollbarGeometry, hl_group: string): number ---- @field redraw_if_needed fun(self: blink.cmp.ScrollbarWin) - ---- @type blink.cmp.ScrollbarWin ---- @diagnostic disable-next-line: missing-fields -local scrollbar_win = {} - -function scrollbar_win.new(opts) return setmetatable(opts, { __index = scrollbar_win }) end - -function scrollbar_win:is_visible() return self.thumb_win ~= nil and vim.api.nvim_win_is_valid(self.thumb_win) end - -function scrollbar_win:show_thumb(geometry) - -- create window if it doesn't exist - if self.thumb_win == nil or not vim.api.nvim_win_is_valid(self.thumb_win) then - self.thumb_win = self:_make_win(geometry, 'BlinkCmpScrollBarThumb') - else - -- update with the geometry - local thumb_existing_config = vim.api.nvim_win_get_config(self.thumb_win) - local thumb_config = vim.tbl_deep_extend('force', thumb_existing_config, geometry) - vim.api.nvim_win_set_config(self.thumb_win, thumb_config) - end - - self:redraw_if_needed() -end - -function scrollbar_win:show_gutter(geometry) - if not self.enable_gutter then return end - - -- create window if it doesn't exist - if self.gutter_win == nil or not vim.api.nvim_win_is_valid(self.gutter_win) then - self.gutter_win = self:_make_win(geometry, 'BlinkCmpScrollBarGutter') - else - -- update with the geometry - local gutter_existing_config = vim.api.nvim_win_get_config(self.gutter_win) - local gutter_config = vim.tbl_deep_extend('force', gutter_existing_config, geometry) - vim.api.nvim_win_set_config(self.gutter_win, gutter_config) - end - - self:redraw_if_needed() -end - -function scrollbar_win:hide_thumb() - if self.thumb_win and vim.api.nvim_win_is_valid(self.thumb_win) then - vim.api.nvim_win_close(self.thumb_win, true) - self.thumb_win = nil - self:redraw_if_needed() - end -end - -function scrollbar_win:hide_gutter() - if self.gutter_win and vim.api.nvim_win_is_valid(self.gutter_win) then - vim.api.nvim_win_close(self.gutter_win, true) - self.gutter_win = nil - self:redraw_if_needed() - end -end - -function scrollbar_win:hide() - self:hide_thumb() - self:hide_gutter() -end - -function scrollbar_win:_make_win(geometry, hl_group) - if self.buf == nil or not vim.api.nvim_buf_is_valid(self.buf) then self.buf = vim.api.nvim_create_buf(false, true) end - - local win_config = vim.tbl_deep_extend('force', geometry, { - style = 'minimal', - focusable = false, - noautocmd = true, - border = 'none', - }) - local win = vim.api.nvim_open_win(self.buf, false, win_config) - vim.api.nvim_set_option_value('winhighlight', 'Normal:' .. hl_group .. ',EndOfBuffer:' .. hl_group, { win = win }) - return win -end - -local redraw_queued = false -function scrollbar_win:redraw_if_needed() - if redraw_queued or vim.api.nvim_get_mode().mode ~= 'c' then return end - - redraw_queued = true - vim.schedule(function() - redraw_queued = false - if self.gutter_win ~= nil and vim.api.nvim_win_is_valid(self.gutter_win) then - vim.api.nvim__redraw({ win = self.gutter_win, flush = true }) - end - if self.thumb_win ~= nil and vim.api.nvim_win_is_valid(self.thumb_win) then - vim.api.nvim__redraw({ win = self.thumb_win, flush = true }) - end - end) -end - -return scrollbar_win diff --git a/lua/blink/cmp/lib/window/utils.lua b/lua/blink/cmp/lib/window/utils.lua index 54623f40d..a3e22e0df 100644 --- a/lua/blink/cmp/lib/window/utils.lua +++ b/lua/blink/cmp/lib/window/utils.lua @@ -8,7 +8,7 @@ function utils.pick_border(border, default) -- On neovim 0.11+, use the vim.o.winborder option by default -- Use `vim.opt.winborder:get()` to handle custom border characters - if vim.fn.exists('&winborder') == 1 and vim.o.winborder ~= '' then + if vim.o.winborder ~= '' then local winborder = vim.opt.winborder:get() return #winborder == 1 and winborder[1] or winborder end diff --git a/lua/blink/cmp/lsp/accept/init.lua b/lua/blink/cmp/lsp/accept/init.lua new file mode 100644 index 000000000..f5405cab1 --- /dev/null +++ b/lua/blink/cmp/lsp/accept/init.lua @@ -0,0 +1,83 @@ +local config = require('blink.cmp.config').completion.accept +local text_edit_lib = require('blink.cmp.lsp.text_edit') + +--- @param ctx blink.cmp.Context +--- @param item blink.cmp.CompletionItem +local function apply_item(ctx, item) + item = vim.deepcopy(item) + + -- Get additional text edits, converted to utf-8 + local all_text_edits = vim.tbl_map( + function(text_edit) return text_edit_lib.to_utf_8(text_edit, text_edit_lib.offset_encoding_from_item(item)) end, + vim.deepcopy(item.additionalTextEdits or {}) + ) + + -- Create an undo point, if it's not a snippet, since the snippet engine handles undo + if + ctx.mode == 'default' + and require('blink.cmp.config').completion.accept.create_undo_point + and item.insertTextFormat ~= vim.lsp.protocol.InsertTextFormat.Snippet + then + -- setting the undolevels forces neovim to create an undo point + vim.o.undolevels = vim.o.undolevels + end + + -- 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 = '' + text_edit_lib.apply(temp_text_edit, 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 + local new_cursor = text_edit_lib.get_apply_end_position(item.textEdit, all_text_edits) + new_cursor[2] = new_cursor[2] + + text_edit_lib.apply(item.textEdit, all_text_edits) + ctx.set_cursor(new_cursor) + end + + -- Notify the rust module that the item was accessed + require('blink.cmp.fuzzy').access(item) +end + +--- 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 (e.g. rust-analyzer) causing them to return the item as-is + -- without e.g. auto-imports + sources + .resolve(ctx, item) + -- Some LSPs may take a long time to resolve the item, so we timeout + :timeout(config.resolve_timeout_ms) + -- and use the item as-is + :catch(function() return item end) + :map(function(resolved_item) + -- Updates the text edit based on the cursor position and converts it to utf-8 + resolved_item = vim.deepcopy(resolved_item) + resolved_item.textEdit = text_edit_lib.get_from_item(resolved_item) + + return apply_item(ctx, resolved_item) + end) + :map(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) + :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/lsp/accept/prefix.lua similarity index 100% rename from lua/blink/cmp/completion/accept/prefix.lua rename to lua/blink/cmp/lsp/accept/prefix.lua diff --git a/lua/blink/cmp/completion/accept/preview.lua b/lua/blink/cmp/lsp/accept/preview.lua similarity index 100% rename from lua/blink/cmp/completion/accept/preview.lua rename to lua/blink/cmp/lsp/accept/preview.lua diff --git a/lua/blink/cmp/lsp/built-in/buffer/cache.lua b/lua/blink/cmp/lsp/built-in/buffer/cache.lua new file mode 100644 index 000000000..b5d5bc766 --- /dev/null +++ b/lua/blink/cmp/lsp/built-in/buffer/cache.lua @@ -0,0 +1,37 @@ +--- @class blink.cmp.BufferCacheEntry +--- @field changedtick integer +--- @field exclude_word_under_cursor boolean +--- @field words string[] + +--- @class blink.cmp.BufferCache +local cache = {} + +local store = {} +vim.api.nvim_create_autocmd({ 'BufDelete', 'BufWipeout' }, { + desc = 'Invalidate buffer cache items when buffer is deleted', + callback = function(args) store[args.buf] = nil end, +}) + +--- Get the cache entry for a buffer. +--- @param bufnr integer +--- @return blink.cmp.BufferCacheEntry|nil +function cache.get(bufnr) return store[bufnr] end + +--- Set the cache entry for a buffer. +--- @param bufnr integer +--- @param value blink.cmp.BufferCacheEntry +function cache.set(bufnr, value) store[bufnr] = value end + +--- Remove cache entries for buffers not in the given list. +--- @param valid_bufnrs integer[] +function cache.keep(valid_bufnrs) + local keep = {} + for _, k in ipairs(valid_bufnrs) do + keep[k] = true + end + for k in pairs(store) do + if not keep[k] then store[k] = nil end + end +end + +return cache diff --git a/lua/blink/cmp/lsp/built-in/buffer/init.lua b/lua/blink/cmp/lsp/built-in/buffer/init.lua new file mode 100644 index 000000000..659017486 --- /dev/null +++ b/lua/blink/cmp/lsp/built-in/buffer/init.lua @@ -0,0 +1,83 @@ +-- todo: nvim-cmp only updates the lines that got changed which is better +-- but this is *speeeeeed* and simple. should add the better way +-- but ensure it doesn't add too much complexity + +local cmp = require('blink.cmp') +local async = require('blink.cmp.lib.async') +local parser = require('blink.cmp.sources.buffer.parser') +local utils = require('blink.cmp.sources.lib.utils') +local deduplicate = require('blink.cmp.lib.utils').deduplicate + +local cache = require('blink.cmp.sources.buffer.cache') +local priority = require('blink.cmp.sources.buffer.priority') + +--- @class blink.cmp.BufferOpts +--- @field get_bufnrs fun(): integer[] +--- @field max_total_buffer_size integer Maximum text size across all buffers (default: 500KB) + +vim.lsp.config('buffer', { + settings = { + get_bufnrs = function() + return vim + .iter(vim.api.nvim_list_wins()) + :map(function(win) return vim.api.nvim_win_get_buf(win) end) + :filter(function(buf) return vim.bo[buf].buftype ~= 'nofile' end) + :totable() + end, + max_total_buffer_size = 500000, + }, + + cmd = cmp.lsp.server({ + capabilities = { completionProvider = {} }, + handlers = { + ['textDocument/completion'] = function(_, _, callback) + local opts = vim.lsp.config.buffer.settings.buffer + --- @cast opts blink.cmp.BufferOpts + + local is_search_context = utils.is_command_line({ '/', '?' }) + if utils.is_command_line() and not is_search_context then + callback() + return + end + + -- Select buffers + local bufnrs = is_search_context and { vim.api.nvim_get_current_buf() } or deduplicate(opts.get_bufnrs()) + local selected_bufnrs = priority.retain_buffers(bufnrs, opts.max_total_buffer_size) + if #selected_bufnrs == 0 then + callback() + return + end + + -- Get words for each buffer + local curr_bufnr = vim.api.nvim_get_current_buf() + local tasks = vim.tbl_map( + function(bufnr) return parser.get_buf_words(bufnr, curr_bufnr == bufnr and not is_search_context) end, + selected_bufnrs + ) + + -- Deduplicate words and respond + async.task.all(tasks):map(function(words_per_buf) + --- @cast words_per_buf string[][] + + local unique = {} + local words = {} + for _, buf_words in ipairs(words_per_buf) do + for _, word in ipairs(buf_words) do + if not unique[word] then + unique[word] = true + table.insert(words, word) + end + end + end + + cache.keep(selected_bufnrs) + + callback({ + isIncomplete = false, + items = utils.words_to_items(words), + }) + end) + end, + }, + }), +}) diff --git a/lua/blink/cmp/sources/buffer/parser.lua b/lua/blink/cmp/lsp/built-in/buffer/parser.lua similarity index 67% rename from lua/blink/cmp/sources/buffer/parser.lua rename to lua/blink/cmp/lsp/built-in/buffer/parser.lua index 6548c5d1c..a8772ff5d 100644 --- a/lua/blink/cmp/sources/buffer/parser.lua +++ b/lua/blink/cmp/lsp/built-in/buffer/parser.lua @@ -1,36 +1,70 @@ local async = require('blink.cmp.lib.async') local fuzzy = require('blink.cmp.fuzzy') -local uv = vim.uv local parser = {} --- @param bufnr integer --- @param exclude_word_under_cursor boolean +--- @return blink.cmp.Task +function parser.get_buf_words(bufnr, exclude_word_under_cursor) + local cache = require('blink.cmp.sources.buffer.cache') + + local cached_item = cache.get(bufnr) + if + cached_item + and cached_item.changedtick == vim.b[bufnr].changedtick + and cached_item.exclude_word_under_cursor == exclude_word_under_cursor + then + return async.task.identity(cached_item.words) + end + + return parser.get_buf_words(bufnr, exclude_word_under_cursor):map(function(words) + cache:set(bufnr, { + changedtick = vim.b[bufnr].changedtick, + exclude_word_under_cursor = exclude_word_under_cursor, + words = words, + }) + return words + end) +end + +function parser._get_buf_words(bufnr, exclude_word_under_cursor) + local buf_text = parser.get_buf_text(bufnr, exclude_word_under_cursor) + local len = #buf_text + + local can_use_rust = package.loaded['blink.cmp.fuzzy.rust'] ~= nil + + -- should take less than 2ms + if len < 20000 then + return parser.run_sync(buf_text) + -- should take less than 10ms + elseif len < 200000 then + if can_use_rust then + return parser.run_async_rust(buf_text) + else + return parser.run_async_lua(buf_text) + end + else + -- Too big, skip + return async.task.identity({}) + end +end + +--- @param bufnr integer --- @return string function parser.get_buf_text(bufnr, exclude_word_under_cursor) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - if bufnr ~= vim.api.nvim_get_current_buf() or not exclude_word_under_cursor then return table.concat(lines, '\n') end - -- exclude word under the cursor for the current buffer - local line_number, column = unpack(vim.api.nvim_win_get_cursor(0)) - local line = lines[line_number] - - local start_col = column - while start_col > 1 do - local char = line:sub(start_col, start_col) - if char:match('[%w_\\-]') == nil then break end - start_col = start_col - 1 - end + if exclude_word_under_cursor then + local line_number, column = unpack(vim.api.nvim_win_get_cursor(0)) + local line = lines[line_number] - local end_col = column - while end_col < #line do - local char = line:sub(end_col + 1, end_col + 1) - if char:match('[%w_\\-]') == nil then break end - end_col = end_col + 1 - end + local before = line:sub(1, column):gsub('%k+$', '') + local after = line:sub(column + 1):gsub('^%k+', '') - lines[line_number] = line:sub(1, start_col) .. ' ' .. line:sub(end_col + 1) + lines[line_number] = before .. ' ' .. after + end return table.concat(lines, '\n') end @@ -43,14 +77,14 @@ function parser.run_sync(text) return async.task.identity(fuzzy.get_words(text)) --- @return blink.cmp.Task function parser.run_async_rust(text) return async.task.new(function(resolve) - local worker = uv.new_work( + local worker = vim.uv.new_work( -- must use rust module directly since the normal one requires the config which isn't present function(text, cpath) package.cpath = cpath - ---@diagnostic disable-next-line: redundant-return-value + --- @diagnostic disable-next-line: redundant-return-value return table.concat(require('blink.cmp.fuzzy.rust').get_words(text), '\n') end, - ---@param words string + --- @param words string function(words) vim.schedule(function() resolve(vim.split(words, '\n')) end) end @@ -106,24 +140,4 @@ function parser.run_async_lua(text) :on_cancel(function() cancelled = true end) end -function parser.get_buf_words(bufnr, exclude_word_under_cursor, opts) - local buf_text = parser.get_buf_text(bufnr, exclude_word_under_cursor) - local len = #buf_text - - -- should take less than 2ms - if len < opts.max_sync_buffer_size then - return parser.run_sync(buf_text) - -- should take less than 10ms - elseif len < opts.max_async_buffer_size then - if opts.fuzzy_implementation_type == 'rust' then - return parser.run_async_rust(buf_text) - else - return parser.run_async_lua(buf_text) - end - else - -- Too big, skip - return async.task.identity({}) - end -end - return parser diff --git a/lua/blink/cmp/lsp/built-in/buffer/priority.lua b/lua/blink/cmp/lsp/built-in/buffer/priority.lua new file mode 100644 index 000000000..591854bfe --- /dev/null +++ b/lua/blink/cmp/lsp/built-in/buffer/priority.lua @@ -0,0 +1,81 @@ +local utils = require('blink.cmp.lsp.buffer.utils') + +local priority = { + recency_bufs = {}, +} + +-- Track recency of buffers +vim.api.nvim_create_autocmd({ 'BufEnter', 'WinEnter' }, { + desc = 'Track buffer recency when entering a buffer', + callback = function() + local bufnr = vim.api.nvim_get_current_buf() + priority.recency_bufs[bufnr] = vim.loop.hrtime() + end, +}) +vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufDelete' }, { + desc = 'Invalidate buffer recency when buffer is deleted', + callback = function(args) priority.recency_bufs[args.buf] = nil end, +}) + +function priority.focused() + return function(bufnr) return bufnr == vim.api.nvim_win_get_buf(0) and 0 or 1 end +end + +function priority.visible() + local visible = {} + for _, win in ipairs(vim.api.nvim_list_wins()) do + visible[vim.api.nvim_win_get_buf(win)] = true + end + return function(bufnr) return visible[bufnr] and 0 or 1 end +end + +function priority.recency() + local time = vim.loop.hrtime() + return function(bufnr) return time - (priority.recency_bufs[bufnr] or 0) end +end + +function priority.largest(buf_sizes) + return function(bufnr) return -buf_sizes[bufnr] end +end + +function priority.comparator(buf_sizes) + local value_fns = {} + for _, key in ipairs({ 'focused', 'visible', 'recency', 'largest' }) do + if priority[key] then table.insert(value_fns, priority[key](buf_sizes)) end + end + + return function(a, b) + for _, fn in ipairs(value_fns) do + local va, vb = fn(a), fn(b) + if va ~= vb then return va < vb end + end + return a < b -- fallback: lower bufnr first + end +end + +--- Retain buffers up to a total size cap, in the specified retention order. +--- @param bufnrs integer[] +--- @param max_total_size integer +--- @return integer[] selected +function priority.retain_buffers(bufnrs, max_total_size) + local buf_sizes = {} + for _, bufnr in ipairs(bufnrs) do + buf_sizes[bufnr] = utils.get_buffer_size(bufnr) + end + + local sorted_bufnrs = vim.deepcopy(bufnrs) + table.sort(sorted_bufnrs, priority.comparator(buf_sizes)) + sorted_bufnrs = vim.tbl_filter(function(bufnr) return buf_sizes[bufnr] <= 200000 end, sorted_bufnrs) + + local selected, total_size = {}, 0 + for _, bufnr in ipairs(sorted_bufnrs) do + local size = buf_sizes[bufnr] + if total_size + size > max_total_size then break end + total_size = total_size + size + table.insert(selected, bufnr) + end + + return selected +end + +return priority diff --git a/lua/blink/cmp/lsp/built-in/buffer/recency.lua b/lua/blink/cmp/lsp/built-in/buffer/recency.lua new file mode 100644 index 000000000..5298b2066 --- /dev/null +++ b/lua/blink/cmp/lsp/built-in/buffer/recency.lua @@ -0,0 +1,14 @@ +local recency = { + is_tracking = false, + --- @type table + bufs = {}, +} + +function recency.start_tracking() + if recency.is_tracking then return end + recency.is_tracking = true +end + +function recency.accessed_at(bufnr) return recency.bufs[bufnr] or 0 end + +return recency diff --git a/lua/blink/cmp/lsp/built-in/buffer/utils.lua b/lua/blink/cmp/lsp/built-in/buffer/utils.lua new file mode 100644 index 000000000..0cb60dfb8 --- /dev/null +++ b/lua/blink/cmp/lsp/built-in/buffer/utils.lua @@ -0,0 +1,32 @@ +local utils = {} + +--- @param bufnr integer +--- @return integer +function utils.get_buffer_size(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local size = 0 + for _, line in ipairs(lines) do + size = size + #line + 1 + end + return size +end + +--- @param words string[] +--- @return lsp.CompletionItem[] +function utils.words_to_items(words) + local text_kind = require('blink.cmp.types').CompletionItemKind.Text + local plain_text = vim.lsp.protocol.InsertTextFormat.PlainText + + local items = {} + for i = 1, #words do + items[i] = { + label = words[i], + kind = text_kind, + insertTextFormat = plain_text, + insertText = words[i], + } + end + return items +end + +return utils diff --git a/lua/blink/cmp/sources/cmdline/constants.lua b/lua/blink/cmp/lsp/built-in/cmdline/constants.lua similarity index 100% rename from lua/blink/cmp/sources/cmdline/constants.lua rename to lua/blink/cmp/lsp/built-in/cmdline/constants.lua diff --git a/lua/blink/cmp/sources/cmdline/help.lua b/lua/blink/cmp/lsp/built-in/cmdline/help.lua similarity index 100% rename from lua/blink/cmp/sources/cmdline/help.lua rename to lua/blink/cmp/lsp/built-in/cmdline/help.lua diff --git a/lua/blink/cmp/sources/cmdline/init.lua b/lua/blink/cmp/lsp/built-in/cmdline/init.lua similarity index 99% rename from lua/blink/cmp/sources/cmdline/init.lua rename to lua/blink/cmp/lsp/built-in/cmdline/init.lua index 0a61b40a0..7ad6c8214 100644 --- a/lua/blink/cmp/sources/cmdline/init.lua +++ b/lua/blink/cmp/lsp/built-in/cmdline/init.lua @@ -159,8 +159,8 @@ function cmdline:get_completions(context, callback) -- The getcompletion() api is inconsistent in whether it returns the prefix or not. -- - -- I.e. :set shiftwidth=| will return '2' - -- I.e. :Neogit kind=| will return 'kind=commit' + -- e.g. :set shiftwidth=| will return '2' + -- e.g. :Neogit kind=| will return 'kind=commit' -- -- For simplicity, excluding the first argument, we always replace the entire command argument, -- so we want to ensure the prefix is always in the new_text. diff --git a/lua/blink/cmp/sources/cmdline/utils.lua b/lua/blink/cmp/lsp/built-in/cmdline/utils.lua similarity index 100% rename from lua/blink/cmp/sources/cmdline/utils.lua rename to lua/blink/cmp/lsp/built-in/cmdline/utils.lua diff --git a/lua/blink/cmp/sources/path/fs.lua b/lua/blink/cmp/lsp/built-in/path/fs.lua similarity index 71% rename from lua/blink/cmp/sources/path/fs.lua rename to lua/blink/cmp/lsp/built-in/path/fs.lua index a4ea1d8f1..1a03925be 100644 --- a/lua/blink/cmp/sources/path/fs.lua +++ b/lua/blink/cmp/lsp/built-in/path/fs.lua @@ -35,26 +35,6 @@ function fs.scan_dir_async(path) end) end ---- @param entries { name: string, type: string }[] ---- @return blink.cmp.Task -function fs.fs_stat_all(cwd, entries) - local tasks = {} - for _, entry in ipairs(entries) do - table.insert( - tasks, - async.task.new(function(resolve) - uv.fs_stat(cwd .. '/' .. entry.name, function(err, stat) - if err then return resolve(nil) end - resolve({ name = entry.name, type = entry.type, stat = stat }) - end) - end) - ) - end - return async.task.all(tasks):map(function(entries) - return vim.tbl_filter(function(entry) return entry ~= nil end, entries) - end) -end - --- @param path string --- @param byte_limit number --- @return blink.cmp.Task diff --git a/lua/blink/cmp/lsp/built-in/path/init.lua b/lua/blink/cmp/lsp/built-in/path/init.lua new file mode 100644 index 000000000..383e81af1 --- /dev/null +++ b/lua/blink/cmp/lsp/built-in/path/init.lua @@ -0,0 +1,83 @@ +-- credit to https://github.com/hrsh7th/cmp-path for the original implementation +-- and https://codeberg.org/FelipeLema/cmp-async-path for the async implementation + +-- TODO: more advanced detection of windows vs unix paths to resolve escape sequences +-- like "Android\ Camera", which currently returns no items + +--- @class blink.cmp.PathOpts +--- @field trailing_slash boolean +--- @field label_trailing_slash boolean +--- @field get_cwd fun(context: blink.cmp.Context): string +--- @field show_hidden_files_by_default boolean +--- @field ignore_root_slash boolean + +vim.lsp.config('path', { + settings = { + trailing_slash = true, + label_trailing_slash = true, + get_cwd = function(context) return vim.fn.expand(('#%d:p:h'):format(context.bufnr)) end, + show_hidden_files_by_default = false, + ignore_root_slash = false, + }, + + cmd = cmp.lsp.server({ + capabilities = { + completionProvider = { + triggerCharacters = { '/', '.', '\\' }, + resolveProvider = true, + }, + }, + handlers = { + ['textDocument/completion'] = function(_, _, callback) + local opts = vim.lsp.config.path.settings.path + --- @cast opts blink.cmp.PathOpts + + -- we use libuv, but the rest of the library expects to be synchronous + callback = vim.schedule_wrap(callback) + + local lib = require('blink.cmp.sources.path.lib') + + local dirname = lib.dirname(opts, context) + if not dirname then return callback(nil, { isIncomplete = false, items = {} }) end + + local include_hidden = self.opts.show_hidden_files_by_default + or (string.sub(context.line, context.bounds.start_col, context.bounds.start_col) == '.' and context.bounds.length == 0) + or ( + string.sub(context.line, context.bounds.start_col - 1, context.bounds.start_col - 1) == '.' + and context.bounds.length > 0 + ) + lib + .candidates(context, dirname, include_hidden, self.opts) + :map(function(candidates) callback(nil, { isIncomplete = false, items = candidates }) end) + :catch(function(err) callback(err) end) + end, + + ['completionItem/resolve'] = function(_, item, callback) + require('blink.cmp.sources.path.fs') + .read_file(item.data.full_path, 1024) + :map(function(content) + local is_binary = content:find('\0') + + -- binary file + if is_binary then + item.documentation = { + kind = 'plaintext', + value = 'Binary file', + } + -- highlight with markdown + else + local ext = vim.fn.fnamemodify(item.data.path, ':e') + item.documentation = { + kind = 'markdown', + value = '```' .. ext .. '\n' .. content .. '```', + } + end + + return item + end) + :map(function(resolved_item) callback(resolved_item) end) + :catch(function() callback(item) end) + end, + }, + }), +}) diff --git a/lua/blink/cmp/sources/path/lib.lua b/lua/blink/cmp/lsp/built-in/path/lib.lua similarity index 95% rename from lua/blink/cmp/sources/path/lib.lua rename to lua/blink/cmp/lsp/built-in/path/lib.lua index cb64a4d64..f3ec63ba4 100644 --- a/lua/blink/cmp/sources/path/lib.lua +++ b/lua/blink/cmp/lsp/built-in/path/lib.lua @@ -26,17 +26,17 @@ function lib.dirname(opts, context) if env_var_value ~= vim.NIL then return vim.fn.resolve(env_var_value .. '/' .. dirname) end end if prefix:match('/$') then - local accept = true -- Ignore URL components - accept = accept and not prefix:match('%a/$') - -- Ignore URL scheme - accept = accept and not prefix:match('%a+:/$') and not prefix:match('%a+://$') - -- Ignore HTML closing tags - accept = accept and not prefix:match(' + entries = {}, +} + +function cache.get(context, client) + local entry = cache.entries[client.id] + if entry == nil then return end + + if context.id ~= entry.context.id then return end + if entry.response.isIncomplete and entry.context.cursor[2] ~= context.cursor[2] then return end + if not entry.response.isIncomplete and entry.context.cursor[2] > context.cursor[2] then return end + + return entry.response +end + +--- @param context blink.cmp.Context +--- @param client vim.lsp.Client +--- @param response lsp.CompletionList +function cache.set(context, client, response) + cache.entries[client.id] = { + context = context, + response = response, + } +end + +----------------- + +--- @param context blink.cmp.Context +--- @param client vim.lsp.Client +--- @return blink.cmp.Task +local function request(context, client) + return async.task.new(function(resolve) + local params = vim.lsp.util.make_position_params(0, client.offset_encoding) + params.context = { + triggerKind = context.trigger.kind == 'trigger_character' + and vim.lsp.protocol.CompletionTriggerKind.TriggerCharacter + or vim.lsp.protocol.CompletionTriggerKind.Invoked, + } + if context.trigger.kind == 'trigger_character' then params.context.triggerCharacter = context.trigger.character end + + local _, request_id = client:request('textDocument/completion', params, function(err, result) + if err or result == nil then return resolve({ isIncomplete = false, items = {} }) end + if result.isIncomplete ~= nil then return resolve(result) end + resolve({ isIncomplete = false, items = result }) + end) + return function() + if request_id ~= nil then client:cancel_request(request_id) end + end + end) +end + +local known_defaults = { + commitCharacters = true, + insertTextFormat = true, + insertTextMode = true, + data = true, +} + +local function get_completions(context, client) + local cache_entry = cache.get(context, client) + if cache_entry ~= nil then return async.task.identity(cache_entry) end + + return request(context, client):map(function(res) + local items = res.items or res + local default_edit_range = res.itemDefaults and res.itemDefaults.editRange or text_edit.guess_edit_range() + for _, item in ipairs(items) do + item.blink_cmp = item.blink_cmp or {} + item.blink_cmp.client_id = client.id + item.blink_cmp.client_name = client.name + + -- score offset for deprecated items + if item.deprecated or (item.tags and vim.tbl_contains(item.tags, 1)) then + item.blink_cmp.score_offset = (item.blink_cmp.score_offset or 0) - 2 + end + + -- set defaults + for key, value in pairs(res.itemDefaults or {}) do + if known_defaults[key] then item[key] = item[key] or value end + end + if item.textEdit == nil then + local new_text = item.textEditText or item.insertText or item.label + if default_edit_range.replace ~= nil then + item.textEdit = { + replace = default_edit_range.replace, + insert = default_edit_range.insert, + newText = new_text, + } + else + item.textEdit = { + range = default_edit_range, + newText = new_text, + } + end + end + end + + res.client_id = client.id + return res + end) +end + +return get_completions diff --git a/lua/blink/cmp/lsp/client/init.lua b/lua/blink/cmp/lsp/client/init.lua new file mode 100644 index 000000000..e6669e382 --- /dev/null +++ b/lua/blink/cmp/lsp/client/init.lua @@ -0,0 +1,93 @@ +local async = require('blink.cmp.lib.async') +local lsp = require('blink.cmp.lsp') +local utils = require('blink.cmp.lib.utils') + +local clients = { + signature = require('blink.cmp.lsp.client.signature'), +} + +function clients.get_trigger_characters() + local trigger_characters = {} + for _, client in ipairs(lsp.get_clients({ bufnr = 0 })) do + if client.server_capabilities.completionProvider ~= nil then + vim.list_extend(trigger_characters, client.server_capabilities.completionProvider.triggerCharacters or {}) + end + end + return utils.deduplicate(trigger_characters) +end + +--- @param context blink.cmp.Context +--- @param _items_by_provider table +function clients.emit_completions(context, _items_by_provider) + local items_by_provider = {} + for name, items in pairs(_items_by_provider) do + if lsp.config[name].should_show_items(context, items) then items_by_provider[name] = items end + end + clients.completions_emitter:emit({ context = context, items = items_by_provider }) +end + +--- @param context blink.cmp.Context +function clients.get_completions(context) + -- create a new context if the id changed or if we haven't created one yet + if clients.completions_queue == nil or context.id ~= clients.completions_queue.id then + if clients.completions_queue ~= nil then clients.completions_queue:destroy() end + clients.completions_queue = require('blink.cmp.lsp.client.queue').new(context, clients.emit_completions) + + -- send cached completions if they exist to immediately trigger updates + elseif clients.completions_queue:get_cached_completions() ~= nil then + clients.emit_completions(context, clients.completions_queue:get_cached_completions() or {}) + end + + clients.completions_queue:get_completions(context) +end + +--- Limits the number of items per LSP as configured +function clients.apply_max_items(context, items) + -- get the configured max items for each LSP + local total_items_for_lsps = {} + local max_items_for_lsps = {} + for _, client in ipairs(vim.lsp.get_clients({ bufnr = 0 })) do + local max_items = lsp.config[client.name].max_items(context, items) + if max_items ~= nil then + max_items_for_lsps[client.name] = max_items + total_items_for_lsps[client.name] = 0 + end + end + + -- no max items configured, return as-is + if #vim.tbl_keys(max_items_for_lsps) == 0 then return items end + + -- apply max items + local filtered_items = {} + for _, item in ipairs(items) do + local max_items = max_items_for_lsps[item.blink.client_name] + total_items_for_lsps[item.blink.client_name] = total_items_for_lsps[item.blink.client_name] + 1 + if max_items == nil or total_items_for_lsps[item.blink.client_name] <= max_items then + table.insert(filtered_items, item) + end + end + return filtered_items +end + +function clients.resolve(item) + local client = vim.lsp.get_client_by_id(item.blink_cmp.client_id) + if client == nil then return async.task.identity(item) end + + local lsp_item = vim.deepcopy(item) + lsp_item.blink_cmp = nil + + return async.task.new(function(resolve, reject) + client:request('completionItem/resolve', lsp_item, function(err, resolved_item) + if err then + return reject(err) + elseif resolved_item ~= nil then + resolved_item.blink_cmp = item.blink_cmp + resolve(resolved_item) + else + resolve(item) + end + end) + end) +end + +return clients diff --git a/lua/blink/cmp/lsp/client/queue.lua b/lua/blink/cmp/lsp/client/queue.lua new file mode 100644 index 000000000..12a858d9b --- /dev/null +++ b/lua/blink/cmp/lsp/client/queue.lua @@ -0,0 +1,67 @@ +local async = require('blink.cmp.lib.async') +local lsp = require('blink.cmp.lsp') + +local queue = {} + +--- @param context blink.cmp.Context +--- @param on_completions_callback fun(context: blink.cmp.Context, responses: table) +--- @return blink.cmp.SourcesQueue +function queue.new(context, on_completions_callback) + local self = setmetatable({}, { __index = queue }) + self.id = context.id + + self.request = nil + self.queued_request_context = nil + self.on_completions_callback = on_completions_callback + + return self +end + +--- @return table? +function queue:get_cached_completions() return self.cached_items_by_lsp end + +--- @param context blink.cmp.Context +function queue:get_completions(context) + assert(context.id == self.id, 'Requested completions on a sources context with a different context ID') + + if self.request ~= nil then + -- already running, queue the request + if self.request.status == async.STATUS.RUNNING then + self.queued_request_context = context + return + end + self.request:cancel() + end + + local get_completions = require('blink.cmp.lsp.client.completion') + local tasks = vim.tbl_map( + function(client) return get_completions(context, client) end, + lsp.get_clients({ method = 'textDocument/completion' }) + ) + + self.request = async.task.all(tasks):map(function(responses) + local items_by_lsp = {} --- @type table + for _, response in ipairs(responses) do + items_by_lsp[response.client_id] = response.items + end + + self.cached_items_by_lsp = items_by_lsp + self.on_completions_callback(context, items_by_lsp) + + -- run the queued request, if it exists + local queued_context = self.queued_request_context + if queued_context ~= nil then + self.queued_request_context = nil + self.request:cancel() + self:get_completions(queued_context) + end + end) +end + +function queue:destroy() + --- @type fun(context: blink.cmp.Context, items: table) + self.on_completions_callback = function(_, _) end + if self.request ~= nil then self.request:cancel() end +end + +return queue diff --git a/lua/blink/cmp/lsp/client/signature.lua b/lua/blink/cmp/lsp/client/signature.lua new file mode 100644 index 000000000..26422ed5a --- /dev/null +++ b/lua/blink/cmp/lsp/client/signature.lua @@ -0,0 +1,42 @@ +local async = require('blink.cmp.lib.async') + +local signature = { + last_request_client = nil, + last_request_id = nil, +} + +function signature.get_trigger_characters() + local trigger_characters = {} + local retrigger_characters = {} + + for _, client in ipairs(vim.lsp.get_clients({ bufnr = 0 })) do + if client.server_capabilities.signatureHelpProvider ~= nil then + vim.list_extend(trigger_characters, client.server_capabilities.signatureHelpProvider.triggerCharacters or {}) + vim.list_extend(retrigger_characters, client.server_capabilities.signatureHelpProvider.retriggerCharacters or {}) + end + end + + return { trigger_characters = trigger_characters, retrigger_characters = retrigger_characters } +end + +function signature.get_signature_help(context) + local tasks = vim.tbl_map(function(client) + local offset_encoding = client.offset_encoding or 'utf-16' + + local params = vim.lsp.util.make_position_params(nil, offset_encoding) + params.context = { + triggerKind = context.trigger.kind, + triggerCharacter = context.trigger.character, + isRetrigger = context.is_retrigger, + activeSignatureHelp = context.active_signature_help, + } + + return client:request('textDocument/signatureHelp', context.params) + end, vim.lsp.get_clients({ bufnr = 0, method = 'textDocument/signatureHelp' })) + + return async.task.all(tasks):map(function(signature_helps) + return vim.tbl_filter(function(signature_help) return signature_help ~= nil end, signature_helps) + end) +end + +return signature diff --git a/lua/blink/cmp/lsp/init.lua b/lua/blink/cmp/lsp/init.lua new file mode 100644 index 000000000..c05d4b089 --- /dev/null +++ b/lua/blink/cmp/lsp/init.lua @@ -0,0 +1,145 @@ +-- TODO: all buffer-local configs should be cleared when the buffer is deleted +-- TODO: tests + +--- @class blink.cmp.lsp +--- @field package _enabled_configs table +local lsp = { _enabled_configs = {}, _per_buffer_enabled_configs = {} } + +--- @class blink.cmp.lsp.Config +--- @field name? string +--- @field async? boolean Whether we should show the completions before this provider returns, without waiting for it +--- @field timeout_ms? number How long to wait for the provider to return before showing completions and treating it as asynchronous +--- @field transform_items? fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): blink.cmp.CompletionItem[] Function to transform the items before they're returned +--- @field should_show_items? boolean | fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): boolean Whether or not to show the items +--- @field max_items? number | fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): number Maximum number of items to display in the menu +--- @field min_keyword_length? number | fun(ctx: blink.cmp.Context): number Minimum number of characters in the keyword to trigger the provider +--- @field score_offset? number | fun(): number Boost/penalize the score of the items + +--- @param name string | string[] +--- @param enable boolean +--- @param filter? { bufnr: integer? } +function lsp.enable(name, enable, filter) + vim.validate('name', name, { 'string', 'table' }) + + local curr_buf = vim.api.nvim_get_current_buf() + for _, nm in vim._ensure_list(name) do + assert(nm ~= '*', 'Cannot call cmp.lsp.enable with name "*"') + if filter ~= nil and filter.bufnr ~= nil then + local bufnr = filter.bufnr == 0 and curr_buf or filter.bufnr + lsp._per_buffer_enabled_configs[bufnr] = lsp._per_buffer_enabled_configs[bufnr] or {} + lsp._per_buffer_enabled_configs[bufnr][nm] = enable + else + lsp._enabled_configs[nm] = enable + end + end +end + +--- @param name string +--- @param filter? { bufnr: integer? } +function lsp.is_enabled(name, filter) + if filter ~= nil and filter.bufnr ~= nil then + local bufnr = filter.bufnr == 0 and vim.api.nvim_get_current_buf() or filter.bufnr + if lsp._per_buffer_enabled_configs[bufnr][name] ~= nil then return lsp._per_buffer_enabled_configs[bufnr][name] end + end + + if lsp._enabled_configs[name] ~= nil then return lsp._enabled_configs[name] end + return true +end + +--- @param filter? vim.lsp.get_clients.Filter +--- @return vim.lsp.Client[] +function lsp.get_clients(filter) + return vim.tbl_filter(function(client) return lsp.is_enabled(client.name, filter) end, vim.lsp.get_clients(filter)) +end + +local buffer_configs = setmetatable({ + _per_buffer_configs = {}, +}, { + __index = function(self, bufnr) + return setmetatable({}, { + __index = function(_, name) + vim.validate('name', name, 'string') + if name == '_per_buffer_configs' then return self._per_buffer_configs end + return vim.tbl_deep_extend( + 'force', + (self._per_buffer_configs[bufnr] or {})['*'] or {}, + (self._per_buffer_configs[bufnr] or {})[name] or {} + ) + end, + + __newindex = function(_, name, cfg) + vim.validate('name', name, 'string') + local hint = ('table (hint: to resolve a config, use cmp.lsp.config.b[bufnr]["%s"])'):format(name) + vim.validate('cfg', cfg, 'table', hint) + self._per_buffer_configs[bufnr] = self._per_buffer_configs[bufnr] or {} + self._per_buffer_configs[bufnr][name] = cfg + end, + }) + end, +}) +vim.api.nvim_create_autocmd({ 'BufDelete', 'BufWipeout' }, { + callback = function(args) buffer_configs._per_buffer_configs[args.buf] = nil end, +}) + +--- @class blink.cmp.lsp.config +--- @field [string] blink.cmp.lsp.Config +--- @field [integer] { [string]: blink.cmp.lsp.Config } Buffer-local configs +--- @field package _configs table +lsp.config = setmetatable({ + _configs = { + ['*'] = { + async = false, + timeout_ms = 2000, + transform_items = function(_, items) return items end, + should_show_items = function() return true end, + max_items = function() end, + min_keyword_length = 0, + score_offset = 0, + }, + }, +}, { + --- @param self blink.cmp.lsp.config + --- @param name_or_bufnr string | integer + --- @return vim.lsp.Config + __index = function(self, name_or_bufnr) + vim.validate('name', name_or_bufnr, { 'string', 'number' }) + + if type(name_or_bufnr) == 'number' then return buffer_configs[name_or_bufnr] end + + -- TODO: use metatable instead of merging every time + local bufnr = vim.api.nvim_get_current_buf() + return vim.tbl_deep_extend( + 'force', + self._configs['*'], + self._configs[name_or_bufnr] or {}, + buffer_configs[bufnr][name_or_bufnr] + ) + end, + + --- @param name string + --- @param cfg blink.cmp.lsp.Config + __newindex = function(self, name, cfg) + vim.validate('name', name, 'string') + vim.validate('cfg', cfg, 'table', ('table (hint: to resolve a config, use cmp.lsp.config["%s"])'):format(name)) + self._configs[name] = cfg + end, + + --- @param name string + --- @param cfg blink.cmp.lsp.Config + --- @param filter? { bufnr: integer? } + __call = function(self, name, cfg, filter) + vim.validate('name', name, 'string') + vim.validate('cfg', cfg, 'table', ('table (hint: to resolve a config, use cmp.lsp.config["%s"])'):format(name)) + vim.validate('filter', filter, 'table', true) + + if filter ~= nil then + vim.validate('filter.bufnr', filter.bufnr, 'number', true) + local bufnr = filter.bufnr == 0 and vim.api.nvim_get_current_buf() or filter.bufnr + buffer_configs[bufnr][name] = cfg + else + self[name] = vim.tbl_deep_extend('force', self._configs[name] or {}, cfg) + end + end, +}) + +return lsp diff --git a/lua/blink/cmp/lsp/server.lua b/lua/blink/cmp/lsp/server.lua new file mode 100644 index 000000000..0fa44bb9e --- /dev/null +++ b/lua/blink/cmp/lsp/server.lua @@ -0,0 +1,55 @@ +-- Based on https://github.com/neovim/neovim/pull/24338 + +local lsp = {} + +--- @class lsp.server.opts +--- @field handlers? table +--- @field on_request? fun(method: string, params) +--- @field on_notify? fun(method: string, params) +--- @field capabilities? table + +--- Create a in-process LSP server that can be used as `cmd` with |vim.lsp.start| +--- @param opts nil|lsp.server.opts +function lsp.server(opts) + opts = opts or {} + local capabilities = opts.capabilities or {} + local on_request = opts.on_request or function(_, _) end + local on_notify = opts.on_notify or function(_, _) end + local handlers = opts.handlers or {} + + return function(dispatchers) + local closing = false + local srv = {} + local request_id = 0 + + function srv.request(method, params, callback) + pcall(on_request, method, params) + local handler = handlers[method] + if handler then + local response, err = handler(method, params, callback) + if response ~= nil or err ~= nil then callback(err, response) end + elseif method == 'initialize' then + callback(nil, { + capabilities = capabilities, + }) + elseif method == 'shutdown' then + callback(nil, nil) + end + request_id = request_id + 1 + return true, request_id + end + + function srv.notify(method, params) + pcall(on_notify, method, params) + if method == 'exit' then dispatchers.on_exit(0, 15) end + end + + function srv.is_closing() return closing end + + function srv.terminate() closing = true end + + return srv + end +end + +return lsp diff --git a/lua/blink/cmp/lib/text_edits.lua b/lua/blink/cmp/lsp/text_edit.lua similarity index 74% rename from lua/blink/cmp/lib/text_edits.lua rename to lua/blink/cmp/lsp/text_edit.lua index fbd083809..90e640a24 100644 --- a/lua/blink/cmp/lib/text_edits.lua +++ b/lua/blink/cmp/lsp/text_edit.lua @@ -2,12 +2,12 @@ local config = require('blink.cmp.config') local utils = require('blink.cmp.lib.utils') local context = require('blink.cmp.completion.trigger.context') -local text_edits = {} +local M = {} --- Applies one or more text edits to the current buffer, assuming utf-8 encoding ---- @param text_edit lsp.TextEdit The main text edit (at the cursor). Can be dot repeated. +--- @param edit lsp.TextEdit The main text edit (at the cursor). Can be dot repeated. --- @param additional_text_edits? lsp.TextEdit[] Additional text edits that can e.g. add import statements. -function text_edits.apply(text_edit, additional_text_edits) +function M.apply(edit, additional_text_edits) additional_text_edits = additional_text_edits or {} local mode = context.get_mode() @@ -18,10 +18,10 @@ function text_edits.apply(text_edit, additional_text_edits) if mode == 'default' or mode == 'cmdwin' then -- writing to dot repeat may fail in command-line window - if mode == 'default' and config.completion.accept.dot_repeat then text_edits.write_to_dot_repeat(text_edit) end + if mode == 'default' and config.completion.accept.dot_repeat then M.write_to_dot_repeat(edit) end local all_edits = utils.shallow_copy(additional_text_edits) - table.insert(all_edits, text_edit) + table.insert(all_edits, edit) -- preserve 'buflisted' state because vim.lsp.util.apply_text_edits forces it to true local cur_bufnr = vim.api.nvim_get_current_buf() @@ -34,12 +34,12 @@ function text_edits.apply(text_edit, additional_text_edits) assert(#additional_text_edits == 0, 'Cmdline mode only supports one text edit. Contributions welcome!') local line = context.get_line() - local edited_line = line:sub(1, text_edit.range.start.character) - .. text_edit.newText - .. line:sub(text_edit.range['end'].character + 1) + local edited_line = line:sub(1, edit.range.start.character) + .. edit.newText + .. line:sub(edit.range['end'].character + 1) -- FIXME: for some reason, we have to set the cursor here, instead of later, -- because this will override the cursor position set later - vim.fn.setcmdline(edited_line, text_edit.range.start.character + #text_edit.newText + 1) + vim.fn.setcmdline(edited_line, edit.range.start.character + #edit.newText + 1) end -- TODO: apply dot repeat @@ -48,10 +48,10 @@ function text_edits.apply(text_edit, additional_text_edits) if vim.bo.channel and vim.bo.channel ~= 0 then local cur_col = vim.api.nvim_win_get_cursor(0)[2] - local n_replaced = cur_col - text_edit.range.start.character + local n_replaced = cur_col - edit.range.start.character local backspace_keycode = '\8' - vim.fn.chansend(vim.bo.channel, backspace_keycode:rep(n_replaced) .. text_edit.newText) + vim.fn.chansend(vim.bo.channel, backspace_keycode:rep(n_replaced) .. edit.newText) end end end @@ -59,23 +59,23 @@ end ------- Undo ------- --- Gets the reverse of the text edit, must be called before applying ---- @param text_edit lsp.TextEdit +--- @param edit lsp.TextEdit --- @return lsp.TextEdit -function text_edits.get_undo_text_edit(text_edit) +function M.get_undo_text_edit(edit) return { - range = text_edits.get_undo_range(text_edit), - newText = text_edits.get_text_to_replace(text_edit), + range = M.get_undo_range(edit), + newText = M.get_text_to_replace(edit), } end --- Gets the range for undoing an applied text edit ---- @param text_edit lsp.TextEdit -function text_edits.get_undo_range(text_edit) - text_edit = vim.deepcopy(text_edit) - local lines = vim.split(text_edit.newText, '\n') +--- @param edit lsp.TextEdit +function M.get_undo_range(edit) + edit = vim.deepcopy(edit) + local lines = vim.split(edit.newText, '\n') local last_line_len = lines[#lines] and #lines[#lines] or 0 - local range = text_edit.range + local range = edit.range range['end'].line = range.start.line + #lines - 1 range['end'].character = #lines > 1 and last_line_len or range.start.character + last_line_len @@ -83,21 +83,21 @@ function text_edits.get_undo_range(text_edit) end --- Gets the text the text edit will replace ---- @param text_edit lsp.TextEdit +--- @param edit lsp.TextEdit --- @return string -function text_edits.get_text_to_replace(text_edit) +function M.get_text_to_replace(edit) local lines = {} - for line = text_edit.range.start.line, text_edit.range['end'].line do + for line = edit.range.start.line, edit.range['end'].line do local line_text = context.get_line() - local is_start_line = line == text_edit.range.start.line - local is_end_line = line == text_edit.range['end'].line + local is_start_line = line == edit.range.start.line + local is_end_line = line == edit.range['end'].line if is_start_line and is_end_line then - table.insert(lines, line_text:sub(text_edit.range.start.character + 1, text_edit.range['end'].character)) + table.insert(lines, line_text:sub(edit.range.start.character + 1, edit.range['end'].character)) elseif is_start_line then - table.insert(lines, line_text:sub(text_edit.range.start.character + 1)) + table.insert(lines, line_text:sub(edit.range.start.character + 1)) elseif is_end_line then - table.insert(lines, line_text:sub(1, text_edit.range['end'].character)) + table.insert(lines, line_text:sub(1, edit.range['end'].character)) else table.insert(lines, line_text) end @@ -131,11 +131,11 @@ end --- offset encodings (utf-16 | utf-32) to utf-8 --- @param item blink.cmp.CompletionItem --- @return lsp.TextEdit -function text_edits.get_from_item(item) +function M.get_from_item(item) local text_edit = vim.deepcopy(item.textEdit) -- Guess the text edit if the item doesn't define it - if text_edit == nil then return text_edits.guess(item) end + if text_edit == nil then return text_edit.guess(item) end -- FIXME: temporarily convert insertReplaceEdit to regular textEdit if text_edit.range == nil then @@ -149,15 +149,15 @@ function text_edits.get_from_item(item) text_edit.replace = nil --- @cast text_edit lsp.TextEdit - local offset_encoding = text_edits.offset_encoding_from_item(item) - text_edit = text_edits.compensate_for_cursor_movement(text_edit, item.cursor_column, context.get_cursor()[2]) + local offset_encoding = text_edit.offset_encoding_from_item(item) + text_edit = text_edit.compensate_for_cursor_movement(text_edit, item.cursor_column, context.get_cursor()[2]) -- convert the offset encoding to utf-8 -- TODO: we have to do this last because it applies a max on the position based on the length of the line -- so it would break the offset code when removing characters at the end of the line - text_edit = text_edits.to_utf_8(text_edit, offset_encoding) + text_edit = text_edit.to_utf_8(text_edit, offset_encoding) - text_edit.range = text_edits.clamp_range_to_bounds(text_edit.range) + text_edit.range = text_edit.clamp_range_to_bounds(text_edit.range) return text_edit end @@ -166,36 +166,36 @@ end --- since the data might be outdated. We compare the cursor column position --- from when the items were fetched versus the current. --- HACK: is there a better way? ---- @param text_edit lsp.TextEdit +--- @param edit lsp.TextEdit --- @param old_cursor_col number Position of the cursor when the text edit was created --- @param new_cursor_col number New position of the cursor --- @return lsp.TextEdit -function text_edits.compensate_for_cursor_movement(text_edit, old_cursor_col, new_cursor_col) - text_edit = vim.deepcopy(text_edit) +function M.compensate_for_cursor_movement(edit, old_cursor_col, new_cursor_col) + edit = vim.deepcopy(edit) local offset = new_cursor_col - old_cursor_col - text_edit.range['end'].character = text_edit.range['end'].character + offset + edit.range['end'].character = edit.range['end'].character + offset - return text_edit + return edit end -function text_edits.offset_encoding_from_item(item) +function M.offset_encoding_from_item(item) local client = vim.lsp.get_client_by_id(item.client_id) return client ~= nil and client.offset_encoding or 'utf-8' end -function text_edits.to_utf_8(text_edit, offset_encoding) - if offset_encoding == 'utf-8' then return text_edit end - text_edit = vim.deepcopy(text_edit) - text_edit.range.start.character = get_line_byte_from_position(text_edit.range.start, offset_encoding) - text_edit.range['end'].character = get_line_byte_from_position(text_edit.range['end'], offset_encoding) - return text_edit +function M.to_utf_8(edit, offset_encoding) + if offset_encoding == 'utf-8' then return edit end + edit = vim.deepcopy(edit) + edit.range.start.character = get_line_byte_from_position(edit.range.start, offset_encoding) + edit.range['end'].character = get_line_byte_from_position(edit.range['end'], offset_encoding) + return edit end --- Uses the keyword_regex to guess the text edit ranges --- @param item blink.cmp.CompletionItem --- TODO: doesnt work when the item contains characters not included in the context regex -function text_edits.guess(item) +function M.guess(item) local word = item.insertText or item.label local start_col, end_col = require('blink.cmp.fuzzy').guess_edit_range( @@ -219,7 +219,7 @@ end --- Clamps the range to the bounds of their respective lines --- @param range lsp.Range --- @return lsp.Range -function text_edits.clamp_range_to_bounds(range) +function M.clamp_range_to_bounds(range) range = vim.deepcopy(range) local line_count = vim.api.nvim_buf_line_count(0) @@ -245,26 +245,26 @@ end --- --- TODO: write tests cases, there are many uncommon cases it doesn't handle --- ---- @param text_edit lsp.TextEdit +--- @param edit lsp.TextEdit --- @param additional_text_edits lsp.TextEdit[] --- @return number[] (1, 0) indexed line and column -function text_edits.get_apply_end_position(text_edit, additional_text_edits) +function M.get_apply_end_position(edit, additional_text_edits) -- Calculate the end position of the range, ignoring the additional text edits - local lines = vim.split(text_edit.newText, '\n') + local lines = vim.split(edit.newText, '\n') local last_line_len = #lines[#lines] local line_count = #lines - local end_line = text_edit.range['end'].line + line_count - 1 + local end_line = edit.range['end'].line + line_count - 1 local end_col = last_line_len - if line_count == 1 then end_col = end_col + text_edit.range.start.character end + if line_count == 1 then end_col = end_col + edit.range.start.character end -- Adjust the end position based on the additional text edits local text_edits_before = vim.tbl_filter( - function(edit) - return edit.range.start.line < text_edit.range.start.line - or edit.range.start.line == text_edit.range.start.line - and edit.range.start.character <= text_edit.range.start.character + function(additional_edit) + return additional_edit.range.start.line < edit.range.start.line + or additional_edit.range.start.line == edit.range.start.line + and additional_edit.range.start.character <= edit.range.start.character end, additional_text_edits ) @@ -276,16 +276,16 @@ function text_edits.get_apply_end_position(text_edit, additional_text_edits) local line_offset = 0 local col_offset = 0 - for _, edit in ipairs(text_edits_before) do - local lines_replaced = edit.range['end'].line - edit.range.start.line - local edit_lines = vim.split(edit.newText, '\n') + for _, edit_before in ipairs(text_edits_before) do + local lines_replaced = edit_before.range['end'].line - edit_before.range.start.line + local edit_lines = vim.split(edit_before.newText, '\n') local lines_added = #edit_lines - 1 line_offset = line_offset - lines_replaced + lines_added -- Same line as the current text edit, offset the column - if edit.range.start.line == text_edit.range.start.line then + if edit_before.range.start.line == edit.range.start.line then if #edit_lines == 1 then - local chars_replaced = edit.range['end'].character - edit.range.start.character + local chars_replaced = edit_before.range['end'].character - edit_before.range.start.character local chars_added = #edit_lines[#edit_lines] col_offset = col_offset + chars_added - chars_replaced else @@ -307,7 +307,7 @@ end --- Other plugins may use feedkeys to switch modes, with `i` set. This would --- cause neovim to run those feedkeys first, potentially causing our to run ---- in the wrong mode. I.e. if the plugin runs `v` (luasnip) +--- in the wrong mode. e.g. if the plugin runs `v` (luasnip) --- --- In normal and visual mode, these keys cause neovim to go to the background --- so we create our own mapping that only runs `` if we're in insert mode @@ -345,20 +345,20 @@ end --- --- See the tracking issue for directly writing to `.` register: --- https://github.com/neovim/neovim/issues/19806#issuecomment-2365146298 ---- @param text_edit lsp.TextEdit -function text_edits.write_to_dot_repeat(text_edit) +--- @param edit lsp.TextEdit +function M.write_to_dot_repeat(edit) local chars_to_delete = #table.concat( vim.api.nvim_buf_get_text( 0, - text_edit.range.start.line, - text_edit.range.start.character, - text_edit.range['end'].line, - text_edit.range['end'].character, + edit.range.start.line, + edit.range.start.character, + edit.range['end'].line, + edit.range['end'].character, {} ), '\n' ) - local chars_to_insert = text_edit.newText + local chars_to_insert = edit.newText utils.defer_neovide_redraw(function() utils.with_no_autocmds(function() @@ -403,11 +403,11 @@ end --- Moves the cursor while preserving dot repeat --- @param amount number Number of characters to move the cursor by, can be negative to move left -function text_edits.move_cursor_in_dot_repeat(amount) +function M.move_cursor_in_dot_repeat(amount) if amount == 0 then return end local keys = string.rep('U' .. (amount > 0 and '' or ''), math.abs(amount)) vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, true, true), 'in', false) end -return text_edits +return M diff --git a/lua/blink/cmp/sources/buffer/cache.lua b/lua/blink/cmp/sources/buffer/cache.lua deleted file mode 100644 index bf02082a9..000000000 --- a/lua/blink/cmp/sources/buffer/cache.lua +++ /dev/null @@ -1,45 +0,0 @@ ----@class blink.cmp.BufferCacheEntry ----@field changedtick integer ----@field exclude_word_under_cursor boolean ----@field words string[] - ----@class blink.cmp.BufferCache ----@field store table -local cache = {} - -cache.__index = cache - -function cache.new() - local self = setmetatable({ store = {} }, cache) - - vim.api.nvim_create_autocmd({ 'BufDelete', 'BufWipeout' }, { - desc = 'Invalidate buffer cache items when buffer is deleted', - callback = function(args) self.store[args.buf] = nil end, - }) - - return self -end - ----Get the cache entry for a buffer. ----@param bufnr integer ----@return blink.cmp.BufferCacheEntry|nil -function cache:get(bufnr) return self.store[bufnr] end - ----Set the cache entry for a buffer. ----@param bufnr integer ----@param value blink.cmp.BufferCacheEntry -function cache:set(bufnr, value) self.store[bufnr] = value end - ----Remove cache entries for buffers not in the given list. ----@param valid_bufnrs integer[] -function cache:cleanup(valid_bufnrs) - local keep = {} - for _, k in ipairs(valid_bufnrs) do - keep[k] = true - end - for k in pairs(self.store) do - if not keep[k] then self.store[k] = nil end - end -end - -return cache diff --git a/lua/blink/cmp/sources/buffer/init.lua b/lua/blink/cmp/sources/buffer/init.lua deleted file mode 100644 index 1492c8719..000000000 --- a/lua/blink/cmp/sources/buffer/init.lua +++ /dev/null @@ -1,203 +0,0 @@ --- todo: nvim-cmp only updates the lines that got changed which is better --- but this is *speeeeeed* and simple. should add the better way --- but ensure it doesn't add too much complexity - -local async = require('blink.cmp.lib.async') -local parser = require('blink.cmp.sources.buffer.parser') -local buf_utils = require('blink.cmp.sources.buffer.utils') -local utils = require('blink.cmp.sources.lib.utils') -local dedup = require('blink.cmp.lib.utils').deduplicate - ---- @class blink.cmp.BufferOpts ---- @field get_bufnrs fun(): integer[] ---- @field get_search_bufnrs fun(): integer[] ---- @field max_sync_buffer_size integer Maximum total number of characters (in an individual buffer) for which buffer completion runs synchronously. Above this, asynchronous processing is used. ---- @field max_async_buffer_size integer Maximum total number of characters (in an individual buffer) for which buffer completion runs asynchronously. Above this, the buffer will be skipped. ---- @field max_total_buffer_size integer Maximum text size across all buffers (default: 500KB) ---- @field retention_order string[] Order in which buffers are retained for completion, up to the max total size limit ---- @field use_cache boolean Cache words for each buffer which increases memory usage but drastically reduces cpu usage. Memory usage depends on the size of the buffers from `get_bufnrs`. For 100k items, it will use ~20MBs of memory. Invalidated and refreshed whenever the buffer content is modified. ---- @field enable_in_ex_commands boolean Whether to enable buffer source in substitute (:s) and global (:g) commands. Note: Enabling this option will temporarily disable Neovim's 'inccommand' feature while editing Ex commands, due to a known redraw issue (see neovim/neovim#9783). This means you will lose live substitution previews when using :s, :smagic, or :snomagic while buffer completions are active. - ---- @param words string[] ---- @return blink.cmp.CompletionItem[] -local function words_to_items(words) - local kind_text = require('blink.cmp.types').CompletionItemKind.Text - local plain_text = vim.lsp.protocol.InsertTextFormat.PlainText - - local items = {} - for i = 1, #words do - items[i] = { - label = words[i], - kind = kind_text, - insertTextFormat = plain_text, - insertText = words[i], - } - end - return items -end - ---- Public API - ---- @class blink.cmp.BufferSource : blink.cmp.Source ---- @field opts blink.cmp.BufferOpts ---- @field cache blink.cmp.BufferCache -local buffer = {} - -function buffer.new(opts) - local self = setmetatable({}, { __index = buffer }) - - --- @type blink.cmp.BufferOpts - opts = vim.tbl_deep_extend('keep', opts or {}, { - get_bufnrs = function() - return vim - .iter(vim.api.nvim_list_wins()) - :map(function(win) return vim.api.nvim_win_get_buf(win) end) - :filter(function(buf) return vim.bo[buf].buftype ~= 'nofile' end) - :totable() - end, - get_search_bufnrs = function() return { vim.api.nvim_get_current_buf() } end, - max_sync_buffer_size = 20000, - max_async_buffer_size = 200000, - max_total_buffer_size = 500000, - retention_order = { 'focused', 'visible', 'recency', 'largest' }, - use_cache = true, - enable_in_ex_commands = false, - }) - require('blink.cmp.config.utils').validate('sources.providers.buffer', { - get_bufnrs = { opts.get_bufnrs, 'function' }, - get_search_bufnrs = { opts.get_search_bufnrs, 'function' }, - max_sync_buffer_size = { opts.max_sync_buffer_size, 'number' }, - max_async_buffer_size = { - opts.max_async_buffer_size, - buf_utils.validate_buffer_size(opts.max_sync_buffer_size), - 'a number greater than max_sync_buffer_size (' .. opts.max_sync_buffer_size .. ')', - }, - max_total_buffer_size = { - opts.max_total_buffer_size, - buf_utils.validate_buffer_size(opts.max_async_buffer_size), - 'a number greater than max_async_buffer_size (' .. opts.max_async_buffer_size .. ')', - }, - retention_order = { - opts.retention_order, - function(retention_order) - if type(retention_order) ~= 'table' then return false end - for _, retention_type in ipairs(retention_order) do - if not vim.tbl_contains({ 'focused', 'visible', 'recency', 'largest' }, retention_type) then return false end - end - return true - end, - 'table of: "focused", "visible", "recency", or "largest"', - }, - use_cache = { opts.use_cache, 'boolean' }, - enable_in_ex_commands = { opts.enable_in_ex_commands, 'boolean' }, - }, opts) - - if vim.tbl_contains(opts.retention_order, 'recency') then - require('blink.cmp.sources.buffer.recency').start_tracking() - end - - -- HACK: When using buffer completion sources in ex commands - -- while 'inccommand' is active, Neovim's UI redraw is delayed by one frame. - -- This causes completion popups to appear out of sync with user input, - -- due to a known Neovim limitation (see neovim/neovim#9783). - -- To work around this, temporarily disable 'inccommand'. - -- This sacrifice live substitution previews, but restores correct redraw. - if opts.enable_in_ex_commands then - vim.on_key(function() - if utils.is_command_line({ ':' }) and vim.o.inccommand ~= '' then vim.o.inccommand = '' end - end) - end - - if opts.use_cache then self.cache = require('blink.cmp.sources.buffer.cache').new() end - - self.opts = opts - - return self -end - ---- @return boolean -function buffer:is_search_context() - -- In search mode - if utils.is_command_line({ '/', '?' }) then return true end - -- In specific ex commands, if user opts in - if self.opts.enable_in_ex_commands and utils.in_ex_context({ 'substitute', 'global', 'vglobal' }) then return true end - - return false -end - ---- @param bufnr integer ---- @param exclude_word_under_cursor boolean ---- @return blink.cmp.Task -function buffer:get_buf_items(bufnr, exclude_word_under_cursor) - local changedtick - - if self.opts.use_cache then - changedtick = vim.b[bufnr].changedtick - local cache = self.cache:get(bufnr) - - if cache and cache.changedtick == changedtick and cache.exclude_word_under_cursor == exclude_word_under_cursor then - return async.task.identity(cache.words) - end - end - - ---@param words string[] - local function store_in_cache(words) - if self.opts.use_cache then - self.cache:set(bufnr, { - changedtick = changedtick, - exclude_word_under_cursor = exclude_word_under_cursor, - words = words, - }) - end - return words - end - - return parser.get_buf_words(bufnr, exclude_word_under_cursor, self.opts):map(store_in_cache) -end - ---- @return boolean -function buffer:enabled() return not utils.is_command_line() or self:is_search_context() end - -function buffer:get_completions(_, callback) - vim.schedule(function() - local is_search = self:is_search_context() - local get_bufnrs = is_search and self.opts.get_search_bufnrs or self.opts.get_bufnrs - local bufnrs = dedup(get_bufnrs()) - - if #bufnrs == 0 then - callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = {} }) - return - end - - local selected_bufnrs = buf_utils.retain_buffers( - bufnrs, - self.opts.max_total_buffer_size, - self.opts.max_async_buffer_size, - self.opts.retention_order - ) - - local tasks = vim.tbl_map(function(buf) return self:get_buf_items(buf, not is_search) end, selected_bufnrs) - async.task.all(tasks):map(function(words_per_buf) - --- @cast words_per_buf string[][] - - local unique = {} - local words = {} - for _, buf_words in ipairs(words_per_buf) do - for _, word in ipairs(buf_words) do - if not unique[word] then - unique[word] = true - table.insert(words, word) - end - end - end - local items = words_to_items(words) - - if self.opts.use_cache then self.cache:cleanup(selected_bufnrs) end - - ---@diagnostic disable-next-line: missing-return - callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = items }) - end) - end) -end - -return buffer diff --git a/lua/blink/cmp/sources/buffer/priority.lua b/lua/blink/cmp/sources/buffer/priority.lua deleted file mode 100644 index 0f82daa03..000000000 --- a/lua/blink/cmp/sources/buffer/priority.lua +++ /dev/null @@ -1,40 +0,0 @@ -local priority = {} - -function priority.focused() - return function(bufnr) return bufnr == vim.api.nvim_win_get_buf(0) and 0 or 1 end -end - -function priority.visible() - local visible = {} - for _, win in ipairs(vim.api.nvim_list_wins()) do - visible[vim.api.nvim_win_get_buf(win)] = true - end - return function(bufnr) return visible[bufnr] and 0 or 1 end -end - -function priority.recency() - local recency = require('blink.cmp.sources.buffer.recency') - local time = vim.loop.hrtime() - return function(bufnr) return time - recency.accessed_at(bufnr) end -end - -function priority.largest(buf_sizes) - return function(bufnr) return -buf_sizes[bufnr] end -end - -function priority.comparator(order, buf_sizes) - local value_fns = {} - for _, key in ipairs(order) do - if priority[key] then table.insert(value_fns, priority[key](buf_sizes)) end - end - - return function(a, b) - for _, fn in ipairs(value_fns) do - local va, vb = fn(a), fn(b) - if va ~= vb then return va < vb end - end - return a < b -- fallback: lower bufnr first - end -end - -return priority diff --git a/lua/blink/cmp/sources/buffer/recency.lua b/lua/blink/cmp/sources/buffer/recency.lua deleted file mode 100644 index aa614b977..000000000 --- a/lua/blink/cmp/sources/buffer/recency.lua +++ /dev/null @@ -1,27 +0,0 @@ -local recency = { - is_tracking = false, - --- @type table - bufs = {}, -} - -function recency.start_tracking() - if recency.is_tracking then return end - recency.is_tracking = true - - vim.api.nvim_create_autocmd({ 'BufEnter', 'WinEnter' }, { - desc = 'Track buffer recency when entering a buffer', - callback = function() - local bufnr = vim.api.nvim_get_current_buf() - recency.bufs[bufnr] = vim.loop.hrtime() - end, - }) - - vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufDelete' }, { - desc = 'Invalidate buffer recency when buffer is deleted', - callback = function(args) recency.bufs[args.buf] = nil end, - }) -end - -function recency.accessed_at(bufnr) return recency.bufs[bufnr] or 0 end - -return recency diff --git a/lua/blink/cmp/sources/buffer/utils.lua b/lua/blink/cmp/sources/buffer/utils.lua deleted file mode 100644 index eb6e1bd3d..000000000 --- a/lua/blink/cmp/sources/buffer/utils.lua +++ /dev/null @@ -1,52 +0,0 @@ -local utils = {} - -local priority = require('blink.cmp.sources.buffer.priority') - ---- @param lower integer -function utils.validate_buffer_size(lower) - return function(val) - if type(val) ~= 'number' then return false end - if lower ~= nil and val <= lower then return false end - return true - end -end - ---- @param bufnr integer ---- @return integer -function utils.get_buffer_size(bufnr) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local size = 0 - for _, line in ipairs(lines) do - size = size + #line + 1 - end - return size -end - ---- Retain buffers up to a total size cap, in the specified retention order. ---- @param bufnrs integer[] ---- @param max_total_size integer ---- @param max_buffer_size integer ---- @param retention_order string[] ---- @return integer[] selected -function utils.retain_buffers(bufnrs, max_total_size, max_buffer_size, retention_order) - local buf_sizes = {} - for _, bufnr in ipairs(bufnrs) do - buf_sizes[bufnr] = utils.get_buffer_size(bufnr) - end - - local sorted_bufnrs = vim.deepcopy(bufnrs) - table.sort(sorted_bufnrs, priority.comparator(retention_order, buf_sizes)) - sorted_bufnrs = vim.tbl_filter(function(bufnr) return buf_sizes[bufnr] <= max_buffer_size end, sorted_bufnrs) - - local selected, total_size = {}, 0 - for _, bufnr in ipairs(sorted_bufnrs) do - local size = buf_sizes[bufnr] - if total_size + size > max_total_size then break end - total_size = total_size + size - table.insert(selected, bufnr) - end - - return selected -end - -return utils diff --git a/lua/blink/cmp/sources/complete_func.lua b/lua/blink/cmp/sources/complete_func.lua deleted file mode 100644 index ae2f660fe..000000000 --- a/lua/blink/cmp/sources/complete_func.lua +++ /dev/null @@ -1,164 +0,0 @@ -local Kind = require('blink.cmp.types').CompletionItemKind - ----@class blink.cmp.CompleteFuncOpts ----@field complete_func fun(): string|nil gets provider of the complete-func, nil to disable - ----@class blink.cmp.CompleteFuncSource : blink.cmp.Source ----@field opts blink.cmp.CompleteFuncOpts -local Source = {} - ----@class blink.cmp.CompleteFuncItem ----@field word string ----@field abbr? string ----@field menu? string ----@field info? string ----@field kind? string ----@field icase? integer ----@field equal? integer ----@field dup? integer ----@field empty? integer ----@field user_data? any - ----@alias blink.cmp.CompleteFuncWords (string | blink.cmp.CompleteFuncItem)[] - ----@param _ string ----@param config blink.cmp.SourceProviderConfig ----@return blink.cmp.Source -function Source.new(_, config) - local self = setmetatable({}, { __index = Source }) - - self.opts = vim.tbl_deep_extend('force', { - complete_func = function() return nil end, - }, config.opts or {}) - - return self -end - -function Source:enabled() - return not vim.tbl_contains({ nil, '' }, self.opts.complete_func()) and vim.api.nvim_get_mode().mode == 'i' -end - ----Invoke an complete_func handling `v:lua.*` ----@return (table<{ words: blink.cmp.CompleteFuncWords, refresh: string }> | blink.cmp.CompleteFuncWords) | integer ----@overload fun(func: string, findstart: 1, base: ''): integer ----@overload fun(func: string, findstart: 0, base: string): table<{ words: blink.cmp.CompleteFuncWords, refresh: string }> | blink.cmp.CompleteFuncWords -local function invoke_complete_func(func, findstart, base) - local prev_pos = vim.api.nvim_win_get_cursor(0) - - local _, result = pcall(function() - local args = { findstart, base } - local match = func:match('^v:lua%.(.+)') - - if match then - return vim.fn.luaeval(string.format('%s(_A[1], _A[2], _A[3])', match), args) - else - return vim.api.nvim_call_function(func, args) - end - end) - - local next_pos = vim.api.nvim_win_get_cursor(0) - if prev_pos[1] ~= next_pos[1] or prev_pos[2] ~= next_pos[2] then vim.api.nvim_win_set_cursor(0, prev_pos) end - - return result -end - --- Map the defined `complete-items` 'kind's to blink kinds -local COMPLETE_ITEM_KIND_TO_BLINK_KIND = { - v = Kind.Variable, -- variable - f = Kind.Function, -- function/method - m = Kind.Field, -- struct/class member - t = Kind.TypeParameter, -- typedef - d = Kind.Constant, -- #define/macro -} - ----@param context blink.cmp.Context ----@param resolve fun(response?: blink.cmp.CompletionResponse) ----@return nil -function Source:get_completions(context, resolve) - -- see `:h complete-functions` - local complete_func = assert(self.opts.complete_func()) - - -- get the starting column from which completion will start - local start_col = invoke_complete_func(complete_func, 1, '') - - if type(start_col) ~= 'number' then - resolve() - return nil - end - - local cur_line, cur_col = unpack(context.cursor) - - -- TODO: differentiate between staying in (-2) vs leaving (-3) completion mode? - if start_col == -2 or start_col == -3 then - resolve() - return nil - elseif start_col < 0 or start_col > cur_col then - start_col = cur_col - end - - -- for info on complete-func results see `:h complete-items` - -- get the actual complete-func completion results - local cmp_results = invoke_complete_func(complete_func, 0, string.sub(context.line, start_col + 1, cur_col)) - cmp_results = cmp_results['words'] or cmp_results - ---@cast cmp_results blink.cmp.CompleteFuncWords - - local range = { - ['start'] = { - line = cur_line - 1, - character = start_col, - }, - ['end'] = { - line = cur_line - 1, - character = cur_col, - }, - } - - local items = {} ---@type blink.cmp.CompletionItem[] - for _, cmp in ipairs(cmp_results) do - local item ---@type blink.cmp.CompletionItem - - if type(cmp) == 'string' then - item = { - label = cmp, - textEdit = { - range = range, - newText = cmp, - }, - } - else - item = { - label = cmp.abbr or cmp.word, - textEdit = { - range = range, - newText = cmp.word, - }, - labelDetails = { - description = cmp.menu, - }, - } - - -- if possible, prefer blink's 'kind' to remove redundancy - local blink_kind = COMPLETE_ITEM_KIND_TO_BLINK_KIND[cmp.kind] - if blink_kind ~= nil then - item.kind = blink_kind - else - item.labelDetails.detail = cmp.kind - end - - if cmp.info ~= nil and #cmp.info > 0 then - item.documentation = { - value = cmp.info, - kind = 'plaintext', - } - end - end - - table.insert(items, item) - end - - resolve({ is_incomplete_forward = false, is_incomplete_backward = false, items = items }) - - return nil -end - -return Source diff --git a/lua/blink/cmp/sources/lib/init.lua b/lua/blink/cmp/sources/lib/init.lua deleted file mode 100644 index a9571edae..000000000 --- a/lua/blink/cmp/sources/lib/init.lua +++ /dev/null @@ -1,352 +0,0 @@ -local async = require('blink.cmp.lib.async') -local config = require('blink.cmp.config') -local deduplicate = require('blink.cmp.lib.utils').deduplicate - ---- @class blink.cmp.Sources ---- @field completions_queue blink.cmp.SourcesQueue | nil ---- @field current_signature_help blink.cmp.Task | nil ---- @field providers table ---- @field per_filetype_provider_ids table ---- @field completions_emitter blink.cmp.EventEmitter ---- ---- @field get_all_providers fun(): blink.cmp.SourceProvider[] ---- @field get_enabled_provider_ids fun(mode: blink.cmp.Mode): string[] ---- @field get_enabled_providers fun(mode: blink.cmp.Mode): table ---- @field get_provider_by_id fun(id: string): blink.cmp.SourceProvider ---- @field get_trigger_characters fun(mode: blink.cmp.Mode): string[] ---- @field add_filetype_provider_id fun(filetype: string, provider_id: string) ---- ---- @field emit_completions fun(context: blink.cmp.Context, responses: table) ---- @field request_completions fun(context: blink.cmp.Context) ---- @field cancel_completions fun() ---- @field apply_max_items_for_completions fun(context: blink.cmp.Context, items: blink.cmp.CompletionItem[]): blink.cmp.CompletionItem[] ---- @field listen_on_completions fun(callback: fun(context: blink.cmp.Context, items: blink.cmp.CompletionItem[])) ---- @field resolve fun(context: blink.cmp.Context, item: blink.cmp.CompletionItem): blink.cmp.Task ---- @field execute fun(context: blink.cmp.Context, item: blink.cmp.CompletionItem, default_implementation: fun(context?: blink.cmp.Context, item?: blink.cmp.CompletionItem)): blink.cmp.Task ---- ---- @field get_signature_help_trigger_characters fun(mode: blink.cmp.Mode): { trigger_characters: string[], retrigger_characters: string[] } ---- @field get_signature_help fun(context: blink.cmp.SignatureHelpContext): blink.cmp.Task ---- @field cancel_signature_help fun() ---- ---- @field reload fun(provider?: string) ---- @field get_lsp_capabilities fun(override?: lsp.ClientCapabilities, include_nvim_defaults?: boolean): lsp.ClientCapabilities - ---- @class blink.cmp.SourceCompletionsEvent ---- @field context blink.cmp.Context ---- @field items table - ---- @type blink.cmp.Sources ---- @diagnostic disable-next-line: missing-fields -local sources = { - completions_queue = nil, - providers = {}, - per_filetype_provider_ids = {}, - completions_emitter = require('blink.cmp.lib.event_emitter').new('source_completions'), -} - -function sources.get_all_providers() - local providers = {} - for provider_id, _ in pairs(config.sources.providers) do - providers[provider_id] = sources.get_provider_by_id(provider_id) - end - return providers -end - -function sources.get_enabled_provider_ids(mode) - -- Mode-specific sources - if vim.tbl_contains({ 'cmdline', 'cmdwin', 'term' }, mode) then - -- 'cmdwin' use the 'cmdline' source provider - if mode == 'cmdwin' then mode = 'cmdline' end - - if not config[mode].enabled then return {} end - - local providers = config[mode].sources - if type(providers) == 'function' then providers = providers() end - - return deduplicate(providers) - end - - -- Default sources - local default_providers = config.sources.default - if type(default_providers) == 'function' then default_providers = default_providers() end - --- @cast default_providers string[] - - -- Filetype-specific sources - local providers = nil - local inherit_defaults = false - for _, filetype in pairs(vim.split(vim.bo.filetype, '.', { plain = true, trimempty = true })) do - if config.sources.per_filetype[filetype] ~= nil then - local filetype_providers = config.sources.per_filetype[filetype] - if type(filetype_providers) == 'function' then filetype_providers = filetype_providers() end - - if providers == nil then providers = {} end - vim.list_extend(providers, filetype_providers) - - inherit_defaults = inherit_defaults or filetype_providers.inherit_defaults or false - end - -- Dynamically injected sources - if sources.per_filetype_provider_ids[filetype] ~= nil then - if providers == nil then providers = {} end - vim.list_extend(providers, sources.per_filetype_provider_ids[filetype]) - end - end - if inherit_defaults then - if providers == nil then providers = {} end - vim.list_extend(providers, default_providers) - elseif providers == nil then - providers = default_providers - end - - return deduplicate(providers) -end - -function sources.get_enabled_providers(mode) - local mode_providers = sources.get_enabled_provider_ids(mode) - - --- @type table - local providers = {} - for _, provider_id in ipairs(mode_providers) do - local provider = sources.get_provider_by_id(provider_id) - if provider:enabled() then providers[provider_id] = sources.get_provider_by_id(provider_id) end - end - return providers -end - -function sources.get_provider_by_id(provider_id) - -- TODO: remove in v1.0 - if not sources.providers[provider_id] and provider_id == 'luasnip' then - error( - "Luasnip has been moved to the `snippets` source, alongside a new preset system (`snippets.preset = 'luasnip'`). See the documentation for more information." - ) - end - - assert( - sources.providers[provider_id] ~= nil or config.sources.providers[provider_id] ~= nil, - 'Requested provider "' - .. provider_id - .. '" has not been configured. Available providers: ' - .. table.concat(vim.tbl_keys(sources.providers), ', ') - ) - - -- initialize the provider if it hasn't been initialized yet - if not sources.providers[provider_id] then - local provider_config = config.sources.providers[provider_id] - sources.providers[provider_id] = require('blink.cmp.sources.lib.provider').new(provider_id, provider_config) - end - - return sources.providers[provider_id] -end - -function sources.add_filetype_provider_id(filetype, provider_id) - if sources.per_filetype_provider_ids[filetype] == nil then sources.per_filetype_provider_ids[filetype] = {} end - table.insert(sources.per_filetype_provider_ids[filetype], provider_id) -end - ---- Completion --- - -function sources.get_trigger_characters(mode) - local providers = sources.get_enabled_providers(mode) - local trigger_characters = {} - for _, provider in pairs(providers) do - vim.list_extend(trigger_characters, provider:get_trigger_characters()) - end - return trigger_characters -end - -function sources.emit_completions(context, _items_by_provider) - local items_by_provider = {} - for id, items in pairs(_items_by_provider) do - if sources.providers[id]:should_show_items(context, items) then items_by_provider[id] = items end - end - sources.completions_emitter:emit({ context = context, items = items_by_provider }) -end - -function sources.request_completions(context) - -- create a new context if the id changed or if we haven't created one yet - if sources.completions_queue == nil or context.id ~= sources.completions_queue.id then - if sources.completions_queue ~= nil then sources.completions_queue:destroy() end - sources.completions_queue = require('blink.cmp.sources.lib.queue').new(context, sources.emit_completions) - -- send cached completions if they exist to immediately trigger updates - elseif sources.completions_queue:get_cached_completions() ~= nil then - sources.emit_completions( - context, - --- @diagnostic disable-next-line: param-type-mismatch - sources.completions_queue:get_cached_completions() - ) - end - - sources.completions_queue:get_completions(context) -end - -function sources.cancel_completions() - if sources.completions_queue ~= nil then - sources.completions_queue:destroy() - sources.completions_queue = nil - end -end - ---- Limits the number of items per source as configured -function sources.apply_max_items_for_completions(context, items) - -- get the configured max items for each source - local total_items_for_sources = {} - local max_items_for_sources = {} - for id, source in pairs(sources.providers) do - max_items_for_sources[id] = source.config.max_items(context, items) - total_items_for_sources[id] = 0 - end - - -- no max items configured, return as-is - if #vim.tbl_keys(max_items_for_sources) == 0 then return items end - - -- apply max items - local filtered_items = {} - for _, item in ipairs(items) do - local max_items = max_items_for_sources[item.source_id] - total_items_for_sources[item.source_id] = total_items_for_sources[item.source_id] + 1 - if max_items == nil or total_items_for_sources[item.source_id] <= max_items then - table.insert(filtered_items, item) - end - end - return filtered_items -end - ---- Resolve --- - -function sources.resolve(context, item) - --- @type blink.cmp.SourceProvider? - local item_source = nil - for _, source in pairs(sources.providers) do - if source.id == item.source_id then - item_source = source - break - end - end - if item_source == nil then - return async.task.new(function(resolve) resolve(item) end) - end - - return item_source - :resolve(context, item) - :catch(function(err) vim.print('failed to resolve item with error: ' .. err) end) -end - ---- Execute --- - -function sources.execute(context, item, default_implementation) - local item_source = nil - for _, source in pairs(sources.providers) do - if source.id == item.source_id then - item_source = source - break - end - end - if item_source == nil then - return async.task.new(function(resolve) resolve() end) - end - - return item_source - :execute(context, item, default_implementation) - :catch(function(err) vim.print('failed to execute item with error: ' .. err) end) -end - ---- Signature help --- - -function sources.get_signature_help_trigger_characters(mode) - local trigger_characters = {} - local retrigger_characters = {} - - -- todo: should this be all sources? or should it follow fallbacks? - for _, source in pairs(sources.get_enabled_providers(mode)) do - local res = source:get_signature_help_trigger_characters() - vim.list_extend(trigger_characters, res.trigger_characters) - vim.list_extend(retrigger_characters, res.retrigger_characters) - end - return { trigger_characters = trigger_characters, retrigger_characters = retrigger_characters } -end - -function sources.get_signature_help(context) - local tasks = {} - for _, source in pairs(sources.providers) do - table.insert(tasks, source:get_signature_help(context)) - end - - sources.current_signature_help = async.task.all(tasks):map(function(signature_helps) - return vim.tbl_filter(function(signature_help) return signature_help ~= nil end, signature_helps) - end) - return sources.current_signature_help -end - -function sources.cancel_signature_help() - if sources.current_signature_help ~= nil then - sources.current_signature_help:cancel() - sources.current_signature_help = nil - end -end - ---- Misc --- - ---- For external integrations to force reloading the source -function sources.reload(provider) - -- Reload specific provider - if provider ~= nil then - assert(type(provider) == 'string', 'Expected string for provider') - assert( - sources.providers[provider] ~= nil or config.sources.providers[provider] ~= nil, - 'Source ' .. provider .. ' does not exist' - ) - if sources.providers[provider] ~= nil then sources.providers[provider]:reload() end - return - end - - -- Reload all providers - for _, source in pairs(sources.providers) do - source:reload() - end -end - -function sources.get_lsp_capabilities(override, include_nvim_defaults) - return vim.tbl_deep_extend('force', include_nvim_defaults and vim.lsp.protocol.make_client_capabilities() or {}, { - textDocument = { - completion = { - completionItem = { - snippetSupport = true, - commitCharactersSupport = false, -- todo: - documentationFormat = { 'markdown', 'plaintext' }, - deprecatedSupport = true, - preselectSupport = false, -- todo: - tagSupport = { valueSet = { 1 } }, -- deprecated - insertReplaceSupport = true, -- todo: - resolveSupport = { - properties = { - 'documentation', - 'detail', - 'additionalTextEdits', - 'command', - 'data', - -- todo: support more properties? should test if it improves latency - }, - }, - insertTextModeSupport = { - -- todo: support adjustIndentation - valueSet = { 1 }, -- asIs - }, - labelDetailsSupport = true, - }, - completionList = { - itemDefaults = { - 'commitCharacters', - 'editRange', - 'insertTextFormat', - 'insertTextMode', - 'data', - }, - }, - - contextSupport = true, - insertTextMode = 1, -- asIs - }, - }, - }, override or {}) -end - -return sources diff --git a/lua/blink/cmp/sources/lib/provider/config.lua b/lua/blink/cmp/sources/lib/provider/config.lua deleted file mode 100644 index cda6a5289..000000000 --- a/lua/blink/cmp/sources/lib/provider/config.lua +++ /dev/null @@ -1,46 +0,0 @@ ---- @class blink.cmp.SourceProviderConfigWrapper ---- @field new fun(config: blink.cmp.SourceProviderConfig): blink.cmp.SourceProviderConfigWrapper ---- ---- @field name string ---- @field module string ---- @field enabled fun(): boolean ---- @field async fun(ctx: blink.cmp.Context): boolean ---- @field timeout_ms fun(ctx: blink.cmp.Context): number ---- @field transform_items fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): blink.cmp.CompletionItem[] ---- @field should_show_items fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): boolean ---- @field max_items? fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): number ---- @field min_keyword_length fun(ctx: blink.cmp.Context): number ---- @field fallbacks fun(ctx: blink.cmp.Context): string[] ---- @field score_offset fun(ctx: blink.cmp.Context): number - ---- @class blink.cmp.SourceProviderConfigWrapper ---- @diagnostic disable-next-line: missing-fields -local wrapper = {} - -function wrapper.new(config) - local function call_or_get(fn_or_val, default) - if fn_or_val == nil then - return function() return default end - end - return function(...) - if type(fn_or_val) == 'function' then return fn_or_val(...) end - return fn_or_val - end - end - - local self = setmetatable({}, { __index = config }) - self.name = config.name - self.module = config.module - self.enabled = call_or_get(config.enabled, true) - self.async = call_or_get(config.async, false) - self.timeout_ms = call_or_get(config.timeout_ms, 2000) - self.transform_items = config.transform_items or function(_, items) return items end - self.should_show_items = call_or_get(config.should_show_items, true) - self.max_items = call_or_get(config.max_items, nil) - self.min_keyword_length = call_or_get(config.min_keyword_length, 0) - self.fallbacks = call_or_get(config.fallbacks, {}) - self.score_offset = call_or_get(config.score_offset, 0) - return self -end - -return wrapper diff --git a/lua/blink/cmp/sources/lib/provider/init.lua b/lua/blink/cmp/sources/lib/provider/init.lua deleted file mode 100644 index 080dd293f..000000000 --- a/lua/blink/cmp/sources/lib/provider/init.lua +++ /dev/null @@ -1,196 +0,0 @@ ---- Wraps the sources to respect the configuration options and provide a unified interface ---- @class blink.cmp.SourceProvider ---- @field id string ---- @field name string ---- @field config blink.cmp.SourceProviderConfigWrapper ---- @field module blink.cmp.Source ---- @field list blink.cmp.SourceProviderList | nil ---- @field resolve_cache_context_id number | nil ---- @field resolve_cache table ---- ---- @field new fun(id: string, config: blink.cmp.SourceProviderConfig): blink.cmp.SourceProvider ---- @field enabled fun(self: blink.cmp.SourceProvider): boolean ---- @field get_trigger_characters fun(self: blink.cmp.SourceProvider): string[] ---- @field get_completions fun(self: blink.cmp.SourceProvider, context: blink.cmp.Context, on_items: fun(items: blink.cmp.CompletionItem[], is_cached: boolean)) ---- @field should_show_items fun(self: blink.cmp.SourceProvider, context: blink.cmp.Context, items: blink.cmp.CompletionItem[]): boolean ---- @field transform_items fun(self: blink.cmp.SourceProvider, context: blink.cmp.Context, items: blink.cmp.CompletionItem[]): blink.cmp.CompletionItem[] ---- @field resolve fun(self: blink.cmp.SourceProvider, context: blink.cmp.Context, item: blink.cmp.CompletionItem): blink.cmp.Task ---- @field execute fun(self: blink.cmp.SourceProvider, context: blink.cmp.Context, item: blink.cmp.CompletionItem, default_implementation: fun(context?: blink.cmp.Context, item?: blink.cmp.CompletionItem)): blink.cmp.Task ---- @field get_signature_help_trigger_characters fun(self: blink.cmp.SourceProvider): { trigger_characters: string[], retrigger_characters: string[] } ---- @field get_signature_help fun(self: blink.cmp.SourceProvider, context: blink.cmp.SignatureHelpContext): blink.cmp.Task ---- @field reload (fun(self: blink.cmp.SourceProvider): nil) | nil - ---- @type blink.cmp.SourceProvider ---- @diagnostic disable-next-line: missing-fields -local source = {} - -local async = require('blink.cmp.lib.async') - -function source.new(id, config) - assert(type(config.module) == 'string', 'Each source in config.sources.providers must have a "module" of type string') - - -- Default "name" to capitalized id - if config.name == nil then config.name = id:sub(1, 1):upper() .. id:sub(2) end - - local self = setmetatable({}, { __index = source }) - self.id = id - self.name = config.name - self.module = require('blink.cmp.sources.lib.provider.override').new( - require(config.module).new(config.opts or {}, config), - config.override - ) - self.config = require('blink.cmp.sources.lib.provider.config').new(config) - self.list = nil - self.resolve_cache = {} - - return self -end - -function source:enabled() - -- user defined - if not self.config.enabled() then return false end - - -- source defined - if self.module.enabled == nil then return true end - return self.module:enabled() -end - ---- Completion --- - -function source:get_trigger_characters() - if self.module.get_trigger_characters == nil then return {} end - return self.module:get_trigger_characters() -end - -function source:get_completions(context, on_items) - -- return the previous successful completions if the context is the same - -- and the data doesn't need to be updated - -- or if the list is async, since we don't want to cause a flash of no items - if self.list ~= nil and self.list:is_valid_for_context(context) then - self.list:set_on_items(on_items) - self.list:emit(true) - return - end - - -- the source indicates we should refetch when this character is typed - local trigger_character = context.trigger.character - and vim.tbl_contains(self:get_trigger_characters(), context.trigger.character) - - -- The TriggerForIncompleteCompletions kind is handled by the source provider itself - local source_context = require('blink.cmp.lib.utils').shallow_copy(context) - source_context.trigger = trigger_character - and { kind = vim.lsp.protocol.CompletionTriggerKind.TriggerCharacter, character = context.trigger.character } - or { kind = vim.lsp.protocol.CompletionTriggerKind.Invoked } - - local async_initial_items = self.list ~= nil and self.list.context.id == context.id and self.list.items or {} - if self.list ~= nil then self.list:destroy() end - - self.list = require('blink.cmp.sources.lib.provider.list').new( - self, - context, - on_items, - -- HACK: if the source is async, we're not reusing the previous list and the response was marked as incomplete, - -- the user will see a flash of no items from the provider, since the list emits immediately. So we hack around - -- this for now - { async_initial_items = async_initial_items } - ) -end - -function source:should_show_items(context, items) - -- if keyword length is configured, check if the context is long enough - local provider_min_keyword_length = self.config.min_keyword_length(context) - - -- for manual trigger, we ignore the min_keyword_length set globally, but still respect per-provider - local global_min_keyword_length = 0 - if context.trigger.initial_kind ~= 'manual' and context.trigger.initial_kind ~= 'trigger_character' then - local global_min_keyword_length_func_or_num = require('blink.cmp.config').sources.min_keyword_length - if type(global_min_keyword_length_func_or_num) == 'function' then - global_min_keyword_length = global_min_keyword_length_func_or_num(context) - else - global_min_keyword_length = global_min_keyword_length_func_or_num - end - end - - local min_keyword_length = math.max(provider_min_keyword_length, global_min_keyword_length) - local current_keyword_length = context.bounds.length - if current_keyword_length < min_keyword_length then return false end - - -- check if the source wants to show items - if self.module.should_show_items ~= nil and not self.module:should_show_items(context, items) then return false end - - -- check if the user wants to show items - if self.config.should_show_items == nil then return true end - return self.config.should_show_items(context, items) -end - -function source:transform_items(context, items) - if self.config.transform_items ~= nil then items = self.config.transform_items(context, items) end - items = require('blink.cmp.config').sources.transform_items(context, items) - return items -end - ---- Resolve --- - -function source:resolve(context, item) - -- reset the cache when the context changes - if self.resolve_cache_context_id ~= context.id then - self.resolve_cache_context_id = context.id - self.resolve_cache = {} - end - - local cached_task = self.resolve_cache[item] - if cached_task == nil or cached_task.status == async.STATUS.CANCELLED then - self.resolve_cache[item] = async.task.new(function(resolve) - if self.module.resolve == nil then return resolve(item) end - - return self.module:resolve(item, function(resolved_item) - -- HACK: it's out of spec to update keys not in resolveSupport.properties but some LSPs do it anyway - local merged_item = vim.tbl_deep_extend('force', item, resolved_item or {}) - local transformed_item = self:transform_items(context, { merged_item })[1] or merged_item - vim.schedule(function() resolve(transformed_item) end) - end) - end) - end - return self.resolve_cache[item] -end - ---- Execute --- - -function source:execute(context, item, default_implementation) - if self.module.execute == nil then - default_implementation() - return async.task.empty() - end - - return async.task.new( - function(resolve) return self.module:execute(context, item, resolve, default_implementation) end - ) -end - ---- Signature help --- - -function source:get_signature_help_trigger_characters() - if self.module.get_signature_help_trigger_characters == nil then - return { trigger_characters = {}, retrigger_characters = {} } - end - return self.module:get_signature_help_trigger_characters() -end - -function source:get_signature_help(context) - return async.task.new(function(resolve) - if self.module.get_signature_help == nil then return resolve(nil) end - return self.module:get_signature_help(context, function(signature_help) - vim.schedule(function() resolve(signature_help) end) - end) - end) -end - ---- Misc --- - ---- For external integrations to force reloading the source -function source:reload() - if self.module.reload == nil then return end - self.module:reload() -end - -return source diff --git a/lua/blink/cmp/sources/lib/provider/list.lua b/lua/blink/cmp/sources/lib/provider/list.lua deleted file mode 100644 index 562460c5c..000000000 --- a/lua/blink/cmp/sources/lib/provider/list.lua +++ /dev/null @@ -1,128 +0,0 @@ ---- @class blink.cmp.SourceProviderList ---- @field provider blink.cmp.SourceProvider ---- @field context blink.cmp.Context ---- @field items blink.cmp.CompletionItem[] ---- @field on_items fun(items: blink.cmp.CompletionItem[], is_cached: boolean) ---- @field has_completed boolean ---- @field is_incomplete_backward boolean ---- @field is_incomplete_forward boolean ---- @field cancel_completions? fun(): nil ---- ---- @field new fun(provider: blink.cmp.SourceProvider,context: blink.cmp.Context, on_items: fun(items: blink.cmp.CompletionItem[], is_cached: boolean), opts: blink.cmp.SourceProviderListOpts): blink.cmp.SourceProviderList ---- @field append fun(self: blink.cmp.SourceProviderList, response: blink.cmp.CompletionResponse) ---- @field emit fun(self: blink.cmp.SourceProviderList, is_cached?: boolean) ---- @field destroy fun(self: blink.cmp.SourceProviderList): nil ---- @field set_on_items fun(self: blink.cmp.SourceProviderList, on_items: fun(items: blink.cmp.CompletionItem[], is_cached: boolean)) ---- @field is_valid_for_context fun(self: blink.cmp.SourceProviderList, context: blink.cmp.Context): boolean ---- ---- @class blink.cmp.SourceProviderListOpts ---- @field async_initial_items blink.cmp.CompletionItem[] - ---- @type blink.cmp.SourceProviderList ---- @diagnostic disable-next-line: missing-fields -local list = {} - -function list.new(provider, context, on_items, opts) - --- @type blink.cmp.SourceProviderList - local self = setmetatable({ - provider = provider, - context = context, - items = opts.async_initial_items, - on_items = on_items, - - has_completed = false, - is_incomplete_backward = true, - is_incomplete_forward = true, - }, { __index = list }) - - -- Immediately fetch completions - local default_response = { - is_incomplete_forward = true, - is_incomplete_backward = true, - items = {}, - } - if self.provider.module.get_completions == nil then - self:append(default_response) - else - self.cancel_completions = self.provider.module:get_completions( - self.context, - function(response) self:append(response or default_response) end - ) - end - - -- if async, immediately send the default response/initial items - local is_async = self.provider.config.async(self.context) - if is_async and not self.has_completed then self:emit() end - - -- if not async and timeout is set, send the default response after the timeout - local timeout_ms = self.provider.config.timeout_ms(self.context) - if not is_async and timeout_ms > 0 then - vim.defer_fn(function() - if not self.has_completed then self:append(default_response) end - end, timeout_ms) - end - - return self -end - -function list:append(response) - if self.has_completed and #response.items == 0 then return end - - if not self.has_completed then - self.has_completed = true - self.is_incomplete_backward = response.is_incomplete_backward - self.is_incomplete_forward = response.is_incomplete_forward - self.items = {} - end - - -- add metadata and default kind - local source_score_offset = self.provider.config.score_offset(self.context) or 0 - for _, item in ipairs(response.items) do - item.score_offset = (item.score_offset or 0) + source_score_offset - item.cursor_column = item.cursor_column or self.context.cursor[2] - item.source_id = self.provider.id - item.source_name = self.provider.name - item.kind = item.kind or require('blink.cmp.types').CompletionItemKind.Property - end - - -- combine with existing items - local new_items = {} - vim.list_extend(new_items, self.items) - vim.list_extend(new_items, response.items) - self.items = new_items - - -- run provider-local and global transform_items functions - self.items = self.provider:transform_items(self.context, self.items) - - self:emit() -end - -function list:emit(is_cached) - if is_cached == nil then is_cached = false end - self.on_items(self.items, is_cached) -end - -function list:destroy() - if self.cancel_completions ~= nil then self.cancel_completions() end - self.on_items = function() end -end - -function list:set_on_items(on_items) self.on_items = on_items end - -function list:is_valid_for_context(new_context) - if self.context.id ~= new_context.id then return false end - - -- get the text for the current and queued context - local old_context_query = self.context.line:sub(self.context.bounds.start_col, self.context.cursor[2]) - local new_context_query = new_context.line:sub(new_context.bounds.start_col, new_context.cursor[2]) - - -- check if the texts are overlapping - local is_before = vim.startswith(old_context_query, new_context_query) - local is_after = vim.startswith(new_context_query, old_context_query) - - return (is_before and not self.is_incomplete_backward) - or (is_after and not self.is_incomplete_forward) - or (is_after == is_before and not (self.is_incomplete_backward or self.is_incomplete_forward)) -end - -return list diff --git a/lua/blink/cmp/sources/lib/provider/override.lua b/lua/blink/cmp/sources/lib/provider/override.lua deleted file mode 100644 index 72c19c017..000000000 --- a/lua/blink/cmp/sources/lib/provider/override.lua +++ /dev/null @@ -1,19 +0,0 @@ ---- @class blink.cmp.Override : blink.cmp.Source ---- @field new fun(module: blink.cmp.Source, override_config: blink.cmp.SourceOverride): blink.cmp.Override - -local override = {} - -function override.new(module, override_config) - override_config = override_config or {} - - return setmetatable({}, { - __index = function(_, key) - if override_config[key] ~= nil then - return function(_, ...) return override_config[key](module, ...) end - end - return module[key] - end, - }) -end - -return override diff --git a/lua/blink/cmp/sources/lib/queue.lua b/lua/blink/cmp/sources/lib/queue.lua deleted file mode 100644 index 34ff53c38..000000000 --- a/lua/blink/cmp/sources/lib/queue.lua +++ /dev/null @@ -1,67 +0,0 @@ -local async = require('blink.cmp.lib.async') - ---- @class blink.cmp.SourcesQueue ---- @field id number ---- @field providers table ---- @field request blink.cmp.Task | nil ---- @field queued_request_context blink.cmp.Context | nil ---- @field cached_items_by_provider table | nil ---- @field on_completions_callback fun(context: blink.cmp.Context, responses: table) ---- ---- @field new fun(context: blink.cmp.Context, on_completions_callback: fun(context: blink.cmp.Context, responses: table)): blink.cmp.SourcesQueue ---- @field get_cached_completions fun(self: blink.cmp.SourcesQueue): table | nil ---- @field get_completions fun(self: blink.cmp.SourcesQueue, context: blink.cmp.Context) ---- @field destroy fun(self: blink.cmp.SourcesQueue) - ---- @type blink.cmp.SourcesQueue ---- @diagnostic disable-next-line: missing-fields -local queue = {} - -function queue.new(context, on_completions_callback) - local self = setmetatable({}, { __index = queue }) - self.id = context.id - - self.request = nil - self.queued_request_context = nil - self.on_completions_callback = on_completions_callback - - return self -end - -function queue:get_cached_completions() return self.cached_items_by_provider end - -function queue:get_completions(context) - assert(context.id == self.id, 'Requested completions on a sources context with a different context ID') - - if self.request ~= nil then - if self.request.status == async.STATUS.RUNNING then - self.queued_request_context = context - return - else - self.request:cancel() - end - end - - -- Create a task to get the completions, send responses upstream - -- and run the queued request, if it exists - local tree = require('blink.cmp.sources.lib.tree').new(context) - self.request = tree:get_completions(context, function(items_by_provider) - self.cached_items_by_provider = items_by_provider - self.on_completions_callback(context, items_by_provider) - - -- run the queued request, if it exists - local queued_context = self.queued_request_context - if queued_context ~= nil then - self.queued_request_context = nil - self.request:cancel() - self:get_completions(queued_context) - end - end) -end - -function queue:destroy() - self.on_completions_callback = function() end - if self.request ~= nil then self.request:cancel() end -end - -return queue diff --git a/lua/blink/cmp/sources/lib/tree.lua b/lua/blink/cmp/sources/lib/tree.lua deleted file mode 100644 index 7f5bc61db..000000000 --- a/lua/blink/cmp/sources/lib/tree.lua +++ /dev/null @@ -1,169 +0,0 @@ ---- @class blink.cmp.SourceTreeNode ---- @field id string ---- @field source blink.cmp.SourceProvider ---- @field dependencies blink.cmp.SourceTreeNode[] ---- @field dependents blink.cmp.SourceTreeNode[] - ---- @class blink.cmp.SourceTree ---- @field nodes blink.cmp.SourceTreeNode[] ---- @field new fun(context: blink.cmp.Context): blink.cmp.SourceTree ---- @field get_completions fun(self: blink.cmp.SourceTree, context: blink.cmp.Context, on_items_by_provider: fun(items_by_provider: table)): blink.cmp.Task ---- @field emit_completions fun(self: blink.cmp.SourceTree, items_by_provider: table, on_items_by_provider: fun(items_by_provider: table)): nil ---- @field get_top_level_nodes fun(self: blink.cmp.SourceTree): blink.cmp.SourceTreeNode[] ---- @field detect_cycle fun(node: blink.cmp.SourceTreeNode, visited?: table, path?: table): boolean - -local sources_lib = require('blink.cmp.sources.lib') -local utils = require('blink.cmp.lib.utils') -local async = require('blink.cmp.lib.async') - ---- @type blink.cmp.SourceTree ---- @diagnostic disable-next-line: missing-fields -local tree = {} - ---- @param context blink.cmp.Context -function tree.new(context) - -- only include enabled sources for the given context - local sources = {} - for _, provider_id in ipairs(context.providers) do - local provider = sources_lib.get_provider_by_id(provider_id) - if provider:enabled() then table.insert(sources, provider) end - end - local source_ids = vim.tbl_map(function(source) return source.id end, sources) - - -- create a node for each source - local nodes = vim.tbl_map( - function(source) return { id = source.id, source = source, dependencies = {}, dependents = {} } end, - sources - ) - - -- build the tree - for idx, source in ipairs(sources) do - local node = nodes[idx] - for _, fallback_source_id in ipairs(source.config.fallbacks(context, source_ids)) do - local fallback_node = nodes[utils.index_of(source_ids, fallback_source_id)] - if fallback_node ~= nil then - table.insert(node.dependents, fallback_node) - table.insert(fallback_node.dependencies, node) - end - end - end - - -- circular dependency check - for _, node in ipairs(nodes) do - tree.detect_cycle(node) - end - - return setmetatable({ nodes = nodes }, { __index = tree }) -end - -function tree:get_completions(context, on_items_by_provider) - local should_push_upstream = false - local items_by_provider = {} - local is_all_cached = true - local nodes_falling_back = {} - - --- @param node blink.cmp.SourceTreeNode - local function get_completions_for_node(node) - -- check that all the dependencies have been triggered, and are falling back - for _, dependency in ipairs(node.dependencies) do - if not nodes_falling_back[dependency.id] then return async.task.empty() end - end - - return async.task.new(function(resolve, reject) - return node.source:get_completions(context, function(items, is_cached) - items_by_provider[node.id] = items - is_all_cached = is_all_cached and is_cached - - if should_push_upstream then self:emit_completions(items_by_provider, on_items_by_provider) end - if #items ~= 0 then return resolve() end - - -- run dependents if the source returned 0 items - nodes_falling_back[node.id] = true - local tasks = vim.tbl_map(function(dependent) return get_completions_for_node(dependent) end, node.dependents) - async.task.all(tasks):map(resolve):catch(reject) - end) - end) - end - - -- run the top level nodes and let them fall back to their dependents if needed - local tasks = vim.tbl_map(function(node) return get_completions_for_node(node) end, self:get_top_level_nodes()) - return async.task - .all(tasks) - :map(function() - should_push_upstream = true - - -- if atleast one of the results wasn't cached, emit the results - if not is_all_cached then self:emit_completions(items_by_provider, on_items_by_provider) end - end) - :catch(function(err) vim.print('failed to get completions with error: ' .. err) end) -end - -function tree:emit_completions(items_by_provider, on_items_by_provider) - local nodes_falling_back = {} - local final_items_by_provider = {} - - local add_node_items - add_node_items = function(node) - for _, dependency in ipairs(node.dependencies) do - if not nodes_falling_back[dependency.id] then return end - end - local items = items_by_provider[node.id] - if items ~= nil and #items > 0 then - final_items_by_provider[node.id] = items - else - nodes_falling_back[node.id] = true - for _, dependent in ipairs(node.dependents) do - add_node_items(dependent) - end - end - end - - for _, node in ipairs(self:get_top_level_nodes()) do - add_node_items(node) - end - - on_items_by_provider(final_items_by_provider) -end - ---- Internal --- - -function tree:get_top_level_nodes() - local top_level_nodes = {} - for _, node in ipairs(self.nodes) do - if #node.dependencies == 0 then table.insert(top_level_nodes, node) end - end - return top_level_nodes -end - ---- Helper function to detect cycles using DFS ---- @param node blink.cmp.SourceTreeNode ---- @param visited? table ---- @param path? table ---- @return boolean -function tree.detect_cycle(node, visited, path) - visited = visited or {} - path = path or {} - - if path[node.id] then - -- Found a cycle - construct the cycle path for error message - local cycle = { node.id } - for id, _ in pairs(path) do - table.insert(cycle, id) - end - error('Circular dependency detected: ' .. table.concat(cycle, ' -> ')) - end - - if visited[node.id] then return false end - - visited[node.id] = true - path[node.id] = true - - for _, dependent in ipairs(node.dependents) do - if tree.detect_cycle(dependent, visited, path) then return true end - end - - path[node.id] = nil - return false -end - -return tree diff --git a/lua/blink/cmp/sources/lib/types.lua b/lua/blink/cmp/sources/lib/types.lua deleted file mode 100644 index 5a80d64bd..000000000 --- a/lua/blink/cmp/sources/lib/types.lua +++ /dev/null @@ -1,31 +0,0 @@ ---- @class blink.cmp.CompletionTriggerContext ---- @field kind number ---- @field character string | nil - ---- @class blink.cmp.CompletionResponse ---- @field is_incomplete_forward boolean ---- @field is_incomplete_backward boolean ---- @field items blink.cmp.CompletionItem[] - ---- @class blink.cmp.Source ---- @field new fun(opts: table, config: blink.cmp.SourceProviderConfig): blink.cmp.Source ---- @field enabled? fun(self: blink.cmp.Source): boolean ---- @field get_trigger_characters? fun(self: blink.cmp.Source): string[] ---- @field get_completions? fun(self: blink.cmp.Source, context: blink.cmp.Context, callback: fun(response?: blink.cmp.CompletionResponse)): (fun(): nil) | nil ---- @field should_show_items? fun(self: blink.cmp.Source, context: blink.cmp.Context, items: blink.cmp.CompletionItem[]): boolean ---- @field resolve? fun(self: blink.cmp.Source, item: blink.cmp.CompletionItem, callback: fun(resolved_item?: lsp.CompletionItem)): ((fun(): nil) | nil) ---- @field execute? fun(self: blink.cmp.Source, context: blink.cmp.Context, item: blink.cmp.CompletionItem, callback: fun(), default_implementation: fun(context?: blink.cmp.Context, item?: blink.cmp.CompletionItem)): ((fun(): nil) | nil) ---- @field get_signature_help_trigger_characters? fun(self: blink.cmp.Source): string[] ---- @field get_signature_help? fun(self: blink.cmp.Source, context: blink.cmp.SignatureHelpContext, callback: fun(signature_help: lsp.SignatureHelp | nil)): (fun(): nil) | nil ---- @field reload? fun(self: blink.cmp.Source): nil - ---- @class blink.cmp.SourceOverride ---- @field enabled? fun(self: blink.cmp.Source): boolean ---- @field get_trigger_characters? fun(self: blink.cmp.Source): string[] ---- @field get_completions? fun(self: blink.cmp.Source, context: blink.cmp.Context, callback: fun(response: blink.cmp.CompletionResponse | nil)): (fun(): nil) | nil ---- @field should_show_items? fun(self: blink.cmp.Source, context: blink.cmp.Context, items: blink.cmp.CompletionItem[]): boolean ---- @field resolve? fun(self: blink.cmp.Source, item: blink.cmp.CompletionItem, callback: fun(resolved_item: lsp.CompletionItem | nil)): ((fun(): nil) | nil) ---- @field execute? fun(self: blink.cmp.Source, context: blink.cmp.Context, item: blink.cmp.CompletionItem, callback: fun(), default_implementation: fun(context?: blink.cmp.Context, item?: blink.cmp.CompletionItem)): ((fun(): nil) | nil) ---- @field get_signature_help_trigger_characters? fun(self: blink.cmp.Source): string[] ---- @field get_signature_help? fun(self: blink.cmp.Source, context: blink.cmp.SignatureHelpContext, callback: fun(signature_help: lsp.SignatureHelp | nil)): (fun(): nil) | nil ---- @field reload? fun(self: blink.cmp.Source): nil diff --git a/lua/blink/cmp/sources/lib/utils.lua b/lua/blink/cmp/sources/lib/utils.lua deleted file mode 100644 index 21787f13e..000000000 --- a/lua/blink/cmp/sources/lib/utils.lua +++ /dev/null @@ -1,113 +0,0 @@ -local utils = {} -local cmdline_constants = require('blink.cmp.sources.cmdline.constants') - ---- @param item blink.cmp.CompletionItem ---- @return lsp.CompletionItem -function utils.blink_item_to_lsp_item(item) - local lsp_item = vim.deepcopy(item) - lsp_item.score_offset = nil - lsp_item.source_id = nil - lsp_item.source_name = nil - lsp_item.cursor_column = nil - lsp_item.client_id = nil - lsp_item.client_name = nil - lsp_item.exact = nil - lsp_item.score = nil - return lsp_item -end - ---- Check if we are in cmdline or cmdwin, optionally for specific types. ---- @param types? string[] Optional list of command types to check. If nil or empty, only checks for context. ---- @return boolean -function utils.is_command_line(types) - local mode = vim.api.nvim_get_mode().mode - - -- If types is nil or empty, just check context - if not types or #types == 0 then return mode == 'c' or vim.fn.win_gettype() == 'command' end - - -- If in cmdline mode, check type - if mode == 'c' then - local cmdtype = vim.fn.getcmdtype() - return vim.tbl_contains(types, cmdtype) - end - - -- If in command-line window, check type - if vim.fn.win_gettype() == 'command' then - local cmdtype = vim.fn.getcmdwintype() - return vim.tbl_contains(types, cmdtype) - end - - return false -end - ---- Checks if the current command is one of the given Ex commands. ---- @param commands table List of command names to check against. ---- @return boolean -function utils.in_ex_context(commands) - if not utils.is_command_line({ ':' }) then return false end - - local line = nil - local mode = vim.api.nvim_get_mode().mode - if mode == 'c' then - line = vim.fn.getcmdline() - elseif vim.fn.win_gettype() == 'command' then - line = vim.api.nvim_get_current_line() - end - - if not line then return false end - - local ok, parsed = pcall(vim.api.nvim_parse_cmd, line, {}) - local cmd = (ok and parsed.cmd) or '' - local has_args = (ok and parsed.args and #parsed.args > 0) or false - return vim.tbl_contains(commands, cmd) and has_args -end - ----Get the current completion type. ----@param mode blink.cmp.Mode ----@return string completion_type The detected completion type, or an empty string if unknown. -function utils.get_completion_type(mode) - if mode == 'cmdline' then - return vim.fn.getcmdcompltype() - elseif mode == 'cmdwin' then - -- TODO: Remove the fallback below once 0.12 is the minimum supported version - if vim.fn.exists('*getcompletiontype') == 1 then - local line = vim.api.nvim_get_current_line() - return vim.fn.getcompletiontype(line) - end - - -- As fallback, parse the command-line and map it to a known completion type, - -- either by guessing from the last argument or from the command name. - local line = vim.api.nvim_get_current_line() - local ok, parse_cmd = pcall(vim.api.nvim_parse_cmd, line, {}) - if ok then - local function guess_type_by_prefix(arg) - for prefix, completion_type in pairs(cmdline_constants.arg_prefix_type) do - if vim.startswith(arg, prefix) then return completion_type end - end - return nil - end - - -- Guess by last argument - local args = parse_cmd.args or {} - if #args > 0 then - local last_arg = args[#args] - local completion_type = guess_type_by_prefix(last_arg) - if completion_type then return completion_type end - end - - -- Guess by command name - local completion_type = cmdline_constants.commands_type[parse_cmd.cmd] or '' - if #args > 0 then - -- Adjust some completion type when args exists (to match cmdline) - if completion_type == 'shellcmd' then completion_type = 'file' end - if completion_type == 'command' then completion_type = '' end - end - - return completion_type - end - end - - return '' -end - -return utils diff --git a/lua/blink/cmp/sources/lsp/cache.lua b/lua/blink/cmp/sources/lsp/cache.lua deleted file mode 100644 index 839d99b12..000000000 --- a/lua/blink/cmp/sources/lsp/cache.lua +++ /dev/null @@ -1,32 +0,0 @@ ---- @class blink.cmp.LSPCacheEntry ---- @field context blink.cmp.Context ---- @field response blink.cmp.CompletionResponse - ---- @class blink.cmp.LSPCache -local cache = { - --- @type table - entries = {}, -} - -function cache.get(context, client) - local entry = cache.entries[client.id] - if entry == nil then return end - - if context.id ~= entry.context.id then return end - if entry.response.is_incomplete_forward and entry.context.cursor[2] ~= context.cursor[2] then return end - if not entry.response.is_incomplete_forward and entry.context.cursor[2] > context.cursor[2] then return end - - return entry.response -end - ---- @param context blink.cmp.Context ---- @param client vim.lsp.Client ---- @param response blink.cmp.CompletionResponse -function cache.set(context, client, response) - cache.entries[client.id] = { - context = context, - response = response, - } -end - -return cache diff --git a/lua/blink/cmp/sources/lsp/commands.lua b/lua/blink/cmp/sources/lsp/commands.lua deleted file mode 100644 index 2b1ebba1d..000000000 --- a/lua/blink/cmp/sources/lsp/commands.lua +++ /dev/null @@ -1,14 +0,0 @@ ---- LSPs may call "client commands" which must be registered inside of neovim ---- I don't know of a standard for these so we'll have to discover and implement them ---- as we find them - -local commands = {} - -function commands.register() - vim.lsp.commands['editor.action.triggerParameterHints'] = function() require('blink.cmp').show_signature() end - vim.lsp.commands['editor.action.triggerSuggest'] = function() - require('blink.cmp.completion.trigger').show({ trigger_kind = 'manual' }) - end -end - -return commands diff --git a/lua/blink/cmp/sources/lsp/completion.lua b/lua/blink/cmp/sources/lsp/completion.lua deleted file mode 100644 index 3e4037039..000000000 --- a/lua/blink/cmp/sources/lsp/completion.lua +++ /dev/null @@ -1,116 +0,0 @@ -local async = require('blink.cmp.lib.async') -local cache = require('blink.cmp.sources.lsp.cache') - -local CompletionTriggerKind = vim.lsp.protocol.CompletionTriggerKind ---- @param context blink.cmp.Context ---- @param client vim.lsp.Client ---- @return blink.cmp.Task -local function request(context, client) - return async.task.new(function(resolve) - local params = vim.lsp.util.make_position_params(0, client.offset_encoding) - params.context = { - triggerKind = context.trigger.kind == 'trigger_character' and CompletionTriggerKind.TriggerCharacter - or CompletionTriggerKind.Invoked, - } - if context.trigger.kind == 'trigger_character' then params.context.triggerCharacter = context.trigger.character end - - local _, request_id = client:request( - 'textDocument/completion', - params, - function(err, result) resolve({ err = err, result = result }) end - ) - return function() - if request_id ~= nil then client:cancel_request(request_id) end - end - end) -end - -local known_defaults = { - 'commitCharacters', - 'insertTextFormat', - 'insertTextMode', - 'data', -} ---- @param context blink.cmp.Context ---- @param client vim.lsp.Client ---- @param res lsp.CompletionList ---- @return blink.cmp.CompletionResponse -local function process_response(context, client, res) - local items = res.items or res - local default_edit_range = res.itemDefaults and res.itemDefaults.editRange - for _, item in ipairs(items) do - item.client_id = client.id - item.client_name = client.name - -- we must set the cursor column because this will be cached and used later - -- by default, blink.cmp will use the cursor column at the time of the request - item.cursor_column = context.cursor[2] - - -- score offset for deprecated items - -- todo: make configurable - if item.deprecated or (item.tags and vim.tbl_contains(item.tags, 1)) then item.score_offset = -2 end - - -- set defaults - for key, value in pairs(res.itemDefaults or {}) do - if vim.tbl_contains(known_defaults, key) then item[key] = item[key] or value end - end - if default_edit_range and item.textEdit == nil then - local new_text = item.textEditText or item.insertText or item.label - if default_edit_range.replace ~= nil then - item.textEdit = { - replace = default_edit_range.replace, - insert = default_edit_range.insert, - newText = new_text, - } - else - item.textEdit = { - range = res.itemDefaults.editRange, - newText = new_text, - } - end - end - end - - return { - is_incomplete_forward = res.isIncomplete or false, - is_incomplete_backward = true, - items = items, - } -end - -local completion = {} - ---- @param context blink.cmp.Context ---- @param client vim.lsp.Client ---- @param opts blink.cmp.LSPSourceOpts ---- @return blink.cmp.Task -function completion.get_completion_for_client(context, client, opts) - -- We have multiple clients and some may return isIncomplete = false while others return isIncomplete = true - -- If any are marked as incomplete, we must tell blink.cmp, but this will cause a fetch on every keystroke - -- So we cache the responses and only re-request completions from isIncomplete = true clients - local cache_entry = cache.get(context, client) - if cache_entry ~= nil then return async.task.identity(cache_entry) end - - return request(context, client):map(function(res) - if res.err or res.result == nil then - return { is_incomplete_forward = true, is_incomplete_backward = true, items = {} } - end - - local response = process_response(context, client, res.result) - - -- client specific hacks - if client.name == 'emmet_ls' or client.name == 'emmet-language-server' then - require('blink.cmp.sources.lsp.hacks.emmet').process_response(response) - end - if client.name == 'tailwindcss' or client.name == 'cssls' then - require('blink.cmp.sources.lsp.hacks.tailwind').process_response(response, opts.tailwind_color_icon) - end - if client.name == 'clangd' then require('blink.cmp.sources.lsp.hacks.clangd').process_response(response) end - if client.name == 'lua_ls' then require('blink.cmp.sources.lsp.hacks.lua_ls').process_response(response) end - - cache.set(context, client, response) - - return response - end) -end - -return completion diff --git a/lua/blink/cmp/sources/lsp/hacks/clangd.lua b/lua/blink/cmp/sources/lsp/hacks/clangd.lua deleted file mode 100644 index 7fe95be6b..000000000 --- a/lua/blink/cmp/sources/lsp/hacks/clangd.lua +++ /dev/null @@ -1,17 +0,0 @@ -local clangd = {} - ---- @param response blink.cmp.CompletionResponse | nil ---- @return blink.cmp.CompletionResponse | nil -function clangd.process_response(response) - if not response then return response end - - local items = response.items - if not items then return response end - - for _, item in ipairs(items) do - item.lsp_score = item.score - end - return response -end - -return clangd diff --git a/lua/blink/cmp/sources/lsp/hacks/docs.lua b/lua/blink/cmp/sources/lsp/hacks/docs.lua deleted file mode 100644 index f588b5f5f..000000000 --- a/lua/blink/cmp/sources/lsp/hacks/docs.lua +++ /dev/null @@ -1,92 +0,0 @@ -local docs = {} - ---- Gets the start and end row of the code block for the given row ---- Or returns nil if there's no code block ---- @param lines string[] ---- @param row number ---- @return number?, number? -function docs.get_code_block_range(lines, row) - if row < 1 or row > #lines then return end - -- get the start of the code block - local code_block_start = nil - for i = 1, row do - local line = lines[i] - if line:match('^%s*```') then - if code_block_start == nil then - code_block_start = i - else - code_block_start = nil - end - end - end - if code_block_start == nil then return end - - -- get the end of the code block - local code_block_end = nil - for i = row, #lines do - local line = lines[i] - if line:match('^%s*```') then - code_block_end = i - break - end - end - if code_block_end == nil then return end - - return code_block_start, code_block_end -end - ---- Avoids showing the detail if it's part of the documentation ---- or, if the detail is in a code block in the doc, ---- extracts the code block into the detail ----@param detail string ----@param documentation string ----@return string, string ---- TODO: Also move the code block into detail if it's at the start of the doc ---- and we have no detail -function docs.extract_detail_from_doc(detail, documentation) - local detail_lines = docs.split_lines(detail) - local doc_lines = docs.split_lines(documentation) - - local doc_str_detail_row = documentation:find(detail, 1, true) - - -- didn't find the detail in the doc, so return as is - if doc_str_detail_row == nil or #detail == 0 or #documentation == 0 then return detail, documentation end - - -- get the line of the match - -- hack: surely there's a better way to do this but it's late - -- and I can't be bothered - local offset = 1 - local detail_line = 1 - for line_num, line in ipairs(doc_lines) do - if #line + offset > doc_str_detail_row then - detail_line = line_num - break - end - offset = offset + #line + 1 - end - - -- extract the code block, if it exists, and use it as the detail - local code_block_start, code_block_end = docs.get_code_block_range(doc_lines, detail_line) - if code_block_start ~= nil and code_block_end ~= nil then - detail_lines = vim.list_slice(doc_lines, code_block_start + 1, code_block_end - 1) - - local doc_lines_start = vim.list_slice(doc_lines, 1, code_block_start - 1) - local doc_lines_end = vim.list_slice(doc_lines, code_block_end + 1, #doc_lines) - vim.list_extend(doc_lines_start, doc_lines_end) - doc_lines = doc_lines_start - else - detail_lines = {} - end - - return table.concat(detail_lines, '\n'), table.concat(doc_lines, '\n') -end - -function docs.split_lines(text) - local lines = {} - for s in text:gmatch('[^\r\n]+') do - table.insert(lines, s) - end - return lines -end - -return docs diff --git a/lua/blink/cmp/sources/lsp/hacks/emmet.lua b/lua/blink/cmp/sources/lsp/hacks/emmet.lua deleted file mode 100644 index b058b0630..000000000 --- a/lua/blink/cmp/sources/lsp/hacks/emmet.lua +++ /dev/null @@ -1,11 +0,0 @@ -local emmet_hack = {} - ---- @param response blink.cmp.CompletionResponse -function emmet_hack.process_response(response) - response.is_incomplete_forward = true - for _, item in ipairs(response.items) do - item.score_offset = -6 -- Negate exact match bonus plus some extra - end -end - -return emmet_hack diff --git a/lua/blink/cmp/sources/lsp/hacks/lua_ls.lua b/lua/blink/cmp/sources/lsp/hacks/lua_ls.lua deleted file mode 100644 index affa2ef8a..000000000 --- a/lua/blink/cmp/sources/lsp/hacks/lua_ls.lua +++ /dev/null @@ -1,20 +0,0 @@ -local lua_ls = {} - ---- @param response blink.cmp.CompletionResponse | nil ---- @return blink.cmp.CompletionResponse | nil -function lua_ls.process_response(response) - if not response or not response.items then return response end - - local kind = require('blink.cmp.types').CompletionItemKind - - -- Filter out items of kind Text - local filtered = {} - for _, item in ipairs(response.items) do - if item.kind ~= kind.Text then table.insert(filtered, item) end - end - response.items = filtered - - return response -end - -return lua_ls diff --git a/lua/blink/cmp/sources/lsp/hacks/tailwind.lua b/lua/blink/cmp/sources/lsp/hacks/tailwind.lua deleted file mode 100644 index e51a42668..000000000 --- a/lua/blink/cmp/sources/lsp/hacks/tailwind.lua +++ /dev/null @@ -1,49 +0,0 @@ -local tailwind = {} - -local kinds = require('blink.cmp.types').CompletionItemKind - ---- @param response blink.cmp.CompletionResponse | nil ---- @param icon string ---- @return blink.cmp.CompletionResponse | nil -function tailwind.process_response(response, icon) - if not response then return response end - - local items = response.items - if not items then return response end - - for _, item in ipairs(items) do - local hex_color = tailwind.get_hex_color(item) - if hex_color ~= nil then - item.kind_icon = icon - item.kind_hl = tailwind.get_hl_group(hex_color) - end - end - return response -end - ---- @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 == 7 and content:match('^#%x%x%x%x%x%x$') then return content end -end - ---- @type table -local hl_cache = {} - ---- @param color string ---- @return string -function tailwind.get_hl_group(color) - local hl_name = 'HexColor' .. color:sub(2) - - if not hl_cache[hl_name] then - if #vim.api.nvim_get_hl(0, { name = hl_name }) == 0 then vim.api.nvim_set_hl(0, hl_name, { fg = color }) end - hl_cache[hl_name] = true - end - - return hl_name -end - -return tailwind diff --git a/lua/blink/cmp/sources/lsp/init.lua b/lua/blink/cmp/sources/lsp/init.lua deleted file mode 100644 index c7576735d..000000000 --- a/lua/blink/cmp/sources/lsp/init.lua +++ /dev/null @@ -1,207 +0,0 @@ -local async = require('blink.cmp.lib.async') - ---- Wraps client to support both 0.11 and 0.10 without deprecation warnings ---- @param client vim.lsp.Client ---- @return vim.lsp.Client -local function wrap_client(client) - if vim.fn.has('nvim-0.11') == 1 then return client end - - return setmetatable({ - cancel_request = function(_, ...) return client.cancel_request(...) end, - request = function(_, ...) return client.request(...) end, - }, { __index = client }) -end - ---- @class blink.cmp.LSPSourceOpts ---- @field tailwind_color_icon? string - ---- @class blink.cmp.LSPSource : blink.cmp.Source ---- @field opts blink.cmp.LSPSourceOpts - ---- @type blink.cmp.LSPSource ---- @diagnostic disable-next-line: missing-fields -local lsp = {} - -function lsp.new(opts) - opts = opts or {} - opts.tailwind_color_icon = opts.tailwind_color_icon or '██' - - require('blink.cmp.config.utils').validate( - 'sources.providers.lsp.opts', - { tailwind_color_icon = { opts.tailwind_color_icon, 'string' } }, - opts - ) - - require('blink.cmp.sources.lsp.commands').register() - - return setmetatable({ opts = opts }, { __index = lsp }) -end - ---- Completion --- - -function lsp:get_trigger_characters() - local clients = vim.lsp.get_clients({ bufnr = 0 }) - local trigger_characters = {} - - for _, client in pairs(clients) do - local completion_provider = client.server_capabilities.completionProvider - if completion_provider and completion_provider.triggerCharacters then - for _, trigger_character in pairs(completion_provider.triggerCharacters) do - table.insert(trigger_characters, trigger_character) - end - end - end - - return trigger_characters -end - -function lsp:get_completions(context, callback) - local completion_lib = require('blink.cmp.sources.lsp.completion') - local clients = vim.tbl_filter( - function(client) return client.server_capabilities and client.server_capabilities.completionProvider end, - vim.lsp.get_clients({ bufnr = 0, method = 'textDocument/completion' }) - ) - clients = vim.tbl_map(wrap_client, clients) - - -- TODO: implement a timeout before returning the menu as-is. In the future, it would be neat - -- to detect slow LSPs and consistently run them async - local task = async.task - .all( - vim.tbl_map( - function(client) return completion_lib.get_completion_for_client(context, client, self.opts) end, - clients - ) - ) - :map(function(responses) - local final = { is_incomplete_forward = false, is_incomplete_backward = false, items = {} } - for _, response in ipairs(responses) do - final.is_incomplete_forward = final.is_incomplete_forward or response.is_incomplete_forward - final.is_incomplete_backward = final.is_incomplete_backward or response.is_incomplete_backward - - vim.list_extend(final.items, response.items) - end - callback(final) - end) - return function() task:cancel() end -end - ---- Resolve --- - -function lsp:resolve(item, callback) - local client = vim.lsp.get_client_by_id(item.client_id) - if client == nil or not client.server_capabilities.completionProvider.resolveProvider then - callback(item) - return - end - client = wrap_client(client) - - -- strip blink specific fields to avoid decoding errors on some LSPs - item = require('blink.cmp.sources.lib.utils').blink_item_to_lsp_item(item) - - local success, request_id = client:request('completionItem/resolve', item, function(error, resolved_item) - if error or resolved_item == nil then - callback(item) - return - end - - -- Snippet with no detail, fill in the detail with the snippet - if resolved_item.detail == nil and resolved_item.insertTextFormat == vim.lsp.protocol.InsertTextFormat.Snippet then - local parsed_snippet = require('blink.cmp.sources.snippets.utils').safe_parse(item.insertText) - local snippet = parsed_snippet and tostring(parsed_snippet) or item.insertText - resolved_item.detail = snippet - end - - -- Lua LSP returns the detail like `table` while the documentation contains the signature - -- We extract this into the detail instead - if client.name == 'lua_ls' and resolved_item.documentation ~= nil and resolved_item.detail ~= nil then - local docs = require('blink.cmp.sources.lsp.hacks.docs') - resolved_item.detail, resolved_item.documentation.value = - docs.extract_detail_from_doc(resolved_item.detail, resolved_item.documentation.value) - end - - callback(resolved_item) - end) - if not success then callback(item) end - if request_id ~= nil then - return function() client:cancel_request(request_id) end - end -end - ---- Signature help --- - -function lsp:get_signature_help_trigger_characters() - local clients = vim.lsp.get_clients({ bufnr = 0 }) - local trigger_characters = {} - local retrigger_characters = {} - - for _, client in pairs(clients) do - local signature_help_provider = client.server_capabilities.signatureHelpProvider - if signature_help_provider and signature_help_provider.triggerCharacters then - for _, trigger_character in pairs(signature_help_provider.triggerCharacters) do - table.insert(trigger_characters, trigger_character) - end - end - if signature_help_provider and signature_help_provider.retriggerCharacters then - for _, retrigger_character in pairs(signature_help_provider.retriggerCharacters) do - table.insert(retrigger_characters, retrigger_character) - end - end - end - - return { trigger_characters = trigger_characters, retrigger_characters = retrigger_characters } -end - -function lsp:get_signature_help(context, callback) - -- no providers with signature help support - if #vim.lsp.get_clients({ bufnr = 0, method = 'textDocument/signatureHelp' }) == 0 then - callback(nil) - return function() end - end - - -- TODO: offset encoding is global but should be per-client - local first_client = vim.lsp.get_clients({ bufnr = 0 })[1] - local offset_encoding = first_client and first_client.offset_encoding or 'utf-16' - - local params = vim.lsp.util.make_position_params(nil, offset_encoding) - params.context = { - triggerKind = context.trigger.kind, - triggerCharacter = context.trigger.character, - isRetrigger = context.is_retrigger, - activeSignatureHelp = context.active_signature_help, - } - - -- otherwise, we call all clients - -- TODO: some LSPs never response (typescript-tools.nvim) - return vim.lsp.buf_request_all(0, 'textDocument/signatureHelp', params, function(result) - local signature_helps = {} - for client_id, res in pairs(result) do - local signature_help = res.result - if signature_help ~= nil then - signature_help.client_id = client_id - table.insert(signature_helps, signature_help) - end - end - -- TODO: pick intelligently - callback(signature_helps[1]) - end) -end - ---- Execute --- - -function lsp:execute(ctx, item, callback, default_implementation) - default_implementation() - - local client = vim.lsp.get_client_by_id(item.client_id) - if client and item.command then - if vim.fn.has('nvim-0.11') == 1 then - client:exec_cmd(item.command, { bufnr = ctx.bufnr }, function() callback() end) - else - -- TODO: remove this once 0.11 is the minimum version - client:_exec_cmd(item.command, { bufnr = ctx.bufnr }, function() callback() end) - end - else - callback() - end -end - -return lsp diff --git a/lua/blink/cmp/sources/path/init.lua b/lua/blink/cmp/sources/path/init.lua deleted file mode 100644 index 362de9ce0..000000000 --- a/lua/blink/cmp/sources/path/init.lua +++ /dev/null @@ -1,95 +0,0 @@ --- credit to https://github.com/hrsh7th/cmp-path for the original implementation --- and https://codeberg.org/FelipeLema/cmp-async-path for the async implementation - --- TODO: more advanced detection of windows vs unix paths to resolve escape sequences --- like "Android\ Camera", which currently returns no items - ---- @class blink.cmp.PathOpts ---- @field trailing_slash boolean ---- @field label_trailing_slash boolean ---- @field get_cwd fun(context: blink.cmp.Context): string ---- @field show_hidden_files_by_default boolean ---- @field ignore_root_slash boolean - ---- @class blink.cmp.Source ---- @field opts blink.cmp.PathOpts -local path = {} - -function path.new(opts) - local self = setmetatable({}, { __index = path }) - - --- @type blink.cmp.PathOpts - opts = vim.tbl_deep_extend('keep', opts, { - trailing_slash = true, - label_trailing_slash = true, - get_cwd = function(context) return vim.fn.expand(('#%d:p:h'):format(context.bufnr)) end, - show_hidden_files_by_default = false, - ignore_root_slash = false, - }) - require('blink.cmp.config.utils').validate('sources.providers.path', { - trailing_slash = { opts.trailing_slash, 'boolean' }, - label_trailing_slash = { opts.label_trailing_slash, 'boolean' }, - get_cwd = { opts.get_cwd, 'function' }, - show_hidden_files_by_default = { opts.show_hidden_files_by_default, 'boolean' }, - ignore_root_slash = { opts.ignore_root_slash, 'boolean' }, - }, opts) - - self.opts = opts - return self -end - -function path:get_trigger_characters() return { '/', '.', '\\' } end - -function path:get_completions(context, callback) - -- we use libuv, but the rest of the library expects to be synchronous - callback = vim.schedule_wrap(callback) - - local lib = require('blink.cmp.sources.path.lib') - - local dirname = lib.dirname(self.opts, context) - if not dirname then return callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = {} }) end - - local include_hidden = self.opts.show_hidden_files_by_default - or (string.sub(context.line, context.bounds.start_col, context.bounds.start_col) == '.' and context.bounds.length == 0) - or ( - string.sub(context.line, context.bounds.start_col - 1, context.bounds.start_col - 1) == '.' - and context.bounds.length > 0 - ) - lib - .candidates(context, dirname, include_hidden, self.opts) - :map( - function(candidates) - callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = candidates }) - end - ) - :catch(function() callback() end) -end - -function path:resolve(item, callback) - require('blink.cmp.sources.path.fs') - .read_file(item.data.full_path, 1024) - :map(function(content) - local is_binary = content:find('\0') - - -- binary file - if is_binary then - item.documentation = { - kind = 'plaintext', - value = 'Binary file', - } - -- highlight with markdown - else - local ext = vim.fn.fnamemodify(item.data.path, ':e') - item.documentation = { - kind = 'markdown', - value = '```' .. ext .. '\n' .. content .. '```', - } - end - - return item - end) - :map(function(resolved_item) callback(resolved_item) end) - :catch(function() callback(item) end) -end - -return path diff --git a/lua/blink/cmp/sources/snippets/default/builtin.lua b/lua/blink/cmp/sources/snippets/default/builtin.lua deleted file mode 100644 index c7c6f165e..000000000 --- a/lua/blink/cmp/sources/snippets/default/builtin.lua +++ /dev/null @@ -1,193 +0,0 @@ --- credit to https://github.com/L3MON4D3 for these variables --- see: https://github.com/L3MON4D3/LuaSnip/blob/master/lua/luasnip/util/_builtin_vars.lua --- and credit to https://github.com/garymjr for his changes --- see: https://github.com/garymjr/nvim-snippets/blob/main/lua/snippets/utils/builtin.lua - -local builtin = { - lazy = {}, -} - ---- Higher-order function to add single-value caching -local function cached(fn) - local cache_key = -1 - local cached_value = nil - return function(key, ...) - assert(key ~= -1, 'key cannot be -1') - if cache_key == key then return cached_value end - - cached_value = fn(...) - cache_key = key - return cached_value - end -end - -builtin.lazy.TM_FILENAME = cached(function() return vim.fn.expand('%:t') end) -builtin.lazy.TM_FILENAME_BASE = cached(function() return vim.fn.expand('%:t:s?\\.[^\\.]\\+$??') end) -builtin.lazy.TM_DIRECTORY = cached(function() return vim.fn.expand('%:p:h') end) -builtin.lazy.TM_FILEPATH = cached(function() return vim.fn.expand('%:p') end) -builtin.lazy.TM_SELECTED_TEXT = cached(function() return vim.fn.trim(vim.fn.getreg(vim.v.register, true), '\n', 2) end) -builtin.lazy.CLIPBOARD = cached( - function(opts) return vim.fn.getreg(opts.clipboard_register or vim.v.register, true) end -) - -local function buf_to_ws_part() - local LSP_WORSKPACE_PARTS = 'LSP_WORSKPACE_PARTS' - local ok, ws_parts = pcall(vim.api.nvim_buf_get_var, 0, LSP_WORSKPACE_PARTS) - if not ok then - local file_path = vim.fn.expand('%:p') - - for _, ws in pairs(vim.lsp.buf.list_workspace_folders()) do - if file_path:find(ws, 1, true) == 1 then - ws_parts = { ws, file_path:sub(#ws + 2, -1) } - break - end - end - -- If it can't be extracted from lsp, then we use the file path - if not ok and not ws_parts then ws_parts = { vim.fn.expand('%:p:h'), vim.fn.expand('%:p:t') } end - vim.api.nvim_buf_set_var(0, LSP_WORSKPACE_PARTS, ws_parts) - end - return ws_parts -end - -builtin.lazy.RELATIVE_FILEPATH = cached( - function() -- The relative (to the opened workspace or folder) file path of the current document - return buf_to_ws_part()[2] - end -) -builtin.lazy.WORKSPACE_FOLDER = cached(function() -- The path of the opened workspace or folder - return buf_to_ws_part()[1] -end) -builtin.lazy.WORKSPACE_NAME = cached(function() -- The name of the opened workspace or folder - local parts = vim.split(buf_to_ws_part()[1] or '', '[\\/]') - return parts[#parts] -end) - -function builtin.lazy.CURRENT_YEAR() return os.date('%Y') end - -function builtin.lazy.CURRENT_YEAR_SHORT() return os.date('%y') end - -function builtin.lazy.CURRENT_MONTH() return os.date('%m') end - -function builtin.lazy.CURRENT_MONTH_NAME() return os.date('%B') end - -function builtin.lazy.CURRENT_MONTH_NAME_SHORT() return os.date('%b') end - -function builtin.lazy.CURRENT_DATE() return os.date('%d') end - -function builtin.lazy.CURRENT_DAY_NAME() return os.date('%A') end - -function builtin.lazy.CURRENT_DAY_NAME_SHORT() return os.date('%a') end - -function builtin.lazy.CURRENT_HOUR() return os.date('%H') end - -function builtin.lazy.CURRENT_MINUTE() return os.date('%M') end - -function builtin.lazy.CURRENT_SECOND() return os.date('%S') end - -function builtin.lazy.CURRENT_SECONDS_UNIX() return tostring(os.time()) end - -local function get_timezone_offset(ts) - local utcdate = os.date('!*t', ts) - local localdate = os.date('*t', ts) - localdate.isdst = false -- this is the trick - local diff = os.difftime(os.time(localdate), os.time(utcdate)) - local h, m = math.modf(diff / 3600) - return string.format('%+.4d', 100 * h + 60 * m) -end - -function builtin.lazy.CURRENT_TIMEZONE_OFFSET() - return get_timezone_offset(os.time()):gsub('([+-])(%d%d)(%d%d)$', '%1%2:%3') -end - -math.randomseed(os.time()) - -function builtin.lazy.RANDOM() return string.format('%06d', math.random(999999)) end - -function builtin.lazy.RANDOM_HEX() - return string.format('%06x', math.random(16777216)) --16^6 -end - -function builtin.lazy.UUID() - local random = math.random - local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' - local out - local function subs(c) - local v = (((c == 'x') and random(0, 15)) or random(8, 11)) - return string.format('%x', v) - end - - out = template:gsub('[xy]', subs) - return out -end - -local _comments_cache = {} -local function buffer_comment_chars() - local commentstring = vim.bo.commentstring - if _comments_cache[commentstring] then return _comments_cache[commentstring] end - local comments = { '//', '/*', '*/' } - local placeholder = '%s' - local index_placeholder = commentstring:find(vim.pesc(placeholder)) - if index_placeholder then - index_placeholder = index_placeholder - 1 - if index_placeholder + #placeholder == #commentstring then - comments[1] = vim.trim(commentstring:sub(1, -#placeholder - 1)) - else - comments[2] = vim.trim(commentstring:sub(1, index_placeholder)) - comments[3] = vim.trim(commentstring:sub(index_placeholder + #placeholder + 1, -1)) - end - end - _comments_cache[commentstring] = comments - return comments -end - -builtin.lazy.LINE_COMMENT = cached(function() return buffer_comment_chars()[1] end) -builtin.lazy.BLOCK_COMMENT_START = cached(function() return buffer_comment_chars()[2] end) -builtin.lazy.BLOCK_COMMENT_END = cached(function() return buffer_comment_chars()[3] end) - -local function get_cursor() - local c = vim.api.nvim_win_get_cursor(0) - c[1] = c[1] - 1 - return c -end - -local function get_current_line() - local pos = get_cursor() - return vim.api.nvim_buf_get_lines(0, pos[1], pos[1] + 1, false)[1] -end - -local function word_under_cursor(cur, line) - if line == nil then return end - - local ind_start = 1 - local ind_end = #line - - while true do - local tmp = string.find(line, '%W%w', ind_start) - if not tmp then break end - if tmp > cur[2] + 1 then break end - ind_start = tmp + 1 - end - - local tmp = string.find(line, '%w%W', cur[2] + 1) - if tmp then ind_end = tmp end - - return string.sub(line, ind_start, ind_end) -end - -vim.api.nvim_create_autocmd('InsertEnter', { - group = vim.api.nvim_create_augroup('BlinkSnippetsEagerEnter', { clear = true }), - callback = function() - builtin.eager = {} - builtin.eager.TM_CURRENT_LINE = get_current_line() - builtin.eager.TM_CURRENT_WORD = word_under_cursor(get_cursor(), builtin.eager.TM_CURRENT_LINE) - builtin.eager.TM_LINE_INDEX = tostring(get_cursor()[1]) - builtin.eager.TM_LINE_NUMBER = tostring(get_cursor()[1] + 1) - end, -}) - -vim.api.nvim_create_autocmd('InsertLeave', { - group = vim.api.nvim_create_augroup('BlinkSnippetsEagerLeave', { clear = true }), - callback = function() builtin.eager = nil end, -}) - -return builtin diff --git a/lua/blink/cmp/sources/snippets/default/init.lua b/lua/blink/cmp/sources/snippets/default/init.lua deleted file mode 100644 index 6716f7802..000000000 --- a/lua/blink/cmp/sources/snippets/default/init.lua +++ /dev/null @@ -1,67 +0,0 @@ ---- @class blink.cmp.SnippetsOpts ---- @field friendly_snippets? boolean ---- @field search_paths? string[] ---- @field global_snippets? string[] ---- @field extended_filetypes? table ---- @field get_filetype? fun(context: blink.cmp.Context): string ---- @field filter_snippets? fun(filetype: string, file: string): boolean ---- @field clipboard_register? string ---- @field use_label_description? boolean Whether to put the snippet description in the label description - -local snippets = {} - -function snippets.new(opts) - -- TODO: config validation - --- @cast opts blink.cmp.SnippetsOpts - - local self = setmetatable({}, { __index = snippets }) - --- @type table - self.cache = {} - self.registry = require('blink.cmp.sources.snippets.default.registry').new(opts) - self.get_filetype = opts.get_filetype or function() return vim.bo.filetype end - - return self -end - -function snippets:get_completions(context, callback) - local filetype = self.get_filetype(context) - - if not self.cache[filetype] then - local global_snippets = self.registry:get_global_snippets() - local extended_snippets = self.registry:get_extended_snippets(filetype) - local ft_snippets = self.registry:get_snippets_for_ft(filetype) - local snips = vim.list_extend({}, global_snippets) - vim.list_extend(snips, extended_snippets) - vim.list_extend(snips, ft_snippets) - - self.cache[filetype] = snips - end - - local items = vim.tbl_map( - function(item) return self.registry:snippet_to_completion_item(item, context.id) end, - self.cache[filetype] - ) - callback({ - is_incomplete_forward = false, - is_incomplete_backward = false, - items = items, - }) -end - -function snippets:resolve(item, callback) - local parsed_snippet = require('blink.cmp.sources.snippets.utils').safe_parse(item.insertText) - local snippet = parsed_snippet and tostring(parsed_snippet) or item.insertText - - local resolved_item = vim.deepcopy(item) - resolved_item.detail = snippet - resolved_item.documentation = { - kind = 'markdown', - value = item.description, - } - callback(resolved_item) -end - ---- For external integrations to force reloading the snippets -function snippets:reload() self.cache = {} end - -return snippets diff --git a/lua/blink/cmp/sources/snippets/default/registry.lua b/lua/blink/cmp/sources/snippets/default/registry.lua deleted file mode 100644 index e50692913..000000000 --- a/lua/blink/cmp/sources/snippets/default/registry.lua +++ /dev/null @@ -1,162 +0,0 @@ ---- Credit to https://github.com/garymjr/nvim-snippets/blob/main/lua/snippets/utils/init.lua ---- for the original implementation ---- Original License: MIT - ---- @class blink.cmp.Snippet ---- @field prefix string ---- @field body string[] | string ---- @field description? string - -local registry = { - builtin_vars = require('blink.cmp.sources.snippets.default.builtin'), -} - -local utils = require('blink.cmp.sources.snippets.utils') -local default_config = { - friendly_snippets = true, - search_paths = { vim.fn.stdpath('config') .. '/snippets' }, - global_snippets = { 'all' }, - extended_filetypes = {}, - --- @type string? - clipboard_register = nil, - use_label_description = false, -} - ---- @param config blink.cmp.SnippetsOpts -function registry.new(config) - local self = setmetatable({}, { __index = registry }) - self.config = vim.tbl_deep_extend('force', default_config, config) - self.config.search_paths = vim.tbl_map(function(path) return vim.fs.normalize(path) end, self.config.search_paths) - - if self.config.friendly_snippets then - for _, path in ipairs(vim.api.nvim_list_runtime_paths()) do - if string.match(path, 'friendly.snippets') then table.insert(self.config.search_paths, path) end - end - end - self.registry = require('blink.cmp.sources.snippets.default.scan').register_snippets(self.config.search_paths) - - if self.config.filter_snippets then - local filtered_registry = {} - for ft, files in pairs(self.registry) do - filtered_registry[ft] = {} - for _, file in ipairs(files) do - if self.config.filter_snippets(ft, file) then table.insert(filtered_registry[ft], file) end - end - end - - self.registry = filtered_registry - end - - return self -end - ---- @param filetype string ---- @return blink.cmp.Snippet[] -function registry:get_snippets_for_ft(filetype) - local loaded_snippets = {} - local files = self.registry[filetype] - if not files then return loaded_snippets end - - files = type(files) == 'table' and files or { files } - - for _, f in ipairs(files) do - local contents = utils.read_file(f) - if contents then - local snippets = utils.parse_json_with_error_msg(f, contents) - for _, key in ipairs(vim.tbl_keys(snippets)) do - local snippet = utils.read_snippet(snippets[key], key) - for _, snippet_def in pairs(snippet) do - table.insert(loaded_snippets, snippet_def) - end - end - end - end - - return loaded_snippets -end - ---- @param filetype string ---- @return blink.cmp.Snippet[] -function registry:get_extended_snippets(filetype) - local loaded_snippets = {} - if not filetype then return loaded_snippets end - - local extended_snippets = self.config.extended_filetypes[filetype] or {} - for _, ft in ipairs(extended_snippets) do - if vim.tbl_contains(self.config.extended_filetypes, filetype) then - vim.list_extend(loaded_snippets, self:get_extended_snippets(ft)) - else - vim.list_extend(loaded_snippets, self:get_snippets_for_ft(ft)) - end - end - return loaded_snippets -end - ---- @return blink.cmp.Snippet[] -function registry:get_global_snippets() - local loaded_snippets = {} - local global_snippets = self.config.global_snippets - for _, ft in ipairs(global_snippets) do - if vim.tbl_contains(self.config.extended_filetypes, ft) then - vim.list_extend(loaded_snippets, self:get_extended_snippets(ft)) - else - vim.list_extend(loaded_snippets, self:get_snippets_for_ft(ft)) - end - end - return loaded_snippets -end - ---- @param snippet blink.cmp.Snippet ---- @param cache_key number ---- @return blink.cmp.CompletionItem -function registry:snippet_to_completion_item(snippet, cache_key) - local body = type(snippet.body) == 'string' and snippet.body or table.concat(snippet.body, '\n') - return { - kind = require('blink.cmp.types').CompletionItemKind.Snippet, - label = snippet.prefix, - insertTextFormat = vim.lsp.protocol.InsertTextFormat.Snippet, - insertText = self:expand_vars(body, cache_key), - description = snippet.description, - labelDetails = snippet.description and self.config.use_label_description and { description = snippet.description } - or nil, - } -end - ---- @param snippet string ---- @param cache_key number ---- @return string -function registry:expand_vars(snippet, cache_key) - local lazy_vars = self.builtin_vars.lazy - local eager_vars = self.builtin_vars.eager or {} - - local resolved_snippet = snippet - local parsed_snippet = utils.safe_parse(snippet) - if not parsed_snippet then return snippet end - - for _, child in ipairs(parsed_snippet.data.children) do - local type, data = child.type, child.data - - -- Tabstop with placeholder such as `${1:${TM_FILENAME_BASE}}` - -- Get the value inside the placeholder - -- TODO: support nested placeholders when neovim does - if type == vim.lsp._snippet_grammar.NodeType.Placeholder then - type = data.value.type - data = data.value.data - end - - if type == vim.lsp._snippet_grammar.NodeType.Variable then - if eager_vars[data.name] then - resolved_snippet = resolved_snippet:gsub('%$[{]?(' .. data.name .. ')[}]?', eager_vars[data.name]) - elseif lazy_vars[data.name] then - local replacement = lazy_vars[data.name](cache_key, { clipboard_register = self.config.clipboard_register }) - -- gsub otherwise fails with strings like `%20` in the replacement string - local escaped_for_gsub = replacement:gsub('%%', '%%%%') - resolved_snippet = resolved_snippet:gsub('%$[{]?(' .. data.name .. ')[}]?', escaped_for_gsub) - end - end - end - - return resolved_snippet -end - -return registry diff --git a/lua/blink/cmp/sources/snippets/default/scan.lua b/lua/blink/cmp/sources/snippets/default/scan.lua deleted file mode 100644 index 6971691bc..000000000 --- a/lua/blink/cmp/sources/snippets/default/scan.lua +++ /dev/null @@ -1,94 +0,0 @@ -local utils = require('blink.cmp.sources.snippets.utils') -local scan = {} - -function scan.register_snippets(search_paths) - local registry = {} - - for _, path in ipairs(search_paths) do - local files = scan.load_package_json(path) or scan.scan_for_snippets(path) - for ft, file in pairs(files) do - local key - if type(ft) == 'number' then - key = vim.fn.fnamemodify(files[ft], ':t:r') - else - key = ft - end - - if not key then return end - - registry[key] = registry[key] or {} - if type(file) == 'table' then - vim.list_extend(registry[key], file) - else - table.insert(registry[key], file) - end - end - end - - return registry -end - ----@type fun(self: utils, dir: string, result?: string[]): string[] ----@return string[] -function scan.scan_for_snippets(dir, result) - result = result or {} - - local stat = vim.uv.fs_stat(dir) - if not stat then return result end - - if stat.type == 'directory' then - local req = vim.uv.fs_scandir(dir) - if not req then return result end - - local function iter() return vim.uv.fs_scandir_next(req) end - - for name, ftype in iter do - local path = string.format('%s/%s', dir, name) - - if ftype == 'directory' then - result[name] = scan.scan_for_snippets(path, result[name] or {}) - else - scan.scan_for_snippets(path, result) - end - end - elseif stat.type == 'file' then - local name = vim.fn.fnamemodify(dir, ':t') - - if name:match('%.json$') then table.insert(result, dir) end - elseif stat.type == 'link' then - local target = vim.uv.fs_readlink(dir) - - if target then scan.scan_for_snippets(target, result) end - end - - return result -end - ---- This will try to load the snippets from the package.json file ----@param path string -function scan.load_package_json(path) - local file = path .. '/package.json' - -- todo: ideally this is async, although it takes 0.5ms on my system so it might not matter - local data = utils.read_file(file) - if not data then return end - - local pkg = require('blink.cmp.sources.snippets.utils').parse_json_with_error_msg(file, data) - - ---@type {path: string, language: string|string[]}[] - local snippets = vim.tbl_get(pkg, 'contributes', 'snippets') - if not snippets then return end - - local ret = {} ---@type table - for _, s in ipairs(snippets) do - local langs = s.language or {} - langs = type(langs) == 'string' and { langs } or langs - ---@cast langs string[] - for _, lang in ipairs(langs) do - ret[lang] = ret[lang] or {} - table.insert(ret[lang], vim.fs.normalize(vim.fs.joinpath(path, s.path))) - end - end - return ret -end - -return scan diff --git a/lua/blink/cmp/sources/snippets/init.lua b/lua/blink/cmp/sources/snippets/init.lua deleted file mode 100644 index 2a4b0baa5..000000000 --- a/lua/blink/cmp/sources/snippets/init.lua +++ /dev/null @@ -1,9 +0,0 @@ -local source = {} - -function source.new(opts) - local preset = opts.preset or require('blink.cmp.config').snippets.preset - local module = 'blink.cmp.sources.snippets.' .. preset - return require(module).new(opts) -end - -return source diff --git a/lua/blink/cmp/sources/snippets/luasnip.lua b/lua/blink/cmp/sources/snippets/luasnip.lua deleted file mode 100644 index 32342e23f..000000000 --- a/lua/blink/cmp/sources/snippets/luasnip.lua +++ /dev/null @@ -1,219 +0,0 @@ ---- @class blink.cmp.LuasnipSourceOptions ---- @field use_show_condition? boolean Whether to use show_condition for filtering snippets ---- @field show_autosnippets? boolean Whether to show autosnippets in the completion list ---- @field prefer_doc_trig? boolean When expanding `regTrig` snippets, prefer `docTrig` over `trig` placeholder ---- @field use_label_description? boolean Whether to put the snippet description in the label description - ---- @class blink.cmp.LuasnipSource : blink.cmp.Source ---- @field config blink.cmp.LuasnipSourceOptions ---- @field items_cache table - -local utils = require('blink.cmp.lib.utils') - ---- @type blink.cmp.LuasnipSource ---- @diagnostic disable-next-line: missing-fields -local source = {} - -local default_config = { - use_show_condition = true, - show_autosnippets = true, - prefer_doc_trig = false, - use_label_description = false, -} - ----@param snippet table ----@param event string ----@param callback fun(table, table) -local function add_luasnip_callback(snippet, event, callback) - local events = require('luasnip.util.events') - -- not defined for autosnippets - if snippet.callbacks == nil then return end - snippet.callbacks[-1] = snippet.callbacks[-1] or {} - snippet.callbacks[-1][events[event]] = callback -end - -function source.new(opts) - local config = vim.tbl_deep_extend('keep', opts, default_config) - require('blink.cmp.config.utils').validate('sources.providers.snippets.opts', { - use_show_condition = { config.use_show_condition, 'boolean' }, - show_autosnippets = { config.show_autosnippets, 'boolean' }, - prefer_doc_trig = { config.prefer_doc_trig, 'boolean' }, - use_label_description = { config.use_label_description, 'boolean' }, - }, config) - - local self = setmetatable({}, { __index = source }) - self.config = config - self.items_cache = {} - - local luasnip_ag = vim.api.nvim_create_augroup('BlinkCmpLuaSnipReload', { clear = true }) - vim.api.nvim_create_autocmd('User', { - pattern = 'LuasnipSnippetsAdded', - callback = function() self:reload() end, - group = luasnip_ag, - desc = 'Reset internal cache of luasnip source of blink.cmp when new snippets are added', - }) - vim.api.nvim_create_autocmd('User', { - pattern = 'LuasnipCleanup', - callback = function() self:reload() end, - group = luasnip_ag, - desc = 'Reload luasnip source of blink.cmp when snippets are cleared', - }) - - return self -end - -function source:enabled() - local ok, _ = pcall(require, 'luasnip') - return ok -end - -function source:get_completions(ctx, callback) - --- @type blink.cmp.CompletionItem[] - local items = {} - - -- gather snippets from relevant filetypes, including extensions - for _, ft in ipairs(require('luasnip.util.util').get_snippet_filetypes()) do - if self.items_cache[ft] then - for _, item in ipairs(self.items_cache[ft]) do - table.insert(items, utils.shallow_copy(item)) - end - goto continue - end - - -- cache not yet available for this filetype - self.items_cache[ft] = {} - -- Gather filetype snippets and, optionally, autosnippets - local snippets = require('luasnip').get_snippets(ft, { type = 'snippets' }) - if self.config.show_autosnippets then - local autosnippets = require('luasnip').get_snippets(ft, { type = 'autosnippets' }) - for _, s in ipairs(autosnippets) do - add_luasnip_callback(s, 'enter', require('blink.cmp').hide) - end - snippets = require('blink.cmp.lib.utils').shallow_copy(snippets) - vim.list_extend(snippets, autosnippets) - end - snippets = vim.tbl_filter(function(snip) return not snip.hidden end, snippets) - - -- Get the max priority for use with sortText - local max_priority = 0 - for _, snip in ipairs(snippets) do - max_priority = math.max(max_priority, snip.effective_priority or 0) - end - - for _, snip in ipairs(snippets) do - -- Convert priority of 1000 (with max of 8000) to string like "00007000|||asd" for sorting - -- This will put high priority snippets at the top of the list, and break ties based on the trigger - local inversed_priority = max_priority - (snip.effective_priority or 0) - local sort_text = ('0'):rep(8 - #tostring(inversed_priority), '') .. inversed_priority .. '|||' .. snip.trigger - - --- @type lsp.CompletionItem - local item = { - kind = require('blink.cmp.types').CompletionItemKind.Snippet, - label = snip.regTrig and snip.name or snip.trigger, - insertText = self.config.prefer_doc_trig and snip.docTrig or snip.trigger, - insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText, - sortText = sort_text, - data = { snip_id = snip.id, show_condition = snip.show_condition }, - labelDetails = snip.dscr and self.config.use_label_description and { - description = table.concat(snip.dscr, ' '), - } or nil, - } - -- populate snippet cache for this filetype - table.insert(self.items_cache[ft], item) - -- while we're at it, also populate completion items for this request - table.insert(items, utils.shallow_copy(item)) - end - - ::continue:: - end - - -- Filter items based on show_condition, if configured - if self.config.use_show_condition then - local line_to_cursor = ctx.line:sub(0, ctx.cursor[2] - 1) - items = vim.tbl_filter(function(item) return item.data.show_condition(line_to_cursor) end, items) - end - - callback({ - is_incomplete_forward = false, - is_incomplete_backward = false, - items = items, - context = ctx, - }) -end - -function source:resolve(item, callback) - local snip = require('luasnip').get_id_snippet(item.data.snip_id) - - local resolved_item = vim.deepcopy(item) - - local detail = snip:get_docstring() - if type(detail) == 'table' then detail = table.concat(detail, '\n') end - resolved_item.detail = detail - - if snip.dscr then - resolved_item.documentation = { - kind = 'markdown', - value = table.concat(vim.lsp.util.convert_input_to_markdown_lines(snip.dscr), '\n'), - } - end - - callback(resolved_item) -end - -function source:execute(ctx, item) - local luasnip = require('luasnip') - local snip = luasnip.get_id_snippet(item.data.snip_id) - - -- if trigger is a pattern, expand "pattern" instead of actual snippet - if snip.regTrig then - local docTrig = self.config.prefer_doc_trig and snip.docTrig - snip = snip:get_pattern_expand_helper() - - if docTrig then - add_luasnip_callback(snip, 'pre_expand', function(snip, _) - if #snip.insert_nodes == 0 then - snip.insert_nodes[0].static_text = { docTrig } - else - local matches = { string.match(docTrig, snip.trigger) } - for i, match in ipairs(matches) do - local idx = i ~= #matches and i or 0 - snip.insert_nodes[idx].static_text = { match } - end - end - end) - end - end - - -- get (0, 0) indexed cursor position - local cursor = ctx.get_cursor() - cursor[1] = cursor[1] - 1 - - local range = require('blink.cmp.lib.text_edits').get_from_item(item).range - local clear_region = { - from = { range.start.line, range.start.character }, - to = cursor, - } - - local line = ctx.get_line() - local line_to_cursor = line:sub(1, range['end'].character) - local range_text = line:sub(range.start.character + 1, range['end'].character) - - local expand_params = snip:matches(line_to_cursor, { fallback_match = range_text }) - - if expand_params ~= nil then - if expand_params.clear_region ~= nil then - clear_region = expand_params.clear_region - elseif expand_params.trigger ~= nil then - clear_region = { - from = { cursor[1], cursor[2] - #expand_params.trigger }, - to = cursor, - } - end - end - - luasnip.snip_expand(snip, { expand_params = expand_params, clear_region = clear_region }) -end - -function source:reload() self.items_cache = {} end - -return source diff --git a/lua/blink/cmp/sources/snippets/mini_snippets.lua b/lua/blink/cmp/sources/snippets/mini_snippets.lua deleted file mode 100644 index 626e9b490..000000000 --- a/lua/blink/cmp/sources/snippets/mini_snippets.lua +++ /dev/null @@ -1,150 +0,0 @@ ---- @module 'mini.snippets' - ---- @class blink.cmp.MiniSnippetsSourceOptions ---- @field use_items_cache? boolean completion items are cached using default mini.snippets context ---- @field use_label_description? boolean Whether to put the snippet description in the label description - ---- @class blink.cmp.MiniSnippetsSource : blink.cmp.Source ---- @field config blink.cmp.MiniSnippetsSourceOptions ---- @field items_cache table - ---- @class blink.cmp.MiniSnippetsSnippet ---- @field prefix string string snippet identifier. ---- @field body string string snippet content with appropriate syntax. ---- @field desc string string snippet description in human readable form. - ---- @type blink.cmp.MiniSnippetsSource ---- @diagnostic disable-next-line: missing-fields -local source = {} - -local default_config = { - --- Whether to use a cache for completion items - use_items_cache = true, - --- Whether to put the snippet description in the label description - use_label_description = false, -} - -function source.new(opts) - local config = vim.tbl_deep_extend('keep', opts, default_config) - require('blink.cmp.config.utils').validate('sources.providers.snippets.opts', { - use_items_cache = { - config.use_items_cache, - 'boolean', - 'use_items_cache must be a boolean when using mini__snippets preset', - }, - use_label_description = { config.use_label_description, 'boolean' }, - }, opts) - - local self = setmetatable({}, { __index = source }) - self.config = config - self.items_cache = {} - return self -end - -function source:enabled() - ---@diagnostic disable-next-line: undefined-field - return _G.MiniSnippets ~= nil -- ensure that user has explicitly setup mini.snippets -end - -local function to_completion_items(snippets, use_label_description) - local result = {} - - for _, snip in ipairs(snippets) do - --- @type lsp.CompletionItem - local item = { - kind = require('blink.cmp.types').CompletionItemKind.Snippet, - label = snip.prefix, - insertText = snip.prefix, - insertTextFormat = vim.lsp.protocol.InsertTextFormat.Snippet, - data = { snip = snip }, - labelDetails = snip.desc and use_label_description and { description = snip.desc } or nil, - } - table.insert(result, item) - end - return result -end - --- NOTE: Completion items are cached by default using the default 'mini.snippets' context --- --- vim.b.minisnippets_config can contain buffer-local snippets. --- a buffer can contain code in multiple languages --- --- See :h MiniSnippets.default_prepare --- --- Return completion items produced from snippets either directly or from cache -local function get_completion_items(cache, use_label_description) - if not cache then - return to_completion_items(MiniSnippets.expand({ match = false, insert = false }), use_label_description) - end - - -- Compute cache id - local _, context = MiniSnippets.default_prepare({}) - local id = 'buf=' .. context.buf_id .. ',lang=' .. context.lang - - -- Return the completion items for this context from cache - if cache[id] then return cache[id] end - - -- Retrieve all raw snippets in context and transform into completion items - local snippets = MiniSnippets.expand({ match = false, insert = false }) - --- @cast snippets table - local items = to_completion_items(vim.deepcopy(snippets), use_label_description) - cache[id] = items - - return items -end - -function source:get_completions(ctx, callback) - local cache = self.config.use_items_cache and self.items_cache or nil - - --- @type blink.cmp.CompletionItem[] - local items = get_completion_items(cache, self.config.use_label_description) - callback({ - is_incomplete_forward = false, - is_incomplete_backward = false, - items = items, - context = ctx, - ---@diagnostic disable-next-line: missing-return - }) -end - -function source:resolve(item, callback) - --- @type blink.cmp.MiniSnippetsSnippet - local snip = item.data.snip - - local desc = snip.desc - if desc and not item.documentation then - item.documentation = { - kind = 'markdown', - value = table.concat(vim.lsp.util.convert_input_to_markdown_lines(desc), '\n'), - } - end - - local detail = snip.body - if not item.detail then - if type(detail) == 'table' then detail = table.concat(detail, '\n') end - item.detail = detail - end - - callback(item) -end - -function source:execute(_, item) - require('blink.cmp.lib.text_edits').apply({ newText = '', range = item.textEdit.range }) - - -- It's safe to assume that mode is insert during completion - - --- @type blink.cmp.MiniSnippetsSnippet - local snip = item.data.snip - - local insert = MiniSnippets.config.expand.insert or MiniSnippets.default_insert - ---@diagnostic disable-next-line: missing-return - insert({ body = snip.body }) -- insert at cursor -end - --- For external integrations to force reloading the snippets -function source:reload() - MiniSnippets.setup(MiniSnippets.config) - self.items_cache = {} -end - -return source diff --git a/lua/blink/cmp/sources/snippets/utils.lua b/lua/blink/cmp/sources/snippets/utils.lua deleted file mode 100644 index 7cbae4ee1..000000000 --- a/lua/blink/cmp/sources/snippets/utils.lua +++ /dev/null @@ -1,114 +0,0 @@ -local utils = { - parse_cache = {}, -} - ---- Parses the json file and notifies the user if there's an error ----@param path string ----@param json string -function utils.parse_json_with_error_msg(path, json) - local ok, parsed = pcall(vim.json.decode, json) - if not ok then - vim.notify( - 'Failed to parse json file "' .. path .. '" for blink.cmp snippets. Error: ' .. parsed, - vim.log.levels.ERROR, - { title = 'blink.cmp' } - ) - return {} - end - return parsed -end - ----@type fun(path: string): string|nil -function utils.read_file(path) - local file = io.open(path, 'r') - if not file then return nil end - local content = file:read('*a') - file:close() - return content -end - ----@type fun(input: string): vim.snippet.Node|nil -function utils.safe_parse(input) - if utils.parse_cache[input] then return utils.parse_cache[input] end - - local safe, parsed = pcall(vim.lsp._snippet_grammar.parse, input) - if not safe then return nil end - - utils.parse_cache[input] = parsed - return parsed -end - ----@type fun(snippet: blink.cmp.Snippet, fallback: string): table -function utils.read_snippet(snippet, fallback) - local snippets = {} - local prefix = snippet.prefix or fallback - local description = snippet.description or fallback - local body = snippet.body - - if type(description) == 'table' then description = vim.fn.join(description, '') end - - if type(prefix) == 'table' then - for _, p in ipairs(prefix) do - snippets[p] = { - prefix = p, - body = body, - description = description, - } - end - else - snippets[prefix] = { - prefix = prefix, - body = body, - description = description, - } - end - return snippets -end - --- Add the current line's indentation to all but the first line of --- the provided text ----@param text string -function utils.add_current_line_indentation(text) - local base_indent = vim.api.nvim_get_current_line():match('^%s*') or '' - local snippet_lines = vim.split(text, '\n', { plain = true }) - - local shiftwidth = vim.fn.shiftwidth() - local curbuf = vim.api.nvim_get_current_buf() - local expandtab = vim.bo[curbuf].expandtab - - local lines = {} --- @type string[] - for i, line in ipairs(snippet_lines) do - -- Replace tabs with spaces - if expandtab then - line = line:gsub('\t', (' '):rep(shiftwidth)) --- @type string - end - -- Add the base indentation - if i > 1 then line = base_indent .. line end - lines[#lines + 1] = line - end - - return table.concat(lines, '\n') -end - -function utils.get_tab_stops(snippet) - local expanded_snippet = require('blink.cmp.sources.snippets.utils').safe_parse(snippet) - if not expanded_snippet then return end - - local tabstops = {} - local grammar = require('vim.lsp._snippet_grammar') - local line = 1 - local character = 1 - for _, child in ipairs(expanded_snippet.data.children) do - local lines = tostring(child) == '' and {} or vim.split(tostring(child), '\n') - line = line + math.max(#lines - 1, 0) - character = #lines == 0 and character or #lines > 1 and #lines[#lines] or (character + #lines[#lines]) - if child.type == grammar.NodeType.Placeholder or child.type == grammar.NodeType.Tabstop then - table.insert(tabstops, { index = child.data.tabstop, line = line, character = character }) - end - end - - table.sort(tabstops, function(a, b) return a.index < b.index end) - return tabstops -end - -return utils diff --git a/lua/blink/cmp/sources/snippets/vsnip.lua b/lua/blink/cmp/sources/snippets/vsnip.lua deleted file mode 100644 index ae15a0e85..000000000 --- a/lua/blink/cmp/sources/snippets/vsnip.lua +++ /dev/null @@ -1,85 +0,0 @@ --- Based on https://raw.githubusercontent.com/hrsh7th/cmp-vsnip/refs/heads/main/lua/cmp_vsnip/init.lua --- Contributed by @FelipeLema: https://codeberg.org/FelipeLema/blink-cmp-vsnip - ---- @module 'vsnip' - ---- @class vsnip.CompleteItem ---- @field abbr string ---- @field dup 1|0 ---- @field kind string probably just "Snippet" ? ---- @field menu string something like "[v] snip short info" ---- @field user_data string json definition, coded in string ---- @field word string - ---- @class blink.cmp.VSnipSource : blink.cmp.Source - ---- @type blink.cmp.VSnipSource ---- @diagnostic disable-next-line: missing-fields -local source = {} - -function source.new() return setmetatable({}, { __index = source }) end - -function source:enabled() return vim.g.loaded_vsnip == 1 end - -function source:get_completions(ctx, callback) - local items = vim - .iter(vim.fn['vsnip#get_complete_items'](ctx.bufnr)) - :map( - --- @param vsnip vsnip.CompleteItem - --- @return lsp.CompletionItem? - function(vsnip) - local user_data = vim.fn.json_decode(vsnip.user_data) - return { - kind = require('blink.cmp.types').CompletionItemKind.Snippet, - label = vsnip.abbr, - insertText = vsnip.word, - insertTextFormat = vim.lsp.protocol.InsertTextFormat.Snippet, - data = { - snippet = user_data.vsnip.snippet, - }, - } - end - ) - :filter(function(item) return item ~= nil end) - :totable() - - callback({ - is_incomplete_forward = false, - is_incomplete_backward = false, - items = items, - }) -end - -function source:resolve(item, callback) - local resolved_item = vim.deepcopy(item) - - --- @diagnostic disable-next-line: need-check-nil - local snippet = resolved_item.data.snippet - if vim.fn.empty(snippet.description) ~= 1 and not resolved_item.documentation then - resolved_item.documentation = { - kind = 'markdown', - value = table.concat(vim.lsp.util.convert_input_to_markdown_lines(vim.fn['vsnip#to_string'](snippet)), '\n'), - } - end - - if not resolved_item.detail then - assert(resolved_item.data) - resolved_item.detail = vim.fn['vsnip#to_string'](snippet) - end - - callback(resolved_item) -end - -function source:execute(_, item) - -- remove keyword / stuff before cursor - require('blink.cmp.lib.text_edits').apply({ - newText = '', - range = require('blink.cmp.lib.text_edits').get_from_item(item).range, - }) - - -- paste expanded snippet from item (not from text in buffer) - --- @diagnostic disable-next-line: need-check-nil - vim.fn['vsnip#anonymous'](vim.iter(item.data.snippet):join('\n')) -end - -return source diff --git a/lua/blink/cmp/types.lua b/lua/blink/cmp/types.lua index 3948fd3c9..bd21c22ff 100644 --- a/lua/blink/cmp/types.lua +++ b/lua/blink/cmp/types.lua @@ -1,22 +1,21 @@ --- @alias blink.cmp.Mode 'cmdline' | 'cmdwin' | 'term' | 'default' ---- @class blink.cmp.CompletionItem : lsp.CompletionItem ---- @field documentation? string | { kind: lsp.MarkupKind, value: string, draw?: fun(opts?: blink.cmp.CompletionDocumentationDrawOpts) } +--- @class blink.cmp.CompletionItemKindExt +--- @field name? string +--- @field icon? string +--- @field hl? string + +--- @class blink.cmp.CompletionItemExt +--- @field cursor_column number TODO: can we remove this? --- @field score_offset? number ---- @field source_id string ---- @field source_name string ---- @field cursor_column number ---- @field client_id? number ---- @field client_name? string ---- @field kind_name? string ---- @field kind_icon? string ---- @field kind_hl? string ---- @field exact? boolean ---- @field score? number - -return { - -- some plugins mutate the vim.lsp.protocol.CompletionItemKind table - -- so we use our own copy +--- @field kind blink.cmp.CompletionItemKindExt + +--- @class blink.cmp.CompletionItem : lsp.CompletionItem +--- @field blink blink.cmp.CompletionItemExt + +-- some plugins mutate the vim.lsp.protocol.CompletionItemKind table +-- so we use our own copy +local utils = { CompletionItemKind = { 'Text', 'Method', @@ -71,3 +70,189 @@ return { TypeParameter = 25, }, } + +--- Shallow copy table +--- @generic T +--- @param t T +--- @return T +function utils.shallow_copy(t) + local t2 = {} + for k, v in pairs(t) do + t2[k] = v + end + return t2 +end + +--- Returns a list of unique values from the input array +--- @generic T +--- @param arr T[] +--- @return T[] +function utils.deduplicate(arr) + local seen = {} + local result = {} + for _, v in ipairs(arr) do + if not seen[v] then + seen[v] = true + table.insert(result, v) + end + end + return result +end + +function utils.schedule_if_needed(fn) + if vim.in_fast_event() then + vim.schedule(fn) + else + fn() + end +end + +--- Flattens an arbitrarily deep table into a single level table +--- @param t table +--- @return table +function utils.flatten(t) + if t[1] == nil then return t end + + local flattened = {} + for _, v in ipairs(t) do + if type(v) == 'table' and vim.tbl_isempty(v) then goto continue end + + if v[1] == nil then + table.insert(flattened, v) + else + vim.list_extend(flattened, utils.flatten(v)) + end + + ::continue:: + end + return flattened +end + +--- Finds an item in an array using a predicate function +--- @generic T +--- @param arr T[] +--- @param predicate fun(item: T): boolean +--- @return number? +function utils.find_idx(arr, predicate) + for idx, v in ipairs(arr) do + if predicate(v) then return idx end + end + return nil +end + +--- Slices an array +--- @generic T +--- @param arr T[] +--- @param start number? +--- @param finish number? +--- @return T[] +function utils.slice(arr, start, finish) + start = start or 1 + finish = finish or #arr + local sliced = {} + for i = start, finish do + sliced[#sliced + 1] = arr[i] + end + return sliced +end + +--- Gets the full Unicode character at cursor position +--- @return string +function utils.get_char_at_cursor() + local context = require('blink.cmp.completion.trigger.context') + + 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 + +--- Disables all autocmds for the duration of the callback +--- @param cb fun() +function utils.with_no_autocmds(cb) + local original_eventignore = vim.opt.eventignore + vim.opt.eventignore = 'all' + + local success, result_or_err = pcall(cb) + + vim.opt.eventignore = original_eventignore + + if not success then error(result_or_err) end + return result_or_err +end + +--- Disable redraw in neovide for the duration of the callback +--- Useful for preventing the cursor from jumping to the top left during `vim.fn.complete` +--- @generic T +--- @param fn fun(): T +--- @return T +function utils.defer_neovide_redraw(fn) + -- don't do anything special when not running inside neovide + if not _G.neovide or not neovide.enable_redraw or not neovide.disable_redraw then return fn() end + + neovide.disable_redraw() + + local success, result = pcall(fn) + + -- make sure that the screen is updated and the mouse cursor returned to the right position before re-enabling redrawing + pcall(vim.api.nvim__redraw, { cursor = true, flush = true }) + + neovide.enable_redraw() + + if not success then error(result) end + return result +end + +local ui_entered = vim.v.vim_did_enter == 1 -- technically for VimEnter, but should be good enough for when we're lazy loaded +local notification_queue = {} +vim.api.nvim_create_autocmd('UIEnter', { + callback = function() + ui_entered = true + for _, fn in ipairs(notification_queue) do + pcall(fn) + end + end, +}) + +--- Fancy notification wrapper. +--- @param msg [string, string?][] +--- @param lvl? number +function utils.notify(msg, lvl) + local header_hl = 'DiagnosticVirtualTextWarn' + if lvl == vim.log.levels.ERROR then + header_hl = 'DiagnosticVirtualTextError' + elseif lvl == vim.log.levels.INFO then + header_hl = 'DiagnosticVirtualTextInfo' + end + + table.insert(msg, 1, { ' blink.cmp ', header_hl }) + table.insert(msg, 2, { ' ' }) + + local echo_opts = { verbose = false } + if lvl == vim.log.levels.ERROR and vim.fn.has('nvim-0.11') == 1 then echo_opts.err = true end + if ui_entered then + vim.schedule(function() vim.api.nvim_echo(msg, true, echo_opts) end) + else + -- Queue notification for the UIEnter event. + table.insert(notification_queue, function() vim.api.nvim_echo(msg, true, echo_opts) end) + end +end + +return utils diff --git a/plugin/blink-cmp.lua b/plugin/blink-cmp.lua index 6ef545d10..9c722ded9 100644 --- a/plugin/blink-cmp.lua +++ b/plugin/blink-cmp.lua @@ -1,8 +1,29 @@ -if vim.fn.has('nvim-0.11') == 1 and vim.lsp.config then - vim.lsp.config('*', { - capabilities = require('blink.cmp').get_lsp_capabilities(), - }) -end +local cmp = require('blink.cmp') + +-- LSP capabilities/commands +vim.lsp.config('*', { capabilities = require('blink.cmp').get_lsp_capabilities() }) +vim.lsp.commands['editor.action.triggerParameterHints'] = function() cmp.show_signature() end +vim.lsp.commands['editor.action.triggerSuggest'] = function() cmp.show() end + +-- LSP configs +cmp.lsp.config('clangd', { + transform_items = function(_, items) + for _, item in ipairs(items) do + if item.score ~= nil then item.blink_cmp.score_offset = item.score end + end + end, +}) +cmp.lsp.config('emmet_ls', { score_offset = -6 }) +cmp.lsp.config('emmet-language-server', { score_offset = -6 }) +cmp.lsp.config('lua_ls', { + transform_items = function(_, items) + return vim.tbl_filter( + function(item) return item.kind ~= require('blink.cmp.types').CompletionItemKind.Text end, + items + ) + end, +}) +-- TODO: tailwind hack -- Commands local subcommands = { @@ -18,3 +39,43 @@ vim.api.nvim_create_user_command('BlinkCmp', function(cmd) vim.notify("[blink.cmp] invalid subcommand '" .. subcommand.args .. "'", vim.log.levels.ERROR) end end, { nargs = 1, complete = function() vim.tbl_keys(subcommands) end, desc = 'blink.cmp' }) + +-- Highlights +--- @param hl_group string Highlight group name, e.g. 'ErrorMsg' +--- @param opts vim.api.keyset.highlight Highlight definition map +local set_hl = function(hl_group, opts) + opts.default = true -- Prevents overriding existing definitions + vim.api.nvim_set_hl(0, hl_group, opts) +end + +local function apply_highlights() + set_hl('BlinkCmpLabelDeprecated', { link = 'PmenuExtra' }) + set_hl('BlinkCmpLabelDetail', { link = 'PmenuExtra' }) + set_hl('BlinkCmpLabelDescription', { link = 'PmenuExtra' }) + set_hl('BlinkCmpSource', { link = 'PmenuExtra' }) + set_hl('BlinkCmpKind', { link = 'PmenuKind' }) + for _, kind in ipairs(require('blink.cmp.types').CompletionItemKind) do + set_hl('BlinkCmpKind' .. kind, { link = 'BlinkCmpKind' }) + end + + set_hl('BlinkCmpScrollBarThumb', { link = 'PmenuThumb' }) + set_hl('BlinkCmpScrollBarGutter', { link = 'PmenuSbar' }) + + set_hl('BlinkCmpGhostText', { link = 'NonText' }) + + set_hl('BlinkCmpMenu', { link = 'Pmenu' }) + set_hl('BlinkCmpMenuBorder', { link = 'Pmenu' }) + set_hl('BlinkCmpMenuSelection', { link = 'PmenuSel' }) + + set_hl('BlinkCmpDoc', { link = 'NormalFloat' }) + set_hl('BlinkCmpDocBorder', { link = 'NormalFloat' }) + set_hl('BlinkCmpDocSeparator', { link = 'NormalFloat' }) + set_hl('BlinkCmpDocCursorLine', { link = 'Visual' }) + + set_hl('BlinkCmpSignatureHelp', { link = 'NormalFloat' }) + set_hl('BlinkCmpSignatureHelpBorder', { link = 'NormalFloat' }) + set_hl('BlinkCmpSignatureHelpActiveParameter', { link = 'LspSignatureActiveParameter' }) +end + +apply_highlights() +vim.api.nvim_create_autocmd('ColorScheme', { callback = apply_highlights })