* dev

* Improve sync design

* Support buffer local mapping

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* stylua

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* integration

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* update

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp
This commit is contained in:
hrsh7th
2021-08-04 01:07:12 +09:00
committed by GitHub
parent b32a6e7e77
commit d23d3533cf
53 changed files with 4681 additions and 0 deletions

34
lua/cmp/autocmd.lua Normal file
View File

@@ -0,0 +1,34 @@
local debug = require('cmp.utils.debug')
local autocmd = {}
autocmd.events = {}
---Subscribe autocmd
---@param event string
---@param callback function
---@return function
autocmd.subscribe = function(event, callback)
autocmd.events[event] = autocmd.events[event] or {}
table.insert(autocmd.events[event], callback)
return function()
for i, callback_ in ipairs(autocmd.events[event]) do
if callback_ == callback then
table.remove(autocmd.events[event], i)
break
end
end
end
end
---Emit autocmd
---@param event string
autocmd.emit = function(event)
debug.log(string.format('>>> %s', event))
autocmd.events[event] = autocmd.events[event] or {}
for _, callback in ipairs(autocmd.events[event]) do
callback()
end
end
return autocmd

63
lua/cmp/config.lua Normal file
View File

@@ -0,0 +1,63 @@
local cache = require('cmp.utils.cache')
local misc = require('cmp.utils.misc')
---@class cmp.Config
---@field public g cmp.ConfigSchema
local config = {}
---@type cmp.Cache
config.cache = cache.new()
---@type cmp.ConfigSchema
config.global = require('cmp.config.default')()
---@type table<number, cmp.ConfigSchema>
config.buffers = {}
---Set configuration for global.
---@param c cmp.ConfigSchema
config.set_global = function(c)
config.global = misc.merge(c, config.global)
config.global.revision = config.global.revision or 1
config.global.revision = config.global.revision + 1
end
---Set configuration for buffer
---@param c cmp.ConfigSchema
---@param bufnr number|nil
config.set_buffer = function(c, bufnr)
config.buffers[bufnr] = c
config.buffers[bufnr].revision = config.buffers[bufnr].revision or 1
config.buffers[bufnr].revision = config.buffers[bufnr].revision + 1
end
---@return cmp.ConfigSchema
config.get = function()
local global = config.global
local buffer = config.buffers[vim.api.nvim_get_current_buf()] or { revision = 1 }
return config.cache:ensure({ 'get', global.revision or 0, buffer.revision or 0 }, function()
return misc.merge(buffer, global)
end)
end
---Return source option
---@param name string
---@return table
config.get_source_option = function(name)
local global = config.global
local buffer = config.buffers[vim.api.nvim_get_current_buf()] or { revision = 1 }
return config.cache:ensure({ 'get_source_config', global.revision or 0, buffer.revision or 0, name }, function()
local c = config.get()
for _, s in ipairs(c.sources) do
if s.name == name then
if type(s.opts) == 'table' then
return s.opts
end
return {}
end
end
return nil
end)
end
return config

View File

@@ -0,0 +1,88 @@
local types = require'cmp.types'
local misc = require 'cmp.utils.misc'
local compare = {}
-- offset
compare.offset = function(entry1, entry2)
local diff = entry1:get_offset() - entry2:get_offset()
if diff < 0 then
return true
elseif diff > 0 then
return false
end
end
-- exact
compare.exact = function(entry1, entry2)
if entry1.exact ~= entry2.exact then
return entry1.exact
end
end
-- score
compare.score = function(entry1, entry2)
local diff = entry2.score - entry1.score
if diff < 0 then
return true
elseif diff > 0 then
return false
end
end
-- kind
compare.kind = function(entry1, entry2)
local kind1 = entry1:get_kind()
kind1 = kind1 == types.lsp.CompletionItemKind.Text and 100 or kind1
local kind2 = entry2:get_kind()
kind2 = kind2 == types.lsp.CompletionItemKind.Text and 100 or kind2
if kind1 ~= kind2 then
if kind1 == types.lsp.CompletionItemKind.Snippet then
return true
end
if kind2 == types.lsp.CompletionItemKind.Snippet then
return false
end
local diff = kind1 - kind2
if diff < 0 then
return true
elseif diff > 0 then
return false
end
end
end
-- sortText
compare.sort_text = function(entry1, entry2)
if misc.safe(entry1.completion_item.sortText) and misc.safe(entry2.completion_item.sortText) then
local diff = vim.stricmp(entry1.completion_item.sortText, entry2.completion_item.sortText)
if diff < 0 then
return true
elseif diff > 0 then
return false
end
end
end
-- length
compare.length = function(entry1, entry2)
local diff = #entry1.completion_item.label - #entry2.completion_item.label
if diff < 0 then
return true
elseif diff > 0 then
return false
end
end
-- order
compare.order = function(entry1, entry2)
local diff = entry1.id - entry2.id
if diff < 0 then
return true
elseif diff > 0 then
return false
end
end
return compare

119
lua/cmp/config/default.lua Normal file
View File

@@ -0,0 +1,119 @@
local str = require('cmp.utils.str')
local misc = require('cmp.utils.misc')
local compare = require('cmp.config.compare')
local types = require('cmp.types')
local WIDE_HEIGHT = 40
---@return cmp.ConfigSchema
return function()
return {
completion = {
autocomplete = {
types.cmp.TriggerEvent.InsertEnter,
types.cmp.TriggerEvent.TextChanged,
},
completeopt = 'menu,menuone,noselect',
keyword_pattern = [[\%(-\?\d\+\%(\.\d\+\)\?\|\h\w*\%(-\w*\)*\)]],
keyword_length = 1,
},
snippet = {
expand = function()
error('snippet engine does not configured.')
end,
},
documentation = {
border = { '', '', '', ' ', '', '', '', ' ' },
winhighlight = 'NormalFloat:CmpDocumentation,FloatBorder:CmpDocumentationBorder',
maxwidth = math.floor((WIDE_HEIGHT * 2) * (vim.o.columns / (WIDE_HEIGHT * 2 * 16 / 9))),
maxheight = math.floor(WIDE_HEIGHT * (WIDE_HEIGHT / vim.o.lines)),
},
confirmation = {
default_behavior = types.cmp.ConfirmBehavior.Replace,
mapping = {
['<CR>'] = {
behavior = types.cmp.ConfirmBehavior.Replace,
select = true,
},
}
},
sorting = {
sort = function(entries)
table.sort(entries, function(entry1, entry2)
for _, fn in ipairs({
compare.offset,
compare.exact,
compare.score,
compare.kind,
compare.sort_text,
compare.length,
compare.order,
}) do
local diff = fn(entry1, entry2)
if diff ~= nil then
return diff
end
end
return true
end)
return entries
end
},
formatting = {
format = function(e, suggest_offset)
local item = e:get_completion_item()
local word = e:get_word()
local abbr = str.trim(item.label)
-- ~ indicator
if #(misc.safe(item.additionalTextEdits) or {}) > 0 then
abbr = abbr .. '~'
elseif item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then
local insert_text = e:get_insert_text()
if word ~= insert_text then
abbr = abbr .. '~'
end
end
-- deprecated
if item.deprecated or vim.tbl_contains(item.tags or {}, types.lsp.CompletionItemTag.Deprecated) then
abbr = str.strikethrough(abbr)
end
-- append delta text
if suggest_offset < e:get_offset() then
word = string.sub(e.context.cursor_before_line, suggest_offset, e:get_offset() - 1) .. word
end
-- labelDetails.
local menu = nil
if misc.safe(item.labelDetails) then
menu = ''
if misc.safe(item.labelDetails.parameters) then
menu = menu .. item.labelDetails.parameters
end
if misc.safe(item.labelDetails.type) then
menu = menu .. item.labelDetails.type
end
if misc.safe(item.labelDetails.qualifier) then
menu = menu .. item.labelDetails.qualifier
end
end
return {
word = word,
abbr = abbr,
kind = types.lsp.CompletionItemKind[e:get_kind()] or types.lsp.CompletionItemKind[1],
menu = menu,
}
end
},
sources = {},
}
end

142
lua/cmp/context.lua Normal file
View File

@@ -0,0 +1,142 @@
local misc = require('cmp.utils.misc')
local pattern = require('cmp.utils.pattern')
local types = require('cmp.types')
local cache = require('cmp.utils.cache')
---@class cmp.Context
---@field public id string
---@field public cache cmp.Cache
---@field public prev_context cmp.Context
---@field public option cmp.ContextOption
---@field public pumvisible boolean
---@field public pumselect boolean
---@field public filetype string
---@field public time number
---@field public mode string
---@field public bufnr number
---@field public cursor vim.Position
---@field public cursor_line string
---@field public cursor_after_line string
---@field public cursor_before_line string
---@field public before_char string
local context = {}
---Create new empty context
---@return cmp.Context
context.empty = function()
local ctx = context.new({}) -- dirty hack to prevent recursive call `context.empty`.
ctx.bufnr = -1
ctx.input = ''
ctx.cursor = {}
ctx.cursor.row = -1
ctx.cursor.col = -1
return ctx
end
---Create new context
---@param prev_context cmp.Context
---@param option cmp.ContextOption
---@return cmp.Context
context.new = function(prev_context, option)
option = option or {}
local self = setmetatable({}, { __index = context })
local completeinfo = vim.fn.complete_info({ 'selected', 'mode', 'pum_visible' })
self.id = misc.id('context')
self.cache = cache.new()
self.prev_context = prev_context or context.empty()
self.option = option or { reason = types.cmp.ContextReason.None }
self.pumvisible = completeinfo.pum_visible ~= 0
self.pumselect = completeinfo.selected ~= -1
self.filetype = vim.api.nvim_buf_get_option(0, 'filetype')
self.time = vim.loop.now()
self.mode = vim.api.nvim_get_mode().mode
self.bufnr = vim.api.nvim_get_current_buf()
self.cursor = {}
self.cursor.row = vim.api.nvim_win_get_cursor(0)[1]
self.cursor.col = vim.api.nvim_win_get_cursor(0)[2] + 1
self.cursor_line = vim.api.nvim_get_current_line()
self.cursor_before_line = string.sub(self.cursor_line, 1, self.cursor.col - 1)
self.cursor_after_line = string.sub(self.cursor_line, self.cursor.col)
self.before_char = string.sub(self.cursor_line, self.cursor.col - 1, self.cursor.col - 1)
return self
end
---Return context creation reason.
---@return cmp.ContextReason
context.get_reason = function(self)
return self.option.reason
end
---Get keyword pattern offset
---@return number|nil
context.get_offset = function(self, keyword_pattern)
return self.cache:ensure({ 'get_offset', keyword_pattern, self.cursor_before_line }, function()
return pattern.offset(keyword_pattern .. '$', self.cursor_before_line) or self.cursor.col
end)
end
---if cursor moves from left to right.
---@param self cmp.Context
context.is_forwarding = function(self)
local prev = self.prev_context
local curr = self
return prev.bufnr == curr.bufnr and prev.cursor.row == curr.cursor.row and prev.cursor.col < curr.cursor.col
end
---Return if this context is continueing previous context.
context.continue = function(self, offset)
local prev = self.prev_context
local curr = self
if curr.bufnr ~= prev.bufnr then
return false
end
if curr.cursor.row ~= prev.cursor.row then
return false
end
if curr.cursor.col < offset then
return false
end
return true
end
---Return if this context is changed from previous context or not.
---@return boolean
context.changed = function(self, ctx)
local curr = self
if self.pumvisible then
local completed_item = vim.v.completed_item or {}
if completed_item.word then
return false
end
end
if curr.bufnr ~= ctx.bufnr then
return true
end
if curr.cursor.row ~= ctx.cursor.row then
return true
end
if curr.cursor.col ~= ctx.cursor.col then
return true
end
if curr:get_reason() == types.cmp.ContextReason.Manual then
return true
end
return false
end
---Shallow clone
context.clone = function(self)
local cloned = {}
for k, v in pairs(self) do
cloned[k] = v
end
return cloned
end
return context

