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

638 lines
21 KiB
Lua

local cache = require('cmp.utils.cache')
local char = require('cmp.utils.char')
local misc = require('cmp.utils.misc')
local str = require('cmp.utils.str')
local snippet = require('cmp.utils.snippet')
local config = require('cmp.config')
local types = require('cmp.types')
local matcher = require('cmp.matcher')
---@class cmp.Entry
---@field public id integer
---@field public cache cmp.Cache
---@field public match_cache cmp.Cache
---@field public score integer
---@field public exact boolean
---@field public matches table
---@field public context cmp.Context
---@field public source cmp.Source
---@field public source_offset integer
---@field public source_insert_range lsp.Range
---@field public source_replace_range lsp.Range
---@field public completion_item lsp.CompletionItem
---@field public item_defaults? lsp.internal.CompletionItemDefaults
---@field public resolved_completion_item lsp.CompletionItem|nil
---@field public resolved_callbacks fun()[]
---@field public resolving boolean
---@field public confirmed boolean
---@field public insert_range lsp.Range
---@field public replace_range lsp.Range
---@field public offset integer
---@field public word string
---@field public filter_text string
---@field private match_view_args_ret {input:string, word:string, option:cmp.MatchingConfig, matches:table[]}
local entry = {}
entry.__index = entry
---Create new entry
---@param ctx cmp.Context
---@param source cmp.Source
---@param completion_item lsp.CompletionItem
---@param item_defaults? lsp.internal.CompletionItemDefaults
---@return cmp.Entry
entry.new = function(ctx, source, completion_item, item_defaults)
local self = setmetatable({}, entry)
self.id = misc.id('entry.new')
self.cache = cache.new()
self.match_cache = cache.new()
self.score = 0
self.exact = false
self.matches = {}
self.context = ctx
self.source = source
self.offset = source.request_offset
self.source_offset = source.request_offset
self.source_insert_range = source.default_insert_range
self.source_replace_range = source.default_replace_range
self.item_defaults = item_defaults
self.resolved_completion_item = nil
self.resolved_callbacks = {}
self.resolving = false
self.confirmed = false
self:_set_completion_item(completion_item)
return self
end
---@package
entry._set_completion_item = function(self, completion_item)
if not self.completion_item then
self.completion_item = self:fill_defaults(completion_item, self.item_defaults)
else
-- @see https://github.com/microsoft/vscode/blob/85eea4a9b2ccc99615e970bf2181edbc1781d0f9/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts#L588
-- @see https://github.com/microsoft/vscode/blob/85eea4a9b2ccc99615e970bf2181edbc1781d0f9/src/vs/base/common/objects.ts#L89
-- @see https://github.com/microsoft/vscode/blob/a00f2e64f4fa9a1f774875562e1e9697d7138ed3/src/vs/editor/contrib/suggest/browser/suggest.ts#L147
for k, v in pairs(completion_item) do
self.completion_item[k] = v or self.completion_item[k]
end
end
local item = self.completion_item
---Create filter text
self.filter_text = item.filterText or str.trim(item.label)
-- TODO: the order below is important
if item.textEdit then
self.insert_range = self:convert_range_encoding(item.textEdit.insert or item.textEdit.range)
self.replace_range = self:convert_range_encoding(item.textEdit.replace or item.textEdit.range)
end
self.word = self:_get_word()
self.offset = self:_get_offset()
if not self.insert_range then
self.insert_range = {
start = {
line = self.context.cursor.row - 1,
character = self.offset - 1,
},
['end'] = self.source_insert_range['end'],
}
end
if not self.replace_range or ((self.context.cursor.col - 1) == self.replace_range['end'].character) then
self.replace_range = {
start = {
line = self.source_replace_range.start.line,
character = self.offset - 1,
},
['end'] = self.source_replace_range['end'],
}
end
end
---@deprecated use entry.offset instead
entry.get_offset = function(self)
return self.offset
end
---Make offset value
---@package
---@return integer
entry._get_offset = function(self)
local offset = self.source_offset
if self.completion_item.textEdit then
local range = self.insert_range
if range then
local start = math.min(range.start.character + 1, offset)
for idx = start, self.source_offset do
local byte = string.byte(self.context.cursor_line, idx)
if byte == nil or not char.is_white(byte) then
return idx
end
end
return offset
end
else
-- NOTE
-- The VSCode does not implement this but it's useful if the server does not care about word patterns.
-- We should care about this performance.
local word = self.word
for idx = self.source_offset - 1, self.source_offset - #word, -1 do
if char.is_semantic_index(self.context.cursor_line, idx) then
local c = string.byte(self.context.cursor_line, idx)
if char.is_white(c) then
break
end
local match = true
for i = 1, self.source_offset - idx do
local c1 = string.byte(word, i)
local c2 = string.byte(self.context.cursor_line, idx + i - 1)
if not c1 or not c2 or c1 ~= c2 then
match = false
break
end
end
if match then
offset = math.min(offset, idx)
end
end
end
end
return offset
end
---@deprecated use entry.word instead
entry.get_word = function(self)
return self.word
end
---Create word for vim.CompletedItem
---NOTE: This method doesn't clear the cache after completionItem/resolve.
---@package
---@return string
entry._get_word = function(self)
--NOTE: This is nvim-cmp specific implementation.
local completion_item = self.completion_item
if completion_item.word then
return completion_item.word
end
local word
if completion_item.textEdit and not misc.empty(completion_item.textEdit.newText) then
word = str.trim(completion_item.textEdit.newText)
if completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then
word = tostring(snippet.parse(word))
end
local overwrite = self:get_overwrite()
if 0 < overwrite[2] or completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then
word = str.get_word(word, string.byte(self.context.cursor_after_line, 1), overwrite[1] or 0)
end
elseif not misc.empty(completion_item.insertText) then
word = str.trim(completion_item.insertText)
if completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then
word = str.get_word(tostring(snippet.parse(word)))
end
else
word = str.trim(completion_item.label)
end
return str.oneline(word)
end
---Get overwrite information
---@return integer[]
entry.get_overwrite = function(self)
return self.cache:ensure('get_overwrite', entry._get_overwrite, self)
end
---@package
entry._get_overwrite = function(self)
if self.completion_item.textEdit then
local range = self.insert_range
if range then
local vim_start = range.start.character + 1
local vim_end = range['end'].character + 1
local before = self.context.cursor.col - vim_start
local after = vim_end - self.context.cursor.col
return { before, after }
end
end
return { 0, 0 }
end
---@package
entry.get_filter_text = function(self)
return self.filter_text
end
---Get LSP's insert text
---@return string
entry.get_insert_text = function(self)
return self.cache:ensure('get_insert_text', entry._get_insert_text, self)
end
---@package
entry._get_insert_text = function(self)
local completion_item = self.completion_item
local word
if completion_item.textEdit then
word = str.trim(completion_item.textEdit.newText)
if completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then
word = str.remove_suffix(str.remove_suffix(word, '$0'), '${0}')
end
elseif completion_item.insertText then
word = str.trim(completion_item.insertText)
if completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then
word = str.remove_suffix(str.remove_suffix(word, '$0'), '${0}')
end
else
word = str.trim(completion_item.label)
end
return word
end
---Return the item is deprecated or not.
---@return boolean
entry.is_deprecated = function(self)
return self.completion_item.deprecated or vim.tbl_contains(self.completion_item.tags or {}, types.lsp.CompletionItemTag.Deprecated)
end
---Return view information.
---@param suggest_offset integer
---@param entries_buf integer The buffer this entry will be rendered into.
---@return { abbr: { text: string, bytes: integer, width: integer, hl_group: string }, kind: { text: string, bytes: integer, width: integer, hl_group: string }, menu: { text: string, bytes: integer, width: integer, hl_group: string } }
entry.get_view = function(self, suggest_offset, entries_buf)
local item = self:get_vim_item(suggest_offset)
return self.cache:ensure('get_view:' .. tostring(entries_buf), entry._get_view, self, item, entries_buf)
end
---@package
entry._get_view = function(self, item, entries_buf)
local view = {}
-- The result of vim.fn.strdisplaywidth depends on which buffer it was
-- called in because it reads the values of the option 'tabstop' when
-- rendering <Tab> characters.
vim.api.nvim_buf_call(entries_buf, function()
view.abbr = {}
view.abbr.text = item.abbr or ''
view.abbr.bytes = #view.abbr.text
view.abbr.width = vim.fn.strdisplaywidth(view.abbr.text)
view.abbr.hl_group = item.abbr_hl_group or (self:is_deprecated() and 'CmpItemAbbrDeprecated' or 'CmpItemAbbr')
view.kind = {}
view.kind.text = item.kind or ''
view.kind.bytes = #view.kind.text
view.kind.width = vim.fn.strdisplaywidth(view.kind.text)
view.kind.hl_group = item.kind_hl_group or ('CmpItemKind' .. (types.lsp.CompletionItemKind[self:get_kind()] or ''))
view.menu = {}
view.menu.text = item.menu or ''
view.menu.bytes = #view.menu.text
view.menu.width = vim.fn.strdisplaywidth(view.menu.text)
view.menu.hl_group = item.menu_hl_group or 'CmpItemMenu'
view.dup = item.dup
end)
return view
end
---Make vim.CompletedItem
---@param suggest_offset integer
---@return vim.CompletedItem
entry.get_vim_item = function(self, suggest_offset)
return self.cache:ensure('get_vim_item:' .. tostring(suggest_offset), entry._get_vim_item, self, suggest_offset)
end
---@package
entry._get_vim_item = function(self, suggest_offset)
local completion_item = self.completion_item
local word = self.word
local abbr = str.oneline(completion_item.label)
-- ~ indicator
local is_expandable = false
local expandable_indicator = config.get().formatting.expandable_indicator
if #(completion_item.additionalTextEdits or {}) > 0 then
is_expandable = true
elseif completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then
is_expandable = self:get_insert_text() ~= word
elseif completion_item.kind == types.lsp.CompletionItemKind.Snippet then
is_expandable = true
end
if expandable_indicator and is_expandable then
abbr = abbr .. '~'
end
-- append delta text
if suggest_offset < self.offset then
word = string.sub(self.context.cursor_before_line, suggest_offset, self.offset - 1) .. word
end
-- labelDetails.
local menu = nil
if completion_item.labelDetails then
menu = ''
if completion_item.labelDetails.detail then
menu = menu .. completion_item.labelDetails.detail
end
if completion_item.labelDetails.description then
menu = menu .. completion_item.labelDetails.description
end
end
-- remove duplicated string.
if self.offset ~= self.context.cursor.col then
for i = 1, #word do
if str.has_prefix(self.context.cursor_after_line, string.sub(word, i, #word)) then
word = string.sub(word, 1, i - 1)
break
end
end
end
local cmp_opts = completion_item.cmp or {}
local vim_item = {
word = word,
abbr = abbr,
kind = cmp_opts.kind_text or types.lsp.CompletionItemKind[self:get_kind()] or types.lsp.CompletionItemKind[1],
kind_hl_group = cmp_opts.kind_hl_group,
menu = menu,
dup = completion_item.dup or 1,
}
if config.get().formatting.format then
vim_item = config.get().formatting.format(self, vim_item)
end
vim_item.word = str.oneline(vim_item.word or '')
vim_item.abbr = str.oneline(vim_item.abbr or '')
vim_item.kind = str.oneline(vim_item.kind or '')
vim_item.menu = str.oneline(vim_item.menu or '')
vim_item.equal = 1
vim_item.empty = 1
return vim_item
end
---Get commit characters
---@return string[]
entry.get_commit_characters = function(self)
return self.completion_item.commitCharacters or {}
end
---@deprecated use entry.insert_range instead
entry.get_insert_range = function(self)
return self.insert_range
end
---@deprecated use entry.replace_range instead
entry.get_replace_range = function(self)
return self.replace_range
end
---Match line.
---@param input string
---@param matching_config cmp.MatchingConfig
---@return { score: integer, matches: table[] }
entry.match = function(self, input, matching_config)
-- https://www.lua.org/pil/11.6.html
-- do not use '..' to allocate multiple strings
local cache_key = string.format('%s:%d:%d:%d:%d:%d:%d', input, self.resolved_completion_item and 1 or 0, matching_config.disallow_fuzzy_matching and 1 or 0, matching_config.disallow_partial_matching and 1 or 0, matching_config.disallow_prefix_unmatching and 1 or 0, matching_config.disallow_partial_fuzzy_matching and 1 or 0, matching_config.disallow_symbol_nonprefix_matching and 1 or 0)
local matched = self.match_cache:get(cache_key)
if matched then
if self.match_view_args_ret and self.match_view_args_ret.input ~= input then
self.match_view_args_ret.input = input
self.match_view_args_ret.word = matched._word
self.match_view_args_ret.matches = matched.matches
end
return matched
end
matched = self:_match(input, matching_config)
self.match_cache:set(cache_key, matched)
return matched
end
---@package
entry._match = function(self, input, matching_config)
local completion_item = self.completion_item
local option = {
disallow_fuzzy_matching = matching_config.disallow_fuzzy_matching,
disallow_partial_fuzzy_matching = matching_config.disallow_partial_fuzzy_matching,
disallow_partial_matching = matching_config.disallow_partial_matching,
disallow_prefix_unmatching = matching_config.disallow_prefix_unmatching,
disallow_symbol_nonprefix_matching = matching_config.disallow_symbol_nonprefix_matching,
synonyms = {
self.word,
self.completion_item.label,
},
}
local score, matches, filter_text
local checked = {} ---@type table<string, boolean>
filter_text = self.filter_text
checked[filter_text] = true
score, matches = matcher.match(input, filter_text, option)
-- Support the language server that doesn't respect VSCode's behaviors.
if score == 0 then
if completion_item.textEdit and not misc.empty(completion_item.textEdit.newText) then
local diff = self.source_offset - self.offset
if diff > 0 then
local prefix = string.sub(self.context.cursor_line, self.offset, self.offset + diff)
local accept = nil
accept = accept or string.match(prefix, '^[^%a]+$')
accept = accept or string.find(completion_item.textEdit.newText, prefix, 1, true)
if accept then
filter_text = prefix .. filter_text
if not checked[filter_text] then
checked[filter_text] = true
score, matches = matcher.match(input, filter_text, option)
end
end
end
end
end
-- Fix highlight if filterText is not the same to vim_item.abbr.
if score > 0 then
self.match_view_args_ret = {
input = input,
word = filter_text,
option = option,
matches = matches,
}
end
return { score = score, matches = matches, _word = filter_text }
end
---@param view string
entry.get_view_matches = function(self, view)
if self.match_view_args_ret then
if self.match_view_args_ret.word == view then
return self.match_view_args_ret.matches
end
self.match_view_args_ret.word = view
local input = self.match_view_args_ret.input
local diff = self.source_offset - self.offset
if diff > 0 then
input = input:sub(1 + diff)
end
local _, matches = matcher.match(input, view, self.match_view_args_ret.option)
self.match_view_args_ret.matches = matches
return matches
end
end
---@deprecated use entry.completion_item instead
entry.get_completion_item = function(self)
return self.completion_item
end
---Create documentation
---@return string[]
entry.get_documentation = function(self)
local item = self.completion_item
local documents = {}
-- detail
if item.detail and item.detail ~= '' then
local ft = self.context.filetype
local dot_index = string.find(ft, '%.')
if dot_index ~= nil then
ft = string.sub(ft, 0, dot_index - 1)
end
table.insert(documents, {
kind = types.lsp.MarkupKind.Markdown,
value = ('```%s\n%s\n```'):format(ft, str.trim(item.detail)),
})
end
local documentation = item.documentation
if type(documentation) == 'string' and documentation ~= '' then
local value = str.trim(documentation)
if value ~= '' then
table.insert(documents, {
kind = types.lsp.MarkupKind.PlainText,
value = value,
})
end
elseif type(documentation) == 'table' and not misc.empty(documentation.value) then
local value = str.trim(documentation.value)
if value ~= '' then
table.insert(documents, {
kind = documentation.kind,
value = value,
})
end
end
return vim.lsp.util.convert_input_to_markdown_lines(documents)
end
---Get completion item kind
---@return lsp.CompletionItemKind
entry.get_kind = function(self)
return self.completion_item.kind or types.lsp.CompletionItemKind.Text
end
---Execute completion item's command.
---@param callback fun()
entry.execute = function(self, callback)
self.source:execute(self.completion_item, callback)
end
---Resolve completion item.
---@param callback fun()
entry.resolve = function(self, callback)
if self.resolved_completion_item then
return callback()
end
table.insert(self.resolved_callbacks, callback)
if not self.resolving then
self.resolving = true
self.source:resolve(self.completion_item, function(completion_item)
self.resolving = false
if not completion_item then
return
end
self:_set_completion_item(completion_item)
self.resolved_completion_item = self.completion_item
self.cache:clear()
for _, c in ipairs(self.resolved_callbacks) do
c()
end
end)
end
end
---@param completion_item lsp.CompletionItem
---@param defaults? lsp.internal.CompletionItemDefaults
---@return lsp.CompletionItem
entry.fill_defaults = function(_, completion_item, defaults)
defaults = defaults or {}
if defaults.data then
completion_item.data = completion_item.data or defaults.data
end
if defaults.commitCharacters then
completion_item.commitCharacters = completion_item.commitCharacters or defaults.commitCharacters
end
if defaults.insertTextFormat then
completion_item.insertTextFormat = completion_item.insertTextFormat or defaults.insertTextFormat
end
if defaults.insertTextMode then
completion_item.insertTextMode = completion_item.insertTextMode or defaults.insertTextMode
end
if defaults.editRange then
if not completion_item.textEdit then
if defaults.editRange.insert then
completion_item.textEdit = {
insert = defaults.editRange.insert,
replace = defaults.editRange.replace,
newText = completion_item.textEditText or completion_item.label,
}
else
completion_item.textEdit = {
range = defaults.editRange, --[[@as lsp.Range]]
newText = completion_item.textEditText or completion_item.label,
}
end
end
end
return completion_item
end
---Convert the oneline range encoding.
entry.convert_range_encoding = function(self, range)
local from_encoding = self.source.position_encoding
local cache_key = string.format('entry.convert_range_encoding:%d:%d:%s', range.start.character, range['end'].character, from_encoding)
local res = self.context.cache:get(cache_key)
if res then
return res
end
res = {
start = types.lsp.Position.to_utf8(self.context.cursor_line, range.start, from_encoding),
['end'] = types.lsp.Position.to_utf8(self.context.cursor_line, range['end'], from_encoding),
}
self.context.cache:set(cache_key, res)
return res
end
---Return true if the entry is invalid.
entry.is_invalid = function(self)
local is_invalid = false
is_invalid = is_invalid or misc.empty(self.completion_item.label)
if self.completion_item.textEdit then
local range = self.completion_item.textEdit.range or self.completion_item.textEdit.insert
is_invalid = is_invalid or range.start.line ~= range['end'].line or range.start.line ~= self.context.cursor.line
end
return is_invalid
end
return entry