Files
nvim-cmp/lua/cmp/core.lua
yioneko 1a1d7ecb73 perf: improve for source providing huge list of items (#1980)
* perf: avoid creating closure in cache.ensure and drop some cached getters

This mainly addresses the perf issue on large amount of calls to
`entry.new`. Previously every `cache.ensure` calls in the code path of
it creates an anonymous function, and it seems that luajit just could
not inline it. Function creation is not expensive in luajit, but that
overhead is noticeable if every `cache.ensure` call creates a function.
The first improvemnt is to solidate the cache callback and attach it to
the metatable of `entry`. This ensures that every created entry instance
share the same cache callback and no new functions will be frequently created,
reduces the ram usage and GC overhead.

To improve it further, some frequently accessed fields of entry like
`completion_item` and `offset` is refactored to use simple table access
instead of getter pattern. The current cached getter is implemented
using `cache.ensure`, which introduces two more levels of function calls
on each access: `cache.key` and `cache.get`. The overhead is okay if but
noticeable if entries amount is quite large: you need to call 4 functions on
a simple `completion_item` field access for each item.

All of the changes done in the commit is just constant time
optimization. But the different is huge if tested with LS providing
large amount of entries like tailwindcss.

* perf: delay fuzzy match on displayed vim item

`entry.get_vim_item` is a very heavy call, especially when user do
complex stuff on item formatting. Delay its call to window displaying to
let `performance.max_view_entries` applied to it.

* remove unneeded fill_defaults

* update gha

---------

Co-authored-by: hrsh7th <629908+hrsh7th@users.noreply.github.com>
2024-10-20 13:28:20 +09:00

525 lines
16 KiB
Lua

local debug = require('cmp.utils.debug')
local str = require('cmp.utils.str')
local char = require('cmp.utils.char')
local feedkeys = require('cmp.utils.feedkeys')
local async = require('cmp.utils.async')
local keymap = require('cmp.utils.keymap')
local context = require('cmp.context')
local source = require('cmp.source')
local view = require('cmp.view')
local misc = require('cmp.utils.misc')
local config = require('cmp.config')
local types = require('cmp.types')
local api = require('cmp.utils.api')
local event = require('cmp.utils.event')
---@class cmp.Core
---@field public suspending boolean
---@field public view cmp.View
---@field public sources cmp.Source[]
---@field public context cmp.Context
---@field public event cmp.Event
local core = {}
core.new = function()
local self = setmetatable({}, { __index = core })
self.suspending = false
self.sources = {}
self.context = context.new()
self.event = event.new()
self.view = view.new()
self.view.event:on('keymap', function(...)
self:on_keymap(...)
end)
for _, event_name in ipairs({ 'complete_done', 'menu_opened', 'menu_closed' }) do
self.view.event:on(event_name, function(evt)
self.event:emit(event_name, evt)
end)
end
return self
end
---Register source
---@param s cmp.Source
core.register_source = function(self, s)
self.sources[s.id] = s
end
---Unregister source
---@param source_id integer
core.unregister_source = function(self, source_id)
self.sources[source_id] = nil
end
---Get new context
---@param option? cmp.ContextOption
---@return cmp.Context
core.get_context = function(self, option)
self.context:abort()
local prev = self.context:clone()
prev.prev_context = nil
prev.cache = nil
local ctx = context.new(prev, option)
self:set_context(ctx)
return self.context
end
---Set new context
---@param ctx cmp.Context
core.set_context = function(self, ctx)
self.context = ctx
end
---Suspend completion
core.suspend = function(self)
self.suspending = true
-- It's needed to avoid conflicting with autocmd debouncing.
return vim.schedule_wrap(function()
self.suspending = false
end)
end
---Get sources that sorted by priority
---@param filter? cmp.SourceStatus[]|fun(s: cmp.Source): boolean
---@return cmp.Source[]
core.get_sources = function(self, filter)
local f = function(s)
if type(filter) == 'table' then
return vim.tbl_contains(filter, s.status)
elseif type(filter) == 'function' then
return filter(s)
end
return true
end
local sources = {}
for _, c in pairs(config.get().sources) do
for _, s in pairs(self.sources) do
if c.name == s.name then
if s:is_available() and f(s) then
table.insert(sources, s)
end
end
end
end
return sources
end
---Keypress handler
core.on_keymap = function(self, keys, fallback)
local mode = api.get_mode()
for key, mapping in pairs(config.get().mapping) do
if keymap.equals(key, keys) and mapping[mode] then
return mapping[mode](fallback)
end
end
--Commit character. NOTE: This has a lot of cmp specific implementation to make more user-friendly.
local chars = keymap.t(keys)
local e = self.view:get_active_entry()
if e and vim.tbl_contains(config.get().confirmation.get_commit_characters(e:get_commit_characters()), chars) then
local is_printable = char.is_printable(string.byte(chars, 1))
self:confirm(e, {
behavior = is_printable and 'insert' or 'replace',
commit_character = chars,
}, function()
local ctx = self:get_context()
local word = e.word
if string.sub(ctx.cursor_before_line, -#word, ctx.cursor.col - 1) == word and is_printable then
fallback()
else
self:reset()
end
end)
return
end
fallback()
end
---Prepare completion
core.prepare = function(self)
for keys, mapping in pairs(config.get().mapping) do
for mode in pairs(mapping) do
keymap.listen(mode, keys, function(...)
self:on_keymap(...)
end)
end
end
end
---Check auto-completion
core.on_change = function(self, trigger_event)
local ignore = false
ignore = ignore or self.suspending
ignore = ignore or (vim.fn.pumvisible() == 1 and (vim.v.completed_item).word)
ignore = ignore or not self.view:ready()
if ignore then
self:get_context({ reason = types.cmp.ContextReason.Auto })
return
end
self:autoindent(trigger_event, function()
local ctx = self:get_context({ reason = types.cmp.ContextReason.Auto })
debug.log(('ctx: `%s`'):format(ctx.cursor_before_line))
if ctx:changed(ctx.prev_context) then
self.view:on_change()
debug.log('changed')
if vim.tbl_contains(config.get().completion.autocomplete or {}, trigger_event) then
self:complete(ctx)
else
self.filter.timeout = self.view:visible() and config.get().performance.throttle or 0
self:filter()
end
else
debug.log('unchanged')
end
end)
end
---Cursor moved.
core.on_moved = function(self)
local ignore = false
ignore = ignore or self.suspending
ignore = ignore or (vim.fn.pumvisible() == 1 and (vim.v.completed_item).word)
ignore = ignore or not self.view:visible()
if ignore then
return
end
self:filter()
end
---Returns the suffix of the specified `line`.
---
---Contains `%s`: returns everything after the last `%s` in `line`
---Else: returns `line` unmodified
---@param line string
---@return string suffix
local function find_line_suffix(line)
return line:match('%S*$') --[[@as string]]
end
---Check autoindent
---@param trigger_event cmp.TriggerEvent
---@param callback function
core.autoindent = function(self, trigger_event, callback)
if trigger_event ~= types.cmp.TriggerEvent.TextChanged then
return callback()
end
if not api.is_insert_mode() then
return callback()
end
-- Check prefix
local cursor_before_line = api.get_cursor_before_line()
local prefix = find_line_suffix(cursor_before_line) or ''
if #prefix == 0 then
return callback()
end
-- Reset current completion if indentkeys matched.
for _, key in ipairs(vim.split(vim.bo.indentkeys, ',')) do
if vim.tbl_contains({ '=' .. prefix, '0=' .. prefix }, key) then
self:reset()
self:set_context(context.empty())
break
end
end
callback()
end
---Complete common string for current completed entries.
core.complete_common_string = function(self)
if not self.view:visible() or self.view:get_selected_entry() then
return false
end
config.set_onetime({
sources = config.get().sources,
matching = {
disallow_prefix_unmatching = true,
disallow_partial_matching = true,
disallow_fuzzy_matching = true,
},
})
self:filter()
self.filter:sync(1000)
config.set_onetime({})
local cursor = api.get_cursor()
local offset = self.view:get_offset() or cursor[2]
local common_string
for _, e in ipairs(self.view:get_entries()) do
local vim_item = e:get_vim_item(offset)
if not common_string then
common_string = vim_item.word
else
common_string = str.get_common_string(common_string, vim_item.word)
end
end
local cursor_before_line = api.get_cursor_before_line()
local pretext = cursor_before_line:sub(offset)
if common_string and #common_string > #pretext then
feedkeys.call(keymap.backspace(pretext) .. common_string, 'n')
return true
end
return false
end
---Invoke completion
---@param ctx cmp.Context
core.complete = function(self, ctx)
if not api.is_suitable_mode() then
return
end
self:set_context(ctx)
-- Invoke completion sources.
local sources = self:get_sources()
for _, s in ipairs(sources) do
local callback
callback = (function(s_)
return function()
local new = context.new(ctx)
if s_.incomplete and new:changed(s_.context) then
s_:complete(new, callback)
else
if not self.view:get_active_entry() then
self.filter.stop()
self.filter.timeout = config.get().performance.debounce
self:filter()
end
end
end
end)(s)
s:complete(ctx, callback)
end
if not self.view:get_active_entry() then
self.filter.timeout = self.view:visible() and config.get().performance.throttle or 1
self:filter()
end
end
---Update completion menu
local async_filter = async.wrap(function(self)
self.filter.timeout = config.get().performance.throttle
-- Check invalid condition.
local ignore = false
ignore = ignore or not api.is_suitable_mode()
if ignore then
return
end
-- Check fetching sources.
local sources = {}
for _, s in ipairs(self:get_sources({ source.SourceStatus.FETCHING, source.SourceStatus.COMPLETED })) do
-- Reserve filter call for timeout.
if not s.incomplete and config.get().performance.fetching_timeout > s:get_fetching_time() then
self.filter.timeout = config.get().performance.fetching_timeout - s:get_fetching_time()
self:filter()
if #sources == 0 then
return
end
end
table.insert(sources, s)
end
local ctx = self:get_context()
-- Display completion results.
local did_open = self.view:open(ctx, sources)
local fetching = #self:get_sources(function(s)
return s.status == source.SourceStatus.FETCHING
end)
-- Check onetime config.
if not did_open and fetching == 0 then
config.set_onetime({})
end
end)
core.filter = async.throttle(async_filter, config.get().performance.throttle)
---Confirm completion.
---@param e cmp.Entry
---@param option cmp.ConfirmOption
---@param callback function
core.confirm = function(self, e, option, callback)
if not (e and not e.confirmed) then
if callback then
callback()
end
return
end
e.confirmed = true
debug.log('entry.confirm', e.completion_item)
async.sync(function(done)
e:resolve(done)
end, config.get().performance.confirm_resolve_timeout)
local release = self:suspend()
-- Close menus.
self.view:close()
feedkeys.call(keymap.indentkeys(), 'n')
feedkeys.call('', 'n', function()
-- Emulate `<C-y>` behavior to save `.` register.
local ctx = context.new()
local keys = {}
table.insert(keys, keymap.backspace(ctx.cursor_before_line:sub(e.offset)))
table.insert(keys, e.word)
table.insert(keys, keymap.undobreak())
feedkeys.call(table.concat(keys, ''), 'in')
end)
feedkeys.call('', 'n', function()
-- Restore the line at the time of request.
local ctx = context.new()
if api.is_cmdline_mode() then
local keys = {}
table.insert(keys, keymap.backspace(ctx.cursor_before_line:sub(e.offset)))
table.insert(keys, string.sub(e.context.cursor_before_line, e.offset))
feedkeys.call(table.concat(keys, ''), 'in')
else
vim.cmd([[silent! undojoin]])
-- This logic must be used nvim_buf_set_text.
-- If not used, the snippet engine's placeholder wil be broken.
vim.api.nvim_buf_set_text(0, e.context.cursor.row - 1, e.offset - 1, ctx.cursor.row - 1, ctx.cursor.col - 1, {
e.context.cursor_before_line:sub(e.offset),
})
vim.api.nvim_win_set_cursor(0, { e.context.cursor.row, e.context.cursor.col - 1 })
end
end)
feedkeys.call('', 'n', function()
-- Apply additionalTextEdits.
local ctx = context.new()
if #(e.completion_item.additionalTextEdits or {}) == 0 then
e:resolve(function()
local new = context.new()
local text_edits = e.completion_item.additionalTextEdits or {}
if #text_edits == 0 then
return
end
local has_cursor_line_text_edit = (function()
local minrow = math.min(ctx.cursor.row, new.cursor.row)
local maxrow = math.max(ctx.cursor.row, new.cursor.row)
for _, te in ipairs(text_edits) do
local srow = te.range.start.line + 1
local erow = te.range['end'].line + 1
if srow <= minrow and maxrow <= erow then
return true
end
end
return false
end)()
if has_cursor_line_text_edit then
return
end
vim.cmd([[silent! undojoin]])
vim.lsp.util.apply_text_edits(text_edits, ctx.bufnr, e.source:get_position_encoding_kind())
end)
else
vim.cmd([[silent! undojoin]])
vim.lsp.util.apply_text_edits(e.completion_item.additionalTextEdits, ctx.bufnr, e.source:get_position_encoding_kind())
end
end)
feedkeys.call('', 'n', function()
local ctx = context.new()
local completion_item = misc.copy(e.completion_item)
if not completion_item.textEdit then
completion_item.textEdit = {}
local insertText = completion_item.insertText
if misc.empty(insertText) then
insertText = nil
end
completion_item.textEdit.newText = insertText or completion_item.word or completion_item.label
end
local behavior = option.behavior or config.get().confirmation.default_behavior
if behavior == types.cmp.ConfirmBehavior.Replace then
completion_item.textEdit.range = e.replace_range
else
completion_item.textEdit.range = e.insert_range
end
local diff_before = math.max(0, e.context.cursor.col - (completion_item.textEdit.range.start.character + 1))
local diff_after = math.max(0, (completion_item.textEdit.range['end'].character + 1) - e.context.cursor.col)
local new_text = completion_item.textEdit.newText
completion_item.textEdit.range.start.line = ctx.cursor.line
completion_item.textEdit.range.start.character = (ctx.cursor.col - 1) - diff_before
completion_item.textEdit.range['end'].line = ctx.cursor.line
completion_item.textEdit.range['end'].character = (ctx.cursor.col - 1) + diff_after
if api.is_insert_mode() then
if false then
--To use complex expansion debug.
vim.print({ -- luacheck: ignore
item = e.completion_item,
diff_before = diff_before,
diff_after = diff_after,
new_text = new_text,
text_edit_new_text = completion_item.textEdit.newText,
range_start = completion_item.textEdit.range.start.character,
range_end = completion_item.textEdit.range['end'].character,
original_range_start = e.completion_item.textEdit.range.start.character,
original_range_end = e.completion_item.textEdit.range['end'].character,
cursor_line = ctx.cursor_line,
cursor_col0 = ctx.cursor.col - 1,
})
end
local is_snippet = completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet
if is_snippet then
completion_item.textEdit.newText = ''
end
vim.lsp.util.apply_text_edits({ completion_item.textEdit }, ctx.bufnr, 'utf-8')
local texts = vim.split(completion_item.textEdit.newText, '\n')
vim.api.nvim_win_set_cursor(0, {
completion_item.textEdit.range.start.line + #texts,
(#texts == 1 and (completion_item.textEdit.range.start.character + #texts[1]) or #texts[#texts]),
})
if is_snippet then
config.get().snippet.expand({
body = new_text,
insert_text_mode = completion_item.insertTextMode,
})
end
else
local keys = {}
table.insert(keys, keymap.backspace(ctx.cursor_line:sub(completion_item.textEdit.range.start.character + 1, ctx.cursor.col - 1)))
table.insert(keys, keymap.delete(ctx.cursor_line:sub(ctx.cursor.col, completion_item.textEdit.range['end'].character)))
table.insert(keys, new_text)
feedkeys.call(table.concat(keys, ''), 'in')
end
end)
feedkeys.call(keymap.indentkeys(vim.bo.indentkeys), 'n')
feedkeys.call('', 'n', function()
e:execute(vim.schedule_wrap(function()
release()
self.event:emit('confirm_done', {
entry = e,
commit_character = option.commit_character,
})
if callback then
callback()
end
end))
end)
end
---Reset current completion state
core.reset = function(self)
for _, s in pairs(self.sources) do
s:reset()
end
self.context = context.empty()
end
return core