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 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 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