diff --git a/lua/blink/cmp/completion/trigger/init.lua b/lua/blink/cmp/completion/trigger/init.lua index 1844922fc..b774afd35 100644 --- a/lua/blink/cmp/completion/trigger/init.lua +++ b/lua/blink/cmp/completion/trigger/init.lua @@ -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 }) @@ -41,99 +42,99 @@ 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, @@ -141,6 +142,11 @@ function trigger.activate() }) 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) diff --git a/lua/blink/cmp/config/snippets.lua b/lua/blink/cmp/config/snippets.lua index 1541bf19d..b1960b9f4 100644 --- a/lua/blink/cmp/config/snippets.lua +++ b/lua/blink/cmp/config/snippets.lua @@ -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({ diff --git a/lua/blink/cmp/init.lua b/lua/blink/cmp/init.lua index a5bc0ccf0..eae713152 100644 --- a/lua/blink/cmp/init.lua +++ b/lua/blink/cmp/init.lua @@ -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 diff --git a/lua/blink/cmp/lib/buffer_events.lua b/lua/blink/cmp/lib/buffer_events.lua index 9386e302b..eb0fb5df3 100644 --- a/lua/blink/cmp/lib/buffer_events.lua +++ b/lua/blink/cmp/lib/buffer_events.lua @@ -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 @@ -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 . 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 @@ -111,7 +127,7 @@ 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) @@ -119,6 +135,16 @@ function buffer_events:listen(opts) 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