From b20184f3855756f494c99781be65a883bf95c737 Mon Sep 17 00:00:00 2001 From: Liam Dyer Date: Wed, 11 Dec 2024 16:24:08 -0500 Subject: [PATCH] feat: command line completions --- lua/blink/cmp/completion/accept/init.lua | 12 +- lua/blink/cmp/completion/brackets/kind.lua | 6 +- lua/blink/cmp/completion/list.lua | 2 +- lua/blink/cmp/completion/trigger.lua | 255 ------------------ lua/blink/cmp/completion/trigger/context.lua | 169 ++++++++++++ lua/blink/cmp/completion/trigger/init.lua | 199 ++++++++++++++ lua/blink/cmp/completion/windows/menu.lua | 37 ++- .../cmp/completion/windows/render/context.lua | 18 +- .../cmp/completion/windows/render/init.lua | 10 +- lua/blink/cmp/config/sources.lua | 10 + lua/blink/cmp/fuzzy/init.lua | 13 - lua/blink/cmp/keymap/apply.lua | 62 ++++- lua/blink/cmp/keymap/fallback.lua | 2 +- lua/blink/cmp/keymap/init.lua | 3 + lua/blink/cmp/lib/command_events.lua | 104 +++++++ lua/blink/cmp/lib/text_edits.lua | 28 +- lua/blink/cmp/lib/utils.lua | 45 ---- lua/blink/cmp/lib/window/init.lua | 37 ++- lua/blink/cmp/lib/window/scrollbar/win.lua | 10 +- lua/blink/cmp/signature/trigger.lua | 8 +- lua/blink/cmp/sources/command/init.lua | 173 ++++++++++++ lua/blink/cmp/sources/command/regex.lua | 60 +++++ lua/blink/cmp/sources/lib/init.lua | 29 +- lua/blink/cmp/sources/lib/queue.lua | 2 +- lua/blink/cmp/types.lua | 2 + 25 files changed, 916 insertions(+), 380 deletions(-) delete mode 100644 lua/blink/cmp/completion/trigger.lua create mode 100644 lua/blink/cmp/completion/trigger/context.lua create mode 100644 lua/blink/cmp/completion/trigger/init.lua create mode 100644 lua/blink/cmp/lib/command_events.lua create mode 100644 lua/blink/cmp/sources/command/init.lua create mode 100644 lua/blink/cmp/sources/command/regex.lua diff --git a/lua/blink/cmp/completion/accept/init.lua b/lua/blink/cmp/completion/accept/init.lua index 49ac2dcf..99173e07 100644 --- a/lua/blink/cmp/completion/accept/init.lua +++ b/lua/blink/cmp/completion/accept/init.lua @@ -29,18 +29,21 @@ local function accept(ctx, item, callback) -- Create an undo point, if it's not a snippet, since the snippet engine should handle undo if - item.insertTextFormat ~= vim.lsp.protocol.InsertTextFormat.Snippet + ctx.mode == 'default' + and item.insertTextFormat ~= vim.lsp.protocol.InsertTextFormat.Snippet and require('blink.cmp.config').completion.accept.create_undo_point then vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('u', true, true, true), 'n', true) end -- Add brackets to the text edit if needed - local brackets_status, text_edit_with_brackets, offset = brackets_lib.add_brackets(vim.bo.filetype, item) + 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) @@ -56,10 +59,7 @@ local function accept(ctx, item, callback) table.insert(all_text_edits, item.textEdit) text_edits_lib.apply(all_text_edits) -- TODO: should move the cursor only by the offset since text edit handles everything else? - vim.api.nvim_win_set_cursor(0, { - vim.api.nvim_win_get_cursor(0)[1], - item.textEdit.range.start.character + #item.textEdit.newText + offset, - }) + ctx.set_cursor({ ctx.get_cursor()[1], item.textEdit.range.start.character + #item.textEdit.newText + offset }) end -- Let the source execute the item itself diff --git a/lua/blink/cmp/completion/brackets/kind.lua b/lua/blink/cmp/completion/brackets/kind.lua index 29bb5b6d..91889eca 100644 --- a/lua/blink/cmp/completion/brackets/kind.lua +++ b/lua/blink/cmp/completion/brackets/kind.lua @@ -1,13 +1,17 @@ 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(filetype, item) +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)? if utils.has_brackets_in_front(text_edit, brackets_for_filetype[1]) then diff --git a/lua/blink/cmp/completion/list.lua b/lua/blink/cmp/completion/list.lua index 50eefd00..1552f957 100644 --- a/lua/blink/cmp/completion/list.lua +++ b/lua/blink/cmp/completion/list.lua @@ -103,7 +103,7 @@ end function list.fuzzy(context, items_by_source) local fuzzy = require('blink.cmp.fuzzy') - local filtered_items = fuzzy.fuzzy(fuzzy.get_query(), items_by_source) + local filtered_items = fuzzy.fuzzy(context:get_keyword(), items_by_source) -- apply the per source max_items filtered_items = require('blink.cmp.sources.lib').apply_max_items_for_completions(context, filtered_items) diff --git a/lua/blink/cmp/completion/trigger.lua b/lua/blink/cmp/completion/trigger.lua deleted file mode 100644 index 034e9b4c..00000000 --- a/lua/blink/cmp/completion/trigger.lua +++ /dev/null @@ -1,255 +0,0 @@ --- 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.ContextBounds ---- @field line string ---- @field line_number number ---- @field start_col number ---- @field end_col number ---- @field length number - ---- @class blink.cmp.Context ---- @field id number ---- @field bufnr number ---- @field cursor number[] ---- @field line string ---- @field bounds blink.cmp.ContextBounds ---- @field trigger { kind: number, character: string | nil } ---- @field providers string[] - ---- @class blink.cmp.CompletionTrigger ---- @field buffer_events blink.cmp.BufferEvents ---- @field current_context_id number ---- @field context? blink.cmp.Context ---- @field show_emitter blink.cmp.EventEmitter<{ context: blink.cmp.Context }> ---- @field hide_emitter blink.cmp.EventEmitter<{}> ---- ---- @field activate fun() ---- @field is_trigger_character fun(char: string, is_retrigger?: boolean): boolean ---- @field suppress_events_for_callback fun(cb: fun()) ---- @field show_if_on_trigger_character fun(opts?: { is_accept?: boolean }) ---- @field show fun(opts?: { trigger_character?: string, force?: boolean, send_upstream?: boolean, providers?: string[] }) ---- @field hide fun() ---- @field within_query_bounds fun(cursor: number[]): boolean ---- @field get_context_bounds fun(regex: string): blink.cmp.ContextBounds - -local keyword_config = require('blink.cmp.config').completion.keyword -local config = require('blink.cmp.config').completion.trigger - -local keyword_regex = vim.regex(keyword_config.regex) - ---- @type blink.cmp.CompletionTrigger ---- @diagnostic disable-next-line: missing-fields -local trigger = { - current_context_id = -1, - show_emitter = require('blink.cmp.lib.event_emitter').new('show'), - hide_emitter = require('blink.cmp.lib.event_emitter').new('hide'), -} - -function trigger.activate() - trigger.buffer_events = require('blink.cmp.lib.buffer_events').new({ - has_context = function() return trigger.context ~= nil end, - show_in_snippet = config.show_in_snippet, - }) - trigger.buffer_events:listen({ - on_char_added = function(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 }) end - - -- character forces a trigger according to the sources, create a fresh context - elseif trigger.is_trigger_character(char) and (config.show_on_trigger_character or trigger.context ~= nil) then - trigger.context = nil - trigger.show({ trigger_character = char }) - - -- character is part of a keyword - elseif keyword_regex:match_str(char) ~= nil and (config.show_on_keyword or trigger.context ~= nil) then - trigger.show() - - -- nothing matches so hide - else - trigger.hide() - end - end, - on_cursor_moved = function(event, is_ignored) - -- 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 == 'CursorMovedI' then - if trigger.context ~= nil then trigger.show({ send_upstream = false }) end - return - end - - local cursor_col = vim.api.nvim_win_get_cursor(0)[2] - local char_under_cursor = vim.api.nvim_get_current_line():sub(cursor_col, cursor_col) - local is_on_trigger_for_show = trigger.is_trigger_character(char_under_cursor) - local is_on_trigger_for_show_on_insert = trigger.is_trigger_character(char_under_cursor, true) - local is_on_keyword_char = keyword_regex:match_str(char_under_cursor) ~= nil - - local insert_enter_on_trigger_character = config.show_on_trigger_character - and config.show_on_insert_on_trigger_character - and is_on_trigger_for_show_on_insert - and event == 'InsertEnter' - - -- check if we're still within the bounds of the query used for the context - if trigger.within_query_bounds(vim.api.nvim_win_get_cursor(0)) then - trigger.show() - - -- check if we've entered insert mode on a trigger character - -- or if we've moved onto a trigger character (by accepting for example) - elseif insert_enter_on_trigger_character or (is_on_trigger_for_show and trigger.context ~= nil) then - trigger.context = nil - trigger.show({ 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_on_keyword_char and trigger.context ~= nil and cursor_col == trigger.context.bounds.start_col - 1 then - trigger.context = nil - trigger.show() - - -- otherwise hide - else - trigger.hide() - end - end, - on_insert_leave = function() trigger.hide() end, - }) -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(), char) - - local show_on_blocked_trigger_characters = type(config.show_on_blocked_trigger_characters) == 'function' - and config.show_on_blocked_trigger_characters() - or config.show_on_blocked_trigger_characters - --- @cast show_on_blocked_trigger_characters string[] - local show_on_x_blocked_trigger_characters = type(config.show_on_x_blocked_trigger_characters) == 'function' - and config.show_on_x_blocked_trigger_characters() - or config.show_on_x_blocked_trigger_characters - --- @cast show_on_x_blocked_trigger_characters string[] - - local is_blocked = vim.tbl_contains(show_on_blocked_trigger_characters, char) - or (is_show_on_x and vim.tbl_contains(show_on_x_blocked_trigger_characters, char)) - - return is_trigger and not is_blocked -end - ---- Suppresses on_hide and on_show events for the duration of the callback ---- TODO: extract into an autocmd module ---- 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 trigger.suppress_events_for_callback(cb) - if not trigger.buffer_events then return cb() end - trigger.buffer_events:suppress_events_for_callback(cb) -end - -function trigger.show_if_on_trigger_character(opts) - if - (opts and opts.is_accept) - and (not config.show_on_trigger_character or not config.show_on_accept_on_trigger_character) - then - return - end - - local cursor_col = vim.api.nvim_win_get_cursor(0)[2] - local char_under_cursor = vim.api.nvim_get_current_line():sub(cursor_col, cursor_col) - - if trigger.is_trigger_character(char_under_cursor, true) then - trigger.show({ trigger_character = char_under_cursor }) - end -end - -function trigger.show(opts) - opts = opts or {} - - local cursor = vim.api.nvim_win_get_cursor(0) - -- already triggered at this position, ignore - if - not opts.force - and trigger.context ~= nil - and cursor[1] == trigger.context.cursor[1] - and cursor[2] == trigger.context.cursor[2] - then - return - end - - -- update context - if trigger.context == nil or opts.providers ~= nil then - trigger.current_context_id = trigger.current_context_id + 1 - end - - local providers = opts.providers - or (trigger.context and trigger.context.providers) - or require('blink.cmp.sources.lib').get_enabled_provider_ids() - - trigger.context = { - id = trigger.current_context_id, - bufnr = vim.api.nvim_get_current_buf(), - cursor = cursor, - line = vim.api.nvim_buf_get_lines(0, cursor[1] - 1, cursor[1], false)[1], - bounds = trigger.get_context_bounds(keyword_config.regex), - trigger = { - kind = opts.trigger_character and vim.lsp.protocol.CompletionTriggerKind.TriggerCharacter - or vim.lsp.protocol.CompletionTriggerKind.Invoked, - character = opts.trigger_character, - }, - providers = providers, - } - - if opts.send_upstream ~= false then trigger.show_emitter:emit({ context = trigger.context }) end -end - -function trigger.hide() - if not trigger.context then return end - trigger.context = nil - trigger.hide_emitter:emit() -end - ---- @param cursor number[] ---- @return boolean -function trigger.within_query_bounds(cursor) - if not trigger.context then return false end - - local row, col = cursor[1], cursor[2] - local bounds = trigger.context.bounds - return row == bounds.line_number and col >= bounds.start_col and col <= bounds.end_col -end - ---- Moves forward and backwards around the cursor looking for word boundaries ---- @return blink.cmp.ContextBounds -function trigger.get_context_bounds() - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - local cursor_col = vim.api.nvim_win_get_cursor(0)[2] - - local line = vim.api.nvim_buf_get_lines(0, cursor_line - 1, cursor_line, false)[1] - local start_col = cursor_col - while start_col >= 1 do - local char = line:sub(start_col, start_col) - if keyword_regex:match_str(char) == nil then - start_col = start_col + 1 - break - end - start_col = start_col - 1 - end - start_col = math.max(start_col, 1) - - local end_col = cursor_col - while end_col < #line do - local char = line:sub(end_col + 1, end_col + 1) - if keyword_regex:match_str(char) == nil then break end - end_col = end_col + 1 - end - - -- hack: why do we have to math.min here? - start_col = math.min(start_col, end_col) - - local length = end_col - start_col + 1 - -- Since sub(1, 1) returns a single char string, we need to check if that single char matches - -- and otherwise mark the length as 0 - if start_col == end_col and keyword_regex:match_str(line:sub(start_col, end_col)) == nil then length = 0 end - - return { line_number = cursor_line, start_col = start_col, end_col = end_col, length = length } -end - -return trigger diff --git a/lua/blink/cmp/completion/trigger/context.lua b/lua/blink/cmp/completion/trigger/context.lua new file mode 100644 index 00000000..c0267e3d --- /dev/null +++ b/lua/blink/cmp/completion/trigger/context.lua @@ -0,0 +1,169 @@ +-- TODO: remove the end_col field from ContextBounds + +--- @class blink.cmp.ContextBounds +--- @field line string +--- @field line_number number +--- @field start_col number +--- @field end_col number +--- @field length number + +--- @class blink.cmp.Context +--- @field id number +--- @field bufnr number +--- @field cursor number[] +--- @field line string +--- @field bounds blink.cmp.ContextBounds +--- @field trigger { kind: number, character: string | nil } +--- @field providers string[] +--- +--- @field new fun(opts: blink.cmp.ContextOpts): blink.cmp.Context +--- @field get_keyword fun(self: blink.cmp.Context): string +--- @field within_query_bounds fun(self: blink.cmp.Context, cursor: number[]): boolean +--- +--- @field get_mode fun(): blink.cmp.Mode +--- @field get_cursor fun(): number[] +--- @field set_cursor fun(cursor: number[]) +--- @field get_line fun(): string +--- @field get_context_bounds fun(line: string, cursor: number[]): blink.cmp.ContextBounds +--- @field get_regex_around_cursor fun(range: string, regex_str: string, exclude_from_prefix_regex_str: string): { start_col: number, length: number } + +--- @class blink.cmp.ContextOpts +--- @field id number +--- @field providers string[] +--- @field trigger_character? string + +local keyword_regex = vim.regex(require('blink.cmp.config').completion.keyword.regex) + +--- @type blink.cmp.Context +--- @diagnostic disable-next-line: missing-fields +local context = {} + +function context.new(opts) + local cursor = context.get_cursor() + local line = context.get_line() + + return setmetatable({ + mode = context.get_mode(), + id = opts.id, + bufnr = vim.api.nvim_get_current_buf(), + cursor = cursor, + line = line, + bounds = context.get_context_bounds(line, cursor), + trigger = { + kind = opts.trigger_character and vim.lsp.protocol.CompletionTriggerKind.TriggerCharacter + or vim.lsp.protocol.CompletionTriggerKind.Invoked, + character = opts.trigger_character, + }, + providers = opts.providers, + }, { __index = context }) +end + +function context:get_keyword() + local keyword = require('blink.cmp.config').completion.keyword + local range = self.get_regex_around_cursor(keyword.range, keyword.regex, keyword.exclude_from_prefix_regex) + return string.sub(self.line, range.start_col, range.start_col + range.length - 1) +end + +--- @param cursor number[] +--- @return boolean +function context:within_query_bounds(cursor) + local row, col = cursor[1], cursor[2] + local bounds = self.bounds + return row == bounds.line_number and col >= bounds.start_col and col <= bounds.end_col +end + +function context.get_mode() return vim.api.nvim_get_mode().mode == 'c' and 'command' or 'default' end + +function context.get_cursor() + return context.get_mode() == 'command' and { 1, vim.fn.getcmdpos() - 1 } or vim.api.nvim_win_get_cursor(0) +end + +function context.set_cursor(cursor) + local mode = context.get_mode() + if mode == 'default' then return vim.api.nvim_win_set_cursor(0, cursor) end + + assert(mode == 'command', 'Unsupported mode for setting cursor: ' .. mode) + assert(cursor[1] == 1, 'Cursor must be on the first line in command mode') + vim.fn.setcmdpos(cursor[2]) +end + +function context.get_line() + return context.get_mode() == 'command' and vim.fn.getcmdline() + or vim.api.nvim_buf_get_lines(0, context.get_cursor()[1] - 1, context.get_cursor()[1], false)[1] +end + +--- Moves forward and backwards around the cursor looking for word boundaries +function context.get_context_bounds(line, cursor) + local cursor_line = cursor[1] + local cursor_col = cursor[2] + + local start_col = cursor_col + while start_col >= 1 do + local char = line:sub(start_col, start_col) + if keyword_regex:match_str(char) == nil then + start_col = start_col + 1 + break + end + start_col = start_col - 1 + end + start_col = math.max(start_col, 1) + + local end_col = cursor_col + while end_col < #line do + local char = line:sub(end_col + 1, end_col + 1) + if keyword_regex:match_str(char) == nil then break end + end_col = end_col + 1 + end + + -- hack: why do we have to math.min here? + start_col = math.min(start_col, end_col) + + local length = end_col - start_col + 1 + -- Since sub(1, 1) returns a single char string, we need to check if that single char matches + -- and otherwise mark the length as 0 + if start_col == end_col and keyword_regex:match_str(line:sub(start_col, end_col)) == nil then length = 0 end + + return { line_number = cursor_line, start_col = start_col, end_col = end_col, length = length } +end + +--- Gets characters around the cursor and returns the range, 0-indexed +function context.get_regex_around_cursor(range, regex_str, exclude_from_prefix_regex_str) + local line = context.get_line() + local current_col = context.get_cursor()[2] + 1 + + local backward_regex = vim.regex('\\(' .. regex_str .. '\\)\\+$') + local forward_regex = vim.regex('^\\(' .. regex_str .. '\\)\\+') + + local length = 0 + local start_col = current_col + + -- Search backward for the start of the word + local line_before = line:sub(1, current_col - 1) + local before_match_start, _ = backward_regex:match_str(line_before) + if before_match_start ~= nil then + start_col = before_match_start + 1 + length = current_col - start_col + end + + -- Search forward for the end of the word if configured + if range == 'full' then + local line_after = line:sub(current_col) + local _, after_match_end = forward_regex:match_str(line_after) + if after_match_end ~= nil then length = length + after_match_end end + end + + -- exclude characters matching exclude_prefix_regex from the beginning of the bounds + if exclude_from_prefix_regex_str ~= nil then + local exclude_from_prefix_regex = vim.regex(exclude_from_prefix_regex_str) + while length > 0 do + local char = line:sub(start_col, start_col) + if exclude_from_prefix_regex:match_str(char) == nil then break end + start_col = start_col + 1 + length = length - 1 + end + end + + return { start_col = start_col, length = length } +end + +return context diff --git a/lua/blink/cmp/completion/trigger/init.lua b/lua/blink/cmp/completion/trigger/init.lua new file mode 100644 index 00000000..9dff0578 --- /dev/null +++ b/lua/blink/cmp/completion/trigger/init.lua @@ -0,0 +1,199 @@ +-- 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 command_events blink.cmp.CommandEvents +--- @field current_context_id number +--- @field context? blink.cmp.Context +--- @field show_emitter blink.cmp.EventEmitter<{ context: blink.cmp.Context }> +--- @field hide_emitter blink.cmp.EventEmitter<{}> +--- +--- @field activate fun() +--- @field is_trigger_character fun(char: string, is_retrigger?: boolean): boolean +--- @field suppress_events_for_callback fun(cb: fun()) +--- @field show_if_on_trigger_character fun(opts?: { is_accept?: boolean }) +--- @field show fun(opts?: { trigger_character?: string, force?: boolean, send_upstream?: boolean, providers?: string[] }) +--- @field hide fun() +--- @field within_query_bounds fun(cursor: number[]): boolean +--- @field get_context_bounds fun(regex: vim.regex, line: string, cursor: number[]): blink.cmp.ContextBounds + +local keyword_config = require('blink.cmp.config').completion.keyword +local config = require('blink.cmp.config').completion.trigger +local context = require('blink.cmp.completion.trigger.context') + +local keyword_regex = vim.regex(keyword_config.regex) + +--- @type blink.cmp.CompletionTrigger +--- @diagnostic disable-next-line: missing-fields +local trigger = { + current_context_id = -1, + show_emitter = require('blink.cmp.lib.event_emitter').new('show'), + hide_emitter = require('blink.cmp.lib.event_emitter').new('hide'), +} + +function trigger.activate() + trigger.buffer_events = require('blink.cmp.lib.buffer_events').new({ + has_context = function() return trigger.context ~= nil end, + show_in_snippet = config.show_in_snippet, + }) + trigger.command_events = require('blink.cmp.lib.command_events').new() + + local function on_char_added(char, is_ignored) + -- we were told to ignore the text changed event, so we update the context + -- but don't send an on_show event upstream + if is_ignored then + if trigger.context ~= nil then trigger.show({ send_upstream = false }) end + + -- character forces a trigger according to the sources, create a fresh context + elseif trigger.is_trigger_character(char) and (config.show_on_trigger_character or trigger.context ~= nil) then + trigger.context = nil + trigger.show({ trigger_character = char }) + + -- character is part of a keyword + elseif keyword_regex:match_str(char) ~= nil and (config.show_on_keyword or trigger.context ~= nil) then + trigger.show() + + -- nothing matches so hide + else + trigger.hide() + end + end + + local function on_cursor_moved(event, is_ignored) + -- 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 == 'CursorMovedI' then + if trigger.context ~= nil then trigger.show({ send_upstream = false }) end + return + end + + local cursor = context.get_cursor() + local cursor_col = cursor[2] + local char_under_cursor = context.get_line():sub(cursor_col, cursor_col) + local is_on_trigger_for_show = trigger.is_trigger_character(char_under_cursor) + local is_on_trigger_for_show_on_insert = trigger.is_trigger_character(char_under_cursor, true) + local is_on_keyword_char = keyword_regex:match_str(char_under_cursor) ~= nil + + local insert_enter_on_trigger_character = config.show_on_trigger_character + and config.show_on_insert_on_trigger_character + and is_on_trigger_for_show_on_insert + and event == 'InsertEnter' + + -- check if we're still within the bounds of the query used for the context + if trigger.context ~= nil and trigger.context:within_query_bounds(cursor) then + trigger.show() + + -- check if we've entered insert mode on a trigger character + -- or if we've moved onto a trigger character (by accepting for example) + elseif insert_enter_on_trigger_character or (is_on_trigger_for_show and trigger.context ~= nil) then + trigger.context = nil + trigger.show({ 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_on_keyword_char and trigger.context ~= nil and cursor_col == trigger.context.bounds.start_col - 1 then + trigger.context = nil + trigger.show() + + -- otherwise hide + else + trigger.hide() + end + end + + trigger.buffer_events:listen({ + on_char_added = on_char_added, + on_cursor_moved = on_cursor_moved, + on_insert_leave = function() trigger.hide() end, + }) + trigger.command_events:listen({ + on_char_added = on_char_added, + on_cursor_moved = on_cursor_moved, + on_leave = function() trigger.hide() end, + }) +end + +function trigger.is_trigger_character(char, is_show_on_x) + local sources = require('blink.cmp.sources.lib') + local is_trigger = vim.tbl_contains(sources.get_trigger_characters(context.get_mode()), char) + + local show_on_blocked_trigger_characters = type(config.show_on_blocked_trigger_characters) == 'function' + and config.show_on_blocked_trigger_characters() + or config.show_on_blocked_trigger_characters + --- @cast show_on_blocked_trigger_characters string[] + local show_on_x_blocked_trigger_characters = type(config.show_on_x_blocked_trigger_characters) == 'function' + and config.show_on_x_blocked_trigger_characters() + or config.show_on_x_blocked_trigger_characters + --- @cast show_on_x_blocked_trigger_characters string[] + + local is_blocked = vim.tbl_contains(show_on_blocked_trigger_characters, char) + or (is_show_on_x and vim.tbl_contains(show_on_x_blocked_trigger_characters, char)) + + return is_trigger and not is_blocked +end + +--- Suppresses on_hide and on_show events for the duration of the callback +function trigger.suppress_events_for_callback(cb) + local mode = vim.api.nvim_get_mode().mode == 'c' and 'command' or 'default' + + local events = mode == 'default' and trigger.buffer_events or trigger.command_events + if not events then return cb() end + + events:suppress_events_for_callback(cb) +end + +function trigger.show_if_on_trigger_character(opts) + if + (opts and opts.is_accept) + and (not config.show_on_trigger_character or not config.show_on_accept_on_trigger_character) + then + return + end + + local cursor_col = vim.api.nvim_win_get_cursor(0)[2] + local char_under_cursor = vim.api.nvim_get_current_line():sub(cursor_col, cursor_col) + + if trigger.is_trigger_character(char_under_cursor, true) then + trigger.show({ trigger_character = char_under_cursor }) + end +end + +function trigger.show(opts) + opts = opts or {} + + -- already triggered at this position, ignore + local mode = context.get_mode() + local cursor = context.get_cursor() + if + not opts.force + and trigger.context ~= nil + and trigger.context.mode == mode + and cursor[1] == trigger.context.cursor[1] + and cursor[2] == trigger.context.cursor[2] + then + return + end + + -- update the context id to indicate a new context, and not an update to an existing context + if trigger.context == nil or opts.providers ~= nil then + trigger.current_context_id = trigger.current_context_id + 1 + end + + local providers = opts.providers + or (trigger.context and trigger.context.providers) + or require('blink.cmp.sources.lib').get_enabled_provider_ids(context.get_mode()) + + trigger.context = + context.new({ id = trigger.current_context_id, providers = providers, trigger_character = opts.trigger_character }) + + if opts.send_upstream ~= false then trigger.show_emitter:emit({ context = trigger.context }) end +end + +function trigger.hide() + if not trigger.context then return end + trigger.context = nil + trigger.hide_emitter:emit() +end + +return trigger diff --git a/lua/blink/cmp/completion/windows/menu.lua b/lua/blink/cmp/completion/windows/menu.lua index 7ca30c4e..774a83a8 100644 --- a/lua/blink/cmp/completion/windows/menu.lua +++ b/lua/blink/cmp/completion/windows/menu.lua @@ -13,6 +13,7 @@ --- @field close fun() --- @field set_selected_item_idx fun(idx?: number) --- @field update_position fun() +--- @field redraw_if_needed fun() local config = require('blink.cmp.config').completion.menu @@ -27,7 +28,7 @@ local menu = { winhighlight = config.winhighlight, cursorline = false, scrolloff = config.scrolloff, - scrollbar = config.scrollbar, + scrollbar = false, filetype = 'blink-cmp-menu', }), items = {}, @@ -51,7 +52,7 @@ function menu.open_with_items(context, items) menu.selected_item_idx = menu.selected_item_idx ~= nil and math.min(menu.selected_item_idx, #items) or nil if not menu.renderer then menu.renderer = require('blink.cmp.completion.windows.render').new(config.draw) end - menu.renderer:draw(menu.win:get_buf(), items) + menu.renderer:draw(context, menu.win:get_buf(), items) if menu.auto_show then menu.open() @@ -76,12 +77,16 @@ function menu.close() menu.win:close() menu.close_emitter:emit() + menu.redraw_if_needed() end function menu.set_selected_item_idx(idx) menu.win:set_option_value('cursorline', idx ~= nil) menu.selected_item_idx = idx - if menu.win:is_open() then vim.api.nvim_win_set_cursor(menu.win:get_win(), { idx or 1, 0 }) end + if menu.win:is_open() then + vim.api.nvim_win_set_cursor(menu.win:get_win(), { idx or 1, 0 }) + menu.redraw_if_needed() + end end --- TODO: Don't switch directions if the context is the same @@ -111,10 +116,34 @@ function menu.update_position() local cursor_col = vim.api.nvim_win_get_cursor(0)[2] local col = context.bounds.start_col - cursor_col - (context.bounds.length == 0 and 0 or 1) - border_size.left local row = pos.direction == 's' and 1 or -pos.height - border_size.vertical - vim.api.nvim_win_set_config(winnr, { relative = 'cursor', row = row, col = col - start_col }) + if vim.api.nvim_get_mode().mode == 'c' then + vim.api.nvim_win_set_config(winnr, { + relative = 'editor', + row = vim.o.lines + row - 1, + col = math.max(col - start_col, 0), + }) + else + vim.api.nvim_win_set_config(winnr, { relative = 'cursor', row = row, col = col - start_col }) + end vim.api.nvim_win_set_height(winnr, pos.height) menu.position_update_emitter:emit() + menu.redraw_if_needed() +end + +local redraw_queued = false +--- In command mode, the window won't be redrawn automatically so we redraw ourselves on schedule +function menu.redraw_if_needed() + if vim.api.nvim_get_mode().mode ~= 'c' or menu.win:get_win() == nil then return end + if redraw_queued then return end + + -- We redraw on schedule to avoid the cmdline disappearing during redraw + -- and to batch multiple redraws together + redraw_queued = true + vim.schedule(function() + redraw_queued = false + vim.api.nvim__redraw({ win = menu.win:get_win(), flush = true }) + end) end return menu diff --git a/lua/blink/cmp/completion/windows/render/context.lua b/lua/blink/cmp/completion/windows/render/context.lua index cf72b447..43fa7b80 100644 --- a/lua/blink/cmp/completion/windows/render/context.lua +++ b/lua/blink/cmp/completion/windows/render/context.lua @@ -13,19 +13,21 @@ --- @field source_id string --- @field source_name string -local context = {} +local draw_context = {} +--- @param context blink.cmp.Context --- @param draw blink.cmp.Draw --- @param items blink.cmp.CompletionItem[] --- @return blink.cmp.DrawItemContext[] -function context.get_from_items(draw, items) - local fuzzy = require('blink.cmp.fuzzy') - local matched_indices = - fuzzy.fuzzy_matched_indices(fuzzy.get_query(), vim.tbl_map(function(item) return item.label end, items)) +function draw_context.get_from_items(context, draw, items) + local matched_indices = require('blink.cmp.fuzzy').fuzzy_matched_indices( + context:get_keyword(), + vim.tbl_map(function(item) return item.label end, items) + ) local ctxs = {} for idx, item in ipairs(items) do - ctxs[idx] = context.new(draw, idx, item, matched_indices[idx]) + ctxs[idx] = draw_context.new(draw, idx, item, matched_indices[idx]) end return ctxs end @@ -35,7 +37,7 @@ end --- @param item blink.cmp.CompletionItem --- @param matched_indices number[] --- @return blink.cmp.DrawItemContext -function context.new(draw, item_idx, item, matched_indices) +function draw_context.new(draw, item_idx, item, matched_indices) local config = require('blink.cmp.config').appearance local kind = require('blink.cmp.types').CompletionItemKind[item.kind] or 'Unknown' local kind_icon = config.kind_icons[kind] or config.kind_icons.Field @@ -70,4 +72,4 @@ function context.new(draw, item_idx, item, matched_indices) } end -return context +return draw_context diff --git a/lua/blink/cmp/completion/windows/render/init.lua b/lua/blink/cmp/completion/windows/render/init.lua index cb6bc340..70fa7ba5 100644 --- a/lua/blink/cmp/completion/windows/render/init.lua +++ b/lua/blink/cmp/completion/windows/render/init.lua @@ -5,7 +5,7 @@ --- @field columns blink.cmp.DrawColumn[] --- --- @field new fun(draw: blink.cmp.Draw): blink.cmp.Renderer ---- @field draw fun(self: blink.cmp.Renderer, bufnr: number, items: blink.cmp.CompletionItem[]) +--- @field draw fun(self: blink.cmp.Renderer, context: blink.cmp.Context, bufnr: number, items: blink.cmp.CompletionItem[]) --- @field get_component_column_location fun(self: blink.cmp.Renderer, component_name: string): { column_idx: number, component_idx: number } --- @field get_component_start_col fun(self: blink.cmp.Renderer, component_name: string): number --- @field get_alignment_start_col fun(self: blink.cmp.Renderer): number @@ -52,18 +52,18 @@ function renderer.new(draw) return self end -function renderer:draw(bufnr, items) +function renderer:draw(context, bufnr, items) -- gather contexts - local ctxs = require('blink.cmp.completion.windows.render.context').get_from_items(self.def, items) + local draw_contexts = require('blink.cmp.completion.windows.render.context').get_from_items(context, self.def, items) -- render the columns for _, column in ipairs(self.columns) do - column:render(ctxs) + column:render(draw_contexts) end -- apply to the buffer local lines = {} - for idx, _ in ipairs(ctxs) do + for idx, _ in ipairs(draw_contexts) do local line = '' if self.padding[1] > 0 then line = string.rep(' ', self.padding[1]) end diff --git a/lua/blink/cmp/config/sources.lua b/lua/blink/cmp/config/sources.lua index 1acea657..d067fa61 100644 --- a/lua/blink/cmp/config/sources.lua +++ b/lua/blink/cmp/config/sources.lua @@ -16,6 +16,7 @@ --- ``` --- @field default string[] | fun(): string[] --- @field per_filetype table +--- @field command string[] | fun(): string[] --- @field providers table --- @class blink.cmp.SourceProviderConfig @@ -39,6 +40,11 @@ local sources = { default = { default = { 'lsp', 'path', 'snippets', 'buffer' }, per_filetype = {}, + command = function() + local type = vim.fn.getcmdtype() + if type == '/' or type == '?' then return { 'buffer' } end + return { 'command' } + end, providers = { lsp = { name = 'LSP', @@ -64,6 +70,10 @@ local sources = { name = 'Buffer', module = 'blink.cmp.sources.buffer', }, + command = { + name = 'Command', + module = 'blink.cmp.sources.command', + }, }, }, } diff --git a/lua/blink/cmp/fuzzy/init.lua b/lua/blink/cmp/fuzzy/init.lua index 31fdfa40..064e5b8b 100644 --- a/lua/blink/cmp/fuzzy/init.lua +++ b/lua/blink/cmp/fuzzy/init.lua @@ -71,17 +71,4 @@ function fuzzy.fuzzy(needle, haystacks_by_provider) return require('blink.cmp.fuzzy.sort').sort(filtered_items) end ---- Gets the text under the cursor to be used for fuzzy matching ---- @return string -function fuzzy.get_query() - local line = vim.api.nvim_get_current_line() - local keyword = config.completion.keyword - local range = require('blink.cmp.lib.utils').get_regex_around_cursor( - keyword.range, - keyword.regex, - keyword.exclude_from_prefix_regex - ) - return string.sub(line, range.start_col, range.start_col + range.length - 1) -end - return fuzzy diff --git a/lua/blink/cmp/keymap/apply.lua b/lua/blink/cmp/keymap/apply.lua index b172e79b..a25371b0 100644 --- a/lua/blink/cmp/keymap/apply.lua +++ b/lua/blink/cmp/keymap/apply.lua @@ -15,7 +15,6 @@ function apply.keymap_to_current_buffer(keys_to_commands) if #commands == 0 then goto continue end local fallback = require('blink.cmp.keymap.fallback').wrap('i', key) - apply.set('i', key, function() for _, command in ipairs(commands) do -- special case for fallback @@ -36,7 +35,7 @@ function apply.keymap_to_current_buffer(keys_to_commands) ::continue:: end - -- snippet mode + -- snippet mode: uses only snippet commands for key, commands in pairs(keys_to_commands) do local has_snippet_command = false for _, command in ipairs(commands) do @@ -68,18 +67,61 @@ function apply.keymap_to_current_buffer(keys_to_commands) end end +function apply.command_mode_keymaps(keys_to_commands) + -- command mode: uses only insert commands + for key, commands in pairs(keys_to_commands) do + local has_insert_command = false + for _, command in ipairs(commands) do + has_insert_command = has_insert_command or not vim.tbl_contains(snippet_commands, command) + end + if not has_insert_command or #commands == 0 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' then + return fallback() + + -- 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) - vim.api.nvim_buf_set_keymap(0, mode, key, '', { - callback = callback, - expr = true, - silent = true, - noremap = true, - replace_keycodes = false, - desc = 'blink.cmp', - }) + if mode == 'c' then + vim.api.nvim_set_keymap(mode, key, '', { + callback = callback, + expr = true, + silent = true, + 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 index 15c86eab..d02c5357 100644 --- a/lua/blink/cmp/keymap/fallback.lua +++ b/lua/blink/cmp/keymap/fallback.lua @@ -44,7 +44,7 @@ end --- @param key string --- @return fun(): string? function fallback.wrap(mode, key) - local buffer_mapping = fallback.get_non_blink_buffer_mapping_for_key(mode, key) + local buffer_mapping = mode ~= 'c' and fallback.get_non_blink_buffer_mapping_for_key(mode, key) or nil return function() 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 diff --git a/lua/blink/cmp/keymap/init.lua b/lua/blink/cmp/keymap/init.lua index 0b0d5973..cc79fa4b 100644 --- a/lua/blink/cmp/keymap/init.lua +++ b/lua/blink/cmp/keymap/init.lua @@ -30,6 +30,9 @@ function keymap.setup() if vim.api.nvim_get_mode().mode == 'i' and require('blink.cmp.config').enabled() then require('blink.cmp.keymap.apply').keymap_to_current_buffer(mappings) end + + -- Always apply command mode keymaps since they're global + require('blink.cmp.keymap.apply').command_mode_keymaps(mappings) end return keymap diff --git a/lua/blink/cmp/lib/command_events.lua b/lua/blink/cmp/lib/command_events.lua new file mode 100644 index 00000000..341f8976 --- /dev/null +++ b/lua/blink/cmp/lib/command_events.lua @@ -0,0 +1,104 @@ +--- @class blink.cmp.CommandEvents +--- @field has_context fun(): boolean +--- @field ignore_next_text_changed boolean +--- @field ignore_next_cursor_moved boolean +--- +--- @field new fun(): blink.cmp.CommandEvents +--- @field listen fun(self: blink.cmp.CommandEvents, opts: blink.cmp.CommandEventsListener) +--- @field suppress_events_for_callback fun(self: blink.cmp.CommandEvents, cb: fun()) + +--- @class blink.cmp.CommandEventsListener +--- @field on_char_added fun(char: string, is_ignored: boolean) +--- @field on_cursor_moved fun(event: 'CursorMovedI' | 'InsertEnter', is_ignored: boolean) +--- @field on_leave fun() + +--- @type blink.cmp.CommandEvents +--- @diagnostic disable-next-line: missing-fields +local command_events = {} + +function command_events.new() + return setmetatable({ + ignore_next_text_changed = false, + ignore_next_cursor_moved = false, + }, { __index = command_events }) +end + +function command_events:listen(opts) + local previous_cmdline = '' + + vim.api.nvim_create_autocmd('CmdlineEnter', { + callback = function() previous_cmdline = '' end, + }) + + vim.api.nvim_create_autocmd('CmdlineChanged', { + callback = function() + local cmdline = vim.fn.getcmdline() + local cursor_col = vim.fn.getcmdpos() + + local is_text_changed_ignored = self.ignore_next_text_changed + self.ignore_next_text_changed = false + + -- added a character + if #cmdline > #previous_cmdline then + local new_char = cmdline:sub(cursor_col - 1, cursor_col - 1) + opts.on_char_added(new_char, is_text_changed_ignored) + end + previous_cmdline = cmdline + end, + }) + + if vim.fn.has('nvim-0.11.0') == 1 then + vim.api.nvim_create_autocmd('CursorMovedC', { + callback = function() + local is_cursor_moved_ignored = self.ignore_next_cursor_moved + self.ignore_next_cursor_moved = false + + opts.on_cursor_moved('CursorMovedI', is_cursor_moved_ignored) + end, + }) + else + -- HACK: check every 16ms (60 times/second) to see if the cursor moved + -- for neovim < 0.11 + local timer = vim.uv.new_timer() + local previous_cursor + local callback + callback = vim.schedule_wrap(function() + timer:start(16, 0, callback) + if vim.api.nvim_get_mode().mode ~= 'c' then return end + + local cursor = vim.fn.getcmdpos() + if cursor == previous_cursor then return end + previous_cursor = cursor + + local is_cursor_moved_ignored = self.ignore_next_cursor_moved + self.ignore_next_cursor_moved = false + + opts.on_cursor_moved('CursorMovedI', is_cursor_moved_ignored) + end) + timer:start(16, 0, callback) + 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 command_events:suppress_events_for_callback(cb) + local cursor_before = vim.fn.getcmdpos() + local text_before = vim.fn.getcmdline() + + cb() + + local cursor_after = vim.fn.getcmdpos() + local text_after = vim.fn.getcmdline() + + if not vim.api.nvim_get_mode().mode == 'c' then return end + + self.ignore_next_text_changed = text_after ~= text_before + -- TODO: does this guarantee that the CmdlineChanged event will fire? + self.ignore_next_cursor_moved = cursor_after ~= cursor_before +end + +return command_events diff --git a/lua/blink/cmp/lib/text_edits.lua b/lua/blink/cmp/lib/text_edits.lua index b1475262..40287922 100644 --- a/lua/blink/cmp/lib/text_edits.lua +++ b/lua/blink/cmp/lib/text_edits.lua @@ -1,9 +1,26 @@ local config = require('blink.cmp.config') +local context = require('blink.cmp.completion.trigger.context') + local text_edits = {} --- Applies one or more text edits to the current buffer, assuming utf-8 encoding --- @param edits lsp.TextEdit[] -function text_edits.apply(edits) vim.lsp.util.apply_text_edits(edits, vim.api.nvim_get_current_buf(), 'utf-8') end +function text_edits.apply(edits) + local mode = context.get_mode() + if mode == 'default' then vim.lsp.util.apply_text_edits(edits, vim.api.nvim_get_current_buf(), 'utf-8') end + + assert(mode == 'command', 'Unsupported mode for text edits: ' .. mode) + assert(#edits == 1, 'Command mode only supports one text edit. Contributions welcome!') + + local edit = edits[1] + local line = context.get_line() + 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, edit.range.start.character + #edit.newText + 1) +end ------- Undo ------- @@ -145,14 +162,11 @@ end --- TODO: doesnt work when the item contains characters not included in the context regex function text_edits.guess(item) local word = item.insertText or item.label + local context = require('blink.cmp.completion.trigger.context') local keyword = config.completion.keyword - local range = require('blink.cmp.lib.utils').get_regex_around_cursor( - keyword.range, - keyword.regex, - keyword.exclude_from_prefix_regex - ) - local current_line = vim.api.nvim_win_get_cursor(0)[1] + local range = context.get_regex_around_cursor(keyword.range, keyword.regex, keyword.exclude_from_prefix_regex) + local current_line = context.get_cursor()[1] -- convert to 0-index return { diff --git a/lua/blink/cmp/lib/utils.lua b/lua/blink/cmp/lib/utils.lua index 7df5494d..e047b7cc 100644 --- a/lua/blink/cmp/lib/utils.lua +++ b/lua/blink/cmp/lib/utils.lua @@ -40,51 +40,6 @@ function utils.deduplicate(arr) return vim.tbl_keys(hash) end ---- Gets characters around the cursor and returns the range, 0-indexed ---- @param range 'prefix' | 'full' ---- @param regex_str string ---- @param exclude_from_prefix_regex_str string ---- @return { start_col: number, length: number } ---- TODO: switch to return start_col, length to simplify downstream logic -function utils.get_regex_around_cursor(range, regex_str, exclude_from_prefix_regex_str) - local backward_regex = vim.regex('\\(' .. regex_str .. '\\)\\+$') - local forward_regex = vim.regex('^\\(' .. regex_str .. '\\)\\+') - - local current_col = vim.api.nvim_win_get_cursor(0)[2] + 1 - local line = vim.api.nvim_get_current_line() - - local length = 0 - local start_col = current_col - - -- Search backward for the start of the word - local line_before = line:sub(1, current_col - 1) - local before_match_start, _ = backward_regex:match_str(line_before) - if before_match_start ~= nil then - start_col = before_match_start + 1 - length = current_col - start_col - end - - -- Search forward for the end of the word if configured - if range == 'full' then - local line_after = line:sub(current_col) - local _, after_match_end = forward_regex:match_str(line_after) - if after_match_end ~= nil then length = length + after_match_end end - end - - -- exclude characters matching exclude_prefix_regex from the beginning of the bounds - if exclude_from_prefix_regex_str ~= nil then - local exclude_from_prefix_regex = vim.regex(exclude_from_prefix_regex_str) - while length > 0 do - local char = line:sub(start_col, start_col) - if exclude_from_prefix_regex:match_str(char) == nil then break end - start_col = start_col + 1 - length = length - 1 - end - end - - return { start_col = start_col, length = length } -end - function utils.schedule_if_needed(fn) if vim.in_fast_event() then vim.schedule(fn) diff --git a/lua/blink/cmp/lib/window/init.lua b/lua/blink/cmp/lib/window/init.lua index dc1d5395..4c38aa14 100644 --- a/lua/blink/cmp/lib/window/init.lua +++ b/lua/blink/cmp/lib/window/init.lua @@ -227,6 +227,18 @@ function win.get_cursor_screen_position() local screen_height = vim.o.lines local screen_width = vim.o.columns + -- command line + if vim.api.nvim_get_mode().mode == 'c' then + local cursor_col = vim.fn.getcmdpos() + return { + distance_from_top = screen_height - 1, + distance_from_bottom = 0, + distance_from_left = cursor_col, + distance_from_right = screen_width - cursor_col, + } + end + + -- buffer local cursor_line, cursor_column = unpack(vim.api.nvim_win_get_cursor(0)) -- todo: convert cursor_column to byte index local pos = vim.fn.screenpos(vim.api.nvim_win_get_number(0), cursor_line, cursor_column) @@ -265,14 +277,23 @@ function win:get_direction_with_window_constraints(anchor_win, direction_priorit local cursor_constraints = self.get_cursor_screen_position() -- nvim.api.nvim_win_get_position doesn't return the correct position most of the time - -- so we calculate the position ourselves, and assume the anchor window uses relative = 'win' + -- so we calculate the position ourselves + local anchor_config local anchor_win_config = vim.api.nvim_win_get_config(anchor_win:get_win()) - assert(anchor_win_config.relative == 'win', 'The anchor window must be relative to a window') - local anchor_relative_win_position = vim.api.nvim_win_get_position(anchor_win_config.win) - local anchor_config = { - row = anchor_win_config.row + anchor_relative_win_position[1] + 1, - col = anchor_win_config.col + anchor_relative_win_position[2] + 1, - } + if anchor_win_config.relative == 'win' then + local anchor_relative_win_position = vim.api.nvim_win_get_position(anchor_win_config.win) + anchor_config = { + row = anchor_win_config.row + anchor_relative_win_position[1] + 1, + col = anchor_win_config.col + anchor_relative_win_position[2] + 1, + } + elseif anchor_win_config.relative == 'editor' then + anchor_config = { + row = anchor_win_config.row + 1, + col = anchor_win_config.col + 1, + } + end + assert(anchor_config ~= nil, 'The anchor window must be relative to a window or the editor') + -- compensate for the anchor window being too wide given the screen width and configured column if anchor_config.col + anchor_win_config.width > vim.o.columns then anchor_config.col = vim.o.columns - anchor_win_config.width @@ -286,7 +307,7 @@ function win:get_direction_with_window_constraints(anchor_win, direction_priorit -- we want to avoid covering the cursor line, so we need to get the direction of the window -- that we're anchoring against - local cursor_screen_row = vim.fn.winline() + local cursor_screen_row = vim.api.nvim_get_mode().mode == 'c' and vim.o.lines - 1 or vim.fn.winline() local anchor_is_above_cursor = anchor_config.row - cursor_screen_row < 0 local screen_height = vim.o.lines diff --git a/lua/blink/cmp/lib/window/scrollbar/win.lua b/lua/blink/cmp/lib/window/scrollbar/win.lua index 8fb12f0e..5973f700 100644 --- a/lua/blink/cmp/lib/window/scrollbar/win.lua +++ b/lua/blink/cmp/lib/window/scrollbar/win.lua @@ -31,6 +31,8 @@ function scrollbar_win:show_thumb(geometry) local thumb_config = vim.tbl_deep_extend('force', thumb_existing_config, geometry) vim.api.nvim_win_set_config(self.thumb_win, thumb_config) end + + if vim.api.nvim_get_mode().mode == 'c' then vim.api.nvim__redraw({ win = self.thumb_win, flush = true }) end end function scrollbar_win:show_gutter(geometry) @@ -45,15 +47,21 @@ function scrollbar_win:show_gutter(geometry) local gutter_config = vim.tbl_deep_extend('force', gutter_existing_config, geometry) vim.api.nvim_win_set_config(self.gutter_win, gutter_config) end + + if vim.api.nvim_get_mode().mode == 'c' then vim.api.nvim__redraw({ win = self.gutter_win, flush = true }) end 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) end + if self.thumb_win and vim.api.nvim_win_is_valid(self.thumb_win) then + vim.api.nvim_win_close(self.thumb_win, true) + if vim.api.nvim_get_mode().mode == 'c' then vim.api.nvim__redraw({ flush = true }) end + 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) + if vim.api.nvim_get_mode().mode == 'c' then vim.api.nvim__redraw({ flush = true }) end end end diff --git a/lua/blink/cmp/signature/trigger.lua b/lua/blink/cmp/signature/trigger.lua index ab6bbb8f..95c1a573 100644 --- a/lua/blink/cmp/signature/trigger.lua +++ b/lua/blink/cmp/signature/trigger.lua @@ -75,7 +75,10 @@ function trigger.activate() end function trigger.is_trigger_character(char, is_retrigger) - local res = require('blink.cmp.sources.lib').get_signature_help_trigger_characters() + -- TODO: should the get_mode() be moved to sources or somewhere else? + local mode = require('blink.cmp.completion.trigger.context').get_mode() + + local res = require('blink.cmp.sources.lib').get_signature_help_trigger_characters(mode) local trigger_characters = is_retrigger and res.retrigger_characters or res.trigger_characters local is_trigger = vim.tbl_contains(trigger_characters, char) @@ -87,8 +90,11 @@ function trigger.is_trigger_character(char, is_retrigger) end function trigger.show_if_on_trigger_character() + if require('blink.cmp.completion.trigger.context').get_mode() ~= 'default' then return end + local cursor_col = vim.api.nvim_win_get_cursor(0)[2] local char_under_cursor = vim.api.nvim_get_current_line():sub(cursor_col, cursor_col) + -- TODO: accept a mode parameter here if we end up supporting more modes for signature help if trigger.is_trigger_character(char_under_cursor) then trigger.show({ trigger_character = char_under_cursor }) end end diff --git a/lua/blink/cmp/sources/command/init.lua b/lua/blink/cmp/sources/command/init.lua new file mode 100644 index 00000000..a932e50b --- /dev/null +++ b/lua/blink/cmp/sources/command/init.lua @@ -0,0 +1,173 @@ +local regex = require('blink.cmp.sources.command.regex') + +--- @class blink.cmp.Source +local cmdline = {} + +---@param word string +---@return boolean? +local function is_boolean_option(word) + local ok, opt = pcall(function() return vim.opt[word]:get() end) + if ok then return type(opt) == 'boolean' end +end + +---@class cmp.Cmdline.Definition +---@field ctype string +---@field regex string +---@field kind lsp.CompletionItemKind +---@field isIncomplete boolean +---@field exec fun(option: table, arglead: string, cmdline: string, force: boolean): lsp.CompletionItem[] +---@field fallback boolean? + +---@type cmp.Cmdline.Definition[] +local definitions = { + { + ctype = 'cmdline', + regex = [=[[^[:blank:]]*$]=], + kind = require('blink.cmp.types').CompletionItemKind.Variable, + isIncomplete = true, + ---@param option cmp-cmdline.Option + exec = function(option, arglead, target, force) + -- Ignore range only cmdline. (e.g.: 4, '<,'>) + if not force and regex.ONLY_RANGE_REGEX:match_str(target) then return {} end + + local _, parsed = pcall(function() + local s, e = regex.COUNT_RANGE_REGEX:match_str(target) + if s and e then target = target:sub(e + 1) end + -- nvim_parse_cmd throw error when the cmdline contains range specifier. + return vim.api.nvim_parse_cmd(target, {}) or {} + end) + parsed = parsed or {} + + -- Check ignore cmd. + if vim.tbl_contains(option.ignore_cmds, parsed.cmd) then return {} end + + -- Cleanup modifiers. + -- We can just remove modifiers because modifiers is always separated by space. + if arglead ~= target then + while true do + local s, e = regex.MODIFIER_REGEX:match_str(target) + if s == nil then break end + target = string.sub(target, e + 1) + end + end + + -- Support `lua vim.treesitter._get|` or `'<,'>del|` completion. + -- In this case, the `vim.fn.getcompletion` will return only `get_query` for `vim.treesitter.get_|`. + -- We should detect `vim.treesitter.` and `get_query` separately. + -- TODO: The `\h\w*` was choosed by huristic. We should consider more suitable detection. + local fixed_input + do + local suffix_pos = vim.regex([[\h\w*$]]):match_str(arglead) + fixed_input = string.sub(arglead, 1, suffix_pos or #arglead) + end + + -- The `vim.fn.getcompletion` does not return `*no*cursorline` option. + -- cmp-cmdline corrects `no` prefix for option name. + local is_option_name_completion = regex.OPTION_NAME_COMPLETION_REGEX:match_str(target) ~= nil + + --- create items. + local items = {} + local escaped = target:gsub([[\\]], [[\\\\]]) + for _, word_or_item in ipairs(vim.fn.getcompletion(escaped, 'cmdline')) do + local word = type(word_or_item) == 'string' and word_or_item or word_or_item.word + local item = { label = word } + table.insert(items, item) + if is_option_name_completion and is_boolean_option(word) then + table.insert( + items, + vim.tbl_deep_extend('force', {}, item, { + label = 'no' .. word, + filterText = word, + }) + ) + end + end + + -- fix label with `fixed_input` + for _, item in ipairs(items) do + if not string.find(item.label, fixed_input, 1, true) then item.label = fixed_input .. item.label end + end + + -- fix trailing slash for path like item + if option.treat_trailing_slash then + for _, item in ipairs(items) do + local is_target = string.match(item.label, [[/$]]) + is_target = is_target and not (string.match(item.label, [[~/$]])) + is_target = is_target and not (string.match(item.label, [[%./$]])) + is_target = is_target and not (string.match(item.label, [[%.%./$]])) + if is_target then item.label = item.label:sub(1, -2) end + end + end + return items + end, + }, +} + +function cmdline.new() + local self = setmetatable({}, { __index = cmdline }) + self.before_line = '' + self.offset = -1 + self.ctype = '' + self.items = {} + return self +end + +function cmdline:get_trigger_characters() return { ' ', '.', '#', '-' } end + +function cmdline:get_completions(context, callback) + local cursor_before_line = context.line:sub(0, context.cursor[2]) + + local offset = 0 + local ctype = '' + local items = {} + local kind + local isIncomplete = false + for _, def in ipairs(definitions) do + local s, e = vim.regex(def.regex):match_str(cursor_before_line) + if s and e then + offset = s + ctype = def.ctype + items = def.exec( + vim.tbl_deep_extend('keep', {}, regex.DEFAULT_OPTION), + string.sub(cursor_before_line, s + 1), + cursor_before_line, + false -- TODO: + ) + kind = def.kind + isIncomplete = def.isIncomplete + if not (#items == 0 and def.fallback) then break end + end + end + + local labels = {} + for _, item in ipairs(items) do + item.kind = kind + labels[item.label] = true + end + + -- `vim.fn.getcompletion` does not handle fuzzy matches. So, we must return all items, including items that were matched in the previous input. + local should_merge_previous_items = false + if #cursor_before_line > #self.before_line then + should_merge_previous_items = string.find(cursor_before_line, self.before_line, 1, true) == 1 + elseif #cursor_before_line < #self.before_line then + should_merge_previous_items = string.find(self.before_line, cursor_before_line, 1, true) == 1 + end + + if should_merge_previous_items and self.offset == offset and self.ctype == ctype then + for _, item in ipairs(self.items) do + if not labels[item.label] then table.insert(items, item) end + end + end + self.before_line = cursor_before_line + self.offset = offset + self.ctype = ctype + self.items = items + + callback({ + is_incomplete_backward = true, + is_incomplete_forward = isIncomplete, + items = items, + }) +end + +return cmdline diff --git a/lua/blink/cmp/sources/command/regex.lua b/lua/blink/cmp/sources/command/regex.lua new file mode 100644 index 00000000..003b66ba --- /dev/null +++ b/lua/blink/cmp/sources/command/regex.lua @@ -0,0 +1,60 @@ +---@param patterns string[] +---@param head boolean +---@return table #regex object +local function create_regex(patterns, head) + local pattern = [[\%(]] .. table.concat(patterns, [[\|]]) .. [[\)]] + if head then pattern = '^' .. pattern end + return vim.regex(pattern) +end + +---@class cmp-cmdline.Option +---@field treat_trailing_slash boolean +---@field ignore_cmds string[] +local DEFAULT_OPTION = { + treat_trailing_slash = true, + ignore_cmds = { 'Man', '!' }, +} + +local MODIFIER_REGEX = create_regex({ + [=[\s*abo\%[veleft]\s*]=], + [=[\s*bel\%[owright]\s*]=], + [=[\s*bo\%[tright]\s*]=], + [=[\s*bro\%[wse]\s*]=], + [=[\s*conf\%[irm]\s*]=], + [=[\s*hid\%[e]\s*]=], + [=[\s*keepal\s*t]=], + [=[\s*keeppa\%[tterns]\s*]=], + [=[\s*lefta\%[bove]\s*]=], + [=[\s*loc\%[kmarks]\s*]=], + [=[\s*nos\%[wapfile]\s*]=], + [=[\s*rightb\%[elow]\s*]=], + [=[\s*sil\%[ent]\s*]=], + [=[\s*tab\s*]=], + [=[\s*to\%[pleft]\s*]=], + [=[\s*verb\%[ose]\s*]=], + [=[\s*vert\%[ical]\s*]=], +}, true) + +local COUNT_RANGE_REGEX = create_regex({ + [=[\s*\%(\d\+\|\$\)\%[,\%(\d\+\|\$\)]\s*]=], + [=[\s*'\%[<,'>]\s*]=], + [=[\s*\%(\d\+\|\$\)\s*]=], +}, true) + +local ONLY_RANGE_REGEX = create_regex({ + [=[^\s*\%(\d\+\|\$\)\%[,\%(\d\+\|\$\)]\s*$]=], + [=[^\s*'\%[<,'>]\s*$]=], + [=[^\s*\%(\d\+\|\$\)\s*$]=], +}, true) + +local OPTION_NAME_COMPLETION_REGEX = create_regex({ + [=[se\%[tlocal][^=]*$]=], +}, true) + +return { + DEFAULT_OPTION = DEFAULT_OPTION, + MODIFIER_REGEX = MODIFIER_REGEX, + COUNT_RANGE_REGEX = COUNT_RANGE_REGEX, + ONLY_RANGE_REGEX = ONLY_RANGE_REGEX, + OPTION_NAME_COMPLETION_REGEX = OPTION_NAME_COMPLETION_REGEX, +} diff --git a/lua/blink/cmp/sources/lib/init.lua b/lua/blink/cmp/sources/lib/init.lua index f6c111ca..fa9bc0d3 100644 --- a/lua/blink/cmp/sources/lib/init.lua +++ b/lua/blink/cmp/sources/lib/init.lua @@ -2,16 +2,17 @@ local async = require('blink.cmp.lib.async') local config = require('blink.cmp.config') --- @class blink.cmp.Sources ---- @field completions_queue blink.cmp.SourcesContext | nil +--- @field completions_queue blink.cmp.SourcesQueue | nil --- @field current_signature_help blink.cmp.Task | nil --- @field sources_registered boolean --- @field providers table --- @field completions_emitter blink.cmp.EventEmitter --- --- @field get_all_providers fun(): blink.cmp.SourceProvider[] ---- @field get_enabled_provider_ids fun(): string[] ---- @field get_enabled_providers fun(): table ---- @field get_trigger_characters fun(): string[] +--- @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 emit_completions fun(context: blink.cmp.Context, responses: table) --- @field request_completions fun(context: blink.cmp.Context) @@ -21,7 +22,7 @@ local config = require('blink.cmp.config') --- @field resolve fun(item: blink.cmp.CompletionItem): blink.cmp.Task --- @field execute fun(context: blink.cmp.Context, item: blink.cmp.CompletionItem): blink.cmp.Task --- ---- @field get_signature_help_trigger_characters fun(): { trigger_characters: string[], retrigger_characters: string[] } +--- @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, callback: fun(signature_help: lsp.SignatureHelp | nil)) --- @field cancel_signature_help fun() --- @@ -48,15 +49,17 @@ function sources.get_all_providers() return providers end -function sources.get_enabled_provider_ids() - local enabled_providers = config.sources.per_filetype[vim.bo.filetype] or config.sources.default +function sources.get_enabled_provider_ids(mode) + local enabled_providers = mode ~= 'default' and config.sources[mode] + or config.sources.per_filetype[vim.bo.filetype] + or config.sources.default if type(enabled_providers) == 'function' then return enabled_providers() end --- @cast enabled_providers string[] return enabled_providers end -function sources.get_enabled_providers() - local mode_providers = sources.get_enabled_provider_ids() +function sources.get_enabled_providers(mode) + local mode_providers = sources.get_enabled_provider_ids(mode) --- @type table local providers = {} @@ -87,8 +90,8 @@ end --- Completion --- -function sources.get_trigger_characters() - local providers = sources.get_enabled_providers() +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()) @@ -189,12 +192,12 @@ end --- Signature help --- -function sources.get_signature_help_trigger_characters() +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()) do + 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) diff --git a/lua/blink/cmp/sources/lib/queue.lua b/lua/blink/cmp/sources/lib/queue.lua index 5f2b31e5..39479020 100644 --- a/lua/blink/cmp/sources/lib/queue.lua +++ b/lua/blink/cmp/sources/lib/queue.lua @@ -8,7 +8,7 @@ local async = require('blink.cmp.lib.async') --- @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, providers: table, on_completions_callback: fun(context: blink.cmp.Context, responses: table)): blink.cmp.SourcesContext +--- @field new fun(context: blink.cmp.Context, providers: table, 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) diff --git a/lua/blink/cmp/types.lua b/lua/blink/cmp/types.lua index 95ae85b0..f722a796 100644 --- a/lua/blink/cmp/types.lua +++ b/lua/blink/cmp/types.lua @@ -1,3 +1,5 @@ +--- @alias blink.cmp.Mode 'command' | 'default' + --- @class blink.cmp.CompletionItem : lsp.CompletionItem --- @field score_offset? number --- @field source_id string