Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 71 additions & 65 deletions lua/blink/cmp/completion/trigger/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
--- @field hide_emitter blink.cmp.EventEmitter<{}>
---
--- @field activate fun()
--- @field resubscribe fun()
--- @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 })
Expand Down Expand Up @@ -41,106 +42,111 @@ local trigger = {
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
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 })
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 fuzzy.is_keyword_character(char) and (config.show_on_keyword or trigger.context ~= nil) then
trigger.show({ trigger_kind = 'keyword' })
elseif fuzzy.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
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 = fuzzy.is_keyword_character(char_under_cursor)

-- we were told to ignore the cursor moved event, so we update the context
-- but don't send an on_show event upstream
if is_ignored and event == 'CursorMoved' then
if trigger.context ~= nil then
-- 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
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 = fuzzy.is_keyword_character(char_under_cursor)

-- we were told to ignore the cursor moved event, so we update the context
-- but don't send an on_show event upstream
if is_ignored and event == 'CursorMoved' then
if trigger.context ~= nil then
-- 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)
local is_on_trigger_for_show = trigger.is_trigger_character(char_under_cursor)

-- TODO: doesn't handle `a` where the cursor moves immediately after
-- Reproducible with `example.|a` and pressing `a`, should not show the menu
local insert_enter_on_trigger_character = config.show_on_trigger_character
and config.show_on_insert_on_trigger_character
and event == 'InsertEnter'
and trigger.is_trigger_character(char_under_cursor, true)
-- TODO: doesn't handle `a` where the cursor moves immediately after
-- Reproducible with `example.|a` and pressing `a`, should not show the menu
local insert_enter_on_trigger_character = config.show_on_trigger_character
and config.show_on_insert_on_trigger_character
and 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'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 })
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' })
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' })
elseif event == 'InsertEnter' and config.prefetch_on_insert then
trigger.show({ trigger_kind = 'prefetch' })

-- otherwise hide
else
trigger.hide()
end
else
trigger.hide()
end
end

function trigger.activate()
trigger.buffer_events = require('blink.cmp.lib.buffer_events').new({
-- TODO: should this ignore trigger.kind == 'prefetch'?
has_context = function() return trigger.context ~= nil end,
show_in_snippet = config.show_in_snippet,
})
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 = require('blink.cmp.lib.cmdline_events').new()
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.resubscribe()
---@diagnostic disable-next-line: missing-fields
trigger.buffer_events:resubscribe({ on_char_added = on_char_added })
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)
Expand Down
1 change: 1 addition & 0 deletions lua/blink/cmp/config/snippets.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ local snippets = {
if not _G.MiniSnippets then error('mini.snippets has not been setup') end
local insert = MiniSnippets.config.expand.insert or MiniSnippets.default_insert
insert({ body = snippet })
require('blink.cmp').resubscribe()
end,
}),
active = by_preset({
Expand Down
6 changes: 6 additions & 0 deletions lua/blink/cmp/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,12 @@ function cmp.snippet_backward()
return true
end

--- Ensures that blink.cmp will be notified last when a user adds a character
function cmp.resubscribe()
local trigger = require('blink.cmp.completion.trigger')
trigger.resubscribe()
end

--- Tells the sources to reload a specific provider or all providers (when nil)
--- @param provider? string
function cmp.reload(provider) require('blink.cmp.sources.lib').reload(provider) end
Expand Down
126 changes: 76 additions & 50 deletions lua/blink/cmp/lib/buffer_events.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
--- @field show_in_snippet boolean
--- @field ignore_next_text_changed boolean
--- @field ignore_next_cursor_moved boolean
--- @field last_char string
--- @field textchangedi_id number
---
--- @field new fun(opts: blink.cmp.BufferEventsOptions): blink.cmp.BufferEvents
--- @field listen fun(self: blink.cmp.BufferEvents, opts: blink.cmp.BufferEventsListener)
--- @field resubscribe fun(self: blink.cmp.BufferEvents, opts: blink.cmp.BufferEventsListener)
--- @field suppress_events_for_callback fun(self: blink.cmp.BufferEvents, cb: fun())

--- @class blink.cmp.BufferEventsOptions
Expand All @@ -31,77 +34,90 @@ function buffer_events.new(opts)
show_in_snippet = opts.show_in_snippet,
ignore_next_text_changed = false,
ignore_next_cursor_moved = false,
last_char = '',
textchangedi_id = -1,
}, { __index = buffer_events })
end

local function make_char_added(self, snippet, on_char_added)
return function()
if not require('blink.cmp.config').enabled() then return end
if snippet.active() and not self.show_in_snippet and not self.has_context() then return end

local is_ignored = self.ignore_next_text_changed
self.ignore_next_text_changed = false

-- no characters added so let cursormoved handle it
if self.last_char == '' then return end

on_char_added(self.last_char, is_ignored)

self.last_char = ''
end
end

