diff options
| author | Mike Vink <mike@pionative.com> | 2025-01-19 13:52:52 +0100 |
|---|---|---|
| committer | Mike Vink <mike@pionative.com> | 2025-01-19 13:52:52 +0100 |
| commit | b77413ff8f59f380612074f0c9bd49093d8db695 (patch) | |
| tree | 32c39a811ba96ed4ab0a1c81cce9f8d518ed7e31 /lua/blink/cmp/completion/trigger | |
Squashed 'mut/neovim/pack/plugins/start/blink.cmp/' content from commit 1cc3b1a
git-subtree-dir: mut/neovim/pack/plugins/start/blink.cmp
git-subtree-split: 1cc3b1a908fbcfd15451c4772759549724f38524
Diffstat (limited to 'lua/blink/cmp/completion/trigger')
| -rw-r--r-- | lua/blink/cmp/completion/trigger/context.lua | 118 | ||||
| -rw-r--r-- | lua/blink/cmp/completion/trigger/init.lua | 241 | ||||
| -rw-r--r-- | lua/blink/cmp/completion/trigger/utils.lua | 30 |
3 files changed, 389 insertions, 0 deletions
diff --git a/lua/blink/cmp/completion/trigger/context.lua b/lua/blink/cmp/completion/trigger/context.lua new file mode 100644 index 0000000..6eb367d --- /dev/null +++ b/lua/blink/cmp/completion/trigger/context.lua @@ -0,0 +1,118 @@ +-- TODO: remove the end_col field from ContextBounds + +--- @class blink.cmp.ContextBounds +--- @field line string +--- @field line_number number +--- @field start_col number +--- @field length number + +--- @class blink.cmp.Context +--- @field mode blink.cmp.Mode +--- @field id number +--- @field bufnr number +--- @field cursor number[] +--- @field line string +--- @field bounds blink.cmp.ContextBounds +--- @field trigger blink.cmp.ContextTrigger +--- @field providers string[] +--- +--- @field new fun(opts: blink.cmp.ContextOpts): blink.cmp.Context +--- @field get_keyword fun(): string +--- @field within_query_bounds fun(self: blink.cmp.Context, cursor: number[]): boolean +--- +--- @field get_mode fun(): blink.cmp.Mode +--- @field get_cursor fun(): number[] +--- @field set_cursor fun(cursor: number[]) +--- @field get_line fun(num?: number): string +--- @field get_bounds fun(range: blink.cmp.CompletionKeywordRange): blink.cmp.ContextBounds + +--- @class blink.cmp.ContextTrigger +--- @field initial_kind blink.cmp.CompletionTriggerKind The trigger kind when the context was first created +--- @field initial_character? string The trigger character when initial_kind == 'trigger_character' +--- @field kind blink.cmp.CompletionTriggerKind The current trigger kind +--- @field character? string The trigger character when kind == 'trigger_character' + +--- @class blink.cmp.ContextOpts +--- @field id number +--- @field providers string[] +--- @field initial_trigger_kind blink.cmp.CompletionTriggerKind +--- @field initial_trigger_character? string +--- @field trigger_kind blink.cmp.CompletionTriggerKind +--- @field trigger_character? string + +--- @type blink.cmp.Context +--- @diagnostic disable-next-line: missing-fields +local context = {} + +function context.new(opts) + local cursor = context.get_cursor() + local line = context.get_line() + + return setmetatable({ + mode = context.get_mode(), + id = opts.id, + bufnr = vim.api.nvim_get_current_buf(), + cursor = cursor, + line = line, + bounds = context.get_bounds('full'), + trigger = { + initial_kind = opts.initial_trigger_kind, + initial_character = opts.initial_trigger_character, + kind = opts.trigger_kind, + character = opts.trigger_character, + }, + providers = opts.providers, + }, { __index = context }) +end + +function context.get_keyword() + local keyword = require('blink.cmp.config').completion.keyword + local range = context.get_bounds(keyword.range) + return string.sub(context.get_line(), range.start_col, range.start_col + range.length - 1) +end + +--- @param cursor number[] +--- @return boolean +function context:within_query_bounds(cursor) + local row, col = cursor[1], cursor[2] + local bounds = self.bounds + return row == bounds.line_number and col >= bounds.start_col and col < (bounds.start_col + bounds.length) +end + +function context.get_mode() return vim.api.nvim_get_mode().mode == 'c' and 'cmdline' or 'default' end + +function context.get_cursor() + return context.get_mode() == 'cmdline' and { 1, vim.fn.getcmdpos() - 1 } or vim.api.nvim_win_get_cursor(0) +end + +function context.set_cursor(cursor) + local mode = context.get_mode() + if mode == 'default' then return vim.api.nvim_win_set_cursor(0, cursor) end + + assert(mode == 'cmdline', 'Unsupported mode for setting cursor: ' .. mode) + assert(cursor[1] == 1, 'Cursor must be on the first line in cmdline mode') + vim.fn.setcmdpos(cursor[2]) +end + +function context.get_line(num) + if context.get_mode() == 'cmdline' then + assert( + num == nil or num == 0, + 'Cannot get line number ' .. tostring(num) .. ' in cmdline mode. Only 0 is supported' + ) + return vim.fn.getcmdline() + end + + if num == nil then num = context.get_cursor()[1] - 1 end + return vim.api.nvim_buf_get_lines(0, num, num + 1, false)[1] +end + +--- Gets characters around the cursor and returns the range, 0-indexed +function context.get_bounds(range) + local line = context.get_line() + local cursor = context.get_cursor() + local start_col, end_col = require('blink.cmp.fuzzy').get_keyword_range(line, cursor[2], range) + return { line_number = cursor[1], start_col = start_col + 1, length = end_col - start_col } +end + +return context diff --git a/lua/blink/cmp/completion/trigger/init.lua b/lua/blink/cmp/completion/trigger/init.lua new file mode 100644 index 0000000..1bd330f --- /dev/null +++ b/lua/blink/cmp/completion/trigger/init.lua @@ -0,0 +1,241 @@ +--- @alias blink.cmp.CompletionTriggerKind 'manual' | 'prefetch' | 'keyword' | 'trigger_character' +--- +-- Handles hiding and showing the completion window. When a user types a trigger character +-- (provided by the sources) or anything matching the `keyword_regex`, we create a new `context`. +-- This can be used downstream to determine if we should make new requests to the sources or not. +--- @class blink.cmp.CompletionTrigger +--- @field buffer_events blink.cmp.BufferEvents +--- @field cmdline_events blink.cmp.CmdlineEvents +--- @field current_context_id number +--- @field context? blink.cmp.Context +--- @field show_emitter blink.cmp.EventEmitter<{ context: blink.cmp.Context }> +--- @field hide_emitter blink.cmp.EventEmitter<{}> +--- +--- @field activate fun() +--- @field is_keyword_character fun(char: string): boolean +--- @field is_trigger_character fun(char: string, is_show_on_x?: boolean): boolean +--- @field suppress_events_for_callback fun(cb: fun()) +--- @field show_if_on_trigger_character fun(opts?: { is_accept?: boolean }) +--- @field show fun(opts?: { trigger_kind: blink.cmp.CompletionTriggerKind, trigger_character?: string, force?: boolean, send_upstream?: boolean, providers?: string[] }): blink.cmp.Context? +--- @field hide fun() +--- @field within_query_bounds fun(cursor: number[]): boolean +--- @field get_bounds fun(regex: vim.regex, line: string, cursor: number[]): blink.cmp.ContextBounds + +local config = require('blink.cmp.config').completion.trigger +local context = require('blink.cmp.completion.trigger.context') +local utils = require('blink.cmp.completion.trigger.utils') + +--- @type blink.cmp.CompletionTrigger +--- @diagnostic disable-next-line: missing-fields +local trigger = { + current_context_id = -1, + show_emitter = require('blink.cmp.lib.event_emitter').new('show'), + hide_emitter = require('blink.cmp.lib.event_emitter').new('hide'), +} + +function trigger.activate() + trigger.buffer_events = require('blink.cmp.lib.buffer_events').new({ + -- TODO: should this ignore trigger.kind == 'prefetch'? + has_context = function() return trigger.context ~= nil end, + show_in_snippet = config.show_in_snippet, + }) + trigger.cmdline_events = require('blink.cmp.lib.cmdline_events').new() + + local function on_char_added(char, is_ignored) + -- we were told to ignore the text changed event, so we update the context + -- but don't send an on_show event upstream + if is_ignored then + if trigger.context ~= nil then trigger.show({ send_upstream = false, trigger_kind = 'keyword' }) end + + -- character forces a trigger according to the sources, create a fresh context + elseif trigger.is_trigger_character(char) and (config.show_on_trigger_character or trigger.context ~= nil) then + trigger.context = nil + trigger.show({ trigger_kind = 'trigger_character', trigger_character = char }) + + -- character is part of a keyword + elseif trigger.is_keyword_character(char) and (config.show_on_keyword or trigger.context ~= nil) then + trigger.show({ trigger_kind = 'keyword' }) + + -- nothing matches so hide + else + trigger.hide() + end + end + + local function on_cursor_moved(event, is_ignored) + local cursor = context.get_cursor() + local cursor_col = cursor[2] + + local char_under_cursor = utils.get_char_at_cursor() + local is_keyword = trigger.is_keyword_character(char_under_cursor) + + -- we were told to ignore the cursor moved event, so we update the context + -- but don't send an on_show event upstream + if is_ignored and event == 'CursorMoved' then + if trigger.context ~= nil then + -- TODO: If we `auto_insert` with the `path` source, we may end up on a trigger character + -- i.e. `downloads/`. If we naively update the context, we'll show the menu with the + -- existing context. So we clear the context if we're not on a keyword character. + -- Is there a better solution here? + if not is_keyword then trigger.context = nil end + + trigger.show({ send_upstream = false, trigger_kind = 'keyword' }) + end + return + end + + local is_on_trigger_for_show = trigger.is_trigger_character(char_under_cursor) + + -- TODO: doesn't handle `a` where the cursor moves immediately after + -- Reproducable with `example.|a` and pressing `a`, should not show the menu + local insert_enter_on_trigger_character = config.show_on_trigger_character + and config.show_on_insert_on_trigger_character + and event == 'InsertEnter' + and trigger.is_trigger_character(char_under_cursor, true) + + -- check if we're still within the bounds of the query used for the context + if trigger.context ~= nil and trigger.context:within_query_bounds(cursor) then + trigger.show({ trigger_kind = 'keyword' }) + + -- check if we've entered insert mode on a trigger character + -- or if we've moved onto a trigger character while open + elseif + insert_enter_on_trigger_character + or (is_on_trigger_for_show and trigger.context ~= nil and trigger.context.trigger.kind ~= 'prefetch') + then + trigger.context = nil + trigger.show({ trigger_kind = 'trigger_character', trigger_character = char_under_cursor }) + + -- show if we currently have a context, and we've moved outside of it's bounds by 1 char + elseif is_keyword and trigger.context ~= nil and cursor_col == trigger.context.bounds.start_col - 1 then + trigger.context = nil + trigger.show({ trigger_kind = 'keyword' }) + + -- prefetch completions without opening window on InsertEnter + elseif event == 'InsertEnter' and config.prefetch_on_insert then + trigger.show({ trigger_kind = 'prefetch' }) + + -- otherwise hide + else + trigger.hide() + end + end + + trigger.buffer_events:listen({ + on_char_added = on_char_added, + on_cursor_moved = on_cursor_moved, + on_insert_leave = function() trigger.hide() end, + }) + trigger.cmdline_events:listen({ + on_char_added = on_char_added, + on_cursor_moved = on_cursor_moved, + on_leave = function() trigger.hide() end, + }) +end + +function trigger.is_keyword_character(char) + -- special case for hyphen, since we don't consider a lone hyphen to be a keyword + if char == '-' then return true end + + local keyword_start_col, keyword_end_col = require('blink.cmp.fuzzy').get_keyword_range(char, #char, 'prefix') + return keyword_start_col ~= keyword_end_col +end + +function trigger.is_trigger_character(char, is_show_on_x) + local sources = require('blink.cmp.sources.lib') + local is_trigger = vim.tbl_contains(sources.get_trigger_characters(context.get_mode()), char) + + local show_on_blocked_trigger_characters = type(config.show_on_blocked_trigger_characters) == 'function' + and config.show_on_blocked_trigger_characters() + or config.show_on_blocked_trigger_characters + --- @cast show_on_blocked_trigger_characters string[] + local show_on_x_blocked_trigger_characters = type(config.show_on_x_blocked_trigger_characters) == 'function' + and config.show_on_x_blocked_trigger_characters() + or config.show_on_x_blocked_trigger_characters + --- @cast show_on_x_blocked_trigger_characters string[] + + local is_blocked = vim.tbl_contains(show_on_blocked_trigger_characters, char) + or (is_show_on_x and vim.tbl_contains(show_on_x_blocked_trigger_characters, char)) + + return is_trigger and not is_blocked +end + +--- Suppresses on_hide and on_show events for the duration of the callback +function trigger.suppress_events_for_callback(cb) + local mode = vim.api.nvim_get_mode().mode == 'c' and 'cmdline' or 'default' + + local events = mode == 'default' and trigger.buffer_events or trigger.cmdline_events + if not events then return cb() end + + events:suppress_events_for_callback(cb) +end + +function trigger.show_if_on_trigger_character(opts) + if + (opts and opts.is_accept) + and (not config.show_on_trigger_character or not config.show_on_accept_on_trigger_character) + then + return + end + + local cursor_col = context.get_cursor()[2] + local char_under_cursor = context.get_line():sub(cursor_col, cursor_col) + + if trigger.is_trigger_character(char_under_cursor, true) then + trigger.show({ trigger_kind = 'trigger_character', trigger_character = char_under_cursor }) + end +end + +function trigger.show(opts) + if not require('blink.cmp.config').enabled() then return trigger.hide() end + + opts = opts or {} + + -- already triggered at this position, ignore + local mode = context.get_mode() + local cursor = context.get_cursor() + if + not opts.force + and trigger.context ~= nil + and trigger.context.mode == mode + and cursor[1] == trigger.context.cursor[1] + and cursor[2] == trigger.context.cursor[2] + then + return + end + + -- update the context id to indicate a new context, and not an update to an existing context + if trigger.context == nil or opts.providers ~= nil then + trigger.current_context_id = trigger.current_context_id + 1 + end + + local providers = opts.providers + or (trigger.context and trigger.context.providers) + or require('blink.cmp.sources.lib').get_enabled_provider_ids(context.get_mode()) + + local initial_trigger_kind = trigger.context and trigger.context.trigger.initial_kind or opts.trigger_kind + -- if we prefetched, don't keep that as the initial trigger kind + if initial_trigger_kind == 'prefetch' then initial_trigger_kind = opts.trigger_kind end + -- if we're manually triggering, set it as the initial trigger kind + if opts.trigger_kind == 'manual' then initial_trigger_kind = 'manual' end + + trigger.context = context.new({ + id = trigger.current_context_id, + providers = providers, + initial_trigger_kind = initial_trigger_kind, + initial_trigger_character = trigger.context and trigger.context.trigger.initial_character or opts.trigger_character, + trigger_kind = opts.trigger_kind, + trigger_character = opts.trigger_character, + }) + + if opts.send_upstream ~= false then trigger.show_emitter:emit({ context = trigger.context }) end + return trigger.context +end + +function trigger.hide() + if not trigger.context then return end + trigger.context = nil + trigger.hide_emitter:emit() +end + +return trigger diff --git a/lua/blink/cmp/completion/trigger/utils.lua b/lua/blink/cmp/completion/trigger/utils.lua new file mode 100644 index 0000000..b2878c2 --- /dev/null +++ b/lua/blink/cmp/completion/trigger/utils.lua @@ -0,0 +1,30 @@ +local context = require('blink.cmp.completion.trigger.context') +local utils = {} + +--- Gets the full Unicode character at cursor position +--- @return string +function utils.get_char_at_cursor() + local line = context.get_line() + if line == '' then return '' end + local cursor_col = context.get_cursor()[2] + + -- Find the start of the UTF-8 character + local start_col = cursor_col + while start_col > 1 do + local char = string.byte(line:sub(start_col, start_col)) + if char < 0x80 or char > 0xBF then break end + start_col = start_col - 1 + end + + -- Find the end of the UTF-8 character + local end_col = cursor_col + while end_col < #line do + local char = string.byte(line:sub(end_col + 1, end_col + 1)) + if char < 0x80 or char > 0xBF then break end + end_col = end_col + 1 + end + + return line:sub(start_col, end_col) +end + +return utils |
