summaryrefslogtreecommitdiff
path: root/lua/blink/cmp/completion/trigger
diff options
context:
space:
mode:
authorMike Vink <mike@pionative.com>2025-01-19 13:52:52 +0100
committerMike Vink <mike@pionative.com>2025-01-19 13:52:52 +0100
commitb77413ff8f59f380612074f0c9bd49093d8db695 (patch)
tree32c39a811ba96ed4ab0a1c81cce9f8d518ed7e31 /lua/blink/cmp/completion/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.lua118
-rw-r--r--lua/blink/cmp/completion/trigger/init.lua241
-rw-r--r--lua/blink/cmp/completion/trigger/utils.lua30
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