local function make_cursor_moved(self, snippet, on_cursor_moved)
return function(ev)
-- only fire a CursorMoved event (notable not CursorMovedI)
-- when jumping between tab stops in a snippet while showing the menu
if
ev.event == 'CursorMoved'
and (vim.api.nvim_get_mode().mode ~= 'v' or not self.has_context() or not snippet.active())
then
return
end

local is_cursor_moved = ev.event == 'CursorMoved' or ev.event == 'CursorMovedI'

local is_ignored = is_cursor_moved and self.ignore_next_cursor_moved
if is_cursor_moved then self.ignore_next_cursor_moved = false end

-- characters added so let textchanged handle it
if self.last_char ~= '' then return end

if not require('blink.cmp.config').enabled() then return end
if not self.show_in_snippet and not self.has_context() and snippet.active() then return end

on_cursor_moved(is_cursor_moved and 'CursorMoved' or ev.event, is_ignored)
end
end

local function make_insert_leave(self, on_insert_leave)
return function()
self.last_char = ''
-- HACK: when using vim.snippet.expand, the mode switches from insert -> normal -> visual -> select
-- so we schedule to ignore the intermediary modes
-- TODO: deduplicate requests
vim.schedule(function()
if not vim.tbl_contains({ 'i', 's' }, vim.api.nvim_get_mode().mode) then on_insert_leave() end
end)
end
end

--- Normalizes the autocmds + ctrl+c into a common api and handles ignored events
function buffer_events:listen(opts)
local snippet = require('blink.cmp.config').snippets

local last_char = ''
vim.api.nvim_create_autocmd('InsertCharPre', {
callback = function()
if snippet.active() and not self.show_in_snippet and not self.has_context() then return end
-- FIXME: vim.v.char can be an escape code such as <95> in the case of <F2>. This breaks downstream
-- since this isn't a valid utf-8 string. How can we identify and ignore these?
last_char = vim.v.char
self.last_char = vim.v.char
end,
})

vim.api.nvim_create_autocmd('TextChangedI', {
callback = function()
if not require('blink.cmp.config').enabled() then return end
if snippet.active() and not self.show_in_snippet and not self.has_context() then return end

local is_ignored = self.ignore_next_text_changed
self.ignore_next_text_changed = false

-- no characters added so let cursormoved handle it
if last_char == '' then return end

opts.on_char_added(last_char, is_ignored)

last_char = ''
end,
self.textchangedi_id = vim.api.nvim_create_autocmd('TextChangedI', {
callback = make_char_added(self, snippet, opts.on_char_added),
})

vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI', 'InsertEnter' }, {
callback = function(ev)
-- only fire a CursorMoved event (notable not CursorMovedI)
-- when jumping between tab stops in a snippet while showing the menu
if
ev.event == 'CursorMoved'
and (vim.api.nvim_get_mode().mode ~= 'v' or not self.has_context() or not snippet.active())
then
return
end

local is_cursor_moved = ev.event == 'CursorMoved' or ev.event == 'CursorMovedI'

local is_ignored = is_cursor_moved and self.ignore_next_cursor_moved
if is_cursor_moved then self.ignore_next_cursor_moved = false end

-- characters added so let textchanged handle it
if last_char ~= '' then return end

if not require('blink.cmp.config').enabled() then return end
if not self.show_in_snippet and not self.has_context() and snippet.active() then return end

opts.on_cursor_moved(is_cursor_moved and 'CursorMoved' or ev.event, is_ignored)
end,
callback = make_cursor_moved(self, snippet, opts.on_cursor_moved),
})

-- definitely leaving the context
vim.api.nvim_create_autocmd({ 'ModeChanged', 'BufLeave' }, {
callback = function()
last_char = ''
-- HACK: when using vim.snippet.expand, the mode switches from insert -> normal -> visual -> select
-- so we schedule to ignore the intermediary modes
-- TODO: deduplicate requests
vim.schedule(function()
if not vim.tbl_contains({ 'i', 's' }, vim.api.nvim_get_mode().mode) then opts.on_insert_leave() end
end)
end,
callback = make_insert_leave(self, opts.on_insert_leave),
})

-- ctrl+c doesn't trigger InsertLeave so handle it separately
Expand All @@ -111,14 +127,24 @@ function buffer_events:listen(opts)
vim.schedule(function()
local mode = vim.api.nvim_get_mode().mode
if mode ~= 'i' then
last_char = ''
self.last_char = ''
opts.on_insert_leave()
end
end)
end
end)
end

function buffer_events:resubscribe(opts)
if self.textchangedi_id == -1 then return end

local snippet = require('blink.cmp.config').snippets
vim.api.nvim_del_autocmd(self.textchangedi_id)
self.textchangedi_id = vim.api.nvim_create_autocmd('TextChangedI', {
callback = make_char_added(self, snippet, opts.on_char_added),
})
end

--- Suppresses autocmd events for the duration of the callback
--- HACK: there's likely edge cases with this since we can't know for sure
--- if the autocmds will fire for cursor_moved afaik
Expand Down
Loading