Files
nvim-cmp/lua/cmp/view.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

315 lines
8.7 KiB
Lua

local config = require('cmp.config')
local async = require('cmp.utils.async')
local event = require('cmp.utils.event')
local keymap = require('cmp.utils.keymap')
local docs_view = require('cmp.view.docs_view')
local custom_entries_view = require('cmp.view.custom_entries_view')
local wildmenu_entries_view = require('cmp.view.wildmenu_entries_view')
local native_entries_view = require('cmp.view.native_entries_view')
local ghost_text_view = require('cmp.view.ghost_text_view')
---@class cmp.View
---@field public event cmp.Event
---@field private is_docs_view_pinned boolean
---@field private resolve_dedup cmp.AsyncDedup
---@field private native_entries_view cmp.NativeEntriesView
---@field private custom_entries_view cmp.CustomEntriesView
---@field private wildmenu_entries_view cmp.CustomEntriesView
---@field private change_dedup cmp.AsyncDedup
---@field private docs_view cmp.DocsView
---@field private ghost_text_view cmp.GhostTextView
local view = {}
---Create menu
view.new = function()
local self = setmetatable({}, { __index = view })
self.resolve_dedup = async.dedup()
self.is_docs_view_pinned = false
self.custom_entries_view = custom_entries_view.new()
self.native_entries_view = native_entries_view.new()
self.wildmenu_entries_view = wildmenu_entries_view.new()
self.docs_view = docs_view.new()
self.ghost_text_view = ghost_text_view.new()
self.event = event.new()
return self
end
---Return the view components are available or not.
---@return boolean
view.ready = function(self)
return self:_get_entries_view():ready()
end
---OnChange handler.
view.on_change = function(self)
self:_get_entries_view():on_change()
end
---Open menu
---@param ctx cmp.Context
---@param sources cmp.Source[]
---@return boolean did_open
view.open = function(self, ctx, sources)
local source_group_map = {}
for _, s in ipairs(sources) do
local group_index = s:get_source_config().group_index or 0
if not source_group_map[group_index] then
source_group_map[group_index] = {}
end
table.insert(source_group_map[group_index], s)
end
local group_indexes = vim.tbl_keys(source_group_map)
table.sort(group_indexes, function(a, b)
return a ~= b and (a < b) or nil
end)
local entries = {}
for _, group_index in ipairs(group_indexes) do
local source_group = source_group_map[group_index] or {}
-- check the source triggered by character
local has_triggered_by_symbol_source = false
for _, s in ipairs(source_group) do
if #s:get_entries(ctx) > 0 then
if s.is_triggered_by_symbol then
has_triggered_by_symbol_source = true
break
end
end
end
-- create filtered entries.
local offset = ctx.cursor.col
local group_entries = {}
local max_item_counts = {}
for i, s in ipairs(source_group) do
if s.offset <= ctx.cursor.col then
if not has_triggered_by_symbol_source or s.is_triggered_by_symbol then
-- prepare max_item_counts map for filtering after sort.
local max_item_count = s:get_source_config().max_item_count
if max_item_count ~= nil then
max_item_counts[s.name] = max_item_count
end
-- source order priority bonus.
local priority = s:get_source_config().priority or ((#source_group - (i - 1)) * config.get().sorting.priority_weight)
for _, e in ipairs(s:get_entries(ctx)) do
e.score = e.score + priority
table.insert(group_entries, e)
offset = math.min(offset, e.offset)
end
end
end
end
-- sort.
local comparetors = config.get().sorting.comparators
table.sort(group_entries, function(e1, e2)
for _, fn in ipairs(comparetors) do
local diff = fn(e1, e2)
if diff ~= nil then
return diff
end
end
end)
-- filter by max_item_count.
for _, e in ipairs(group_entries) do
if max_item_counts[e.source.name] ~= nil then
if max_item_counts[e.source.name] > 0 then
max_item_counts[e.source.name] = max_item_counts[e.source.name] - 1
table.insert(entries, e)
end
else
table.insert(entries, e)
end
end
local max_view_entries = config.get().performance.max_view_entries or 200
entries = vim.list_slice(entries, 1, max_view_entries)
-- open
if #entries > 0 then
self:_get_entries_view():open(offset, entries)
self.event:emit('menu_opened', {
window = self:_get_entries_view(),
})
break
end
end
-- complete_done.
if #entries == 0 then
self:close()
end
return #entries > 0
end
---Close menu
view.close = function(self)
if self:visible() then
self.is_docs_view_pinned = false
self.event:emit('complete_done', {
entry = self:_get_entries_view():get_selected_entry(),
})
end
self:_get_entries_view():close()
self.docs_view:close()
self.ghost_text_view:hide()
self.event:emit('menu_closed', {
window = self:_get_entries_view(),
})
end
---Abort menu
view.abort = function(self)
if self:visible() then
self.is_docs_view_pinned = false
end
self:_get_entries_view():abort()
self.docs_view:close()
self.ghost_text_view:hide()
self.event:emit('menu_closed', {
window = self:_get_entries_view(),
})
end
---Return the view is visible or not.
---@return boolean
view.visible = function(self)
return self:_get_entries_view():visible()
end
---Opens the documentation window.
view.open_docs = function(self)
self.is_docs_view_pinned = true
local e = self:get_selected_entry()
if e then
e:resolve(vim.schedule_wrap(self.resolve_dedup(function()
if not self:visible() then
return
end
self.docs_view:open(e, self:_get_entries_view():info())
end)))
end
end
---Closes the documentation window.
view.close_docs = function(self)
self.is_docs_view_pinned = false
if self:get_selected_entry() then
self.docs_view:close()
end
end
---Scroll documentation window if possible.
---@param delta integer
view.scroll_docs = function(self, delta)
self.docs_view:scroll(delta)
end
---Get what number candidates are currently selected.
---If not selected, nil is returned.
---@return integer|nil
view.get_selected_index = function(self)
return self:_get_entries_view():get_selected_index()
end
---Select prev menu item.
---@param option cmp.SelectOption
view.select_next_item = function(self, option)
self:_get_entries_view():select_next_item(option)
end
---Select prev menu item.
---@param option cmp.SelectOption
view.select_prev_item = function(self, option)
self:_get_entries_view():select_prev_item(option)
end
---Get offset.
view.get_offset = function(self)
return self:_get_entries_view():get_offset()
end
---Get entries.
---@return cmp.Entry[]
view.get_entries = function(self)
return self:_get_entries_view():get_entries()
end
---Get first entry
---@param self cmp.Entry|nil
view.get_first_entry = function(self)
return self:_get_entries_view():get_first_entry()
end
---Get current selected entry
---@return cmp.Entry|nil
view.get_selected_entry = function(self)
return self:_get_entries_view():get_selected_entry()
end
---Get current active entry
---@return cmp.Entry|nil
view.get_active_entry = function(self)
return self:_get_entries_view():get_active_entry()
end
---Return current configured entries_view
---@return cmp.CustomEntriesView|cmp.NativeEntriesView
view._get_entries_view = function(self)
self.native_entries_view.event:clear()
self.custom_entries_view.event:clear()
self.wildmenu_entries_view.event:clear()
local c = config.get()
local v = self.custom_entries_view
if (c.view and c.view.entries and (c.view.entries.name or c.view.entries)) == 'wildmenu' then
v = self.wildmenu_entries_view
elseif (c.view and c.view.entries and (c.view.entries.name or c.view.entries)) == 'native' then
v = self.native_entries_view
end
v.event:on('change', function()
self:on_entry_change()
end)
return v
end
---On entry change
view.on_entry_change = async.throttle(function(self)
if not self:visible() then
return
end
local e = self:get_selected_entry()
if e then
for _, c in ipairs(config.get().confirmation.get_commit_characters(e:get_commit_characters())) do
keymap.listen('i', c, function(...)
self.event:emit('keymap', ...)
end)
end
e:resolve(vim.schedule_wrap(self.resolve_dedup(function()
if not self:visible() then
return
end
if self.is_docs_view_pinned or config.get().view.docs.auto_open then
self.docs_view:open(e, self:_get_entries_view():info())
end
end)))
else
self.docs_view:close()
end
e = e or self:get_first_entry()
if e then
self.ghost_text_view:show(e)
else
self.ghost_text_view:hide()
end
end, 20)
return view