summaryrefslogtreecommitdiff
path: root/lua/blink/cmp/completion/trigger/init.lua
diff options
context:
space:
mode:
Diffstat (limited to 'lua/blink/cmp/completion/trigger/init.lua')
-rw-r--r--lua/blink/cmp/completion/trigger/init.lua241
1 files changed, 241 insertions, 0 deletions
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