31
lua/cmp/context_spec.lua Normal file
View File

@@ -0,0 +1,31 @@
local spec = require('cmp.utils.spec')
local context = require('cmp.context')
describe('context', function()
before_each(spec.before)
describe('new', function()
it('middle of text', function()
vim.fn.setline('1', 'function! s:name() abort')
vim.bo.filetype = 'vim'
vim.fn.execute('normal! fm')
local ctx = context.new()
assert.are.equal(ctx.filetype, 'vim')
assert.are.equal(ctx.cursor.row, 1)
assert.are.equal(ctx.cursor.col, 15)
assert.are.equal(ctx.cursor_line, 'function! s:name() abort')
end)
it('tab indent', function()
vim.fn.setline('1', '\t\tab')
vim.bo.filetype = 'vim'
vim.fn.execute('normal! fb')
local ctx = context.new()
assert.are.equal(ctx.filetype, 'vim')
assert.are.equal(ctx.cursor.row, 1)
assert.are.equal(ctx.cursor.col, 4)
assert.are.equal(ctx.cursor_line, '\t\tab')
end)
end)
end)

255
lua/cmp/core.lua Normal file
View File

@@ -0,0 +1,255 @@
local debug = require('cmp.utils.debug')
local char = require('cmp.utils.char')
local async = require('cmp.utils.async')
local keymap = require('cmp.utils.keymap')
local context = require('cmp.context')
local source = require('cmp.source')
local menu = require('cmp.menu')
local misc = require('cmp.utils.misc')
local config = require('cmp.config')
local types = require('cmp.types')
local patch = require('cmp.utils.patch')
local core = {}
core.SOURCE_TIMEOUT = 500
---@type cmp.Menu
core.menu = menu.new()
---@type table<number, cmp.Source>
core.sources = {}
---@type cmp.Context
core.context = context.new()
---Register source
---@param s cmp.Source
core.register_source = function(s)
core.sources[s.id] = s
end
---Unregister source
---@param source_id string
core.unregister_source = function(source_id)
core.sources[source_id] = nil
end
---Get new context
---@param option cmp.ContextOption
---@return cmp.Context
core.get_context = function(option)
local prev = core.context:clone()
prev.prev_context = nil
core.context = context.new(prev, option)
return core.context
end
---Get sources that sorted by priority
---@param statuses cmp.SourceStatus[]
---@return cmp.Source[]
core.get_sources = function(statuses)
local sources = {}
for _, c in pairs(config.get().sources) do
for _, s in pairs(core.sources) do
if c.name == s.name then
if not statuses or vim.tbl_contains(statuses, s.status) then
table.insert(sources, s)
end
end
end
end
return sources
end
---Keypress handler
core.on_keymap = function(keys, fallback)
-- Confirm character
if config.get().confirmation.mapping[keys] then
local c = config.get().confirmation.mapping[keys]
local e = core.menu:get_selected_entry() or (c.select and core.menu:get_first_entry())
if not e then
return fallback()
end
return core.confirm(e, {
behavior = c.behavior,
})
end
--Commit character. NOTE: This has a lot of cmp specific implementation to make more user-friendly.
local chars = keymap.t(keys)
local e = core.menu:get_selected_entry()
if e and vim.tbl_contains(e:get_commit_characters(), chars) then
local is_printable = char.is_printable(string.byte(chars, 1))
return core.confirm(e, {
behavior = is_printable and 'insert' or 'replace',
}, function()
local ctx = core.get_context()
local word = e:get_word()
if string.sub(ctx.cursor_before_line, -#word, ctx.cursor.col - 1) == word and is_printable then
fallback()
end
end)
end
fallback()
end
---Prepare completion
core.prepare = function()
for keys in pairs(config.get().confirmation.mapping) do
keymap.listen(keys, core.on_keymap)
end
end
---Check auto-completion
core.autocomplete = function(event)
local ctx = core.get_context({ reason = types.cmp.ContextReason.Auto })
-- Skip autocompletion when the item is selected manually.
if ctx.pumvisible and not vim.tbl_isempty(vim.v.completed_item) then
return
end
debug.log(('ctx: `%s`'):format(ctx.cursor_before_line))
if ctx:is_forwarding() then
debug.log('changed')
core.menu:restore(ctx)
if vim.tbl_contains(config.get().completion.autocomplete, event) then
core.complete(ctx)
else
core.filter.timeout = 50
core.filter()
end
else
debug.log('unchanged')
end
end
---Invoke completion
---@param ctx cmp.Context
core.complete = function(ctx)
for _, s in ipairs(core.get_sources({ source.SourceStatus.WAITING, source.SourceStatus.COMPLETED })) do
s:complete(ctx, function()
local new = context.new(ctx)
if new:changed(new.prev_context) then
core.complete(new)
else
core.filter.timeout = 50
core.filter()
end
end)
end
core.filter.timeout = ctx.pumvisible and 50 or 0
core.filter()
end
---Update completion menu
core.filter = async.throttle(function()
local ctx = core.get_context()
-- To wait for processing source for that's timeout.
for _, s in ipairs(core.get_sources({ source.SourceStatus.FETCHING })) do
local time = core.SOURCE_TIMEOUT - s:get_fetching_time()
if time > 0 then
core.filter.stop()
core.filter.timeout = time + 1
core.filter()
return
end
end
core.menu:update(ctx, core.get_sources())
end, 50)
---Confirm completion.
---@param e cmp.Entry
---@param option cmp.ConfirmOption
---@param callback function
core.confirm = vim.schedule_wrap(function(e, option, callback)
if not (e and not e.confirmed) then
return
end
e.confirmed = true
debug.log('entry.confirm', e:get_completion_item())
--@see https://github.com/microsoft/vscode/blob/main/src/vs/editor/contrib/suggest/suggestController.ts#L334
local pre = context.new()
if #(misc.safe(e:get_completion_item().additionalTextEdits) or {}) == 0 then
local new = context.new(pre)
e:resolve(function()
local text_edits = misc.safe(e:get_completion_item().additionalTextEdits) or {}
if #text_edits == 0 then
return
end
local has_cursor_line_text_edit = (function()
local minrow = math.min(pre.cursor.row, new.cursor.row)
local maxrow = math.max(pre.cursor.row, new.cursor.row)
for _, text_edit in ipairs(text_edits) do
local srow = text_edit.range.start.line + 1
local erow = text_edit.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.fn['cmp#apply_text_edits'](new.bufnr, text_edits)
end)
end
-- Prepare completion item for confirmation
local completion_item = misc.copy(e:get_completion_item())
if not misc.safe(completion_item.textEdit) then
completion_item.textEdit = {}
completion_item.textEdit.newText = misc.safe(completion_item.insertText) 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:get_replace_range()
else
completion_item.textEdit.range = e:get_insert_range()
end
-- First, emulates vim's `<C-y>` behavior and then confirms LSP functionalities.
patch.apply(
pre,
completion_item.textEdit.range,
e:get_word(),
vim.schedule_wrap(function()
vim.fn['cmp#confirm']({
request_offset = e.context.cursor.col,
suggest_offset = e:get_offset(),
completion_item = completion_item,
})
-- execute
e:execute(function()
core.reset()
if callback then
callback()
end
end)
end)
)
end)
---Reset current completion state
core.reset = function()
for _, s in pairs(core.sources) do
s:reset()
end
core.menu:reset()
core.get_context() -- To prevent new event
end
return core

321
lua/cmp/entry.lua Normal file
View File

@@ -0,0 +1,321 @@
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 config = require('cmp.config')
local types = require('cmp.types')
---@class cmp.Entry
---@field public id number
---@field public cache cmp.Cache
---@field public score number
---@field public exact boolean
---@field public context cmp.Context
---@field public source cmp.Source
---@field public source_offset number
---@field public source_insert_range lsp.Range
---@field public source_replace_range lsp.Range
---@field public completion_item lsp.CompletionItem
---@field public resolved_completion_item lsp.CompletionItem|nil
---@field public resolved_callbacks fun()[]
---@field public resolving boolean
---@field public confirmed boolean
local entry = {}
---Create new entry
---@param ctx cmp.Context
---@param source cmp.Source
---@param completion_item lsp.CompletionItem
---@return cmp.Entry
entry.new = function(ctx, source, completion_item)
local self = setmetatable({}, { __index = entry })
self.id = misc.id('entry')
self.cache = cache.new()
self.score = 0
self.context = ctx
self.source = source
self.source_offset = source.offset
self.source_insert_range = source:get_default_insert_range()
self.source_replace_range = source:get_default_replace_range()
self.completion_item = completion_item
self.resolved_completion_item = nil
self.resolved_callbacks = {}
self.resolving = false
self.confirmed = false
return self
end
---Make offset value
---@return number
entry.get_offset = function(self)
return self.cache:ensure('get_offset', function()
local offset = self.source_offset
if misc.safe(self.completion_item.textEdit) then
local range = misc.safe(self.completion_item.textEdit.insert) or misc.safe(self.completion_item.textEdit.range)
if range then
local c = vim.str_byteindex(self.context.cursor_line, range.start.character) + 1
for idx = c, self.source_offset do
if not char.is_white(string.byte(self.context.cursor_line, idx)) then
offset = math.min(offset, idx)
break
end
end
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:get_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)
end
---Create word for vim.CompletedItem
---@return string
entry.get_word = function(self)
return self.cache:ensure('get_word', function()
--NOTE: This is nvim-cmp specific implementation.
if misc.safe(self.completion_item.word) then
return self.completion_item.word
end
local word
if misc.safe(self.completion_item.textEdit) then
word = str.trim(self.completion_item.textEdit.newText)
local _, after = self:get_overwrite()
if 0 < after or self.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then
word = str.get_word(word, string.byte(self.context.cursor_after_line, 1))
end
elseif misc.safe(self.completion_item.insertText) then
word = str.trim(self.completion_item.insertText)
if self.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then
word = str.get_word(word)
end
else
word = str.trim(self.completion_item.label)
end
return word
end)
end
---Get overwrite information
---@return number, number
entry.get_overwrite = function(self)
return self.cache:ensure('get_overwrite', function()
if misc.safe(self.completion_item.textEdit) then
local r = misc.safe(self.completion_item.textEdit.insert) or misc.safe(self.completion_item.textEdit.range)
local s = vim.str_byteindex(self.context.cursor_line, r.start.character) + 1
local e = vim.str_byteindex(self.context.cursor_line, r['end'].character) + 1
local before = self.context.cursor.col - s
local after = e - self.context.cursor.col
return before, after
end
return 0, 0
end)
end
---Create filter text
---@return string
entry.get_filter_text = function(self)
return self.cache:ensure('get_filter_text', function()
local word
if misc.safe(self.completion_item.filterText) then
word = self.completion_item.filterText
else
word = str.trim(self.completion_item.label)
end
-- @see https://github.com/clangd/clangd/issues/815
if misc.safe(self.completion_item.textEdit) then
local diff = self.source_offset - self:get_offset()
if diff > 0 then
if char.is_symbol(string.byte(self.context.cursor_line, self:get_offset())) then
local prefix = string.sub(self.context.cursor_line, self:get_offset(), self:get_offset() + diff)
if string.find(word, prefix, 1, true) ~= 1 then
word = prefix .. word
end
end
end
end
return word
end)
end
---Get LSP's insert text
---@return string
entry.get_insert_text = function(self)
return self.cache:ensure('get_insert_text', function()
local word
if misc.safe(self.completion_item.textEdit) then
word = str.trim(self.completion_item.textEdit.newText)
if self.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then
word = str.remove_suffix(str.remove_suffix(word, '$0'), '${0}')
end
elseif misc.safe(self.completion_item.insertText) then
word = str.trim(self.completion_item.insertText)
if self.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then
word = str.remove_suffix(str.remove_suffix(word, '$0'), '${0}')
end
else
word = str.trim(self.completion_item.label)
end
return word
end)
end
---Make vim.CompletedItem
---@param suggeset_offset number
---@return vim.CompletedItem
entry.get_vim_item = function(self, suggeset_offset)
return self.cache:ensure({ 'get_vim_item', suggeset_offset }, function()
local item = config.get().formatting.format(self, suggeset_offset)
item.equal = 1
item.empty = 1
item.dup = self.completion_item.dup or 1
item.user_data = { cmp = self.id }
return item
end)
end
---Get commit characters
---@return string[]
entry.get_commit_characters = function(self)
return misc.safe(self:get_completion_item().commitCharacters) or {}
end
---Return insert range
---@return lsp.Range|nil
entry.get_insert_range = function(self)
local insert_range
if misc.safe(self.completion_item.textEdit) then
if misc.safe(self.completion_item.textEdit.insert) then
insert_range = self.completion_item.textEdit.insert
else
insert_range = self.completion_item.textEdit.range
end
else
insert_range = {
start = {
line = self.context.cursor.row - 1,
character = math.min(vim.str_utfindex(self.context.cursor_line, self:get_offset() - 1), self.source_insert_range.start.character),
},
['end'] = self.source_insert_range['end'],
}
end
return insert_range
end
---Return replace range
---@return vim.Range|nil
entry.get_replace_range = function(self)
return self.cache:ensure('get_replace_range', function()
local replace_range
if misc.safe(self.completion_item.textEdit) then
if misc.safe(self.completion_item.textEdit.replace) then
replace_range = self.completion_item.textEdit.replace
else
replace_range = self.completion_item.textEdit.range
end
else
replace_range = {
start = {
line = self.source_replace_range.start.line,
character = math.min(vim.str_utfindex(self.context.cursor_line, self:get_offset() - 1), self.source_replace_range.start.character),
},
['end'] = self.source_replace_range['end'],
}
end
return replace_range
end)
end
---Get resolved completion item if possible.
---@return lsp.CompletionItem
entry.get_completion_item = function(self)
if self.resolved_completion_item then
return self.resolved_completion_item
end
return self.completion_item
end
---Create documentation
---@return string
entry.get_documentation = function(self)
local item = self:get_completion_item()
local documents = {}
-- detail
if misc.safe(item.detail) and item.detail ~= '' then
table.insert(documents, {
kind = types.lsp.MarkupKind.Markdown,
value = ('```%s\n%s\n```'):format(self.context.filetype, str.trim(item.detail)),
})
end
if type(item.documentation) == 'string' and item.documentation ~= '' then
table.insert(documents, {
kind = types.lsp.MarkupKind.PlainText,
value = str.trim(item.documentation),
})
elseif type(item.documentation) == 'table' and item.documentation.value ~= '' then
table.insert(documents, item.documentation)
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 misc.safe(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:get_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.resolved_completion_item = misc.safe(completion_item) or self.completion_item
for _, c in ipairs(self.resolved_callbacks) do
c()
end
end)
end
end
return entry

253
lua/cmp/entry_spec.lua Normal file
View File

@@ -0,0 +1,253 @@
local spec = require('cmp.utils.spec')
local entry = require('cmp.entry')
describe('entry', function()
before_each(spec.before)
it('one char', function()
local state = spec.state('@.', 1, 3)
local e = entry.new(state.press('@'), state.source(), {
label = '@',
})
assert.are.equal(e:get_offset(), 3)
assert.are.equal(e:get_vim_item(e:get_offset()).word, '@')
end)
it('word length (no fix)', function()
local state = spec.state('a.b', 1, 4)
local e = entry.new(state.press('.'), state.source(), {
label = 'b',
})
assert.are.equal(e:get_offset(), 5)
assert.are.equal(e:get_vim_item(e:get_offset()).word, 'b')
end)
it('word length (fix)', function()
local state = spec.state('a.b', 1, 4)
local e = entry.new(state.press('.'), state.source(), {
label = 'b.',
})
assert.are.equal(e:get_offset(), 3)
assert.are.equal(e:get_vim_item(e:get_offset()).word, 'b.')
end)
it('semantic index (no fix)', function()
local state = spec.state('a.bc', 1, 5)
local e = entry.new(state.press('.'), state.source(), {
label = 'c.',
})
assert.are.equal(e:get_offset(), 6)
assert.are.equal(e:get_vim_item(e:get_offset()).word, 'c.')
end)
it('semantic index (fix)', function()
local state = spec.state('a.bc', 1, 5)
local e = entry.new(state.press('.'), state.source(), {
label = 'bc.',
})
assert.are.equal(e:get_offset(), 3)
assert.are.equal(e:get_vim_item(e:get_offset()).word, 'bc.')
end)
it('[vscode-html-language-server] 1', function()
local state = spec.state(' </>', 1, 7)
local e = entry.new(state.press('.'), state.source(), {
label = '/div',
textEdit = {
range = {
start = {
line = 0,
character = 0,
},
['end'] = {
line = 0,
character = 6,
},
},
newText = ' </div',
},
})
assert.are.equal(e:get_offset(), 5)
assert.are.equal(e:get_vim_item(e:get_offset()).word, '</div')
end)
it('[clangd] 1', function()
--NOTE: clangd does not return `.foo` as filterText but we should care about it.
--nvim-cmp does care it by special handling in entry.lua.
local state = spec.state('foo', 1, 4)
local e = entry.new(state.press('.'), state.source(), {
insertText = '->foo',
label = ' foo',
textEdit = {
newText = '->foo',
range = {
start = {
character = 3,
line = 1,
},
['end'] = {
character = 4,
line = 1,
},
},
},
})
assert.are.equal(e:get_vim_item(4).word, '->foo')
assert.are.equal(e:get_filter_text(), '.foo')
end)
it('[typescript-language-server] 1', function()
local state = spec.state('Promise.resolve()', 1, 18)
local e = entry.new(state.press('.'), state.source(), {
label = 'catch',
})
-- The offset will be 18 in this situation because the server returns `[Symbol]` as candidate.
assert.are.equal(e:get_vim_item(18).word, '.catch')
assert.are.equal(e:get_filter_text(), 'catch')
end)
it('[typescript-language-server] 2', function()
local state = spec.state('Promise.resolve()', 1, 18)
local e = entry.new(state.press('.'), state.source(), {
filterText = '.Symbol',
label = 'Symbol',
textEdit = {
newText = '[Symbol]',
range = {
['end'] = {
character = 18,
line = 0,
},
start = {
character = 17,
line = 0,
},
},
},
})
assert.are.equal(e:get_vim_item(18).word, '[Symbol]')
assert.are.equal(e:get_filter_text(), '.Symbol')
end)
it('[lua-language-server] 1', function()
local state = spec.state("local m = require'cmp.confi", 1, 28)
local e
-- press g
e = entry.new(state.press('g'), state.source(), {
insertTextFormat = 2,
label = 'cmp.config',
textEdit = {
newText = 'cmp.config',
range = {
['end'] = {
character = 27,
line = 1,
},
start = {
character = 18,
line = 1,
},
},
},
})
assert.are.equal(e:get_vim_item(19).word, 'cmp.config')
assert.are.equal(e:get_filter_text(), 'cmp.config')
-- press '
e = entry.new(state.press("'"), state.source(), {
insertTextFormat = 2,
label = 'cmp.config',
textEdit = {
newText = 'cmp.config',
range = {
['end'] = {
character = 27,
line = 1,
},
start = {
character = 18,
line = 1,
},
},
},
})
assert.are.equal(e:get_vim_item(19).word, 'cmp.config')
assert.are.equal(e:get_filter_text(), 'cmp.config')
end)
it('[lua-language-server] 2', function()
local state = spec.state("local m = require'cmp.confi", 1, 28)
local e
-- press g
e = entry.new(state.press('g'), state.source(), {
insertTextFormat = 2,
label = 'lua.cmp.config',
textEdit = {
newText = 'lua.cmp.config',
range = {
['end'] = {
character = 27,
line = 1,
},
start = {
character = 18,
line = 1,
},
},
},
})
assert.are.equal(e:get_vim_item(19).word, 'lua.cmp.config')
assert.are.equal(e:get_filter_text(), 'lua.cmp.config')
-- press '
e = entry.new(state.press("'"), state.source(), {
insertTextFormat = 2,
label = 'lua.cmp.config',
textEdit = {
newText = 'lua.cmp.config',
range = {
['end'] = {
character = 27,
line = 1,
},
start = {
character = 18,
line = 1,
},
},
},
})
assert.are.equal(e:get_vim_item(19).word, 'lua.cmp.config')
assert.are.equal(e:get_filter_text(), 'lua.cmp.config')
end)
it('[intelephense] 1', function()
local state = spec.state('\t\t', 1, 4)
-- press g
local e = entry.new(state.press('$'), state.source(), {
detail = '\\Nico_URLConf',
kind = 6,
label = '$this',
sortText = '$this',
textEdit = {
newText = '$this',
range = {
['end'] = {
character = 3,
line = 1,
},
start = {
character = 2,
line = 1,
},
},
},
})
assert.are.equal(e:get_vim_item(e:get_offset()).word, '$this')
assert.are.equal(e:get_filter_text(), '$this')
end)
end)

112
lua/cmp/float.lua Normal file
View File

@@ -0,0 +1,112 @@
local async = require('cmp.utils.async')
local config = require('cmp.config')
---@class cmp.Float
---@field public entry cmp.Entry|nil
---@field public buf number|nil
---@field public win number|nil
local float = {}
---Create new floating window module
float.new = function()
local self = setmetatable({}, { __index = float })
self.entry = nil
self.win = nil
self.buf = nil
return self
end
---Show floating window
---@param e cmp.Entry
float.show = function(self, e)
float.close.stop()
local documentation = config.get().documentation
-- update buffer content if needed.
if not self.entry or e.id ~= self.entry.id then
self.entry = e
self.buf = vim.api.nvim_create_buf(true, true)
vim.api.nvim_buf_set_option(self.buf, 'bufhidden', 'wipe')
local documents = e:get_documentation()
if #documents == 0 then
return self:close()
end
vim.lsp.util.stylize_markdown(self.buf, documents, {
max_width = documentation.maxwidth,
max_height = documentation.maxheight,
})
end
local width, height = vim.lsp.util._make_floating_popup_size(vim.api.nvim_buf_get_lines(self.buf, 0, -1, false), {
max_width = documentation.maxwidth,
max_height = documentation.maxheight,
})
if width <= 0 or height <= 0 then
return self:close()
end
local pum = vim.fn.pum_getpos() or {}
if not pum.col then
return self:close()
end
local right_col = pum.col + pum.width + (pum.scrollbar and 1 or 0)
local right_space = vim.o.columns - right_col - 1
local left_col = pum.col - width - 3 -- TODO: Why is this needed -3?
local left_space = pum.col - 1
local col
if right_space >= width and left_space >= width then
if right_space < left_space then
col = left_col
else
col = right_col
end
elseif right_space >= width then
col = right_col
elseif left_space >= width then
col = left_col
else
return self:close()
end
local style = {
relative = 'editor',
style = 'minimal',
width = width,
height = height,
row = pum.row,
col = col,
border = documentation.border,
}
if self.win and vim.api.nvim_win_is_valid(self.win) then
vim.api.nvim_win_set_buf(self.win, self.buf)
vim.api.nvim_win_set_config(self.win, style)
else
self.win = vim.api.nvim_open_win(self.buf, false, style)
vim.api.nvim_win_set_option(self.win, 'conceallevel', 2)
vim.api.nvim_win_set_option(self.win, 'concealcursor', 'n')
vim.api.nvim_win_set_option(self.win, 'winhighlight', config.get().documentation.winhighlight)
vim.api.nvim_win_set_option(self.win, 'foldenable', false)
vim.api.nvim_win_set_option(self.win, 'wrap', true)
vim.api.nvim_win_set_option(self.win, 'scrolloff', 0)
end
end
---Close floating window
float.close = async.throttle(
vim.schedule_wrap(function(self)
if self.win and vim.api.nvim_win_is_valid(self.win) then
vim.api.nvim_win_close(self.win, true)
end
self.entry = nil
self.buf = nil
self.win = nil
end),
0
)
return float

79
lua/cmp/init.lua Normal file
View File

@@ -0,0 +1,79 @@
local core = require('cmp.core')
local types = require('cmp.types')
local source = require('cmp.source')
local config = require('cmp.config')
local autocmd = require('cmp.autocmd')
local cmp = {}
---Expose types
for k, v in pairs(require('cmp.types.cmp')) do
cmp[k] = v
end
cmp.lsp = require('cmp.types.lsp')
cmp.vim = require('cmp.types.vim')
---Register completion sources
---@param name string
---@param s cmp.Source
---@return number
cmp.register_source = function(name, s)
local src = source.new(name, s)
core.register_source(src)
return src.id
end
---Unregister completion source
---@param id number
cmp.unregister_source = function(id)
core.unregister_source(id)
end
---@type cmp.Setup
cmp.setup = setmetatable({
global = function(c)
config.set_global(c)
end,
buffer = function(c)
config.set_buffer(c, vim.api.nvim_get_current_buf())
end,
}, {
__call = function(self, c)
self.global(c)
end,
})
---Invoke completion manually
cmp.complete = function()
core.complete(core.get_context({
reason = types.cmp.ContextReason.Manual,
}))
end
---Close completion
cmp.close = function()
core.reset()
end
---Internal expand snippet function.
---TODO: It should be removed when we remove `autoload/cmp.vim`.
---@param args cmp.SnippetExpansionParams
cmp._expand_snippet = function(args)
return config.get().snippet.expand(args)
end
---Handle events
autocmd.subscribe('InsertEnter', function()
core.prepare()
core.autocomplete('InsertEnter')
end)
autocmd.subscribe('TextChanged', function()
core.autocomplete('TextChanged')
end)
autocmd.subscribe('InsertLeave', function()
core.reset()
end)
return cmp

248
lua/cmp/matcher.lua Normal file
View File

@@ -0,0 +1,248 @@
local char = require('cmp.utils.char')
local matcher = {}
matcher.WORD_BOUNDALY_ORDER_FACTOR = 5
matcher.PREFIX_FACTOR = 8
matcher.NOT_FUZZY_FACTOR = 6
---@type function
matcher.debug = function(...)
return ...
end
--- score
--
-- ### The score
--
-- The `score` is `matched char count` generally.
--
-- But cmp will fix the score with some of the below points so the actual score is not `matched char count`.
--
-- 1. Word boundary order
--
-- cmp prefers the match that near by word-beggining.
--
-- 2. Strict case
--
-- cmp prefers strict match than ignorecase match.
--
--
-- ### Matching specs.
--
-- 1. Prefix matching per word boundary
--
-- `bora` -> `border-radius` # imaginary score: 4
-- ^^~~ ^^ ~~
--
-- 2. Try sequential match first
--
-- `woroff` -> `word_offset` # imaginary score: 6
-- ^^^~~~ ^^^ ~~~
--
-- * The `woroff`'s second `o` should not match `word_offset`'s first `o`
--
-- 3. Prefer early word boundary
--
-- `call` -> `call` # imaginary score: 4.1
-- ^^^^ ^^^^
-- `call` -> `condition_all` # imaginary score: 4
-- ^~~~ ^ ~~~
--
-- 4. Prefer strict match
--
-- `Buffer` -> `Buffer` # imaginary score: 6.1
-- ^^^^^^ ^^^^^^
-- `buffer` -> `Buffer` # imaginary score: 6
-- ^^^^^^ ^^^^^^
--
-- 5. Use remaining characters for substring match
--
-- `fmodify` -> `fnamemodify` # imaginary score: 1
-- ^~~~~~~ ^ ~~~~~~
--
-- 6. Avoid unexpected match detection
--
-- `candlesingle` -> candle#accept#single
-- ^^^^^^~~~~~~ ^^^^^^ ~~~~~~
--
-- * The `accept`'s `a` should not match to `candle`'s `a`
--
---Match entry
---@param input string
---@param word string
---@return number
matcher.match = function(input, word)
-- Empty input
if #input == 0 then
return matcher.PREFIX_FACTOR + matcher.NOT_FUZZY_FACTOR
end
-- Ignore if input is long than word
if #input > #word then
return 0
end
--- Gather matched regions
local matches = {}
local input_start_index = 1
local input_end_index = 1
local word_index = 1
local word_bound_index = 1
while input_end_index <= #input and word_index <= #word do
local m = matcher.find_match_region(input, input_start_index, input_end_index, word, word_index)
if m and input_end_index <= m.input_match_end then
m.index = word_bound_index
input_start_index = m.input_match_start + 1
input_end_index = m.input_match_end + 1
word_index = char.get_next_semantic_index(word, m.word_match_end)
table.insert(matches, m)
else
word_index = char.get_next_semantic_index(word, word_index)
end
word_bound_index = word_bound_index + 1
end
if #matches == 0 then
return 0
end
-- Compute prefix match score
local score = 0
local idx = 1
for _, m in ipairs(matches) do
local s = 0
for i = math.max(idx, m.input_match_start), m.input_match_end do
s = s + 1
idx = i
end
idx = idx + 1
if s > 0 then
score = score + (s * (1 + math.max(0, matcher.WORD_BOUNDALY_ORDER_FACTOR - m.index) / matcher.WORD_BOUNDALY_ORDER_FACTOR))
score = score + (m.strict_match and 0.1 or 0)
end
end
-- Add prefix bonus
score = score + ((matches[1].input_match_start == 1 and matches[1].word_match_start == 1) and matcher.PREFIX_FACTOR or 0)
-- Check remaining input as fuzzy
if matches[#matches].input_match_end < #input then
if matcher.fuzzy(input, word, matches) then
return score
end
return 0
end
return score + matcher.NOT_FUZZY_FACTOR
end
--- fuzzy
matcher.fuzzy = function(input, word, matches)
local last_match = matches[#matches]
-- Lately specified middle of text.
local input_index = last_match.input_match_end + 1
for i = 1, #matches - 1 do
local curr_match = matches[i]
local next_match = matches[i + 1]
local word_offset = 0
local word_index = char.get_next_semantic_index(word, curr_match.word_match_end)
while word_offset + word_index < next_match.word_match_start and input_index <= #input do
if char.match(string.byte(word, word_index + word_offset), string.byte(input, input_index)) then
input_index = input_index + 1
word_offset = word_offset + 1
else
word_index = char.get_next_semantic_index(word, word_index + word_offset)
word_offset = 0
end
end
end
-- Remaining text fuzzy match.
local last_input_index = input_index
local matched = false
local word_offset = 0
local word_index = last_match.word_match_end + 1
while word_offset + word_index <= #word and input_index <= #input do
if char.match(string.byte(word, word_index + word_offset), string.byte(input, input_index)) then
matched = true
input_index = input_index + 1
elseif matched then
input_index = last_input_index
end
word_offset = word_offset + 1
end
if input_index >= #input then
return true
end
return false
end
--- find_match_region
matcher.find_match_region = function(input, input_start_index, input_end_index, word, word_index)
-- determine input position ( woroff -> word_offset )
while input_start_index < input_end_index do
if char.match(string.byte(input, input_end_index), string.byte(word, word_index)) then
break
end
input_end_index = input_end_index - 1
end
-- Can't determine input position
if input_end_index < input_start_index then
return nil
end
local strict_match_count = 0
local input_match_start = -1
local input_index = input_end_index
local word_offset = 0
while input_index <= #input and word_index + word_offset <= #word do
local c1 = string.byte(input, input_index)
local c2 = string.byte(word, word_index + word_offset)
if char.match(c1, c2) then
-- Match start.
if input_match_start == -1 then
input_match_start = input_index
end
-- Increase strict_match_count
if c1 == c2 then
strict_match_count = strict_match_count + 1
end
word_offset = word_offset + 1
else
-- Match end (partial region)
if input_match_start ~= -1 then
return {
input_match_start = input_match_start,
input_match_end = input_index - 1,
word_match_start = word_index,
word_match_end = word_index + word_offset - 1,
strict_match = strict_match_count == input_index - input_match_start,
}
else
return nil
end
end
input_index = input_index + 1
end
-- Match end (whole region)
if input_match_start ~= -1 then
return {
input_match_start = input_match_start,
input_match_end = input_index - 1,
word_match_start = word_index,
word_match_end = word_index + word_offset - 1,
strict_match = strict_match_count == input_index - input_match_start,
}
end
return nil
end
return matcher

33
lua/cmp/matcher_spec.lua Normal file
View File

@@ -0,0 +1,33 @@
local spec = require('cmp.utils.spec')
local matcher = require('cmp.matcher')
describe('matcher', function()
before_each(spec.before)
it('match', function()
assert.is.truthy(matcher.match('', 'a') >= 1)
assert.is.truthy(matcher.match('a', 'a') >= 1)
assert.is.truthy(matcher.match('ab', 'a') == 0)
assert.is.truthy(matcher.match('ab', 'ab') > matcher.match('ab', 'a_b'))
assert.is.truthy(matcher.match('ab', 'a_b_c') > matcher.match('ac', 'a_b_c'))
assert.is.truthy(matcher.match('bora', 'border-radius') >= 1)
assert.is.truthy(matcher.match('woroff', 'word_offset') >= 1)
assert.is.truthy(matcher.match('call', 'call') > matcher.match('call', 'condition_all'))
assert.is.truthy(matcher.match('Buffer', 'Buffer') > matcher.match('Buffer', 'buffer'))
assert.is.truthy(matcher.match('fmodify', 'fnamemodify') >= 1)
assert.is.truthy(matcher.match('candlesingle', 'candle#accept#single') >= 1)
assert.is.truthy(matcher.match('conso', 'console') > matcher.match('conso', 'ConstantSourceNode'))
assert.is.truthy(matcher.match('var_', 'var_dump') >= 1)
end)
it('debug', function()
assert.is.truthy(true)
matcher.debug = function(...)
print(vim.inspect({ ... }))
end
print('score', matcher.match('vsnipnextjump', 'vsnip-jump-next'))
end)
end)

242
lua/cmp/menu.lua Normal file
View File

@@ -0,0 +1,242 @@
local debug = require('cmp.utils.debug')
local async = require('cmp.utils.async')
local float = require('cmp.float')
local types = require('cmp.types')
local config = require('cmp.config')
local autocmd = require('cmp.autocmd')
---@class cmp.Menu
---@field public float cmp.Float
---@field public cache cmp.Cache
---@field public offset number
---@field public on_select fun(e: cmp.Entry)
---@field public items vim.CompletedItem[]
---@field public entries cmp.Entry[]
---@field public entry_map table<number, cmp.Entry>
---@field public selected_entry cmp.Entry|nil
---@field public context cmp.Context
---@field public resolve_dedup fun(callback: function)
local menu = {}
---Create menu
---@return cmp.Menu
menu.new = function()
local self = setmetatable({}, { __index = menu })
self.float = float.new()
self.resolve_dedup = async.dedup()
self.on_select = function() end
self:reset()
autocmd.subscribe('CompleteChanged', function()
local e = self:get_selected_entry()
if e then
self:select(e)
else
self:unselect()
end
end)
return self
end
---Close menu
menu.close = function(self)
if vim.fn.pumvisible() == 1 then
vim.fn.complete(1, {})
end
self:unselect()
end
---Reset menu
menu.reset = function(self)
self.offset = nil
self.items = {}
self.entries = {}
self.entry_map = {}
self.context = nil
self.preselect = 0
self:close()
end
---Update menu
---@param ctx cmp.Context
---@param sources cmp.Source[]
---@return cmp.Menu
menu.update = function(self, ctx, sources)
if not (ctx.mode == 'i' or ctx.mode == 'ic') then
return
end
local entries = {}
local entry_map = {}
-- check the source triggered by character
local has_triggered_by_character_source = false
for _, s in ipairs(sources) do
if s:has_items() then
if s.trigger_kind == types.lsp.CompletionTriggerKind.TriggerCharacter then
has_triggered_by_character_source = true
break
end
end
end
-- create filtered entries.
local offset = ctx.cursor.col
for i, s in ipairs(sources) do
if s:has_items() and s.offset <= offset then
if not has_triggered_by_character_source or s.trigger_kind == types.lsp.CompletionTriggerKind.TriggerCharacter then
-- source order priority bonus.
local priority = (#sources - i - 1) * 2
local filtered = s:get_entries(ctx)
for _, e in ipairs(filtered) do
e.score = e.score + priority
table.insert(entries, e)
entry_map[e.id] = e
end
if #filtered > 0 then
offset = math.min(offset, s.offset)
end
end
end
end
-- sort.
config.get().sorting.sort(entries)
-- create vim items.
local items = {}
local abbrs = {}
local preselect = 0
for i, e in ipairs(entries) do
if preselect == 0 and e.completion_item.preselect then
preselect = i
end
local item = e:get_vim_item(offset)
if not abbrs[item.abbr] or item.dup == 1 then
table.insert(items, item)
abbrs[item.abbr] = true
end
end
-- save recent pum state.
self.offset = offset
self.items = items
self.entries = entries
self.entry_map = entry_map
self.preselect = preselect
self.context = ctx
self:show()
if #self.entries == 0 then
self:unselect()
end
end
---Restore previous menu
---@param ctx cmp.Context
menu.restore = function(self, ctx)
if not (ctx.mode == 'i' or ctx.mode == 'ic') then
return
end
if not ctx.pumvisible then
if #self.items > 0 then
if self.offset <= ctx.cursor.col then
debug.log('menu/restore')
self:show()
end
end
end
end
---Show completion item
menu.show = function(self)
if vim.fn.pumvisible() == 0 and #self.entries == 0 then
return
end
local completeopt = vim.o.completeopt
if self.preselect == 1 then
vim.cmd('set completeopt=menuone,noinsert')
else
vim.cmd('set completeopt=' .. config.get().completion.completeopt)
end
vim.fn.complete(self.offset, self.items)
vim.cmd('set completeopt=' .. completeopt)
if self.preselect > 0 then
vim.api.nvim_select_popupmenu_item(self.preselect - 1, false, false, {})
end
end
---Select current item
---@param e cmp.Entry
menu.select = function(self, e)
-- Documentation (always invoke to follow to the pum position)
e:resolve(self.resolve_dedup(vim.schedule_wrap(function()
if self:get_selected_entry() == e then
self.float:show(e)
end
end)))
self.on_select(e)
end
---Select current item
menu.unselect = function(self)
self.float:close()
end
---Geta current active entry
---@return cmp.Entry|nil
menu.get_active_entry = function(self)
local completed_item = vim.v.completed_item or {}
if vim.fn.pumvisible() == 0 or not completed_item.user_data then
return nil
end
local id = completed_item.user_data.cmp
if id then
return self.entry_map[id]
end
return nil
end
---Get current selected entry
---@return cmp.Entry|nil
menu.get_selected_entry = function(self)
local info = vim.fn.complete_info({ 'items', 'selected' })
if info.selected == -1 then
return nil
end
local completed_item = info.items[math.max(info.selected, 0) + 1] or {}
if not completed_item.user_data then
return nil
end
local id = completed_item.user_data.cmp
if id then
return self.entry_map[id]
end
return nil
end
---Get first entry
---@param self cmp.Entry|nil
menu.get_first_entry = function(self)
local info = vim.fn.complete_info({ 'items' })
local completed_item = info.items[1] or {}
if not completed_item.user_data then
return nil
end
local id = completed_item.user_data.cmp
if id then
return self.entry_map[id]
end
return nil
end
return menu

281
lua/cmp/source.lua Normal file
View File

@@ -0,0 +1,281 @@
local context = require('cmp.context')
local config = require('cmp.config')
local matcher = require('cmp.matcher')
local entry = require('cmp.entry')
local debug = require('cmp.utils.debug')
local misc = require('cmp.utils.misc')
local cache = require('cmp.utils.cache')
local types = require('cmp.types')
local async = require('cmp.utils.async')
local pattern = require('cmp.utils.pattern')
---@class cmp.Source
---@field public id number
---@field public name string
---@field public source any
---@field public cache cmp.Cache
---@field public revision number
---@field public context cmp.Context
---@field public trigger_kind lsp.CompletionTriggerKind|nil
---@field public incomplete boolean
---@field public entries cmp.Entry[]
---@field public offset number|nil
---@field public status cmp.SourceStatus
---@field public complete_dedup function
local source = {}
---@alias cmp.SourceStatus "1" | "2" | "3"
source.SourceStatus = {}
source.SourceStatus.WAITING = 1
source.SourceStatus.FETCHING = 2
source.SourceStatus.COMPLETED = 3
---@alias cmp.SourceChangeKind "1" | "2" | "3"
source.SourceChangeKind = {}
source.SourceChangeKind.RETRIEVE = 1
source.SourceChangeKind.CONTINUE = 2
---@return cmp.Source
source.new = function(name, s)
local self = setmetatable({}, { __index = source })
self.id = misc.id('source')
self.name = name
self.source = s
self.cache = cache.new()
self.complete_dedup = async.dedup()
self.revision = 0
self:reset()
return self
end
---Reset current completion state
---@return boolean
source.reset = function(self)
self.cache:clear()
self.revision = self.revision + 1
self.context = context.empty()
self.trigger_kind = nil
self.incomplete = false
self.entries = {}
self.offset = -1
self.status = source.SourceStatus.WAITING
self.complete_dedup(function() end)
end
---Return source option
---@return table
source.get_option = function(self)
return config.get_source_option(self.name)
end
---Return the source has items or not.
---@return boolean
source.has_items = function(self)
return self.offset ~= -1
end
---Get fetching time
source.get_fetching_time = function(self)
if self.status == source.SourceStatus.FETCHING then
return vim.loop.now() - self.context.time
end
return 100 * 1000 -- return pseudo time if source isn't fetching.
end
---Return filtered entries
---@param ctx cmp.Context
---@return cmp.Entry[]
source.get_entries = function(self, ctx)
if not self:has_items() then
return {}
end
local prev_entries = (function()
local key = { 'get_entries', self.revision }
for i = ctx.cursor.col, self.offset, -1 do
key[3] = string.sub(ctx.cursor_before_line, 1, i)
local prev_entries = self.cache:get(key)
if prev_entries then
return prev_entries
end
end
return nil
end)()
return self.cache:ensure({ 'get_entries', self.revision, ctx.cursor_before_line }, function()
debug.log('filter', self.name, self.id, #(prev_entries or self.entries))
local inputs = {}
local entries = {}
for _, e in ipairs(prev_entries or self.entries) do
local o = e:get_offset()
if not inputs[o] then
inputs[o] = string.sub(ctx.cursor_before_line, o)
end
e.score = matcher.match(inputs[o], e:get_filter_text())
e.exact = inputs[o] == e:get_filter_text()
if e.score >= 1 then
table.insert(entries, e)
end
end
return entries
end)
end
---Get default insert range
---@return lsp.Range|nil
source.get_default_insert_range = function(self)
if not self.context then
return nil
end
return self.cache:ensure({ 'get_default_insert_range', self.revision }, function()
return {
start = {
line = self.context.cursor.row - 1,
character = vim.str_utfindex(self.context.cursor_line, self.offset - 1),
},
['end'] = {
line = self.context.cursor.row - 1,
character = vim.str_utfindex(self.context.cursor_line, self.context.cursor.col - 1),
},
}
end)
end
---Get default replace range
---@return lsp.Range|nil
source.get_default_replace_range = function(self)
if not self.context then
return nil
end
return self.cache:ensure({ 'get_default_replace_range', self.revision }, function()
local _, e = pattern.offset('^' .. self:get_keyword_pattern(), string.sub(self.context.cursor_line, self.offset))
return {
start = {
line = self.context.cursor.row - 1,
character = vim.str_utfindex(self.context.cursor_line, self.offset - 1),
},
['end'] = {
line = self.context.cursor.row - 1,
character = vim.str_utfindex(self.context.cursor_line, e and self.offset + e - 2 or self.context.cursor.col - 1),
},
}
end)
end
---Get keyword_pattern
---@return string
source.get_keyword_pattern = function(self)
if self.source.get_keyword_pattern then
return self.source:get_keyword_pattern()
end
return config.get().completion.keyword_pattern
end
---Get trigger_characters
---@return string[]
source.get_trigger_characters = function(self)
if self.source.get_trigger_characters then
return self.source:get_trigger_characters() or {}
end
return {}
end
---Invoke completion
---@param ctx cmp.Context
---@param callback function
---@return boolean Return true if not trigger completion.
source.complete = function(self, ctx, callback)
local c = config.get()
local offset = ctx:get_offset(self:get_keyword_pattern())
if offset == ctx.cursor.col then
self:reset()
end
local completion_context
if ctx:get_reason() == types.cmp.ContextReason.Manual then
completion_context = {
triggerKind = types.lsp.CompletionTriggerKind.Invoked,
}
elseif vim.tbl_contains(self:get_trigger_characters(), ctx.before_char) then
completion_context = {
triggerKind = types.lsp.CompletionTriggerKind.TriggerCharacter,
triggerCharacter = ctx.before_char,
}
elseif c.completion.keyword_length <= (ctx.cursor.col - offset) and self.offset ~= offset then
completion_context = {
triggerKind = types.lsp.CompletionTriggerKind.Invoked,
}
elseif self.incomplete and offset ~= ctx.cursor.col then
completion_context = {
triggerKind = types.lsp.CompletionTriggerKind.TriggerForIncompleteCompletions,
}
end
if not completion_context then
debug.log('skip empty context', self.name, self.id)
return
end
debug.log('request', self.name, self.id, offset, vim.inspect(completion_context))
local prev_status = self.status
self.status = source.SourceStatus.FETCHING
self.offset = offset
self.context = ctx
self.source:complete(
{
context = ctx,
offset = self.offset,
option = self:get_option(),
completion_context = completion_context,
},
vim.schedule_wrap(self.complete_dedup(function(response)
self.revision = self.revision + 1
if #(misc.safe(response) and response.items or response or {}) > 0 then
debug.log('retrieve', self.name, self.id, #(response.items or response))
self.status = source.SourceStatus.COMPLETED
self.trigger_kind = completion_context.triggerKind
self.incomplete = response.isIncomplete or false
self.entries = {}
for i, item in ipairs(response.items or response) do
local e = entry.new(ctx, self, item)
self.entries[i] = e
self.offset = math.min(self.offset, e:get_offset())
end
else
debug.log('continue', self.name, self.id, 'nil')
self.status = prev_status
end
callback()
end))
)
return true
end
---Resolve CompletionItem
---@param item lsp.CompletionItem
---@param callback fun(item: lsp.CompletionItem)
source.resolve = function(self, item, callback)
if not self.source.resolve then
return callback(item)
end
self.source:resolve(item, function(resolved_item)
callback(resolved_item or item)
end)
end
---Execute command
---@param item lsp.CompletionItem
---@param callback fun()
source.execute = function(self, item, callback)
if not self.source.execute then
return callback()
end
self.source:execute(item, function()
callback()
end)
end
return source

11
lua/cmp/source_spec.lua Normal file
View File

@@ -0,0 +1,11 @@
local spec = require('cmp.utils.spec')
-- local source = require "cmp.source"
describe('source', function()
before_each(spec.before)
it('new', function()
-- local s = source.new()
end)
end)

84
lua/cmp/types/cmp.lua Normal file
View File

@@ -0,0 +1,84 @@
local cmp = {}
---@alias cmp.ConfirmBehavior "'insert'" | "'replace'"
cmp.ConfirmBehavior = {}
cmp.ConfirmBehavior.Insert = 'insert'
cmp.ConfirmBehavior.Replace = 'replace'
---@alias cmp.ContextReason "'auto'" | "'manual'" | "'none'"
cmp.ContextReason = {}
cmp.ContextReason.Auto = 'auto'
cmp.ContextReason.Manual = 'manual'
cmp.ContextReason.None = 'none'
---@alias cmp.TriggerEvent "'InsertEnter'" | "'TextChanged'"
cmp.TriggerEvent = {}
cmp.TriggerEvent.InsertEnter = 'InsertEnter'
cmp.TriggerEvent.TextChanged = 'TextChanged'
---@class cmp.ContextOption
---@field public reason cmp.ContextReason|nil
---@class cmp.ConfirmOption
---@field public behavior cmp.ConfirmBehavior
---@class cmp.SnippetExpansionParams
---@field public body string
---@field public insert_text_mode number
---@class cmp.Setup
---@field public __call fun(c: cmp.ConfigSchema)
---@field public buffer fun(c: cmp.ConfigSchema)
---@field public global fun(c: cmp.ConfigSchema)
---@class cmp.CompletionRequest
---@field public context cmp.Context
---@field public option table
---@field public offset number
---@field public completion_context lsp.CompletionContext
---@class cmp.ConfigSchema
---@field private revision number
---@field public completion cmp.CompletionConfig
---@field public documentation cmp.DocumentationConfig
---@field public confirmation cmp.ConfirmationConfig
---@field public sorting cmp.SortingConfig
---@field public formatting cmp.FormattingConfig
---@field public snippet cmp.SnippetConfig
---@field public sources cmp.SourceConfig[]
---@class cmp.CompletionConfig
---@field public autocomplete cmp.TriggerEvent[]
---@field public completeopt string
---@field public keyword_pattern string
---@field public keyword_length number
---@class cmp.DocumentationConfig
---@field public border string[]
---@field public winhighlight string
---@field public maxwidth number|nil
---@field public maxheight number|nil
---@class cmp.ConfirmationConfig
---@field public default_behavior cmp.ConfirmBehavior
---@field public mapping table<string, cmp.ConfirmMappingConfig>
---@class cmp.ConfirmMappingConfig
---@field behavior cmp.ConfirmBehavior
---@field select boolean
---@class cmp.SortingConfig
---@field public sort fun(entries: cmp.Entry[]): cmp.Entry[]
---@class cmp.FormattingConfig
---@field public format fun(entry: cmp.Entry, suggeset_offset: number): vim.CompletedItem
---@class cmp.SnippetConfig
---@field public expand fun(args: cmp.SnippetExpansionParams)
---@class cmp.SourceConfig
---@field public name string
---@field public opts table
return cmp

8
lua/cmp/types/init.lua Normal file
View File

@@ -0,0 +1,8 @@
local types = {}
types.cmp = require('cmp.types.cmp')
types.lsp = require('cmp.types.lsp')
types.vim = require('cmp.types.vim')
return types

205
lua/cmp/types/lsp.lua Normal file
View File

@@ -0,0 +1,205 @@
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/
---@class lsp
local lsp = {}
lsp.Position = {}
---Convert lsp.Position to vim.Position
---@param buf number|string
---@param position lsp.Position
---@return vim.Position
lsp.Position.to_vim = function(buf, position)
if not vim.api.nvim_buf_is_loaded(buf) then
vim.fn.bufload(buf)
end
local lines = vim.api.nvim_buf_get_lines(buf, position.line, position.line + 1, false)
if #lines > 0 then
for i = position.character, 1, -1 do
local s, v = pcall(function()
return {
row = position.line + 1,
col = vim.str_byteindex(lines[1], i) + 1
}
end)
if s then
return v
end
end
end
return {
row = position.line + 1,
col = position.character + 1,
}
end
---Convert lsp.Position to vim.Position
---@param buf number|string
---@param position vim.Position
---@return lsp.Position
lsp.Position.to_lsp = function(buf, position)
if not vim.api.nvim_buf_is_loaded(buf) then
vim.fn.bufload(buf)
end
local lines = vim.api.nvim_buf_get_lines(buf, position.row - 1, position.row, false)
if #lines > 0 then
return {
line = position.row - 1,
character = vim.str_utfindex(lines[1], math.max(0, math.min(position.col - 1, #lines[1]))),
}
end
return {
line = position.row - 1,
character = position.col - 1,
}
end
lsp.Range = {}
---Convert lsp.Position to vim.Position
---@param buf number|string
---@param range lsp.Range
---@return vim.Range
lsp.Range.to_vim = function(buf, range)
return {
start = lsp.Position.to_vim(buf, range.start),
['end'] = lsp.Position.to_vim(buf, range['end']),
}
end
---Convert lsp.Position to vim.Position
---@param buf number|string
---@param range vim.Range
---@return lsp.Range
lsp.Range.to_lsp = function(buf, range)
return {
start = lsp.Position.to_lsp(buf, range.start),
['end'] = lsp.Position.to_lsp(buf, range['end']),
}
end
---@alias lsp.CompletionTriggerKind "1" | "2" | "3"
lsp.CompletionTriggerKind = {}
lsp.CompletionTriggerKind.Invoked = 1
lsp.CompletionTriggerKind.TriggerCharacter = 2
lsp.CompletionTriggerKind.TriggerForIncompleteCompletions = 3
---@class lsp.CompletionContext
---@field public triggerKind lsp.CompletionTriggerKind
---@field public triggerCharacter string|nil
---@alias lsp.InsertTextFormat "1" | "2"
lsp.InsertTextFormat = {}
lsp.InsertTextFormat.PlainText = 1
lsp.InsertTextFormat.Snippet = 2
lsp.InsertTextFormat = vim.tbl_add_reverse_lookup(lsp.InsertTextFormat)
---@alias lsp.InsertTextMode "1" | "2"
lsp.InsertTextMode = {}
lsp.InsertTextMode.AsIs = 0
lsp.InsertTextMode.AdjustIndentation = 1
lsp.InsertTextMode = vim.tbl_add_reverse_lookup(lsp.InsertTextMode)
---@alias lsp.MarkupKind "'plaintext'" | "'markdown'"
lsp.MarkupKind = {}
lsp.MarkupKind.PlainText = 'plaintext'
lsp.MarkupKind.Markdown = 'markdown'
lsp.MarkupKind.Markdown = 'markdown'
lsp.MarkupKind = vim.tbl_add_reverse_lookup(lsp.MarkupKind)
---@alias lsp.CompletionItemTag "1"
lsp.CompletionItemTag = {}
lsp.CompletionItemTag.Deprecated = 1
lsp.CompletionItemTag = vim.tbl_add_reverse_lookup(lsp.CompletionItemTag)
---@alias lsp.CompletionItemKind "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12" | "13" | "14" | "15" | "16" | "17" | "18" | "19" | "20" | "21" | "22" | "23" | "24" | "25"
lsp.CompletionItemKind = {}
lsp.CompletionItemKind.Text = 1
lsp.CompletionItemKind.Method = 2
lsp.CompletionItemKind.Function = 3
lsp.CompletionItemKind.Constructor = 4
lsp.CompletionItemKind.Field = 5
lsp.CompletionItemKind.Variable = 6
lsp.CompletionItemKind.Class = 7
lsp.CompletionItemKind.Interface = 8
lsp.CompletionItemKind.Module = 9
lsp.CompletionItemKind.Property = 10
lsp.CompletionItemKind.Unit = 11
lsp.CompletionItemKind.Value = 12
lsp.CompletionItemKind.Enum = 13
lsp.CompletionItemKind.Keyword = 14
lsp.CompletionItemKind.Snippet = 15
lsp.CompletionItemKind.Color = 16
lsp.CompletionItemKind.File = 17
lsp.CompletionItemKind.Reference = 18
lsp.CompletionItemKind.Folder = 19
lsp.CompletionItemKind.EnumMember = 20
lsp.CompletionItemKind.Constant = 21
lsp.CompletionItemKind.Struct = 22
lsp.CompletionItemKind.Event = 23
lsp.CompletionItemKind.Operator = 24
lsp.CompletionItemKind.TypeParameter = 25
lsp.CompletionItemKind = vim.tbl_add_reverse_lookup(lsp.CompletionItemKind)
---@class lsp.CompletionList
---@field public isIncomplete boolean
---@field public items lsp.CompletionItem[]
---@alias lsp.CompletionResponse lsp.CompletionList|lsp.CompletionItem[]|nil
---@class lsp.MarkupContent
---@field public kind lsp.MarkupKind
---@field public value string
---@class lsp.Position
---@field public line number
---@field public character number
---@class lsp.Range
---@field public start lsp.Position
---@field public end lsp.Position
---@class lsp.Command
---@field public title string
---@field public command string
---@field public arguments any[]|nil
---@class lsp.TextEdit
---@field public range lsp.Range|nil
---@field public newText string
---@class lsp.InsertReplaceTextEdit
---@field public insert lsp.Range|nil
---@field public replace lsp.Range|nil
---@field public newText string
---@class lsp.CompletionItemLabelDetails
---@field public parameters string|nil
---@field public qualifier string|nil
---@field public type string|nil
---@class lsp.CompletionItem
---@field public label string
---@field public labelDetails lsp.CompletionItemLabelDetails|nil
---@field public kind lsp.CompletionItemKind|nil
---@field public tags lsp.CompletionItemTag[]|nil
---@field public detail string|nil
---@field public documentation lsp.MarkupContent|string|nil
---@field public deprecated boolean|nil
---@field public preselect boolean|nil
---@field public sortText string|nil
---@field public filterText string|nil
---@field public insertText string|nil
---@field public insertTextFormat lsp.InsertTextFormat
---@field public insertTextMode lsp.InsertTextMode
---@field public textEdit lsp.TextEdit|lsp.InsertReplaceTextEdit|nil
---@field public additionalTextEdits lsp.TextEdit[]
---@field public commitCharacters string[]|nil
---@field public command lsp.Command|nil
---@field public data any|nil
---
---TODO: Should send the issue for upstream?
---@field public word string|nil
---@field public dup boolean|nil
return lsp

View File

@@ -0,0 +1,47 @@
local spec = require'cmp.utils.spec'
local lsp = require'cmp.types.lsp'
describe('types.lsp', function ()
before_each(spec.before)
describe('Position', function ()
vim.fn.setline('1', {
'あいうえお',
'かきくけこ',
'さしすせそ',
})
local vim_position, lsp_position
vim_position = lsp.Position.to_vim('%', { line = 1, character = 3 })
assert.are.equal(vim_position.row, 2)
assert.are.equal(vim_position.col, 10)
lsp_position = lsp.Position.to_lsp('%', vim_position)
assert.are.equal(lsp_position.line, 1)
assert.are.equal(lsp_position.character, 3)
vim_position = lsp.Position.to_vim('%', { line = 1, character = 0 })
assert.are.equal(vim_position.row, 2)
assert.are.equal(vim_position.col, 1)
lsp_position = lsp.Position.to_lsp('%', vim_position)
assert.are.equal(lsp_position.line, 1)
assert.are.equal(lsp_position.character, 0)
vim_position = lsp.Position.to_vim('%', { line = 1, character = 5 })
assert.are.equal(vim_position.row, 2)
assert.are.equal(vim_position.col, 16)
lsp_position = lsp.Position.to_lsp('%', vim_position)
assert.are.equal(lsp_position.line, 1)
assert.are.equal(lsp_position.character, 5)
-- overflow (lsp -> vim)
vim_position = lsp.Position.to_vim('%', { line = 1, character = 6 })
assert.are.equal(vim_position.row, 2)
assert.are.equal(vim_position.col, 16)
-- overflow(vim -> lsp)
vim_position.col = vim_position.col + 1
lsp_position = lsp.Position.to_lsp('%', vim_position)
assert.are.equal(lsp_position.line, 1)
assert.are.equal(lsp_position.character, 5)
end)
end)

18
lua/cmp/types/vim.lua Normal file
View File

@@ -0,0 +1,18 @@
---@class vim.CompletedItem
---@field public word string
---@field public abbr string|nil
---@field public kind string|nil
---@field public menu string|nil
---@field public equal "1"|nil
---@field public empty "1"|nil
---@field public dup "1"|nil
---@field public id any
---@class vim.Position
---@field public row number
---@field public col number
---@class vim.Range
---@field public start vim.Position
---@field public end vim.Position

55
lua/cmp/utils/async.lua Normal file
View File

@@ -0,0 +1,55 @@
local async = {}
---@class cmp.AsyncThrottle
---@field public timeout number
---@field public stop function
---@field public __call function
---@param fn function
---@param timeout number
---@return cmp.AsyncThrottle
async.throttle = function(fn, timeout)
local time = nil
local timer = vim.loop.new_timer()
return setmetatable({
timeout = timeout,
stop = function()
time = nil
timer:stop()
end,
}, {
__call = function(self, ...)
local args = { ... }
if time == nil then
time = vim.loop.now()
end
timer:stop()
local delta = math.max(0, self.timeout - (vim.loop.now() - time))
timer:start(delta, 0, vim.schedule_wrap(function()
time = nil
fn(unpack(args))
end))
end
})
end
---Create deduplicated callback
---@return function
async.dedup = function()
local id = 0
return function(callback)
id = id + 1
local current = id
return function(...)
if current == id then
callback(...)
end
end
end
end
return async

View File

@@ -0,0 +1,40 @@
local async = require "cmp.utils.async"
describe('utils.async', function()
it('throttle', function()
local count = 0
local now
local f = async.throttle(function()
count = count + 1
end, 100)
-- 1. delay for 100ms
now = vim.loop.now()
f.timeout = 100
f()
vim.wait(1000, function() return count == 1 end)
assert.is.truthy(math.abs(f.timeout - (vim.loop.now() - now)) < 10)
-- 2. delay for 500ms
now = vim.loop.now()
f.timeout = 500
f()
vim.wait(1000, function() return count == 2 end)
assert.is.truthy(math.abs(f.timeout - (vim.loop.now() - now)) < 10)
-- 4. delay for 500ms and wait 100ms (remain 400ms)
f.timeout = 500
f()
vim.wait(100) -- remain 400ms
-- 5. call immediately (100ms already elapsed from No.4)
now = vim.loop.now()
f.timeout = 100
f()
vim.wait(1000, function() return count == 3 end)
assert.is.truthy(math.abs(vim.loop.now() - now) < 10)
end)
end)

57
lua/cmp/utils/cache.lua Normal file
View File

@@ -0,0 +1,57 @@
---@class cmp.Cache
---@field public entries any
local cache = {}
cache.new = function()
local self = setmetatable({}, { __index = cache })
self.entries = {}
return self
end
---Get cache value
---@param key string
---@return any|nil
cache.get = function(self, key)
key = self:key(key)
if self.entries[key] ~= nil then
return unpack(self.entries[key])
end
return nil
end
---Set cache value explicitly
---@param key string
---@vararg any
cache.set = function(self, key, ...)
key = self:key(key)
self.entries[key] = { ... }
end
---Ensure value by callback
---@param key string
---@param callback fun(): any
cache.ensure = function(self, key, callback)
local value = self:get(key)
if value == nil then
self:set(key, callback())
end
return self:get(key)
end
---Clear all cache entries
cache.clear = function(self)
self.entries = {}
end
---Create key
---@param key string|table
---@return string
cache.key = function(_, key)
if type(key) == 'table' then
return table.concat(key, ':')
end
return key
end
return cache

View File

113
lua/cmp/utils/char.lua Normal file
View File

@@ -0,0 +1,113 @@
local alpha = {}
string.gsub('abcdefghijklmnopqrstuvwxyz', '.', function(char)
alpha[string.byte(char)] = true
end)
local ALPHA = {}
string.gsub('ABCDEFGHIJKLMNOPQRSTUVWXYZ', '.', function(char)
ALPHA[string.byte(char)] = true
end)
local digit = {}
string.gsub('1234567890', '.', function(char)
digit[string.byte(char)] = true
end)
local white = {}
string.gsub(' \t\n', '.', function(char)
white[string.byte(char)] = true
end)
local char = {}
---@param byte number
---@return boolean
char.is_upper = function(byte)
return ALPHA[byte]
end
---@param byte number
---@return boolean
char.is_alpha = function(byte)
return alpha[byte] or ALPHA[byte]
end
---@param byte number
---@return boolean
char.is_digit = function(byte)
return digit[byte]
end
---@param byte number
---@return boolean
char.is_white = function(byte)
return white[byte]
end
---@param byte number
---@return boolean
char.is_symbol = function(byte)
return not (char.is_alnum(byte) or char.is_white(byte))
end
---@param byte number
---@return boolean
char.is_printable = function(byte)
return string.match(string.char(byte), '^%c$') == nil
end
---@param byte number
---@return boolean
char.is_alnum = function(byte)
return char.is_alpha(byte) or char.is_digit(byte)
end
---@param text string
---@param index number
---@return boolean
char.is_semantic_index = function(text, index)
if index <= 1 then
return true
end
local prev = string.byte(text, index - 1)
local curr = string.byte(text, index)
if not char.is_upper(prev) and char.is_upper(curr) then
return true
end
if char.is_symbol(curr) or char.is_white(curr) then
return true
end
if not char.is_alpha(prev) and char.is_alpha(curr) then
return true
end
return false
end
---@param text string
---@param current_index number
---@return boolean
char.get_next_semantic_index = function(text, current_index)
for i = current_index + 1, #text do
if char.is_semantic_index(text, i) then
return i
end
end
return #text + 1
end
---Ignore case match
---@param byte1 number
---@param byte2 number
---@return boolean
char.match = function(byte1, byte2)
if not char.is_alpha(byte1) or not char.is_alpha(byte2) then
return byte1 == byte2
end
local diff = byte1 - byte2
return diff == 0 or diff == 32 or diff == -32
end
return char

20
lua/cmp/utils/debug.lua Normal file
View File

@@ -0,0 +1,20 @@
local debug = {}
local flag = false
---Print log
---@vararg any
debug.log = function(...)
if flag then
local data = {}
for _, v in ipairs({ ... }) do
if not vim.tbl_contains({ 'string', 'number', 'boolean' }, type(v)) then
v = vim.inspect(v)
end
table.insert(data, v)
end
print(table.concat(data, '\t'))
end
end
return debug

134
lua/cmp/utils/keymap.lua Normal file
View File

@@ -0,0 +1,134 @@
local misc = require('cmp.utils.misc')
local cache = require('cmp.utils.cache')
local keymap = {}
---The mapping of vim notation and chars.
keymap._table = {
['<CR>'] = { '\n', '\r', '\r\n' },
['<Tab>'] = { '\t' },
['<BSlash>'] = { '\\' },
['<Bar>'] = { '|' },
['<Space>'] = { ' ' },
}
---Shortcut for nvim_replace_termcodes
---@param keys string
---@return string
keymap.t = function(keys)
return vim.api.nvim_replace_termcodes(keys, true, true, true)
end
---Return vim notation keymapping (simple conversion).
---@param s string
---@return string
keymap.to_keymap = function(s)
return string.gsub(s, '.', function(c)
for key, chars in pairs(keymap._table) do
if vim.tbl_contains(chars, c) then
return key
end
end
return c
end)
end
---Feedkeys with callback
keymap.feedkeys = setmetatable({
callbacks = {},
}, {
__call = function(self, keys, mode, callback)
vim.fn.feedkeys(keymap.t(keys), mode)
if callback then
local current_mode = string.sub(vim.api.nvim_get_mode().mode, 1, 1)
local id = misc.id('cmp.utils.keymap.feedkeys')
local cb = ('<Plug>(cmp-utils-keymap-feedkeys:%s)'):format(id)
self.callbacks[id] = function()
callback()
vim.api.nvim_buf_del_keymap(0, current_mode, cb)
return keymap.t('<Ignore>')
end
vim.api.nvim_buf_set_keymap(0, current_mode, cb, ('v:lua.cmp.utils.keymap.feedkeys.expr(%s)'):format(id), {
expr = true,
nowait = true,
silent = true,
})
vim.fn.feedkeys(keymap.t(cb), '')
end
end
})
misc.set(_G, { 'cmp', 'utils', 'keymap', 'feedkeys', 'expr' }, function(id)
if keymap.feedkeys.callbacks[id] then
keymap.feedkeys.callbacks[id]()
end
return keymap.t('<Ignore>')
end)
---Register keypress handler.
keymap.listen = setmetatable({
cache = cache.new(),
}, {
__call = function(_, keys, callback)
keys = keymap.to_keymap(keys)
local bufnr = vim.api.nvim_get_current_buf()
if keymap.listen.cache:get({ bufnr, keys }) then
return
end
local existing = nil
for _, map in ipairs(vim.api.nvim_buf_get_keymap(0, 'i')) do
if existing then
break
end
if map.lhs == keys then
existing = map
end
end
for _, map in ipairs(vim.api.nvim_get_keymap('i')) do
if existing then
break
end
if map.lhs == keys then
existing = map
break
end
end
existing = existing or {
lhs = keys,
rhs = keys,
expr = 0,
nowait = 0,
noremap = 1,
}
keymap.listen.cache:set({ bufnr, keys }, {
existing = existing,
callback = callback,
})
vim.api.nvim_buf_set_keymap(0, 'i', keys, ('v:lua.cmp.utils.keymap.expr("%s")'):format(keys), {
expr = true,
nowait = true,
noremap = true,
})
end,
})
misc.set(_G, { 'cmp', 'utils', 'keymap', 'expr' }, function(keys)
keys = keymap.to_keymap(keys)
local bufnr = vim.api.nvim_get_current_buf()
local existing = keymap.listen.cache:get({ bufnr, keys }).existing
local callback = keymap.listen.cache:get({ bufnr, keys }).callback
callback(keys, function()
vim.api.nvim_buf_set_keymap(0, 'i', '<Plug>(cmp-utils-keymap:_)', existing.rhs, {
expr = existing.expr == 1,
noremap = existing.noremap == 1,
})
vim.fn.feedkeys(keymap.t('<Plug>(cmp-utils-keymap:_)'), 'i')
end)
return keymap.t('<Ignore>')
end)
return keymap

View File

@@ -0,0 +1,13 @@
local spec = require('cmp.utils.spec')
local keymap = require('cmp.utils.keymap')
describe('keymap', function()
before_each(spec.before)
it('to_keymap', function()
assert.are.equal(keymap.to_keymap('\n'), '<CR>')
assert.are.equal(keymap.to_keymap('<CR>'), '<CR>')
assert.are.equal(keymap.to_keymap('|'), '<Bar>')
end)
end)

112
lua/cmp/utils/misc.lua Normal file
View File

@@ -0,0 +1,112 @@
local misc = {}
---Return concatenated list
---@param list1 any[]
---@param list2 any[]
---@return any[]
misc.concat = function(list1, list2)
local new_list = {}
for _, v in ipairs(list1) do
table.insert(new_list, v)
end
for _, v in ipairs(list2) do
table.insert(new_list, v)
end
return new_list
end
---Merge two tables recursively
---@generic T
---@param v1 T
---@param v2 T
---@return T
misc.merge = function(v1, v2)
local merge1 = type(v1) == "table" and not vim.tbl_islist(v1)
local merge2 = type(v2) == "table" and not vim.tbl_islist(v1)
if merge1 and merge2 then
local new_tbl = {}
for k, v in pairs(v2) do
new_tbl[k] = misc.merge(v1[k], v)
end
for k, v in pairs(v1) do
if v2[k] == nil then
new_tbl[k] = v
end
end
return new_tbl
end
return v1 or v2
end
---Generate id for group name
misc.id = setmetatable({
group = {}
}, {
__call = function(_, group)
misc.id.group[group] = misc.id.group[group] or 0
misc.id.group[group] = misc.id.group[group] + 1
return misc.id.group[group]
end
})
---Check the value is nil or not.
---@param v boolean
---@return boolean
misc.safe = function(v)
if v == nil or v == vim.NIL then
return nil
end
return v
end
---Treat 1/0 as bool value
---@param v boolean|"1"|"0"
---@param def boolean
---@return boolean
misc.bool = function(v, def)
if misc.safe(v) == nil then
return def
end
return v == true or v == 1
end
---Set value to deep object
---@param t table
---@param keys string[]
---@param v any
misc.set = function(t, keys, v)
local c = t
for i = 1, #keys - 1 do
local key = keys[i]
c[key] = misc.safe(c[key]) or {}
c = c[key]
end
c[keys[#keys]] = v
end
---Copy table
---@generic T
---@param tbl T
---@return T
misc.copy = function(tbl)
if type(tbl) ~= 'table' then
return tbl
end
if vim.tbl_islist(tbl) then
local copy = {}
for i, value in ipairs(tbl) do
copy[i] = misc.copy(value)
end
return copy
end
local copy = {}
for key, value in pairs(tbl) do
copy[key] = misc.copy(value)
end
return copy
end
return misc

32
lua/cmp/utils/patch.lua Normal file
View File

@@ -0,0 +1,32 @@
local keymap = require('cmp.utils.keymap')
local types = require('cmp.types')
local patch = {}
---@type table<number, function>
patch.callbacks = {}
---Apply oneline textEdit
---@param ctx cmp.Context
---@param range lsp.Range
---@param word string
---@param callback function
patch.apply = function(ctx, range, word, callback)
local ok = true
ok = ok and range.start.line == ctx.cursor.row - 1
ok = ok and range.start.line == range['end'].line
if not ok then
error("text_edit's range must be current one line.")
end
range = types.lsp.Range.to_vim(ctx.bufnr, range)
local before = string.sub(ctx.cursor_before_line, range.start.col)
local after = string.sub(ctx.cursor_after_line, ctx.cursor.col, range['end'].col)
local before_len = vim.fn.strchars(before)
local after_len = vim.fn.strchars(after)
local keys = string.rep('<Left>', after_len) .. string.rep('<BS>', after_len + before_len) .. word
keymap.feedkeys(keys, 'n', callback)
end
return patch

21
lua/cmp/utils/pattern.lua Normal file
View File

@@ -0,0 +1,21 @@
local pattern = {}
pattern._regexes = {}
pattern.regex = function(p)
if not pattern._regexes[p] then
pattern._regexes[p] = vim.regex(p)
end
return pattern._regexes[p]
end
pattern.offset = function(p, text)
local s, e = pattern.regex(p):match_str(text)
if s then
return s + 1, e + 1
end
return nil, nil
end
return pattern

42
lua/cmp/utils/spec.lua Normal file
View File

@@ -0,0 +1,42 @@
local context = require'cmp.context'
local source = require 'cmp.source'
local types = require('cmp.types')
local spec = {}
spec.before = function()
vim.cmd [[
bdelete!
enew!
setlocal virtualedit=all
]]
end
spec.state = function(text, row, col)
vim.fn.setline(1, text)
vim.fn.cursor(row, col)
local ctx = context.empty()
local s = source.new('spec', {
complete = function()
end
})
return {
context = function()
return ctx
end,
source = function()
return s
end,
press = function(char)
vim.fn.feedkeys(('i%s'):format(char), 'nx')
vim.fn.feedkeys(('l'):format(char), 'nx')
ctx.prev_context = nil
ctx = context.new(ctx, { reason = types.cmp.ContextReason.Manual })
s:complete(ctx, function() end)
return ctx
end
}
end
return spec

150
lua/cmp/utils/str.lua Normal file
View File

@@ -0,0 +1,150 @@
local char = require'cmp.utils.char'
local pattern = require 'cmp.utils.pattern'
local str = {}
local INVALID_CHARS = {}
INVALID_CHARS[string.byte('=')] = true
INVALID_CHARS[string.byte('$')] = true
INVALID_CHARS[string.byte('(')] = true
INVALID_CHARS[string.byte('[')] = true
INVALID_CHARS[string.byte('"')] = true
INVALID_CHARS[string.byte("'")] = true
INVALID_CHARS[string.byte("\n")] = true
local PAIR_CHARS = {}
PAIR_CHARS[string.byte('[')] = string.byte(']')
PAIR_CHARS[string.byte('(')] = string.byte(')')
PAIR_CHARS[string.byte('<')] = string.byte('>')
---Return if specified text has prefix or not
---@param text string
---@param prefix string
---@return boolean
str.has_prefix = function(text, prefix)
if #text < #prefix then
return false
end
for i = 1, #prefix do
if not char.match(string.byte(text, i), string.byte(prefix, i)) then
return false
end
end
return true
end
---Remove suffix
---@param text string
---@param suffix string
---@return string
str.remove_suffix = function(text, suffix)
if #text < #suffix then
return text
end
local i = 0
while i < #suffix do
if string.byte(text, #text - i) ~= string.byte(suffix, #suffix - i) then
return text
end
i = i + 1
end
return string.sub(text, 1, -#suffix - 1)
end
---strikethrough
---@param text string
---@return string
str.strikethrough = function(text)
local r = pattern.regex('.')
local buffer = ''
while text ~= '' do
local s, e = r:match_str(text)
if not s then
break
end
buffer = buffer .. string.sub(text, s, e) .. '̶'
text = string.sub(text, e + 1)
end
return buffer
end
---omit
---@param text string
---@param width number
---@return string
str.omit = function(text, width)
if width == 0 then
return ''
end
if not text then
text = ''
end
if #text > width then
return string.sub(text, 1, width + 1) .. '...'
end
return text
end
---trim
---@param text string
---@return string
str.trim = function(text)
local s = 1
for i = 1, #text do
if not char.is_white(string.byte(text, i)) then
s = i
break
end
end
local e = #text
for i = #text, 1, -1 do
if not char.is_white(string.byte(text, i)) then
e = i
break
end
end
if s == 1 and e == #text then
return text
end
return string.sub(text, s, e)
end
---get_word
---@param text string
---@return string
str.get_word = function(text, stop_char)
local valids = {}
local has_valid = false
for idx = 1, #text do
local c = string.byte(text, idx)
local invalid = INVALID_CHARS[c] and not (valids[c] and stop_char ~= c)
if has_valid and invalid then
return string.sub(text, 1, idx - 1)
end
valids[c] = true
if PAIR_CHARS[c] then
valids[PAIR_CHARS[c]] = true
end
has_valid = has_valid or not invalid
end
return text
end
---Oneline
---@param text string
---@return string
str.oneline = function(text)
for i = 1, #text do
if string.byte(text, i) == string.byte('\n', 1) then
return string.sub(text, 1, i - 1)
end
end
return text
end
return str

View File

@@ -0,0 +1,26 @@
local str = require "cmp.utils.str"
describe('utils.str', function()
it('get_word', function()
assert.are.equal(str.get_word('print'), 'print')
assert.are.equal(str.get_word('$variable'), '$variable')
assert.are.equal(str.get_word('print()'), 'print')
assert.are.equal(str.get_word('["cmp#confirm"]'), '["cmp#confirm"]')
assert.are.equal(str.get_word('"devDependencies":', string.byte('"')), '"devDependencies')
end)
it('strikethrough', function()
assert.are.equal(str.strikethrough('あいうえお'), 'あ̶い̶う̶え̶お̶')
end)
it('remove_suffix', function()
assert.are.equal(str.remove_suffix('log()', '$0'), 'log()')
assert.are.equal(str.remove_suffix('log()$0', '$0'), 'log()')
assert.are.equal(str.remove_suffix('log()${0}', '${0}'), 'log()')
assert.are.equal(str.remove_suffix('log()${0:placeholder}', '${0}'), 'log()${0:placeholder}')
end)
end)