diff options
author | Michael Smith <mikesmiffy128@gmail.com> | 2022-05-22 22:47:23 +0100 |
---|---|---|
committer | Michael Smith <mikesmiffy128@gmail.com> | 2022-05-22 22:47:23 +0100 |
commit | 95aea6b533e71e478d61d18fac71cca116c56a4d (patch) | |
tree | 1a1f42f723772258cc54551495826768f374474a /start/cmp/lua |
Add all the plugins I currently use
Diffstat (limited to 'start/cmp/lua')
56 files changed, 7858 insertions, 0 deletions
diff --git a/start/cmp/lua/cmp/config.lua b/start/cmp/lua/cmp/config.lua new file mode 100644 index 0000000..42e9a43 --- /dev/null +++ b/start/cmp/lua/cmp/config.lua @@ -0,0 +1,229 @@ +local mapping = require('cmp.config.mapping') +local cache = require('cmp.utils.cache') +local keymap = require('cmp.utils.keymap') +local misc = require('cmp.utils.misc') +local api = require('cmp.utils.api') + +---@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 = {} + +---@type table<string, cmp.ConfigSchema> +config.filetypes = {} + +---@type table<string, cmp.ConfigSchema> +config.cmdline = {} + +---@type cmp.ConfigSchema +config.onetime = {} + +---Set configuration for global. +---@param c cmp.ConfigSchema +config.set_global = function(c) + config.global = config.normalize(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) + local revision = (config.buffers[bufnr] or {}).revision or 1 + config.buffers[bufnr] = c or {} + config.buffers[bufnr].revision = revision + 1 +end + +---Set configuration for filetype +---@param c cmp.ConfigSchema +---@param filetypes string[]|string +config.set_filetype = function(c, filetypes) + for _, filetype in ipairs(type(filetypes) == 'table' and filetypes or { filetypes }) do + local revision = (config.filetypes[filetype] or {}).revision or 1 + config.filetypes[filetype] = c or {} + config.filetypes[filetype].revision = revision + 1 + end +end + +---Set configuration for cmdline +---@param c cmp.ConfigSchema +---@param cmdtype string +config.set_cmdline = function(c, cmdtype) + local revision = (config.cmdline[cmdtype] or {}).revision or 1 + config.cmdline[cmdtype] = c or {} + config.cmdline[cmdtype].revision = revision + 1 +end + +---Set configuration as oneshot completion. +---@param c cmp.ConfigSchema +config.set_onetime = function(c) + local revision = (config.onetime or {}).revision or 1 + config.onetime = c or {} + config.onetime.revision = revision + 1 +end + +---@return cmp.ConfigSchema +config.get = function() + local global_config = config.global + if config.onetime.sources then + local onetime_config = config.onetime + return config.cache:ensure({ + 'get', + 'onetime', + global_config.revision or 0, + onetime_config.revision or 0, + }, function() + local c = {} + c = misc.merge(c, config.normalize(onetime_config)) + c = misc.merge(c, config.normalize(global_config)) + return c + end) + elseif api.is_cmdline_mode() then + local cmdtype = vim.fn.getcmdtype() + local cmdline_config = config.cmdline[cmdtype] or { revision = 1, sources = {} } + return config.cache:ensure({ + 'get', + 'cmdline', + global_config.revision or 0, + cmdtype, + cmdline_config.revision or 0, + }, function() + local c = {} + c = misc.merge(c, config.normalize(cmdline_config)) + c = misc.merge(c, config.normalize(global_config)) + return c + end) + else + local bufnr = vim.api.nvim_get_current_buf() + local filetype = vim.api.nvim_buf_get_option(bufnr, 'filetype') + local buffer_config = config.buffers[bufnr] or { revision = 1 } + local filetype_config = config.filetypes[filetype] or { revision = 1 } + return config.cache:ensure({ + 'get', + 'default', + global_config.revision or 0, + filetype, + filetype_config.revision or 0, + bufnr, + buffer_config.revision or 0, + }, function() + local c = {} + c = misc.merge(config.normalize(c), config.normalize(buffer_config)) + c = misc.merge(config.normalize(c), config.normalize(filetype_config)) + c = misc.merge(config.normalize(c), config.normalize(global_config)) + return c + end) + end +end + +---Return cmp is enabled or not. +config.enabled = function() + local enabled = config.get().enabled + if type(enabled) == 'function' then + enabled = enabled() + end + return enabled and api.is_suitable_mode() +end + +---Return source config +---@param name string +---@return cmp.SourceConfig +config.get_source_config = function(name) + local c = config.get() + for _, s in ipairs(c.sources) do + if s.name == name then + return s + end + end + return nil +end + +---Return the current menu is native or not. +config.is_native_menu = function() + local c = config.get() + if c.experimental and c.experimental.native_menu then + return true + end + if c.view and c.view.entries then + return c.view.entries == 'native' or c.view.entries.name == 'native' + end + return false +end + +---Normalize mapping key +---@param c any +---@return cmp.ConfigSchema +config.normalize = function(c) + -- make sure c is not 'nil' + c = c == nil and {} or c + + -- Normalize mapping. + if c.mapping then + local normalized = {} + for k, v in pairs(c.mapping) do + normalized[keymap.normalize(k)] = mapping(v, { 'i' }) + end + c.mapping = normalized + end + + -- Notice experimental.native_menu. + if c.experimental and c.experimental.native_menu then + vim.api.nvim_echo({ + { '[nvim-cmp] ', 'Normal' }, + { 'experimental.native_menu', 'WarningMsg' }, + { ' is deprecated.\n', 'Normal' }, + { '[nvim-cmp] Please use ', 'Normal' }, + { 'view.entries = "native"', 'WarningMsg' }, + { ' instead.', 'Normal' }, + }, true, {}) + + c.view = c.view or {} + c.view.entries = c.view.entries or 'native' + end + + -- Notice documentation. + if c.documentation ~= nil then + vim.api.nvim_echo({ + { '[nvim-cmp] ', 'Normal' }, + { 'documentation', 'WarningMsg' }, + { ' is deprecated.\n', 'Normal' }, + { '[nvim-cmp] Please use ', 'Normal' }, + { 'window.documentation = cmp.config.window.bordered()', 'WarningMsg' }, + { ' instead.', 'Normal' }, + }, true, {}) + c.window = c.window or {} + c.window.documentation = c.documentation + end + + -- Notice sources.[n].opts + if c.sources then + for _, s in ipairs(c.sources) do + if s.opts and not s.option then + s.option = s.opts + s.opts = nil + vim.api.nvim_echo({ + { '[nvim-cmp] ', 'Normal' }, + { 'sources[number].opts', 'WarningMsg' }, + { ' is deprecated.\n', 'Normal' }, + { '[nvim-cmp] Please use ', 'Normal' }, + { 'sources[number].option', 'WarningMsg' }, + { ' instead.', 'Normal' }, + }, true, {}) + end + s.option = s.option or {} + end + end + + return c +end + +return config diff --git a/start/cmp/lua/cmp/config/compare.lua b/start/cmp/lua/cmp/config/compare.lua new file mode 100644 index 0000000..4b4fadc --- /dev/null +++ b/start/cmp/lua/cmp/config/compare.lua @@ -0,0 +1,234 @@ +local types = require('cmp.types') +local cache = require('cmp.utils.cache') +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 + +-- recently_used +compare.recently_used = setmetatable({ + records = {}, + add_entry = function(self, e) + self.records[e.completion_item.label] = vim.loop.now() + end, +}, { + __call = function(self, entry1, entry2) + local t1 = self.records[entry1.completion_item.label] or -1 + local t2 = self.records[entry2.completion_item.label] or -1 + if t1 ~= t2 then + return t1 > t2 + 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 + +-- locality +compare.locality = setmetatable({ + lines_count = 10, + lines_cache = cache.new(), + locality_map = {}, + update = function(self) + local config = require('cmp').get_config() + if not vim.tbl_contains(config.sorting.comparators, compare.scopes) then + return + end + + local win, buf = vim.api.nvim_get_current_win(), vim.api.nvim_get_current_buf() + local cursor_row = vim.api.nvim_win_get_cursor(win)[1] - 1 + local max = vim.api.nvim_buf_line_count(buf) + + if self.lines_cache:get('buf') ~= buf then + self.lines_cache:clear() + self.lines_cache:set('buf', buf) + end + + self.locality_map = {} + for i = math.max(0, cursor_row - self.lines_count), math.min(max, cursor_row + self.lines_count) do + local is_above = i < cursor_row + local buffer = vim.api.nvim_buf_get_lines(buf, i, i + 1, false)[1] or '' + local locality_map = self.lines_cache:ensure({ 'line', buffer }, function() + local locality_map = {} + local regexp = vim.regex(config.completion.keyword_pattern) + while buffer ~= '' do + local s, e = regexp:match_str(buffer) + if s and e then + local w = string.sub(buffer, s + 1, e) + local d = math.abs(i - cursor_row) - (is_above and 0.1 or 0) + locality_map[w] = math.min(locality_map[w] or math.huge, d) + buffer = string.sub(buffer, e + 1) + else + break + end + end + return locality_map + end) + for w, d in pairs(locality_map) do + self.locality_map[w] = math.min(self.locality_map[w] or d, math.abs(i - cursor_row)) + end + end + end, +}, { + __call = function(self, entry1, entry2) + local local1 = self.locality_map[entry1:get_word()] + local local2 = self.locality_map[entry2:get_word()] + if local1 ~= local2 then + if local1 == nil then + return false + end + if local2 == nil then + return true + end + return local1 < local2 + end + end, +}) + +-- scopes +compare.scopes = setmetatable({ + scopes_map = {}, + update = function(self) + local config = require('cmp').get_config() + if not vim.tbl_contains(config.sorting.comparators, compare.scopes) then + return + end + + local ok, locals = pcall(require, 'nvim-treesitter.locals') + if ok then + local win, buf = vim.api.nvim_get_current_win(), vim.api.nvim_get_current_buf() + local cursor_row = vim.api.nvim_win_get_cursor(win)[1] - 1 + + -- Cursor scope. + local cursor_scope = nil + for _, scope in ipairs(locals.get_scopes(buf)) do + if scope:start() <= cursor_row and cursor_row <= scope:end_() then + if not cursor_scope then + cursor_scope = scope + else + if cursor_scope:start() <= scope:start() and scope:end_() <= cursor_scope:end_() then + cursor_scope = scope + end + end + elseif cursor_scope and cursor_scope:end_() <= scope:start() then + break + end + end + + -- Definitions. + local definitions = locals.get_definitions_lookup_table(buf) + + -- Narrow definitions. + local depth = 0 + for scope in locals.iter_scope_tree(cursor_scope, buf) do + local s, e = scope:start(), scope:end_() + + -- Check scope's direct child. + for _, definition in pairs(definitions) do + if s <= definition.node:start() and definition.node:end_() <= e then + if scope:id() == locals.containing_scope(definition.node, buf):id() then + local text = vim.treesitter.query.get_node_text(definition.node, buf) or '' + if not self.scopes_map[text] then + self.scopes_map[text] = depth + end + end + end + end + depth = depth + 1 + end + end + end, +}, { + __call = function(self, entry1, entry2) + local local1 = self.scopes_map[entry1:get_word()] + local local2 = self.scopes_map[entry2:get_word()] + if local1 ~= local2 then + if local1 == nil then + return false + end + if local2 == nil then + return true + end + return local1 < local2 + end + end, +}) + +return compare diff --git a/start/cmp/lua/cmp/config/context.lua b/start/cmp/lua/cmp/config/context.lua new file mode 100644 index 0000000..584f38a --- /dev/null +++ b/start/cmp/lua/cmp/config/context.lua @@ -0,0 +1,65 @@ +local context = {} + +---Check if cursor is in syntax group +---@param group string +---@return boolean +context.in_syntax_group = function(group) + local lnum, col = vim.fn.line('.'), math.min(vim.fn.col('.'), #vim.fn.getline('.')) + for _, syn_id in ipairs(vim.fn.synstack(lnum, col)) do + syn_id = vim.fn.synIDtrans(syn_id) -- Resolve :highlight links + if vim.fn.synIDattr(syn_id, 'name') == group then + return true + end + end + return false +end + +---Check if cursor is in treesitter capture +---@param capture string +---@return boolean +context.in_treesitter_capture = function(capture) + local highlighter = require('vim.treesitter.highlighter') + local ts_utils = require('nvim-treesitter.ts_utils') + local buf = vim.api.nvim_get_current_buf() + + local row, col = unpack(vim.api.nvim_win_get_cursor(0)) + row = row - 1 + if vim.api.nvim_get_mode().mode == 'i' then + col = col - 1 + end + + local self = highlighter.active[buf] + if not self then + return false + end + + local node_types = {} + + self.tree:for_each_tree(function(tstree, tree) + if not tstree then + return + end + + local root = tstree:root() + local root_start_row, _, root_end_row, _ = root:range() + if root_start_row > row or root_end_row < row then + return + end + + local query = self:get_query(tree:lang()) + if not query:query() then + return + end + + local iter = query:query():iter_captures(root, self.bufnr, row, row + 1) + for _, node, _ in iter do + if ts_utils.is_in_node_range(node, row, col) then + table.insert(node_types, node:type()) + end + end + end, true) + + return vim.tbl_contains(node_types, capture) +end + +return context diff --git a/start/cmp/lua/cmp/config/default.lua b/start/cmp/lua/cmp/config/default.lua new file mode 100644 index 0000000..dce5168 --- /dev/null +++ b/start/cmp/lua/cmp/config/default.lua @@ -0,0 +1,97 @@ +local compare = require('cmp.config.compare') +local types = require('cmp.types') + +local WIDE_HEIGHT = 40 + +---@return cmp.ConfigSchema +return function() + return { + enabled = function() + local disabled = false + disabled = disabled or (vim.api.nvim_buf_get_option(0, 'buftype') == 'prompt') + disabled = disabled or (vim.fn.reg_recording() ~= '') + disabled = disabled or (vim.fn.reg_executing() ~= '') + return not disabled + end, + + preselect = types.cmp.PreselectMode.Item, + + mapping = {}, + + snippet = { + expand = function() + error('snippet engine is not configured.') + end, + }, + + completion = { + autocomplete = { + types.cmp.TriggerEvent.TextChanged, + }, + completeopt = 'menu,menuone,noselect', + keyword_pattern = [[\%(-\?\d\+\%(\.\d\+\)\?\|\h\w*\%(-\w*\)*\)]], + keyword_length = 1, + }, + + formatting = { + fields = { 'abbr', 'kind', 'menu' }, + format = function(_, vim_item) + return vim_item + end, + }, + + matching = { + disallow_fuzzy_matching = false, + disallow_partial_matching = false, + disallow_prefix_unmatching = false, + }, + + sorting = { + priority_weight = 2, + comparators = { + compare.offset, + compare.exact, + -- compare.scopes, + compare.score, + compare.recently_used, + compare.locality, + compare.kind, + compare.sort_text, + compare.length, + compare.order, + }, + }, + + sources = {}, + + confirmation = { + default_behavior = types.cmp.ConfirmBehavior.Insert, + get_commit_characters = function(commit_characters) + return commit_characters + end, + }, + + event = {}, + + experimental = { + ghost_text = false, + }, + + view = { + entries = { name = 'custom', selection_order = 'top_down' }, + }, + + window = { + completion = { + border = { '', '', '', '', '', '', '', '' }, + winhighlight = 'Normal:Pmenu,FloatBorder:Pmenu,CursorLine:PmenuSel,Search:None', + }, + documentation = { + max_height = math.floor(WIDE_HEIGHT * (WIDE_HEIGHT / vim.o.lines)), + max_width = math.floor((WIDE_HEIGHT * 2) * (vim.o.columns / (WIDE_HEIGHT * 2 * 16 / 9))), + border = { '', '', '', ' ', '', '', '', ' ' }, + winhighlight = 'FloatBorder:NormalFloat', + }, + }, + } +end diff --git a/start/cmp/lua/cmp/config/mapping.lua b/start/cmp/lua/cmp/config/mapping.lua new file mode 100644 index 0000000..c2028c8 --- /dev/null +++ b/start/cmp/lua/cmp/config/mapping.lua @@ -0,0 +1,172 @@ +local types = require('cmp.types') +local misc = require('cmp.utils.misc') +local feedkeys = require('cmp.utils.feedkeys') +local keymap = require('cmp.utils.keymap') + +local mapping = setmetatable({}, { + __call = function(_, invoke, modes) + if type(invoke) == 'function' then + local map = {} + for _, mode in ipairs(modes or { 'i' }) do + map[mode] = invoke + end + return map + end + return invoke + end, +}) + +---Mapping preset configuration. +mapping.preset = {} + +---Mapping preset insert-mode configuration. +mapping.preset.insert = function(override) + return misc.merge(override or {}, { + ['<Down>'] = { + i = mapping.select_next_item({ behavior = types.cmp.SelectBehavior.Select }), + }, + ['<Up>'] = { + i = mapping.select_prev_item({ behavior = types.cmp.SelectBehavior.Select }), + }, + ['<C-n>'] = { + i = mapping.select_next_item({ behavior = types.cmp.SelectBehavior.Insert }), + }, + ['<C-p>'] = { + i = mapping.select_prev_item({ behavior = types.cmp.SelectBehavior.Insert }), + }, + ['<C-y>'] = { + i = mapping.confirm({ select = false }), + }, + ['<C-e>'] = { + i = mapping.abort(), + }, + }) +end + +---Mapping preset cmdline-mode configuration. +mapping.preset.cmdline = function(override) + return misc.merge(override or {}, { + ['<Tab>'] = { + c = function() + local cmp = require('cmp') + if cmp.visible() then + cmp.select_next_item() + else + feedkeys.call(keymap.t('<C-z>'), 'n') + end + end, + }, + ['<S-Tab>'] = { + c = function() + local cmp = require('cmp') + if cmp.visible() then + cmp.select_prev_item() + else + feedkeys.call(keymap.t('<C-z>'), 'n') + end + end, + }, + ['<C-n>'] = { + c = function(fallback) + local cmp = require('cmp') + if cmp.visible() then + cmp.select_next_item() + else + fallback() + end + end, + }, + ['<C-p>'] = { + c = function(fallback) + local cmp = require('cmp') + if cmp.visible() then + cmp.select_prev_item() + else + fallback() + end + end, + }, + ['<C-e>'] = { + c = mapping.close(), + }, + }) +end + +---Invoke completion +---@param option cmp.CompleteParams +mapping.complete = function(option) + return function(fallback) + if not require('cmp').complete(option) then + fallback() + end + end +end + +---Complete common string. +mapping.complete_common_string = function() + return function(fallback) + if not require('cmp').complete_common_string() then + fallback() + end + end +end + +---Close current completion menu if it displayed. +mapping.close = function() + return function(fallback) + if not require('cmp').close() then + fallback() + end + end +end + +---Abort current completion menu if it displayed. +mapping.abort = function() + return function(fallback) + if not require('cmp').abort() then + fallback() + end + end +end + +---Scroll documentation window. +mapping.scroll_docs = function(delta) + return function(fallback) + if not require('cmp').scroll_docs(delta) then + fallback() + end + end +end + +---Select next completion item. +mapping.select_next_item = function(option) + return function(fallback) + if not require('cmp').select_next_item(option) then + local release = require('cmp').core:suspend() + fallback() + vim.schedule(release) + end + end +end + +---Select prev completion item. +mapping.select_prev_item = function(option) + return function(fallback) + if not require('cmp').select_prev_item(option) then + local release = require('cmp').core:suspend() + fallback() + vim.schedule(release) + end + end +end + +---Confirm selection +mapping.confirm = function(option) + return function(fallback) + if not require('cmp').confirm(option) then + fallback() + end + end +end + +return mapping diff --git a/start/cmp/lua/cmp/config/sources.lua b/start/cmp/lua/cmp/config/sources.lua new file mode 100644 index 0000000..cfb09c0 --- /dev/null +++ b/start/cmp/lua/cmp/config/sources.lua @@ -0,0 +1,10 @@ +return function(...) + local sources = {} + for i, group in ipairs({ ... }) do + for _, source in ipairs(group) do + source.group_index = i + table.insert(sources, source) + end + end + return sources +end diff --git a/start/cmp/lua/cmp/config/window.lua b/start/cmp/lua/cmp/config/window.lua new file mode 100644 index 0000000..b6fec0f --- /dev/null +++ b/start/cmp/lua/cmp/config/window.lua @@ -0,0 +1,12 @@ +local window = {} + +window.bordered = function(opts) + opts = opts or {} + return { + border = opts.border or 'rounded', + winhighlight = opts.winhighlight or 'Normal:Normal,FloatBorder:Normal,CursorLine:Visual,Search:None', + zindex = opts.zindex or 1001, + } +end + +return window diff --git a/start/cmp/lua/cmp/context.lua b/start/cmp/lua/cmp/context.lua new file mode 100644 index 0000000..6188259 --- /dev/null +++ b/start/cmp/lua/cmp/context.lua @@ -0,0 +1,105 @@ +local misc = require('cmp.utils.misc') +local pattern = require('cmp.utils.pattern') +local types = require('cmp.types') +local cache = require('cmp.utils.cache') +local api = require('cmp.utils.api') + +---@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 filetype string +---@field public time number +---@field public bufnr number +---@field public cursor vim.Position|lsp.Position +---@field public cursor_line string +---@field public cursor_after_line string +---@field public cursor_before_line 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 }) + self.id = misc.id('cmp.context.new') + self.cache = cache.new() + self.prev_context = prev_context or context.empty() + self.option = option or { reason = types.cmp.ContextReason.None } + self.filetype = vim.api.nvim_buf_get_option(0, 'filetype') + self.time = vim.loop.now() + self.bufnr = vim.api.nvim_get_current_buf() + + local cursor = api.get_cursor() + self.cursor_line = api.get_current_line() + self.cursor = {} + self.cursor.row = cursor[1] + self.cursor.col = cursor[2] + 1 + self.cursor.line = self.cursor.row - 1 + self.cursor.character = misc.to_utfindex(self.cursor_line, self.cursor.col) + 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) + 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 .. '\\m$', self.cursor_before_line) or self.cursor.col + end) +end + +---Return if this context is changed from previous context or not. +---@return boolean +context.changed = function(self, ctx) + local curr = self + + 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 diff --git a/start/cmp/lua/cmp/context_spec.lua b/start/cmp/lua/cmp/context_spec.lua new file mode 100644 index 0000000..976e194 --- /dev/null +++ b/start/cmp/lua/cmp/context_spec.lua @@ -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) diff --git a/start/cmp/lua/cmp/core.lua b/start/cmp/lua/cmp/core.lua new file mode 100644 index 0000000..08949a9 --- /dev/null +++ b/start/cmp/lua/cmp/core.lua @@ -0,0 +1,486 @@ +local debug = require('cmp.utils.debug') +local str = require('cmp.utils.str') +local char = require('cmp.utils.char') +local pattern = require('cmp.utils.pattern') +local feedkeys = require('cmp.utils.feedkeys') +local async = require('cmp.utils.async') +local keymap = require('cmp.utils.keymap') +local context = require('cmp.context') +local source = require('cmp.source') +local view = require('cmp.view') +local misc = require('cmp.utils.misc') +local config = require('cmp.config') +local types = require('cmp.types') +local api = require('cmp.utils.api') +local event = require('cmp.utils.event') + +local SOURCE_TIMEOUT = 500 +local DEBOUNCE_TIME = 80 +local THROTTLE_TIME = 40 + +---@class cmp.Core +---@field public suspending boolean +---@field public view cmp.View +---@field public sources cmp.Source[] +---@field public context cmp.Context +---@field public event cmp.Event +local core = {} + +core.new = function() + local self = setmetatable({}, { __index = core }) + self.suspending = false + self.sources = {} + self.context = context.new() + self.event = event.new() + self.view = view.new() + self.view.event:on('keymap', function(...) + self:on_keymap(...) + end) + self.view.event:on('complete_done', function(evt) + self.event:emit('complete_done', evt) + end) + return self +end + +---Register source +---@param s cmp.Source +core.register_source = function(self, s) + self.sources[s.id] = s +end + +---Unregister source +---@param source_id string +core.unregister_source = function(self, source_id) + self.sources[source_id] = nil +end + +---Get new context +---@param option cmp.ContextOption +---@return cmp.Context +core.get_context = function(self, option) + local prev = self.context:clone() + prev.prev_context = nil + local ctx = context.new(prev, option) + self:set_context(ctx) + return self.context +end + +---Set new context +---@param ctx cmp.Context +core.set_context = function(self, ctx) + self.context = ctx +end + +---Suspend completion +core.suspend = function(self) + self.suspending = true + -- It's needed to avoid conflicting with autocmd debouncing. + return vim.schedule_wrap(function() + self.suspending = false + end) +end + +---Get sources that sorted by priority +---@param filter cmp.SourceStatus[]|fun(s: cmp.Source): boolean +---@return cmp.Source[] +core.get_sources = function(self, filter) + local f = function(s) + if type(filter) == 'table' then + return vim.tbl_contains(filter, s.status) + elseif type(filter) == 'function' then + return filter(s) + end + return true + end + + local sources = {} + for _, c in pairs(config.get().sources) do + for _, s in pairs(self.sources) do + if c.name == s.name then + if s:is_available() and f(s) then + table.insert(sources, s) + end + end + end + end + return sources +end + +---Keypress handler +core.on_keymap = function(self, keys, fallback) + local mode = api.get_mode() + for key, mapping in pairs(config.get().mapping) do + if keymap.equals(key, keys) and mapping[mode] then + return mapping[mode](fallback) + end + end + + --Commit character. NOTE: This has a lot of cmp specific implementation to make more user-friendly. + local chars = keymap.t(keys) + local e = self.view:get_active_entry() + if e and vim.tbl_contains(config.get().confirmation.get_commit_characters(e:get_commit_characters()), chars) then + local is_printable = char.is_printable(string.byte(chars, 1)) + self:confirm(e, { + behavior = is_printable and 'insert' or 'replace', + commit_character = chars, + }, function() + local ctx = self:get_context() + local word = e:get_word() + if string.sub(ctx.cursor_before_line, -#word, ctx.cursor.col - 1) == word and is_printable then + fallback() + else + self:reset() + end + end) + return + end + + fallback() +end + +---Prepare completion +core.prepare = function(self) + for keys, mapping in pairs(config.get().mapping) do + for mode in pairs(mapping) do + keymap.listen(mode, keys, function(...) + self:on_keymap(...) + end) + end + end +end + +---Check auto-completion +core.on_change = function(self, trigger_event) + local ignore = false + ignore = ignore or self.suspending + ignore = ignore or (vim.fn.pumvisible() == 1 and (vim.v.completed_item).word) + ignore = ignore or not self.view:ready() + if ignore then + self:get_context({ reason = types.cmp.ContextReason.Auto }) + return + end + self:autoindent(trigger_event, function() + local ctx = self:get_context({ reason = types.cmp.ContextReason.Auto }) + debug.log(('ctx: `%s`'):format(ctx.cursor_before_line)) + if ctx:changed(ctx.prev_context) then + self.view:on_change() + debug.log('changed') + + if vim.tbl_contains(config.get().completion.autocomplete or {}, trigger_event) then + self:complete(ctx) + else + self.filter.timeout = self.view:visible() and THROTTLE_TIME or 0 + self:filter() + end + else + debug.log('unchanged') + end + end) +end + +---Cursor moved. +core.on_moved = function(self) + local ignore = false + ignore = ignore or self.suspending + ignore = ignore or (vim.fn.pumvisible() == 1 and (vim.v.completed_item).word) + ignore = ignore or not self.view:visible() + if ignore then + return + end + self:filter() +end + +---Check autoindent +---@param trigger_event cmp.TriggerEvent +---@param callback function +core.autoindent = function(self, trigger_event, callback) + if trigger_event ~= types.cmp.TriggerEvent.TextChanged then + return callback() + end + if not api.is_insert_mode() then + return callback() + end + + -- Check prefix + local cursor_before_line = api.get_cursor_before_line() + local prefix = pattern.matchstr('[^[:blank:]]\\+$', cursor_before_line) or '' + if #prefix == 0 then + return callback() + end + + -- Reset current completion if indentkeys matched. + for _, key in ipairs(vim.split(vim.bo.indentkeys, ',')) do + if vim.tbl_contains({ '=' .. prefix, '0=' .. prefix }, key) then + self:reset() + self:set_context(context.empty()) + break + end + end + + callback() +end + +---Complete common string for current completed entries. +core.complete_common_string = function(self) + if not self.view:visible() or self.view:get_active_entry() then + return false + end + + config.set_onetime({ + sources = config.get().sources, + matching = { + disallow_prefix_unmatching = true, + disallow_partial_matching = true, + disallow_fuzzy_matching = true, + }, + }) + + self:filter() + self.filter:sync(1000) + + config.set_onetime({}) + + local cursor = api.get_cursor() + local offset = self.view:get_offset() + local common_string + for _, e in ipairs(self.view:get_entries()) do + local vim_item = e:get_vim_item(offset) + if not common_string then + common_string = vim_item.word + else + common_string = str.get_common_string(common_string, vim_item.word) + end + end + if common_string and #common_string > (1 + cursor[2] - offset) then + feedkeys.call(keymap.backspace(string.sub(api.get_current_line(), offset, cursor[2])) .. common_string, 'n') + return true + end + return false +end + +---Invoke completion +---@param ctx cmp.Context +core.complete = function(self, ctx) + if not api.is_suitable_mode() then + return + end + + self:set_context(ctx) + + -- Invoke completion sources. + local sources = self:get_sources() + for _, s in ipairs(sources) do + local callback + callback = (function(s_) + return function() + local new = context.new(ctx) + if s_.incomplete and new:changed(s_.context) then + s_:complete(new, callback) + else + if not self.view:get_active_entry() then + self.filter.stop() + self.filter.timeout = DEBOUNCE_TIME + self:filter() + end + end + end + end)(s) + s:complete(ctx, callback) + end + + if not self.view:get_active_entry() then + self.filter.timeout = self.view:visible() and THROTTLE_TIME or 1 + self:filter() + end +end + +---Update completion menu +core.filter = async.throttle(function(self) + self.filter.timeout = THROTTLE_TIME + + -- Check invalid condition. + local ignore = false + ignore = ignore or not api.is_suitable_mode() + if ignore then + return + end + + -- Check fetching sources. + local sources = {} + for _, s in ipairs(self:get_sources({ source.SourceStatus.FETCHING, source.SourceStatus.COMPLETED })) do + -- Reserve filter call for timeout. + if not s.incomplete and SOURCE_TIMEOUT > s:get_fetching_time() then + self.filter.timeout = SOURCE_TIMEOUT - s:get_fetching_time() + self:filter() + if #sources == 0 then + return + end + end + table.insert(sources, s) + end + + local ctx = self:get_context() + + -- Display completion results. + self.view:open(ctx, sources) + + -- Check onetime config. + if #self:get_sources(function(s) + if s.status == source.SourceStatus.FETCHING then + return true + elseif #s:get_entries(ctx) > 0 then + return true + end + return false + end) == 0 then + config.set_onetime({}) + end +end, THROTTLE_TIME) + +---Confirm completion. +---@param e cmp.Entry +---@param option cmp.ConfirmOption +---@param callback function +core.confirm = function(self, e, option, callback) + if not (e and not e.confirmed) then + return callback() + end + e.confirmed = true + + debug.log('entry.confirm', e:get_completion_item()) + + local release = self:suspend() + + -- Close menus. + self.view:close() + + feedkeys.call(keymap.indentkeys(), 'n') + feedkeys.call('', 'n', function() + local ctx = context.new() + local keys = {} + table.insert(keys, keymap.backspace(ctx.cursor.character - misc.to_utfindex(ctx.cursor_line, e:get_offset()))) + table.insert(keys, e:get_word()) + table.insert(keys, keymap.undobreak()) + feedkeys.call(table.concat(keys, ''), 'in') + end) + feedkeys.call('', 'n', function() + local ctx = context.new() + if api.is_cmdline_mode() then + local keys = {} + table.insert(keys, keymap.backspace(ctx.cursor.character - misc.to_utfindex(ctx.cursor_line, e:get_offset()))) + table.insert(keys, string.sub(e.context.cursor_before_line, e:get_offset())) + feedkeys.call(table.concat(keys, ''), 'in') + else + vim.api.nvim_buf_set_text(0, ctx.cursor.row - 1, e:get_offset() - 1, ctx.cursor.row - 1, ctx.cursor.col - 1, { + string.sub(e.context.cursor_before_line, e:get_offset()), + }) + vim.api.nvim_win_set_cursor(0, { e.context.cursor.row, e.context.cursor.col - 1 }) + end + end) + feedkeys.call('', 'n', function() + local ctx = context.new() + if #(misc.safe(e:get_completion_item().additionalTextEdits) or {}) == 0 then + e:resolve(function() + local new = context.new() + 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(ctx.cursor.row, new.cursor.row) + local maxrow = math.max(ctx.cursor.row, new.cursor.row) + for _, te in ipairs(text_edits) do + local srow = te.range.start.line + 1 + local erow = te.range['end'].line + 1 + if srow <= minrow and maxrow <= erow then + return true + end + end + return false + end)() + if has_cursor_line_text_edit then + return + end + vim.lsp.util.apply_text_edits(text_edits, ctx.bufnr, 'utf-16') + end) + else + vim.lsp.util.apply_text_edits(e:get_completion_item().additionalTextEdits, ctx.bufnr, 'utf-16') + end + end) + feedkeys.call('', 'n', function() + local ctx = context.new() + 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.word or completion_item.label + end + local behavior = option.behavior or config.get().confirmation.default_behavior + if behavior == types.cmp.ConfirmBehavior.Replace then + completion_item.textEdit.range = e:get_replace_range() + else + completion_item.textEdit.range = e:get_insert_range() + end + + local diff_before = math.max(0, e.context.cursor.character - completion_item.textEdit.range.start.character) + local diff_after = math.max(0, completion_item.textEdit.range['end'].character - e.context.cursor.character) + local new_text = completion_item.textEdit.newText + + if api.is_insert_mode() then + local is_snippet = completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet + completion_item.textEdit.range.start.line = ctx.cursor.line + completion_item.textEdit.range.start.character = ctx.cursor.character - diff_before + completion_item.textEdit.range['end'].line = ctx.cursor.line + completion_item.textEdit.range['end'].character = ctx.cursor.character + diff_after + if is_snippet then + completion_item.textEdit.newText = '' + end + vim.lsp.util.apply_text_edits({ completion_item.textEdit }, ctx.bufnr, 'utf-16') + local texts = vim.split(completion_item.textEdit.newText, '\n') + local position = completion_item.textEdit.range.start + position.line = position.line + (#texts - 1) + if #texts == 1 then + position.character = position.character + misc.to_utfindex(texts[1]) + else + position.character = misc.to_utfindex(texts[#texts]) + end + local pos = types.lsp.Position.to_vim(0, position) + vim.api.nvim_win_set_cursor(0, { pos.row, pos.col - 1 }) + if is_snippet then + config.get().snippet.expand({ + body = new_text, + insert_text_mode = completion_item.insertTextMode, + }) + end + else + local keys = {} + table.insert(keys, string.rep(keymap.t('<BS>'), diff_before)) + table.insert(keys, string.rep(keymap.t('<Del>'), diff_after)) + table.insert(keys, new_text) + feedkeys.call(table.concat(keys, ''), 'in') + end + end) + feedkeys.call(keymap.indentkeys(vim.bo.indentkeys), 'n') + feedkeys.call('', 'n', function() + e:execute(vim.schedule_wrap(function() + release() + self.event:emit('confirm_done', { + entry = e, + commit_character = option.commit_character, + }) + if callback then + callback() + end + end)) + end) +end + +---Reset current completion state +core.reset = function(self) + for _, s in pairs(self.sources) do + s:reset() + end + self.context = context.empty() +end + +return core diff --git a/start/cmp/lua/cmp/core_spec.lua b/start/cmp/lua/cmp/core_spec.lua new file mode 100644 index 0000000..c37090e --- /dev/null +++ b/start/cmp/lua/cmp/core_spec.lua @@ -0,0 +1,158 @@ +local spec = require('cmp.utils.spec') +local feedkeys = require('cmp.utils.feedkeys') +local types = require('cmp.types') +local core = require('cmp.core') +local source = require('cmp.source') +local keymap = require('cmp.utils.keymap') +local api = require('cmp.utils.api') + +describe('cmp.core', function() + describe('confirm', function() + local confirm = function(request, filter, completion_item) + local c = core.new() + local s = source.new('spec', { + complete = function(_, _, callback) + callback({ completion_item }) + end, + }) + c:register_source(s) + feedkeys.call(request, 'n', function() + c:complete(c:get_context({ reason = types.cmp.ContextReason.Manual })) + vim.wait(5000, function() + return #c.sources[s.id].entries > 0 + end) + end) + feedkeys.call(filter, 'n', function() + c:confirm(c.sources[s.id].entries[1], {}) + end) + local state = {} + feedkeys.call('', 'x', function() + feedkeys.call('', 'n', function() + if api.is_cmdline_mode() then + state.buffer = { api.get_current_line() } + else + state.buffer = vim.api.nvim_buf_get_lines(0, 0, -1, false) + end + state.cursor = api.get_cursor() + end) + end) + return state + end + + describe('insert-mode', function() + before_each(spec.before) + + it('label', function() + local state = confirm('iA', 'IU', { + label = 'AIUEO', + }) + assert.are.same(state.buffer, { 'AIUEO' }) + assert.are.same(state.cursor, { 1, 5 }) + end) + + it('insertText', function() + local state = confirm('iA', 'IU', { + label = 'AIUEO', + insertText = '_AIUEO_', + }) + assert.are.same(state.buffer, { '_AIUEO_' }) + assert.are.same(state.cursor, { 1, 7 }) + end) + + it('textEdit', function() + local state = confirm(keymap.t('i***AEO***<Left><Left><Left><Left><Left>'), 'IU', { + label = 'AIUEO', + textEdit = { + range = { + start = { + line = 0, + character = 3, + }, + ['end'] = { + line = 0, + character = 6, + }, + }, + newText = 'foo\nbar\nbaz', + }, + }) + assert.are.same(state.buffer, { '***foo', 'bar', 'baz***' }) + assert.are.same(state.cursor, { 3, 3 }) + end) + + it('insertText & snippet', function() + local state = confirm('iA', 'IU', { + label = 'AIUEO', + insertText = 'AIUEO($0)', + insertTextFormat = types.lsp.InsertTextFormat.Snippet, + }) + assert.are.same(state.buffer, { 'AIUEO()' }) + assert.are.same(state.cursor, { 1, 6 }) + end) + + it('textEdit & snippet', function() + local state = confirm(keymap.t('i***AEO***<Left><Left><Left><Left><Left>'), 'IU', { + label = 'AIUEO', + insertTextFormat = types.lsp.InsertTextFormat.Snippet, + textEdit = { + range = { + start = { + line = 0, + character = 3, + }, + ['end'] = { + line = 0, + character = 6, + }, + }, + newText = 'foo\nba$0r\nbaz', + }, + }) + assert.are.same(state.buffer, { '***foo', 'bar', 'baz***' }) + assert.are.same(state.cursor, { 2, 2 }) + end) + end) + + describe('cmdline-mode', function() + before_each(spec.before) + + it('label', function() + local state = confirm(':A', 'IU', { + label = 'AIUEO', + }) + assert.are.same(state.buffer, { 'AIUEO' }) + assert.are.same(state.cursor[2], 5) + end) + + it('insertText', function() + local state = confirm(':A', 'IU', { + label = 'AIUEO', + insertText = '_AIUEO_', + }) + assert.are.same(state.buffer, { '_AIUEO_' }) + assert.are.same(state.cursor[2], 7) + end) + + it('textEdit', function() + local state = confirm(keymap.t(':***AEO***<Left><Left><Left><Left><Left>'), 'IU', { + label = 'AIUEO', + textEdit = { + range = { + start = { + line = 0, + character = 3, + }, + ['end'] = { + line = 0, + character = 6, + }, + }, + newText = 'foobarbaz', + }, + }) + assert.are.same(state.buffer, { '***foobarbaz***' }) + assert.are.same(state.cursor[2], 12) + end) + end) + end) +end) diff --git a/start/cmp/lua/cmp/entry.lua b/start/cmp/lua/cmp/entry.lua new file mode 100644 index 0000000..83c53a2 --- /dev/null +++ b/start/cmp/lua/cmp/entry.lua @@ -0,0 +1,468 @@ +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') +local matcher = require('cmp.matcher') + +---@class cmp.Entry +---@field public id number +---@field public cache cmp.Cache +---@field public match_cache cmp.Cache +---@field public score number +---@field public exact boolean +---@field public matches table +---@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.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.source_offset = source.request_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', self.resolved_completion_item and 1 or 0 }, function() + local offset = self.source_offset + if misc.safe(self:get_completion_item().textEdit) then + local range = misc.safe(self:get_completion_item().textEdit.insert) or misc.safe(self:get_completion_item().textEdit.range) + if range then + local c = misc.to_vimindex(self.context.cursor_line, range.start.character) + for idx = c, self.source_offset do + if not char.is_white(string.byte(self.context.cursor_line, idx)) then + 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 +---NOTE: This method doesn't clear the cache after completionItem/resolve. +---@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:get_completion_item().word) then + return self:get_completion_item().word + end + + local word + if misc.safe(self:get_completion_item().textEdit) and not misc.empty(self:get_completion_item().textEdit.newText) then + word = str.trim(self:get_completion_item().textEdit.newText) + if self:get_completion_item().insertTextFormat == types.lsp.InsertTextFormat.Snippet then + word = vim.lsp.util.parse_snippet(word) + end + local overwrite = self:get_overwrite() + if 0 < overwrite[2] or self:get_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(self:get_completion_item().insertText) then + word = str.trim(self:get_completion_item().insertText) + if self:get_completion_item().insertTextFormat == types.lsp.InsertTextFormat.Snippet then + word = str.get_word(vim.lsp.util.parse_snippet(word)) + end + else + word = str.trim(self:get_completion_item().label) + end + return str.oneline(word) + end) +end + +---Get overwrite information +---@return number, number +entry.get_overwrite = function(self) + return self.cache:ensure({ 'get_overwrite', self.resolved_completion_item and 1 or 0 }, function() + if misc.safe(self:get_completion_item().textEdit) then + local r = misc.safe(self:get_completion_item().textEdit.insert) or misc.safe(self:get_completion_item().textEdit.range) + local s = misc.to_vimindex(self.context.cursor_line, r.start.character) + local e = misc.to_vimindex(self.context.cursor_line, r['end'].character) + 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', self.resolved_completion_item and 1 or 0 }, function() + local word + if misc.safe(self:get_completion_item().filterText) then + word = self:get_completion_item().filterText + else + word = str.trim(self:get_completion_item().label) + 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', self.resolved_completion_item and 1 or 0 }, function() + local word + if misc.safe(self:get_completion_item().textEdit) then + word = str.trim(self:get_completion_item().textEdit.newText) + if self:get_completion_item().insertTextFormat == types.lsp.InsertTextFormat.Snippet then + word = str.remove_suffix(str.remove_suffix(word, '$0'), '${0}') + end + elseif misc.safe(self:get_completion_item().insertText) then + word = str.trim(self:get_completion_item().insertText) + if self:get_completion_item().insertTextFormat == types.lsp.InsertTextFormat.Snippet then + word = str.remove_suffix(str.remove_suffix(word, '$0'), '${0}') + end + else + word = str.trim(self:get_completion_item().label) + end + return word + end) +end + +---Return the item is deprecated or not. +---@return boolean +entry.is_deprecated = function(self) + return self:get_completion_item().deprecated or vim.tbl_contains(self:get_completion_item().tags or {}, types.lsp.CompletionItemTag.Deprecated) +end + +---Return view information. +---@param suggest_offset number +---@param entries_buf number The buffer this entry will be rendered into. +---@return { abbr: { text: string, bytes: number, width: number, hl_group: string }, kind: { text: string, bytes: number, width: number, hl_group: string }, menu: { text: string, bytes: number, width: number, 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', self.resolved_completion_item and 1 or 0, entries_buf }, function() + local view = {} + -- The result of vim.fn.strdisplaywidth depends on which buffer it was + -- called in because it reads the values of the option 'tabstop' when + -- rendering <Tab> characters. + vim.api.nvim_buf_call(entries_buf, function() + view.abbr = {} + view.abbr.text = item.abbr or '' + view.abbr.bytes = #view.abbr.text + view.abbr.width = vim.fn.strdisplaywidth(view.abbr.text) + view.abbr.hl_group = item.abbr_hl_group or (self:is_deprecated() and 'CmpItemAbbrDeprecated' or 'CmpItemAbbr') + view.kind = {} + view.kind.text = item.kind or '' + view.kind.bytes = #view.kind.text + view.kind.width = vim.fn.strdisplaywidth(view.kind.text) + view.kind.hl_group = item.kind_hl_group or ('CmpItemKind' .. (types.lsp.CompletionItemKind[self:get_kind()] or '')) + view.menu = {} + view.menu.text = item.menu or '' + view.menu.bytes = #view.menu.text + view.menu.width = vim.fn.strdisplaywidth(view.menu.text) + view.menu.hl_group = item.menu_hl_group or 'CmpItemMenu' + view.dup = item.dup + end) + return view + end) +end + +---Make vim.CompletedItem +---@param suggest_offset number +---@return vim.CompletedItem +entry.get_vim_item = function(self, suggest_offset) + return self.cache:ensure({ 'get_vim_item', suggest_offset, self.resolved_completion_item and 1 or 0 }, function() + local completion_item = self:get_completion_item() + local word = self:get_word() + local abbr = str.oneline(completion_item.label) + + -- ~ indicator + local is_snippet = false + if #(misc.safe(completion_item.additionalTextEdits) or {}) > 0 then + is_snippet = true + elseif completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then + is_snippet = self:get_insert_text() ~= word + elseif completion_item.kind == types.lsp.CompletionItemKind.Snippet then + is_snippet = true + end + if is_snippet then + abbr = abbr .. '~' + end + + -- append delta text + if suggest_offset < self:get_offset() then + word = string.sub(self.context.cursor_before_line, suggest_offset, self:get_offset() - 1) .. word + end + + -- labelDetails. + local menu = nil + if misc.safe(completion_item.labelDetails) then + menu = '' + if misc.safe(completion_item.labelDetails.detail) then + menu = menu .. completion_item.labelDetails.detail + end + if misc.safe(completion_item.labelDetails.description) then + menu = menu .. completion_item.labelDetails.description + end + end + + -- remove duplicated string. + if self:get_offset() ~= self.context.cursor.col then + for i = 1, #word - 1 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 vim_item = { + word = word, + abbr = abbr, + kind = types.lsp.CompletionItemKind[self:get_kind()] or types.lsp.CompletionItemKind[1], + menu = menu, + dup = self:get_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) +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:get_completion_item().textEdit) then + if misc.safe(self:get_completion_item().textEdit.insert) then + insert_range = self:get_completion_item().textEdit.insert + else + insert_range = self:get_completion_item().textEdit.range + end + else + insert_range = { + start = { + line = self.context.cursor.row - 1, + character = math.min(misc.to_utfindex(self.context.cursor_line, self:get_offset()), self.source_insert_range.start.character), + }, + ['end'] = self.source_insert_range['end'], + } + end + return insert_range +end + +---Return replace range +---@return lsp.Range|nil +entry.get_replace_range = function(self) + return self.cache:ensure({ 'get_replace_range', self.resolved_completion_item and 1 or 0 }, function() + local replace_range + if misc.safe(self:get_completion_item().textEdit) and misc.safe(self:get_completion_item().textEdit.replace) then + replace_range = self:get_completion_item().textEdit.replace + else + replace_range = { + start = { + line = self.source_replace_range.start.line, + character = math.min(misc.to_utfindex(self.context.cursor_line, self:get_offset()), self.source_replace_range.start.character), + }, + ['end'] = self.source_replace_range['end'], + } + end + return replace_range + end) +end + +---Match line. +---@param input string +---@param matching_config cmp.MatchingConfig +---@return { score: number, matches: table[] } +entry.match = function(self, input, matching_config) + return self.match_cache:ensure({ + 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, + }, function() + local option = { + disallow_fuzzy_matching = matching_config.disallow_fuzzy_matching, + disallow_partial_matching = matching_config.disallow_partial_matching, + disallow_prefix_unmatching = matching_config.disallow_prefix_unmatching, + synonyms = { + self:get_word(), + self:get_completion_item().label, + }, + } + + local score, matches, _ + score, matches = matcher.match(input, self:get_filter_text(), option) + + -- Support the language server that doesn't respect VSCode's behaviors. + if score == 0 then + if misc.safe(self:get_completion_item().textEdit) and not misc.empty(self:get_completion_item().textEdit.newText) then + local diff = self.source_offset - self:get_offset() + if diff > 0 then + local prefix = string.sub(self.context.cursor_line, self:get_offset(), self:get_offset() + diff) + local accept = false + accept = accept or string.match(prefix, '^[^%a]+$') + accept = accept or string.find(self:get_completion_item().textEdit.newText, prefix, 1, true) + if accept then + score, matches = matcher.match(input, prefix .. self:get_filter_text(), option) + end + end + end + end + + if self:get_filter_text() ~= self:get_completion_item().label then + _, matches = matcher.match(input, self:get_completion_item().label, { self:get_word() }) + end + + return { score = score, matches = matches } + end) +end + +---Get resolved completion item if possible. +---@return lsp.CompletionItem +entry.get_completion_item = function(self) + return self.cache:ensure({ 'get_completion_item', self.resolved_completion_item and 1 or 0 }, function() + if self.resolved_completion_item then + local completion_item = misc.copy(self.completion_item) + for k, v in pairs(self.resolved_completion_item) do + completion_item[k] = v or completion_item[k] + end + return completion_item + end + return self.completion_item + end) +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 + 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 + + 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:get_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 diff --git a/start/cmp/lua/cmp/entry_spec.lua b/start/cmp/lua/cmp/entry_spec.lua new file mode 100644 index 0000000..d01125c --- /dev/null +++ b/start/cmp/lua/cmp/entry_spec.lua @@ -0,0 +1,342 @@ +local spec = require('cmp.utils.spec') +local source = require('cmp.source') +local async = require('cmp.utils.async') + +local entry = require('cmp.entry') + +describe('entry', function() + before_each(spec.before) + + it('one char', function() + local state = spec.state('@.', 1, 3) + state.input('@') + local e = entry.new(state.manual(), 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) + state.input('.') + local e = entry.new(state.manual(), 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) + state.input('.') + local e = entry.new(state.manual(), 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) + state.input('.') + local e = entry.new(state.manual(), 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) + state.input('.') + local e = entry.new(state.manual(), 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) + state.input('.') + local e = entry.new(state.manual(), 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) + state.input('.') + local e = entry.new(state.manual(), 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) + state.input('.') + local e = entry.new(state.manual(), 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) + state.input('.') + local e = entry.new(state.manual(), 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 + state.input('g') + e = entry.new(state.manual(), 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 ' + state.input("'") + e = entry.new(state.manual(), 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 + state.input('g') + e = entry.new(state.manual(), 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 ' + state.input("'") + e = entry.new(state.manual(), 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 + state.input('$') + local e = entry.new(state.manual(), state.source(), { + 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) + + it('[odin-language-server] 1', function() + local state = spec.state('\t\t', 1, 4) + + -- press g + state.input('s') + local e = entry.new(state.manual(), state.source(), { + additionalTextEdits = {}, + command = { + arguments = {}, + command = '', + title = '', + }, + deprecated = false, + detail = 'string', + documentation = '', + insertText = '', + insertTextFormat = 1, + kind = 14, + label = 'string', + tags = {}, + }) + assert.are.equal(e:get_vim_item(e:get_offset()).word, 'string') + end) + + it('[ansiblels] 1', function() + local item = { + detail = 'ansible.builtin', + filterText = 'blockinfile ansible.builtin.blockinfile', + kind = 7, + label = 'blockinfile', + sortText = '2_blockinfile', + textEdit = { + newText = '', + range = { + ['end'] = { + character = 7, + line = 15, + }, + start = { + character = 6, + line = 15, + }, + }, + }, + } + local s = source.new('dummy', { + resolve = function(_, _, callback) + item.textEdit.newText = 'modified' + callback(item) + end, + }) + local e = entry.new(spec.state('', 1, 1).manual(), s, item) + assert.are.equal(e:get_vim_item(e:get_offset()).word, 'blockinfile') + async.sync(function(done) + e:resolve(done) + end, 100) + assert.are.equal(e:get_vim_item(e:get_offset()).word, 'blockinfile') + end) + + it('[#47] word should not contain \\n character', function() + local state = spec.state('', 1, 1) + + -- press g + state.input('_') + local e = entry.new(state.manual(), state.source(), { + kind = 6, + label = '__init__', + insertTextFormat = 1, + insertText = '__init__(self) -> None:\n pass', + }) + assert.are.equal(e:get_vim_item(e:get_offset()).word, '__init__(self) -> None:') + assert.are.equal(e:get_filter_text(), '__init__') + end) +end) diff --git a/start/cmp/lua/cmp/init.lua b/start/cmp/lua/cmp/init.lua new file mode 100644 index 0000000..06f5f41 --- /dev/null +++ b/start/cmp/lua/cmp/init.lua @@ -0,0 +1,336 @@ +local core = require('cmp.core') +local source = require('cmp.source') +local config = require('cmp.config') +local feedkeys = require('cmp.utils.feedkeys') +local autocmd = require('cmp.utils.autocmd') +local keymap = require('cmp.utils.keymap') +local misc = require('cmp.utils.misc') +local async = require('cmp.utils.async') + +local cmp = {} + +cmp.core = core.new() + +---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') + +---Expose event +cmp.event = cmp.core.event + +---Export mapping for special case +cmp.mapping = require('cmp.config.mapping') + +---Export default config presets +cmp.config = {} +cmp.config.disable = misc.none +cmp.config.compare = require('cmp.config.compare') +cmp.config.sources = require('cmp.config.sources') +cmp.config.mapping = require('cmp.config.mapping') +cmp.config.window = require('cmp.config.window') + +---Sync asynchronous process. +cmp.sync = function(callback) + return function(...) + cmp.core.filter:sync(1000) + if callback then + return callback(...) + end + end +end + +---Suspend completion. +cmp.suspend = function() + return cmp.core:suspend() +end + +---Register completion sources +---@param name string +---@param s cmp.Source +---@return number +cmp.register_source = function(name, s) + local src = source.new(name, s) + cmp.core:register_source(src) + return src.id +end + +---Unregister completion source +---@param id number +cmp.unregister_source = function(id) + cmp.core:unregister_source(id) +end + +---Get current configuration. +---@return cmp.ConfigSchema +cmp.get_config = function() + return require('cmp.config').get() +end + +---Invoke completion manually +---@param option cmp.CompleteParams +cmp.complete = cmp.sync(function(option) + option = option or {} + config.set_onetime(option.config) + cmp.core:complete(cmp.core:get_context({ reason = option.reason or cmp.ContextReason.Manual })) + return true +end) + +---Complete common string in current entries. +cmp.complete_common_string = cmp.sync(function() + return cmp.core:complete_common_string() +end) + +---Return view is visible or not. +cmp.visible = cmp.sync(function() + return cmp.core.view:visible() or vim.fn.pumvisible() == 1 +end) + +---Get current selected entry or nil +cmp.get_selected_entry = cmp.sync(function() + return cmp.core.view:get_selected_entry() +end) + +---Get current active entry or nil +cmp.get_active_entry = cmp.sync(function() + return cmp.core.view:get_active_entry() +end) + +---Get current all entries +cmp.get_entries = cmp.sync(function() + return cmp.core.view:get_entries() +end) + +---Close current completion +cmp.close = cmp.sync(function() + if cmp.core.view:visible() then + local release = cmp.core:suspend() + cmp.core.view:close() + cmp.core:reset() + vim.schedule(release) + return true + else + return false + end +end) + +---Abort current completion +cmp.abort = cmp.sync(function() + if cmp.core.view:visible() then + local release = cmp.core:suspend() + cmp.core.view:abort() + vim.schedule(release) + return true + else + return false + end +end) + +---Select next item if possible +cmp.select_next_item = cmp.sync(function(option) + option = option or {} + + if cmp.core.view:visible() then + local release = cmp.core:suspend() + cmp.core.view:select_next_item(option) + vim.schedule(release) + return true + elseif vim.fn.pumvisible() == 1 then + -- Special handling for native pum. Required to facilitate key mapping processing. + if (option.behavior or cmp.SelectBehavior.Insert) == cmp.SelectBehavior.Insert then + feedkeys.call(keymap.t('<C-n>'), 'in') + else + feedkeys.call(keymap.t('<Down>'), 'in') + end + return true + end + return false +end) + +---Select prev item if possible +cmp.select_prev_item = cmp.sync(function(option) + option = option or {} + + if cmp.core.view:visible() then + local release = cmp.core:suspend() + cmp.core.view:select_prev_item(option) + vim.schedule(release) + return true + elseif vim.fn.pumvisible() == 1 then + -- Special handling for native pum. Required to facilitate key mapping processing. + if (option.behavior or cmp.SelectBehavior.Insert) == cmp.SelectBehavior.Insert then + feedkeys.call(keymap.t('<C-p>'), 'in') + else + feedkeys.call(keymap.t('<Up>'), 'in') + end + return true + end + return false +end) + +---Scrolling documentation window if possible +cmp.scroll_docs = cmp.sync(function(delta) + if cmp.core.view:visible() then + cmp.core.view:scroll_docs(delta) + return true + else + return false + end +end) + +---Confirm completion +cmp.confirm = cmp.sync(function(option, callback) + option = option or {} + callback = callback or function() end + + local e = cmp.core.view:get_selected_entry() or (option.select and cmp.core.view:get_first_entry() or nil) + if e then + cmp.core:confirm(e, { + behavior = option.behavior, + }, function() + callback() + cmp.core:complete(cmp.core:get_context({ reason = cmp.ContextReason.TriggerOnly })) + end) + return true + else + -- Special handling for native puma. Required to facilitate key mapping processing. + if vim.fn.complete_info({ 'selected' }).selected ~= -1 then + feedkeys.call(keymap.t('<C-y>'), 'in') + return true + end + return false + end +end) + +---Show status +cmp.status = function() + local kinds = {} + kinds.available = {} + kinds.unavailable = {} + kinds.installed = {} + kinds.invalid = {} + local names = {} + for _, s in pairs(cmp.core.sources) do + names[s.name] = true + + if config.get_source_config(s.name) then + if s:is_available() then + table.insert(kinds.available, s:get_debug_name()) + else + table.insert(kinds.unavailable, s:get_debug_name()) + end + else + table.insert(kinds.installed, s:get_debug_name()) + end + end + for _, s in ipairs(config.get().sources) do + if not names[s.name] then + table.insert(kinds.invalid, s.name) + end + end + + if #kinds.available > 0 then + vim.api.nvim_echo({ { '\n', 'Normal' } }, false, {}) + vim.api.nvim_echo({ { '# ready source names\n', 'Special' } }, false, {}) + for _, name in ipairs(kinds.available) do + vim.api.nvim_echo({ { ('- %s\n'):format(name), 'Normal' } }, false, {}) + end + end + + if #kinds.unavailable > 0 then + vim.api.nvim_echo({ { '\n', 'Normal' } }, false, {}) + vim.api.nvim_echo({ { '# unavailable source names\n', 'Comment' } }, false, {}) + for _, name in ipairs(kinds.unavailable) do + vim.api.nvim_echo({ { ('- %s\n'):format(name), 'Normal' } }, false, {}) + end + end + + if #kinds.installed > 0 then + vim.api.nvim_echo({ { '\n', 'Normal' } }, false, {}) + vim.api.nvim_echo({ { '# unused source names\n', 'WarningMsg' } }, false, {}) + for _, name in ipairs(kinds.installed) do + vim.api.nvim_echo({ { ('- %s\n'):format(name), 'Normal' } }, false, {}) + end + end + + if #kinds.invalid > 0 then + vim.api.nvim_echo({ { '\n', 'Normal' } }, false, {}) + vim.api.nvim_echo({ { '# unknown source names\n', 'ErrorMsg' } }, false, {}) + for _, name in ipairs(kinds.invalid) do + vim.api.nvim_echo({ { ('- %s\n'):format(name), 'Normal' } }, false, {}) + end + end +end + +---@type cmp.Setup +cmp.setup = setmetatable({ + global = function(c) + config.set_global(c) + end, + filetype = function(filetype, c) + config.set_filetype(c, filetype) + end, + buffer = function(c) + config.set_buffer(c, vim.api.nvim_get_current_buf()) + end, + cmdline = function(type, c) + config.set_cmdline(c, type) + end, +}, { + __call = function(self, c) + self.global(c) + end, +}) + +-- In InsertEnter autocmd, vim will detects mode=normal unexpectedly. +local on_insert_enter = function() + if config.enabled() then + cmp.config.compare.scopes:update() + cmp.config.compare.locality:update() + cmp.core:prepare() + cmp.core:on_change('InsertEnter') + end +end +autocmd.subscribe({ 'InsertEnter', 'CmdlineEnter' }, async.debounce_next_tick(on_insert_enter)) + +-- async.throttle is needed for performance. The mapping `:<C-u>...<CR>` will fire `CmdlineChanged` for each character. +local on_text_changed = function() + if config.enabled() then + cmp.core:on_change('TextChanged') + end +end +autocmd.subscribe({ 'TextChangedI', 'TextChangedP' }, on_text_changed) +autocmd.subscribe('CmdlineChanged', async.debounce_next_tick(on_text_changed)) + +autocmd.subscribe('CursorMovedI', function() + if config.enabled() then + cmp.core:on_moved() + else + cmp.core:reset() + cmp.core.view:close() + end +end) + +-- If make this asynchronous, the completion menu will not close when the command output is displayed. +autocmd.subscribe({ 'InsertLeave', 'CmdlineLeave' }, function() + cmp.core:reset() + cmp.core.view:close() +end) + +cmp.event:on('complete_done', function(evt) + if evt.entry then + cmp.config.compare.recently_used:add_entry(evt.entry) + end + cmp.config.compare.scopes:update() + cmp.config.compare.locality:update() +end) + +cmp.event:on('confirm_done', function(evt) + if evt.entry then + cmp.config.compare.recently_used:add_entry(evt.entry) + end +end) + +return cmp diff --git a/start/cmp/lua/cmp/matcher.lua b/start/cmp/lua/cmp/matcher.lua new file mode 100644 index 0000000..7a22d9e --- /dev/null +++ b/start/cmp/lua/cmp/matcher.lua @@ -0,0 +1,324 @@ +local char = require('cmp.utils.char') + +local matcher = {} + +matcher.WORD_BOUNDALY_ORDER_FACTOR = 10 + +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` +-- +-- 7. Avoid false positive matching +-- +-- `,` -> print, +-- ~ +-- * Typically, the middle match with symbol characters only is false positive. should be ignored. +-- +-- +---Match entry +---@param input string +---@param word string +---@param option { synonyms: string[], disallow_fuzzy_matching: boolean, disallow_partial_matching: boolean, disallow_prefix_unmatching: boolean } +---@return number +matcher.match = function(input, word, option) + option = option or {} + + -- 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 + + -- Check prefix matching. + if option.disallow_prefix_unmatching then + if not char.match(string.byte(input, 1), string.byte(word, 1)) then + return 0, {} + end + 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 + local no_symbol_match = false + 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 + no_symbol_match = no_symbol_match or m.no_symbol_match + 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 + + -- Check partial matching. + if option.disallow_partial_matching and #matches > 1 then + return 0, {} + end + + if #matches == 0 then + return 0, {} + end + + matcher.debug(word, matches) + + -- Add prefix bonus + local prefix = false + if matches[1].input_match_start == 1 and matches[1].word_match_start == 1 then + prefix = true + else + for _, synonym in ipairs(option.synonyms or {}) do + prefix = true + local o = 1 + for i = matches[1].input_match_start, matches[1].input_match_end do + if not char.match(string.byte(synonym, o), string.byte(input, i)) then + prefix = false + break + end + o = o + 1 + end + if prefix then + break + end + end + end + + if no_symbol_match and not prefix then + return 0, {} + end + + -- Compute prefix match score + local score = prefix and matcher.PREFIX_FACTOR or 0 + local offset = prefix and matches[1].index - 1 or 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 + s = s * (1 + m.strict_ratio) + s = s * (1 + math.max(0, matcher.WORD_BOUNDALY_ORDER_FACTOR - (m.index - offset)) / matcher.WORD_BOUNDALY_ORDER_FACTOR) + score = score + s + end + end + + -- Check remaining input as fuzzy + if matches[#matches].input_match_end < #input then + if not option.disallow_fuzzy_matching then + if prefix and matcher.fuzzy(input, word, matches) then + return score, matches + end + end + return 0, {} + end + + return score + matcher.NOT_FUZZY_FACTOR, matches +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 + local input_match_start = -1 + local input_match_end = -1 + local word_match_start = -1 + local strict_count = 0 + local match_count = 0 + while word_offset + word_index <= #word and input_index <= #input do + local c1, c2 = string.byte(word, word_index + word_offset), string.byte(input, input_index) + if char.match(c1, c2) then + if not matched then + input_match_start = input_index + word_match_start = word_index + word_offset + end + matched = true + input_index = input_index + 1 + strict_count = strict_count + (c1 == c2 and 1 or 0) + match_count = match_count + 1 + elseif matched then + input_index = last_input_index + input_match_end = input_index - 1 + end + word_offset = word_offset + 1 + end + if input_index > #input then + table.insert(matches, { + input_match_start = input_match_start, + input_match_end = input_match_end, + word_match_start = word_match_start, + word_match_end = word_index + word_offset - 1, + strict_ratio = strict_count / match_count, + fuzzy = true, + }) + 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 input_match_start = -1 + local input_index = input_end_index + local word_offset = 0 + local strict_count = 0 + local match_count = 0 + local no_symbol_match = false + 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 + + strict_count = strict_count + (c1 == c2 and 1 or 0) + match_count = match_count + 1 + word_offset = word_offset + 1 + no_symbol_match = no_symbol_match or char.is_symbol(c1) + 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_ratio = strict_count / match_count, + no_symbol_match = no_symbol_match, + fuzzy = false, + } + 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_ratio = strict_count / match_count, + no_symbol_match = no_symbol_match, + fuzzy = false, + } + end + + return nil +end + +return matcher diff --git a/start/cmp/lua/cmp/matcher_spec.lua b/start/cmp/lua/cmp/matcher_spec.lua new file mode 100644 index 0000000..c95dfc3 --- /dev/null +++ b/start/cmp/lua/cmp/matcher_spec.lua @@ -0,0 +1,64 @@ +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('luacon', 'lua_context') > matcher.match('luacon', 'LuaContext')) + assert.is.truthy(matcher.match('fmodify', 'fnamemodify') >= 1) + assert.is.truthy(matcher.match('candlesingle', 'candle#accept#single') >= 1) + + assert.is.truthy(matcher.match('vi', 'void#') >= 1) + assert.is.truthy(matcher.match('vo', 'void#') >= 1) + assert.is.truthy(matcher.match('var_', 'var_dump') >= 1) + assert.is.truthy(matcher.match('conso', 'console') > matcher.match('conso', 'ConstantSourceNode')) + assert.is.truthy(matcher.match('usela', 'useLayoutEffect') > matcher.match('usela', 'useDataLayer')) + assert.is.truthy(matcher.match('my_', 'my_awesome_variable') > matcher.match('my_', 'completion_matching_strategy_list')) + assert.is.truthy(matcher.match('2', '[[2021') >= 1) + + assert.is.truthy(matcher.match(',', 'pri,') == 0) + assert.is.truthy(matcher.match('/', '/**') >= 1) + + assert.is.truthy(matcher.match('true', 'v:true', { synonyms = { 'true' } }) == matcher.match('true', 'true')) + assert.is.truthy(matcher.match('g', 'get', { synonyms = { 'get' } }) > matcher.match('g', 'dein#get', { 'dein#get' })) + end) + + it('disallow_fuzzy_matching', function() + assert.is.truthy(matcher.match('fmodify', 'fnamemodify', { disallow_fuzzy_matching = true }) == 0) + assert.is.truthy(matcher.match('fmodify', 'fnamemodify', { disallow_fuzzy_matching = false }) >= 1) + end) + + it('disallow_partial_matching', function() + assert.is.truthy(matcher.match('fb', 'foo_bar', { disallow_partial_matching = true }) == 0) + assert.is.truthy(matcher.match('fb', 'foo_bar', { disallow_partial_matching = false }) >= 1) + assert.is.truthy(matcher.match('fb', 'fboo_bar', { disallow_partial_matching = true }) >= 1) + assert.is.truthy(matcher.match('fb', 'fboo_bar', { disallow_partial_matching = false }) >= 1) + end) + + it('disallow_prefix_unmatching', function() + assert.is.truthy(matcher.match('bar', 'foo_bar', { disallow_prefix_unmatching = true }) == 0) + assert.is.truthy(matcher.match('bar', 'foo_bar', { disallow_prefix_unmatching = false }) >= 1) + end) + + it('debug', function() + matcher.debug = function(...) + print(vim.inspect({ ... })) + end + -- print(vim.inspect({ + -- a = matcher.match('true', 'v:true', { 'true' }), + -- b = matcher.match('true', 'true'), + -- })) + end) +end) diff --git a/start/cmp/lua/cmp/source.lua b/start/cmp/lua/cmp/source.lua new file mode 100644 index 0000000..bee0634 --- /dev/null +++ b/start/cmp/lua/cmp/source.lua @@ -0,0 +1,365 @@ +local context = require('cmp.context') +local config = require('cmp.config') +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') +local char = require('cmp.utils.char') + +---@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 incomplete boolean +---@field public is_triggered_by_symbol boolean +---@field public entries cmp.Entry[] +---@field public offset number +---@field public request_offset number +---@field public context cmp.Context +---@field public completion_context lsp.CompletionContext|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 + +---@return cmp.Source +source.new = function(name, s) + local self = setmetatable({}, { __index = source }) + self.id = misc.id('cmp.source.new') + 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.is_triggered_by_symbol = false + self.incomplete = false + self.entries = {} + self.offset = -1 + self.request_offset = -1 + self.completion_context = nil + self.status = source.SourceStatus.WAITING + self.complete_dedup(function() end) +end + +---Return source config +---@return cmp.SourceConfig +source.get_source_config = function(self) + return config.get_source_config(self.name) or {} +end + +---Return matching config +---@return cmp.MatchingConfig +source.get_matching_config = function() + return config.get().matching +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 self.offset == -1 then + return {} + end + + local target_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 self.entries + end)() + + local inputs = {} + local entries = {} + for _, e in ipairs(target_entries) do + local o = e:get_offset() + if not inputs[o] then + inputs[o] = string.sub(ctx.cursor_before_line, o) + end + + local match = e:match(inputs[o], self:get_matching_config()) + e.score = match.score + e.exact = false + if e.score >= 1 then + e.matches = match.matches + e.exact = e:get_filter_text() == inputs[o] or e:get_word() == inputs[o] + table.insert(entries, e) + end + end + self.cache:set({ 'get_entries', self.revision, ctx.cursor_before_line }, entries) + + local max_item_count = self:get_source_config().max_item_count or 200 + local limited_entries = {} + for _, e in ipairs(entries) do + table.insert(limited_entries, e) + if max_item_count and #limited_entries >= max_item_count then + break + end + end + return limited_entries +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 = misc.to_utfindex(self.context.cursor_line, self.offset), + }, + ['end'] = { + line = self.context.cursor.row - 1, + character = misc.to_utfindex(self.context.cursor_line, self.context.cursor.col), + }, + } + 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 = misc.to_utfindex(self.context.cursor_line, self.offset), + }, + ['end'] = { + line = self.context.cursor.row - 1, + character = misc.to_utfindex(self.context.cursor_line, e and self.offset + e - 1 or self.context.cursor.col), + }, + } + end) +end + +---Return source name. +source.get_debug_name = function(self) + local name = self.name + if self.source.get_debug_name then + name = self.source:get_debug_name() + end + return name +end + +---Return the source is available or not. +source.is_available = function(self) + if self.source.is_available then + return self.source:is_available() + end + return true +end + +---Get trigger_characters +---@return string[] +source.get_trigger_characters = function(self) + local c = self:get_source_config() + if c.trigger_characters then + return c.trigger_characters + end + + local trigger_characters = {} + if self.source.get_trigger_characters then + trigger_characters = self.source:get_trigger_characters(misc.copy(c)) or {} + end + if config.get().completion.get_trigger_characters then + return config.get().completion.get_trigger_characters(trigger_characters) + end + return trigger_characters +end + +---Get keyword_pattern +---@return string +source.get_keyword_pattern = function(self) + local c = self:get_source_config() + if c.keyword_pattern then + return c.keyword_pattern + end + if self.source.get_keyword_pattern then + return self.source:get_keyword_pattern(misc.copy(c)) + end + return config.get().completion.keyword_pattern +end + +---Get keyword_length +---@return number +source.get_keyword_length = function(self) + local c = self:get_source_config() + if c.keyword_length then + return c.keyword_length + end + return config.get().completion.keyword_length or 1 +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 offset = ctx:get_offset(self:get_keyword_pattern()) + + -- NOTE: This implementation is nvim-cmp specific. + -- We trigger new completion after core.confirm but we check only the symbol trigger_character in this case. + local before_char = string.sub(ctx.cursor_before_line, -1) + if ctx:get_reason() == types.cmp.ContextReason.TriggerOnly then + before_char = string.match(ctx.cursor_before_line, '(.)%s*$') + if not before_char or not char.is_symbol(string.byte(before_char)) then + before_char = '' + end + 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(), before_char) then + completion_context = { + triggerKind = types.lsp.CompletionTriggerKind.TriggerCharacter, + triggerCharacter = before_char, + } + elseif ctx:get_reason() ~= types.cmp.ContextReason.TriggerOnly then + if self:get_keyword_length() <= (ctx.cursor.col - offset) then + if self.incomplete and self.context.cursor.col ~= ctx.cursor.col and self.status ~= source.SourceStatus.FETCHING then + completion_context = { + triggerKind = types.lsp.CompletionTriggerKind.TriggerForIncompleteCompletions, + } + elseif not vim.tbl_contains({ self.request_offset, self.offset }, offset) then + completion_context = { + triggerKind = types.lsp.CompletionTriggerKind.Invoked, + } + end + else + self:reset() -- Should clear current completion if the TriggerKind isn't TriggerCharacter or Manual and keyword length does not enough. + end + else + self:reset() -- Should clear current completion if ContextReason is TriggerOnly and the triggerCharacter isn't matched + end + + -- Does not perform completions. + if not completion_context then + return + end + + if completion_context.triggerKind == types.lsp.CompletionTriggerKind.TriggerCharacter then + self.is_triggered_by_symbol = char.is_symbol(string.byte(completion_context.triggerCharacter)) + end + + debug.log(self:get_debug_name(), 'request', offset, vim.inspect(completion_context)) + local prev_status = self.status + self.status = source.SourceStatus.FETCHING + self.offset = offset + self.request_offset = offset + self.context = ctx + self.completion_context = completion_context + self.source:complete( + vim.tbl_extend('keep', misc.copy(self:get_source_config()), { + offset = self.offset, + context = ctx, + completion_context = completion_context, + }), + self.complete_dedup(vim.schedule_wrap(function(response) + response = response or {} + + self.incomplete = response.isIncomplete or false + + if #(response.items or response) > 0 then + debug.log(self:get_debug_name(), 'retrieve', #(response.items or response)) + local old_offset = self.offset + local old_entries = self.entries + + self.status = source.SourceStatus.COMPLETED + self.entries = {} + for i, item in ipairs(response.items or response) do + if (misc.safe(item) or {}).label then + local e = entry.new(ctx, self, item) + self.entries[i] = e + self.offset = math.min(self.offset, e:get_offset()) + end + end + self.revision = self.revision + 1 + if #self:get_entries(ctx) == 0 then + self.offset = old_offset + self.entries = old_entries + self.revision = self.revision + 1 + end + else + -- The completion will be invoked when pressing <CR> if the trigger characters contain the <Space>. + -- If the server returns an empty response in such a case, should invoke the keyword completion on the next keypress. + if offset == ctx.cursor.col then + self:reset() + end + 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 diff --git a/start/cmp/lua/cmp/source_spec.lua b/start/cmp/lua/cmp/source_spec.lua new file mode 100644 index 0000000..339149f --- /dev/null +++ b/start/cmp/lua/cmp/source_spec.lua @@ -0,0 +1,109 @@ +local config = require('cmp.config') +local spec = require('cmp.utils.spec') + +local source = require('cmp.source') + +describe('source', function() + before_each(spec.before) + + describe('keyword length', function() + it('not enough', function() + config.set_buffer({ + completion = { + keyword_length = 3, + }, + }, vim.api.nvim_get_current_buf()) + + local state = spec.state('', 1, 1) + local s = source.new('spec', { + complete = function(_, _, callback) + callback({ { label = 'spec' } }) + end, + }) + assert.is.truthy(not s:complete(state.input('a'), function() end)) + end) + + it('enough', function() + config.set_buffer({ + completion = { + keyword_length = 3, + }, + }, vim.api.nvim_get_current_buf()) + + local state = spec.state('', 1, 1) + local s = source.new('spec', { + complete = function(_, _, callback) + callback({ { label = 'spec' } }) + end, + }) + assert.is.truthy(s:complete(state.input('aiu'), function() end)) + end) + + it('enough -> not enough', function() + config.set_buffer({ + completion = { + keyword_length = 3, + }, + }, vim.api.nvim_get_current_buf()) + + local state = spec.state('', 1, 1) + local s = source.new('spec', { + complete = function(_, _, callback) + callback({ { label = 'spec' } }) + end, + }) + assert.is.truthy(s:complete(state.input('aiu'), function() end)) + assert.is.truthy(not s:complete(state.backspace(), function() end)) + end) + + it('continue', function() + config.set_buffer({ + completion = { + keyword_length = 3, + }, + }, vim.api.nvim_get_current_buf()) + + local state = spec.state('', 1, 1) + local s = source.new('spec', { + complete = function(_, _, callback) + callback({ { label = 'spec' } }) + end, + }) + assert.is.truthy(s:complete(state.input('aiu'), function() end)) + assert.is.truthy(not s:complete(state.input('eo'), function() end)) + end) + end) + + describe('isIncomplete', function() + it('isIncomplete=true', function() + local state = spec.state('', 1, 1) + local s = source.new('spec', { + complete = function(_, _, callback) + callback({ + items = { { label = 'spec' } }, + isIncomplete = true, + }) + end, + }) + vim.wait(100, function() + return s.status == source.SourceStatus.COMPLETED + end, 100, false) + assert.is.truthy(s:complete(state.input('s'), function() end)) + vim.wait(100, function() + return s.status == source.SourceStatus.COMPLETED + end, 100, false) + assert.is.truthy(s:complete(state.input('p'), function() end)) + vim.wait(100, function() + return s.status == source.SourceStatus.COMPLETED + end, 100, false) + assert.is.truthy(s:complete(state.input('e'), function() end)) + vim.wait(100, function() + return s.status == source.SourceStatus.COMPLETED + end, 100, false) + assert.is.truthy(s:complete(state.input('c'), function() end)) + vim.wait(100, function() + return s.status == source.SourceStatus.COMPLETED + end, 100, false) + end) + end) +end) diff --git a/start/cmp/lua/cmp/types/cmp.lua b/start/cmp/lua/cmp/types/cmp.lua new file mode 100644 index 0000000..8759aca --- /dev/null +++ b/start/cmp/lua/cmp/types/cmp.lua @@ -0,0 +1,166 @@ +local cmp = {} + +---@alias cmp.ConfirmBehavior 'insert' | 'replace' +cmp.ConfirmBehavior = { + Insert = 'insert', + Replace = 'replace', +} + +---@alias cmp.SelectBehavior 'insert' | 'select' +cmp.SelectBehavior = { + Insert = 'insert', + Select = 'select', +} + +---@alias cmp.ContextReason 'auto' | 'manual' 'triggerOnly' | 'none' +cmp.ContextReason = { + Auto = 'auto', + Manual = 'manual', + TriggerOnly = 'triggerOnly', + None = 'none', +} + +---@alias cmp.TriggerEvent 'InsertEnter' | 'TextChanged' +cmp.TriggerEvent = { + InsertEnter = 'InsertEnter', + TextChanged = 'TextChanged', +} + +---@alias cmp.PreselectMode 'item' | 'None' +cmp.PreselectMode = { + Item = 'item', + None = 'none', +} + +---@alias cmp.ItemField 'abbr' | 'kind' | 'menu' +cmp.ItemField = { + Abbr = 'abbr', + Kind = 'kind', + Menu = 'menu', +} + +---@class cmp.ContextOption +---@field public reason cmp.ContextReason|nil + +---@class cmp.ConfirmOption +---@field public behavior cmp.ConfirmBehavior +---@field public commit_character? string + +---@class cmp.SelectOption +---@field public behavior cmp.SelectBehavior + +---@class cmp.SnippetExpansionParams +---@field public body string +---@field public insert_text_mode number + +---@class cmp.CompleteParams +---@field public reason? cmp.ContextReason +---@field public config? cmp.ConfigSchema + +---@class cmp.Setup +---@field public __call fun(c: cmp.ConfigSchema) +---@field public buffer fun(c: cmp.ConfigSchema) +---@field public global fun(c: cmp.ConfigSchema) +---@field public cmdline fun(type: string, c: cmp.ConfigSchema) +---@field public filetype fun(type: string|string[], c: cmp.ConfigSchema) + +---@class cmp.SourceApiParams: cmp.SourceConfig + +---@class cmp.SourceCompletionApiParams : cmp.SourceConfig +---@field public offset number +---@field public context cmp.Context +---@field public completion_context lsp.CompletionContext + +---@class cmp.Mapping +---@field public i nil|function(fallback: function): void +---@field public c nil|function(fallback: function): void +---@field public x nil|function(fallback: function): void +---@field public s nil|function(fallback: function): void + +---@class cmp.ConfigSchema +---@field private revision number +---@field public enabled fun():boolean|boolean +---@field public preselect cmp.PreselectMode +---@field public completion cmp.CompletionConfig +---@field public window cmp.WindowConfig|nil +---@field public confirmation cmp.ConfirmationConfig +---@field public matching cmp.MatchingConfig +---@field public sorting cmp.SortingConfig +---@field public formatting cmp.FormattingConfig +---@field public snippet cmp.SnippetConfig +---@field public mapping table<string, cmp.Mapping> +---@field public sources cmp.SourceConfig[] +---@field public view cmp.ViewConfig +---@field public experimental cmp.ExperimentalConfig + +---@class cmp.WindowConfig +---@field completion cmp.WindowConfig +---@field documentation cmp.WindowConfig|nil + +---@class cmp.CompletionConfig +---@field public autocomplete cmp.TriggerEvent[] +---@field public completeopt string +---@field public get_trigger_characters fun(trigger_characters: string[]): string[] +---@field public keyword_length number +---@field public keyword_pattern string + +---@class cmp.WindowConfig +---@field public border string|string[] +---@field public winhighlight string +---@field public zindex number|nil +---@field public max_width number|nil +---@field public max_height number|nil + +---@class cmp.ConfirmationConfig +---@field public default_behavior cmp.ConfirmBehavior +---@field public get_commit_characters fun(commit_characters: string[]): string[] + +---@class cmp.MatchingConfig +---@field public disallow_fuzzy_matching boolean +---@field public disallow_partial_matching boolean +---@field public disallow_prefix_unmatching boolean + +---@class cmp.SortingConfig +---@field public priority_weight number +---@field public comparators function[] + +---@class cmp.FormattingConfig +---@field public fields cmp.ItemField[] +---@field public format fun(entry: cmp.Entry, vim_item: vim.CompletedItem): vim.CompletedItem + +---@class cmp.SnippetConfig +---@field public expand fun(args: cmp.SnippetExpansionParams) + +---@class cmp.ExperimentalConfig +---@field public ghost_text cmp.GhostTextConfig|false + +---@class cmp.GhostTextConfig +---@field hl_group string + +---@class cmp.SourceConfig +---@field public name string +---@field public option table|nil +---@field public priority number|nil +---@field public trigger_characters string[]|nil +---@field public keyword_pattern string|nil +---@field public keyword_length number|nil +---@field public max_item_count number|nil +---@field public group_index number|nil + +---@class cmp.ViewConfig +---@field public entries cmp.EntriesConfig + +---@alias cmp.EntriesConfig cmp.CustomEntriesConfig|cmp.NativeEntriesConfig|cmp.WildmenuEntriesConfig|string + +---@class cmp.CustomEntriesConfig +---@field name 'custom' +---@field selection_order 'top_down'|'near_cursor' + +---@class cmp.NativeEntriesConfig +---@field name 'native' + +---@class cmp.WildmenuEntriesConfig +---@field name 'wildmenu' +---@field separator string|nil + +return cmp diff --git a/start/cmp/lua/cmp/types/init.lua b/start/cmp/lua/cmp/types/init.lua new file mode 100644 index 0000000..c4f601e --- /dev/null +++ b/start/cmp/lua/cmp/types/init.lua @@ -0,0 +1,7 @@ +local types = {} + +types.cmp = require('cmp.types.cmp') +types.lsp = require('cmp.types.lsp') +types.vim = require('cmp.types.vim') + +return types diff --git a/start/cmp/lua/cmp/types/lsp.lua b/start/cmp/lua/cmp/types/lsp.lua new file mode 100644 index 0000000..4af54a9 --- /dev/null +++ b/start/cmp/lua/cmp/types/lsp.lua @@ -0,0 +1,197 @@ +local misc = require('cmp.utils.misc') + +---@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 + 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 + return { + row = position.line + 1, + col = misc.to_vimindex(lines[1], position.character), + } + end + return { + row = position.line + 1, + col = position.character + 1, + } + end, + ---Convert vim.Position to lsp.Position + ---@param buf number|string + ---@param position vim.Position + ---@return 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 = misc.to_utfindex(lines[1], position.col), + } + end + return { + line = position.row - 1, + character = position.col - 1, + } + end, +} + +lsp.Range = { + ---Convert lsp.Range to vim.Range + ---@param buf number|string + ---@param range lsp.Range + ---@return vim.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 vim.Range to lsp.Range + ---@param buf number|string + ---@param range vim.Range + ---@return 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 = { + Invoked = 1, + TriggerCharacter = 2, + TriggerForIncompleteCompletions = 3, +} + +---@alias lsp.InsertTextFormat 1 | 2 +lsp.InsertTextFormat = {} +lsp.InsertTextFormat.PlainText = 1 +lsp.InsertTextFormat.Snippet = 2 + +---@alias lsp.InsertTextMode 1 | 2 +lsp.InsertTextMode = { + AsIs = 1, + AdjustIndentation = 2, +} + +---@alias lsp.MarkupKind 'plaintext' | 'markdown' +lsp.MarkupKind = { + PlainText = 'plaintext', + Markdown = 'markdown', +} + +---@alias lsp.CompletionItemTag 1 +lsp.CompletionItemTag = { + Deprecated = 1, +} + +---@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 = { + Text = 1, + Method = 2, + Function = 3, + Constructor = 4, + Field = 5, + Variable = 6, + Class = 7, + Interface = 8, + Module = 9, + Property = 10, + Unit = 11, + Value = 12, + Enum = 13, + Keyword = 14, + Snippet = 15, + Color = 16, + File = 17, + Reference = 18, + Folder = 19, + EnumMember = 20, + Constant = 21, + Struct = 22, + Event = 23, + Operator = 24, + TypeParameter = 25, +} +lsp.CompletionItemKind = vim.tbl_add_reverse_lookup(lsp.CompletionItemKind) + +---@class lsp.CompletionContext +---@field public triggerKind lsp.CompletionTriggerKind +---@field public triggerCharacter string|nil + +---@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 detail string|nil +---@field public description 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 diff --git a/start/cmp/lua/cmp/types/lsp_spec.lua b/start/cmp/lua/cmp/types/lsp_spec.lua new file mode 100644 index 0000000..fc2b857 --- /dev/null +++ b/start/cmp/lua/cmp/types/lsp_spec.lua @@ -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 + + local bufnr = vim.api.nvim_get_current_buf() + vim_position = lsp.Position.to_vim(bufnr, { line = 1, character = 3 }) + assert.are.equal(vim_position.row, 2) + assert.are.equal(vim_position.col, 10) + lsp_position = lsp.Position.to_lsp(bufnr, vim_position) + assert.are.equal(lsp_position.line, 1) + assert.are.equal(lsp_position.character, 3) + + vim_position = lsp.Position.to_vim(bufnr, { line = 1, character = 0 }) + assert.are.equal(vim_position.row, 2) + assert.are.equal(vim_position.col, 1) + lsp_position = lsp.Position.to_lsp(bufnr, vim_position) + assert.are.equal(lsp_position.line, 1) + assert.are.equal(lsp_position.character, 0) + + vim_position = lsp.Position.to_vim(bufnr, { line = 1, character = 5 }) + assert.are.equal(vim_position.row, 2) + assert.are.equal(vim_position.col, 16) + lsp_position = lsp.Position.to_lsp(bufnr, 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(bufnr, { 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(bufnr, vim_position) + assert.are.equal(lsp_position.line, 1) + assert.are.equal(lsp_position.character, 5) + end) +end) diff --git a/start/cmp/lua/cmp/types/vim.lua b/start/cmp/lua/cmp/types/vim.lua new file mode 100644 index 0000000..95e757d --- /dev/null +++ b/start/cmp/lua/cmp/types/vim.lua @@ -0,0 +1,20 @@ +---@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 +---@field public abbr_hl_group string|nil +---@field public kind_hl_group string|nil +---@field public menu_hl_group string|nil + +---@class vim.Position +---@field public row number +---@field public col number + +---@class vim.Range +---@field public start vim.Position +---@field public end vim.Position diff --git a/start/cmp/lua/cmp/utils/api.lua b/start/cmp/lua/cmp/utils/api.lua new file mode 100644 index 0000000..d053409 --- /dev/null +++ b/start/cmp/lua/cmp/utils/api.lua @@ -0,0 +1,69 @@ +local api = {} + +local CTRL_V = vim.api.nvim_replace_termcodes('<C-v>', true, true, true) +local CTRL_S = vim.api.nvim_replace_termcodes('<C-s>', true, true, true) + +api.get_mode = function() + local mode = vim.api.nvim_get_mode().mode:sub(1, 1) + if mode == 'i' then + return 'i' -- insert + elseif mode == 'v' or mode == 'V' or mode == CTRL_V then + return 'x' -- visual + elseif mode == 's' or mode == 'S' or mode == CTRL_S then + return 's' -- select + elseif mode == 'c' and vim.fn.getcmdtype() ~= '=' then + return 'c' -- cmdline + end +end + +api.is_insert_mode = function() + return api.get_mode() == 'i' +end + +api.is_cmdline_mode = function() + return api.get_mode() == 'c' +end + +api.is_select_mode = function() + return api.get_mode() == 's' +end + +api.is_visual_mode = function() + return api.get_mode() == 'x' +end + +api.is_suitable_mode = function() + local mode = api.get_mode() + return mode == 'i' or mode == 'c' +end + +api.get_current_line = function() + if api.is_cmdline_mode() then + return vim.fn.getcmdline() + end + return vim.api.nvim_get_current_line() +end + +api.get_cursor = function() + if api.is_cmdline_mode() then + return { vim.o.lines - (vim.api.nvim_get_option('cmdheight') or 1) + 1, vim.fn.getcmdpos() - 1 } + end + return vim.api.nvim_win_get_cursor(0) +end + +api.get_screen_cursor = function() + if api.is_cmdline_mode() then + local cursor = api.get_cursor() + return { cursor[1], cursor[2] + 1 } + end + local cursor = api.get_cursor() + local pos = vim.fn.screenpos(0, cursor[1], cursor[2] + 1) + return { pos.row, pos.col - 1 } +end + +api.get_cursor_before_line = function() + local cursor = api.get_cursor() + return string.sub(api.get_current_line(), 1, cursor[2]) +end + +return api diff --git a/start/cmp/lua/cmp/utils/api_spec.lua b/start/cmp/lua/cmp/utils/api_spec.lua new file mode 100644 index 0000000..5363b48 --- /dev/null +++ b/start/cmp/lua/cmp/utils/api_spec.lua @@ -0,0 +1,46 @@ +local spec = require('cmp.utils.spec') +local keymap = require('cmp.utils.keymap') +local feedkeys = require('cmp.utils.feedkeys') +local api = require('cmp.utils.api') + +describe('api', function() + describe('get_cursor', function() + before_each(spec.before) + it('insert-mode', function() + local cursor + feedkeys.call(keymap.t('i\t1234567890'), 'nx', function() + cursor = api.get_cursor() + end) + assert.are.equal(cursor[2], 11) + end) + it('cmdline-mode', function() + local cursor + keymap.set_map(0, 'c', '<Plug>(cmp-spec-spy)', function() + cursor = api.get_cursor() + end, { expr = true, noremap = true }) + feedkeys.call(keymap.t(':\t1234567890'), 'n') + feedkeys.call(keymap.t('<Plug>(cmp-spec-spy)'), 'x') + assert.are.equal(cursor[2], 11) + end) + end) + + describe('get_cursor_before_line', function() + before_each(spec.before) + it('insert-mode', function() + local cursor_before_line + feedkeys.call(keymap.t('i\t1234567890<Left><Left>'), 'nx', function() + cursor_before_line = api.get_cursor_before_line() + end) + assert.are.same(cursor_before_line, '\t12345678') + end) + it('cmdline-mode', function() + local cursor_before_line + keymap.set_map(0, 'c', '<Plug>(cmp-spec-spy)', function() + cursor_before_line = api.get_cursor_before_line() + end, { expr = true, noremap = true }) + feedkeys.call(keymap.t(':\t1234567890<Left><Left>'), 'n') + feedkeys.call(keymap.t('<Plug>(cmp-spec-spy)'), 'x') + assert.are.same(cursor_before_line, '\t12345678') + end) + end) +end) diff --git a/start/cmp/lua/cmp/utils/async.lua b/start/cmp/lua/cmp/utils/async.lua new file mode 100644 index 0000000..13f126b --- /dev/null +++ b/start/cmp/lua/cmp/utils/async.lua @@ -0,0 +1,127 @@ +local async = {} + +---@class cmp.AsyncThrottle +---@field public running boolean +---@field public timeout number +---@field public sync function(self: cmp.AsyncThrottle, timeout: number|nil) +---@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({ + running = false, + timeout = timeout, + sync = function(self, timeout_) + vim.wait(timeout_ or 1000, function() + return not self.running + end) + end, + stop = function() + time = nil + timer:stop() + end, + }, { + __call = function(self, ...) + local args = { ... } + + if time == nil then + time = vim.loop.now() + end + + self.running = true + timer:stop() + timer:start(math.max(1, self.timeout - (vim.loop.now() - time)), 0, function() + vim.schedule(function() + time = nil + fn(unpack(args)) + self.running = false + end) + end) + end, + }) +end + +---Control async tasks. +async.step = function(...) + local tasks = { ... } + local next + next = function(...) + if #tasks > 0 then + table.remove(tasks, 1)(next, ...) + end + end + table.remove(tasks, 1)(next) +end + +---Timeout callback function +---@param fn function +---@param timeout number +---@return function +async.timeout = function(fn, timeout) + local timer + local done = false + local callback = function(...) + if not done then + done = true + timer:stop() + timer:close() + fn(...) + end + end + timer = vim.loop.new_timer() + timer:start(timeout, 0, function() + callback() + end) + return callback +end + +---@alias cmp.AsyncDedup fun(callback: function): function + +---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 + +---Convert async process as sync +async.sync = function(runner, timeout) + local done = false + runner(function() + done = true + end) + vim.wait(timeout, function() + return done + end, 10, false) +end + +---Wait and callback for next safe state. +async.debounce_next_tick = function(callback) + local running = false + return function() + if running then + return + end + running = true + vim.schedule(function() + running = false + callback() + end) + end +end + +return async diff --git a/start/cmp/lua/cmp/utils/async_spec.lua b/start/cmp/lua/cmp/utils/async_spec.lua new file mode 100644 index 0000000..62f5379 --- /dev/null +++ b/start/cmp/lua/cmp/utils/async_spec.lua @@ -0,0 +1,69 @@ +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) + it('step', function() + local done = false + local step = {} + async.step(function(next) + vim.defer_fn(function() + table.insert(step, 1) + next() + end, 10) + end, function(next) + vim.defer_fn(function() + table.insert(step, 2) + next() + end, 10) + end, function(next) + vim.defer_fn(function() + table.insert(step, 3) + next() + end, 10) + end, function() + done = true + end) + vim.wait(1000, function() + return done + end) + assert.are.same(step, { 1, 2, 3 }) + end) +end) diff --git a/start/cmp/lua/cmp/utils/autocmd.lua b/start/cmp/lua/cmp/utils/autocmd.lua new file mode 100644 index 0000000..438e231 --- /dev/null +++ b/start/cmp/lua/cmp/utils/autocmd.lua @@ -0,0 +1,53 @@ +local debug = require('cmp.utils.debug') + +local autocmd = {} + +autocmd.group = vim.api.nvim_create_augroup('___cmp___', { clear = true }) + +autocmd.events = {} + +---Subscribe autocmd +---@param events string|string[] +---@param callback function +---@return function +autocmd.subscribe = function(events, callback) + events = type(events) == 'string' and { events } or events + + for _, event in ipairs(events) do + if not autocmd.events[event] then + autocmd.events[event] = {} + vim.api.nvim_create_autocmd(event, { + desc = ('nvim-cmp: autocmd: %s'):format(event), + group = autocmd.group, + callback = function() + autocmd.emit(event) + end, + }) + end + table.insert(autocmd.events[event], callback) + end + + return function() + for _, event in ipairs(events) do + for i, callback_ in ipairs(autocmd.events[event]) do + if callback_ == callback then + table.remove(autocmd.events[event], i) + break + end + end + end + end +end + +---Emit autocmd +---@param event string +autocmd.emit = function(event) + debug.log(' ') + 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 diff --git a/start/cmp/lua/cmp/utils/binary.lua b/start/cmp/lua/cmp/utils/binary.lua new file mode 100644 index 0000000..c6a7088 --- /dev/null +++ b/start/cmp/lua/cmp/utils/binary.lua @@ -0,0 +1,33 @@ +local binary = {} + +---Insert item to list to ordered index +---@param list any[] +---@param item any +---@param func fun(a: any, b: any): 1|-1|0 +binary.insort = function(list, item, func) + table.insert(list, binary.search(list, item, func), item) +end + +---Search suitable index from list +---@param list any[] +---@param item any +---@param func fun(a: any, b: any): 1|-1|0 +---@return number +binary.search = function(list, item, func) + local s = 1 + local e = #list + while s <= e do + local idx = math.floor((e + s) / 2) + local diff = func(item, list[idx]) + if diff > 0 then + s = idx + 1 + elseif diff < 0 then + e = idx - 1 + else + return idx + 1 + end + end + return s +end + +return binary diff --git a/start/cmp/lua/cmp/utils/binary_spec.lua b/start/cmp/lua/cmp/utils/binary_spec.lua new file mode 100644 index 0000000..92fe129 --- /dev/null +++ b/start/cmp/lua/cmp/utils/binary_spec.lua @@ -0,0 +1,28 @@ +local binary = require('cmp.utils.binary') + +describe('utils.binary', function() + it('insort', function() + local func = function(a, b) + return a.score - b.score + end + local list = {} + binary.insort(list, { id = 'a', score = 1 }, func) + binary.insort(list, { id = 'b', score = 5 }, func) + binary.insort(list, { id = 'c', score = 2.5 }, func) + binary.insort(list, { id = 'd', score = 2 }, func) + binary.insort(list, { id = 'e', score = 8 }, func) + binary.insort(list, { id = 'g', score = 8 }, func) + binary.insort(list, { id = 'h', score = 7 }, func) + binary.insort(list, { id = 'i', score = 6 }, func) + binary.insort(list, { id = 'j', score = 4 }, func) + assert.are.equal(list[1].id, 'a') + assert.are.equal(list[2].id, 'd') + assert.are.equal(list[3].id, 'c') + assert.are.equal(list[4].id, 'j') + assert.are.equal(list[5].id, 'b') + assert.are.equal(list[6].id, 'i') + assert.are.equal(list[7].id, 'h') + assert.are.equal(list[8].id, 'e') + assert.are.equal(list[9].id, 'g') + end) +end) diff --git a/start/cmp/lua/cmp/utils/buffer.lua b/start/cmp/lua/cmp/utils/buffer.lua new file mode 100644 index 0000000..63171c9 --- /dev/null +++ b/start/cmp/lua/cmp/utils/buffer.lua @@ -0,0 +1,28 @@ +local buffer = {} + +buffer.cache = {} + +---@return number buf +buffer.get = function(name) + local buf = buffer.cache[name] + if buf and vim.api.nvim_buf_is_valid(buf) then + return buf + else + return nil + end +end + +---@return number buf +---@return boolean created_new +buffer.ensure = function(name) + local created_new = false + local buf = buffer.get(name) + if not buf then + created_new = true + buf = vim.api.nvim_create_buf(false, true) + buffer.cache[name] = buf + end + return buf, created_new +end + +return buffer diff --git a/start/cmp/lua/cmp/utils/cache.lua b/start/cmp/lua/cmp/utils/cache.lua new file mode 100644 index 0000000..8607b2a --- /dev/null +++ b/start/cmp/lua/cmp/utils/cache.lua @@ -0,0 +1,58 @@ +---@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 self.entries[key] + end + return nil +end + +---Set cache value explicitly +---@param key string +---@vararg any +cache.set = function(self, key, value) + key = self:key(key) + self.entries[key] = value +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 + local v = callback() + self:set(key, v) + return v + end + return value +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 diff --git a/start/cmp/lua/cmp/utils/char.lua b/start/cmp/lua/cmp/utils/char.lua new file mode 100644 index 0000000..6e18994 --- /dev/null +++ b/start/cmp/lua/cmp/utils/char.lua @@ -0,0 +1,117 @@ +local _ + +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 + if not char.is_digit(prev) and char.is_digit(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 diff --git a/start/cmp/lua/cmp/utils/debug.lua b/start/cmp/lua/cmp/utils/debug.lua new file mode 100644 index 0000000..c8b0dba --- /dev/null +++ b/start/cmp/lua/cmp/utils/debug.lua @@ -0,0 +1,20 @@ +local debug = {} + +debug.flag = false + +---Print log +---@vararg any +debug.log = function(...) + if debug.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 diff --git a/start/cmp/lua/cmp/utils/event.lua b/start/cmp/lua/cmp/utils/event.lua new file mode 100644 index 0000000..662d573 --- /dev/null +++ b/start/cmp/lua/cmp/utils/event.lua @@ -0,0 +1,51 @@ +---@class cmp.Event +---@field private events table<string, function[]> +local event = {} + +---Create vents +event.new = function() + local self = setmetatable({}, { __index = event }) + self.events = {} + return self +end + +---Add event listener +---@param name string +---@param callback function +---@return function +event.on = function(self, name, callback) + if not self.events[name] then + self.events[name] = {} + end + table.insert(self.events[name], callback) + return function() + self:off(name, callback) + end +end + +---Remove event listener +---@param name string +---@param callback function +event.off = function(self, name, callback) + for i, callback_ in ipairs(self.events[name] or {}) do + if callback_ == callback then + table.remove(self.events[name], i) + break + end + end +end + +---Remove all events +event.clear = function(self) + self.events = {} +end + +---Emit event +---@param name string +event.emit = function(self, name, ...) + for _, callback in ipairs(self.events[name] or {}) do + callback(...) + end +end + +return event diff --git a/start/cmp/lua/cmp/utils/feedkeys.lua b/start/cmp/lua/cmp/utils/feedkeys.lua new file mode 100644 index 0000000..cd20f60 --- /dev/null +++ b/start/cmp/lua/cmp/utils/feedkeys.lua @@ -0,0 +1,53 @@ +local keymap = require('cmp.utils.keymap') +local misc = require('cmp.utils.misc') + +local feedkeys = {} + +feedkeys.call = setmetatable({ + callbacks = {}, +}, { + __call = function(self, keys, mode, callback) + local is_insert = string.match(mode, 'i') ~= nil + local is_immediate = string.match(mode, 'x') ~= nil + + local queue = {} + if #keys > 0 then + table.insert(queue, { keymap.t('<Cmd>setlocal lazyredraw<CR>'), 'n' }) + table.insert(queue, { keymap.t('<Cmd>setlocal textwidth=0<CR>'), 'n' }) + table.insert(queue, { keymap.t('<Cmd>setlocal backspace=2<CR>'), 'n' }) + table.insert(queue, { keys, string.gsub(mode, '[itx]', ''), true }) + table.insert(queue, { keymap.t('<Cmd>setlocal %slazyredraw<CR>'):format(vim.o.lazyredraw and '' or 'no'), 'n' }) + table.insert(queue, { keymap.t('<Cmd>setlocal textwidth=%s<CR>'):format(vim.bo.textwidth or 0), 'n' }) + table.insert(queue, { keymap.t('<Cmd>setlocal backspace=%s<CR>'):format(vim.go.backspace or 2), 'n' }) + end + + if callback then + local id = misc.id('cmp.utils.feedkeys.call') + self.callbacks[id] = callback + table.insert(queue, { keymap.t('<Cmd>call v:lua.cmp.utils.feedkeys.call.run(%s)<CR>'):format(id), 'n', true }) + end + + if is_insert then + for i = #queue, 1, -1 do + vim.api.nvim_feedkeys(queue[i][1], queue[i][2] .. 'i', queue[i][3]) + end + else + for i = 1, #queue do + vim.api.nvim_feedkeys(queue[i][1], queue[i][2], queue[i][3]) + end + end + + if is_immediate then + vim.api.nvim_feedkeys('', 'x', true) + end + end, +}) +misc.set(_G, { 'cmp', 'utils', 'feedkeys', 'call', 'run' }, function(id) + if feedkeys.call.callbacks[id] then + feedkeys.call.callbacks[id]() + feedkeys.call.callbacks[id] = nil + end + return '' +end) + +return feedkeys diff --git a/start/cmp/lua/cmp/utils/feedkeys_spec.lua b/start/cmp/lua/cmp/utils/feedkeys_spec.lua new file mode 100644 index 0000000..24fba71 --- /dev/null +++ b/start/cmp/lua/cmp/utils/feedkeys_spec.lua @@ -0,0 +1,56 @@ +local spec = require('cmp.utils.spec') +local keymap = require('cmp.utils.keymap') + +local feedkeys = require('cmp.utils.feedkeys') + +describe('feedkeys', function() + before_each(spec.before) + + it('dot-repeat', function() + local reg + feedkeys.call(keymap.t('iaiueo<Esc>'), 'nx', function() + reg = vim.fn.getreg('.') + end) + assert.are.equal(reg, keymap.t('aiueo')) + end) + + it('textwidth', function() + vim.cmd([[setlocal textwidth=6]]) + feedkeys.call(keymap.t('iaiueo '), 'nx') + feedkeys.call(keymap.t('aaiueoaiueo'), 'nx') + assert.are.same(vim.api.nvim_buf_get_lines(0, 0, -1, false), { + 'aiueo aiueoaiueo', + }) + end) + + it('bacckspace', function() + vim.cmd([[setlocal backspace=0]]) + feedkeys.call(keymap.t('iaiueo'), 'nx') + feedkeys.call(keymap.t('a<BS><BS>'), 'nx') + assert.are.same(vim.api.nvim_buf_get_lines(0, 0, -1, false), { + 'aiu', + }) + end) + + it('testability', function() + feedkeys.call('i', 'n', function() + feedkeys.call('', 'n', function() + feedkeys.call('aiueo', 'in') + end) + feedkeys.call('', 'n', function() + feedkeys.call(keymap.t('<BS><BS><BS><BS><BS>'), 'in') + end) + feedkeys.call('', 'n', function() + feedkeys.call(keymap.t('abcde'), 'in') + end) + feedkeys.call('', 'n', function() + feedkeys.call(keymap.t('<BS><BS><BS><BS><BS>'), 'in') + end) + feedkeys.call('', 'n', function() + feedkeys.call(keymap.t('12345'), 'in') + end) + end) + feedkeys.call('', 'x') + assert.are.same(vim.api.nvim_buf_get_lines(0, 0, -1, false), { '12345' }) + end) +end) diff --git a/start/cmp/lua/cmp/utils/highlight.lua b/start/cmp/lua/cmp/utils/highlight.lua new file mode 100644 index 0000000..867632a --- /dev/null +++ b/start/cmp/lua/cmp/utils/highlight.lua @@ -0,0 +1,31 @@ +local highlight = {} + +highlight.keys = { + 'fg', + 'bg', + 'bold', + 'italic', + 'reverse', + 'standout', + 'underline', + 'undercurl', + 'strikethrough', +} + +highlight.inherit = function(name, source, settings) + for _, key in ipairs(highlight.keys) do + if not settings[key] then + local v = vim.fn.synIDattr(vim.fn.hlID(source), key) + if key == 'fg' or key == 'bg' then + local n = tonumber(v, 10) + v = type(n) == 'number' and n or v + else + v = v == 1 + end + settings[key] = v == '' and 'NONE' or v + end + end + vim.api.nvim_set_hl(0, name, settings) +end + +return highlight diff --git a/start/cmp/lua/cmp/utils/keymap.lua b/start/cmp/lua/cmp/utils/keymap.lua new file mode 100644 index 0000000..aea5c1d --- /dev/null +++ b/start/cmp/lua/cmp/utils/keymap.lua @@ -0,0 +1,251 @@ +local misc = require('cmp.utils.misc') +local buffer = require('cmp.utils.buffer') +local api = require('cmp.utils.api') + +local keymap = {} + +---Shortcut for nvim_replace_termcodes +---@param keys string +---@return string +keymap.t = function(keys) + return (string.gsub(keys, '(<[A-Za-z0-9\\%-%[%]%^@]->)', function(match) + return vim.api.nvim_eval(string.format([["\%s"]], match)) + end)) +end + +---Normalize key sequence. +---@param keys string +---@return string +keymap.normalize = function(keys) + local normalize_buf = buffer.ensure('cmp.util.keymap.normalize') + vim.api.nvim_buf_set_keymap(normalize_buf, 't', keys, '<Plug>(cmp.utils.keymap.normalize)', {}) + for _, map in ipairs(vim.api.nvim_buf_get_keymap(normalize_buf, 't')) do + if keymap.equals(map.rhs, '<Plug>(cmp.utils.keymap.normalize)') then + vim.api.nvim_buf_del_keymap(normalize_buf, 't', keys) + return map.lhs + end + end + vim.api.nvim_buf_del_keymap(normalize_buf, 't', keys) + return keys +end + +---Return vim notation keymapping (simple conversion). +---@param s string +---@return string +keymap.to_keymap = setmetatable({ + ['<CR>'] = { '\n', '\r', '\r\n' }, + ['<Tab>'] = { '\t' }, + ['<BSlash>'] = { '\\' }, + ['<Bar>'] = { '|' }, + ['<Space>'] = { ' ' }, +}, { + __call = function(self, s) + return string.gsub(s, '.', function(c) + for key, chars in pairs(self) do + if vim.tbl_contains(chars, c) then + return key + end + end + return c + end) + end, +}) + +---Mode safe break undo +keymap.undobreak = function() + if not api.is_insert_mode() then + return '' + end + return keymap.t('<C-g>u') +end + +---Mode safe join undo +keymap.undojoin = function() + if not api.is_insert_mode() then + return '' + end + return keymap.t('<C-g>U') +end + +---Create backspace keys. +---@param count number +---@return string +keymap.backspace = function(count) + if type(count) == 'string' then + count = vim.fn.strchars(count, true) + end + if count <= 0 then + return '' + end + local keys = {} + table.insert(keys, keymap.t(string.rep('<BS>', count))) + return table.concat(keys, '') +end + +---Update indentkeys. +---@param expr string +---@return string +keymap.indentkeys = function(expr) + return string.format(keymap.t('<Cmd>set indentkeys=%s<CR>'), expr and vim.fn.escape(expr, '| \t\\') or '') +end + +---Return two key sequence are equal or not. +---@param a string +---@param b string +---@return boolean +keymap.equals = function(a, b) + return keymap.t(a) == keymap.t(b) +end + +---Register keypress handler. +keymap.listen = function(mode, lhs, callback) + lhs = keymap.normalize(keymap.to_keymap(lhs)) + + local existing = keymap.get_map(mode, lhs) + local id = string.match(existing.rhs, 'v:lua%.cmp%.utils%.keymap%.set_map%((%d+)%)') + if id and keymap.set_map.callbacks[tonumber(id, 10)] then + return + end + + local bufnr = existing.buffer and vim.api.nvim_get_current_buf() or -1 + local fallback = keymap.fallback(bufnr, mode, existing) + keymap.set_map(bufnr, mode, lhs, function() + local ignore = false + ignore = ignore or (mode == 'c' and vim.fn.getcmdtype() == '=') + if ignore then + fallback() + else + callback(lhs, misc.once(fallback)) + end + end, { + expr = false, + noremap = true, + silent = true, + }) +end + +---Fallback +keymap.fallback = function(bufnr, mode, map) + return function() + if map.expr then + local fallback_expr = string.format('<Plug>(cmp.u.k.fallback_expr:%s)', map.lhs) + keymap.set_map(bufnr, mode, fallback_expr, function() + return keymap.solve(bufnr, mode, map).keys + end, { + expr = true, + noremap = map.noremap, + script = map.script, + nowait = map.nowait, + silent = map.silent and mode ~= 'c', + }) + vim.api.nvim_feedkeys(keymap.t(fallback_expr), 'im', true) + elseif not map.callback then + local solved = keymap.solve(bufnr, mode, map) + vim.api.nvim_feedkeys(solved.keys, solved.mode, true) + else + map.callback() + end + end +end + +---Solve +keymap.solve = function(bufnr, mode, map) + local lhs = keymap.t(map.lhs) + local rhs = map.expr and (map.callback and map.callback() or vim.api.nvim_eval(keymap.t(map.rhs))) or keymap.t(map.rhs) + + if map.noremap then + return { keys = rhs, mode = 'in' } + end + + if string.find(rhs, lhs, 1, true) == 1 then + local recursive = string.format('<SNR>0_(cmp.u.k.recursive:%s)', lhs) + keymap.set_map(bufnr, mode, recursive, lhs, { + noremap = true, + script = map.script, + nowait = map.nowait, + silent = map.silent and mode ~= 'c', + }) + return { keys = keymap.t(recursive) .. string.gsub(rhs, '^' .. vim.pesc(lhs), ''), mode = 'im' } + end + return { keys = rhs, mode = 'im' } +end + +---Get map +---@param mode string +---@param lhs string +---@return table +keymap.get_map = function(mode, lhs) + lhs = keymap.normalize(lhs) + + for _, map in ipairs(vim.api.nvim_buf_get_keymap(0, mode)) do + if keymap.equals(map.lhs, lhs) then + return { + lhs = map.lhs, + rhs = map.rhs or '', + expr = map.expr == 1, + callback = map.callback, + noremap = map.noremap == 1, + script = map.script == 1, + silent = map.silent == 1, + nowait = map.nowait == 1, + buffer = true, + } + end + end + + for _, map in ipairs(vim.api.nvim_get_keymap(mode)) do + if keymap.equals(map.lhs, lhs) then + return { + lhs = map.lhs, + rhs = map.rhs or '', + expr = map.expr == 1, + callback = map.callback, + noremap = map.noremap == 1, + script = map.script == 1, + silent = map.silent == 1, + nowait = map.nowait == 1, + buffer = false, + } + end + end + + return { + lhs = lhs, + rhs = lhs, + expr = false, + callback = nil, + noremap = true, + script = false, + silent = true, + nowait = false, + buffer = false, + } +end + +---Set keymapping +keymap.set_map = setmetatable({ + callbacks = {}, +}, { + __call = function(self, bufnr, mode, lhs, rhs, opts) + if type(rhs) == 'function' then + local id = misc.id('cmp.utils.keymap.set_map') + self.callbacks[id] = rhs + if opts.expr then + rhs = ('v:lua.cmp.utils.keymap.set_map(%s)'):format(id) + else + rhs = ('<Cmd>call v:lua.cmp.utils.keymap.set_map(%s)<CR>'):format(id) + end + end + + if bufnr == -1 then + vim.api.nvim_set_keymap(mode, lhs, rhs, opts) + else + vim.api.nvim_buf_set_keymap(bufnr, mode, lhs, rhs, opts) + end + end, +}) +misc.set(_G, { 'cmp', 'utils', 'keymap', 'set_map' }, function(id) + return keymap.set_map.callbacks[id]() or '' +end) + +return keymap diff --git a/start/cmp/lua/cmp/utils/keymap_spec.lua b/start/cmp/lua/cmp/utils/keymap_spec.lua new file mode 100644 index 0000000..959783f --- /dev/null +++ b/start/cmp/lua/cmp/utils/keymap_spec.lua @@ -0,0 +1,187 @@ +local spec = require('cmp.utils.spec') +local api = require('cmp.utils.api') +local feedkeys = require('cmp.utils.feedkeys') + +local keymap = require('cmp.utils.keymap') + +describe('keymap', function() + before_each(spec.before) + + it('t', function() + for _, key in ipairs({ + '<F1>', + '<C-a>', + '<C-]>', + '<C-[>', + '<C-^>', + '<C-@>', + '<C-\\>', + '<Tab>', + '<S-Tab>', + '<Plug>(example)', + '<C-r>="abc"<CR>', + '<Cmd>normal! ==<CR>', + }) do + assert.are.equal(keymap.t(key), vim.api.nvim_replace_termcodes(key, true, true, true)) + assert.are.equal(keymap.t(key .. key), vim.api.nvim_replace_termcodes(key .. key, true, true, true)) + assert.are.equal(keymap.t(key .. key .. key), vim.api.nvim_replace_termcodes(key .. key .. key, true, true, true)) + end + end) + + 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) + + describe('fallback', function() + before_each(spec.before) + + local run_fallback = function(keys, fallback) + local state = {} + feedkeys.call(keys, '', function() + fallback() + end) + feedkeys.call('', '', function() + if api.is_cmdline_mode() then + state.buffer = { api.get_current_line() } + else + state.buffer = vim.api.nvim_buf_get_lines(0, 0, -1, false) + end + state.cursor = api.get_cursor() + end) + feedkeys.call('', 'x') + return state + end + + describe('basic', function() + it('<Plug>', function() + vim.api.nvim_buf_set_keymap(0, 'i', '<Plug>(pairs)', '()<Left>', { noremap = true }) + vim.api.nvim_buf_set_keymap(0, 'i', '(', '<Plug>(pairs)', { noremap = false }) + local fallback = keymap.fallback(0, 'i', keymap.get_map('i', '(')) + local state = run_fallback('i', fallback) + assert.are.same({ '()' }, state.buffer) + assert.are.same({ 1, 1 }, state.cursor) + end) + + it('<C-r>=', function() + vim.api.nvim_buf_set_keymap(0, 'i', '(', '<C-r>="()"<CR><Left>', {}) + local fallback = keymap.fallback(0, 'i', keymap.get_map('i', '(')) + local state = run_fallback('i', fallback) + assert.are.same({ '()' }, state.buffer) + assert.are.same({ 1, 1 }, state.cursor) + end) + + it('callback', function() + vim.api.nvim_buf_set_keymap(0, 'i', '(', '', { + callback = function() + vim.api.nvim_feedkeys('()' .. keymap.t('<Left>'), 'int', true) + end, + }) + local fallback = keymap.fallback(0, 'i', keymap.get_map('i', '(')) + local state = run_fallback('i', fallback) + assert.are.same({ '()' }, state.buffer) + assert.are.same({ 1, 1 }, state.cursor) + end) + + it('expr-callback', function() + vim.api.nvim_buf_set_keymap(0, 'i', '(', '', { + expr = true, + noremap = false, + silent = true, + callback = function() + return '()' .. keymap.t('<Left>') + end, + }) + local fallback = keymap.fallback(0, 'i', keymap.get_map('i', '(')) + local state = run_fallback('i', fallback) + assert.are.same({ '()' }, state.buffer) + assert.are.same({ 1, 1 }, state.cursor) + end) + + -- it('cmdline default <Tab>', function() + -- local fallback = keymap.fallback(0, 'c', keymap.get_map('c', '<Tab>')) + -- local state = run_fallback(':', fallback) + -- assert.are.same({ '' }, state.buffer) + -- assert.are.same({ 1, 0 }, state.cursor) + -- end) + end) + + describe('recursive', function() + it('non-expr', function() + vim.api.nvim_buf_set_keymap(0, 'i', '(', '()<Left>', { + expr = false, + noremap = false, + silent = true, + }) + local fallback = keymap.fallback(0, 'i', keymap.get_map('i', '(')) + local state = run_fallback('i', fallback) + assert.are.same({ '()' }, state.buffer) + assert.are.same({ 1, 1 }, state.cursor) + end) + + it('expr', function() + vim.api.nvim_buf_set_keymap(0, 'i', '(', '"()<Left>"', { + expr = true, + noremap = false, + silent = true, + }) + local fallback = keymap.fallback(0, 'i', keymap.get_map('i', '(')) + local state = run_fallback('i', fallback) + assert.are.same({ '()' }, state.buffer) + assert.are.same({ 1, 1 }, state.cursor) + end) + + it('expr-callback', function() + pcall(function() + vim.api.nvim_buf_set_keymap(0, 'i', '(', '', { + expr = true, + noremap = false, + silent = true, + callback = function() + return keymap.t('()<Left>') + end, + }) + local fallback = keymap.fallback(0, 'i', keymap.get_map('i', '(')) + local state = run_fallback('i', fallback) + assert.are.same({ '()' }, state.buffer) + assert.are.same({ 1, 1 }, state.cursor) + end) + end) + end) + end) + + describe('realworld', function() + before_each(spec.before) + + it('#226', function() + keymap.listen('i', '<c-n>', function(_, fallback) + fallback() + end) + vim.api.nvim_feedkeys(keymap.t('iaiueo<CR>a<C-n><C-n>'), 'tx', true) + assert.are.same({ 'aiueo', 'aiueo' }, vim.api.nvim_buf_get_lines(0, 0, -1, true)) + end) + + it('#414', function() + keymap.listen('i', '<M-j>', function() + vim.api.nvim_feedkeys(keymap.t('<C-n>'), 'int', true) + end) + vim.api.nvim_feedkeys(keymap.t('iaiueo<CR>a<M-j><M-j>'), 'tx', true) + assert.are.same({ 'aiueo', 'aiueo' }, vim.api.nvim_buf_get_lines(0, 0, -1, true)) + end) + + it('#744', function() + vim.api.nvim_buf_set_keymap(0, 'i', '<C-r>', 'recursive', { + noremap = true, + }) + vim.api.nvim_buf_set_keymap(0, 'i', '<CR>', '<CR>recursive', { + noremap = false, + }) + keymap.listen('i', '<CR>', function(_, fallback) + fallback() + end) + feedkeys.call(keymap.t('i<CR>'), 'tx') + assert.are.same({ '', 'recursive' }, vim.api.nvim_buf_get_lines(0, 0, -1, true)) + end) + end) +end) diff --git a/start/cmp/lua/cmp/utils/misc.lua b/start/cmp/lua/cmp/utils/misc.lua new file mode 100644 index 0000000..7c6d0e7 --- /dev/null +++ b/start/cmp/lua/cmp/utils/misc.lua @@ -0,0 +1,253 @@ +local misc = {} + +---Create once callback +---@param callback function +---@return function +misc.once = function(callback) + local done = false + return function(...) + if done then + return + end + done = true + callback(...) + end +end + +---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 + +---Repeat values +---@generic T +---@param str_or_tbl T +---@param count number +---@return T +misc.rep = function(str_or_tbl, count) + if type(str_or_tbl) == 'string' then + return string.rep(str_or_tbl, count) + end + local rep = {} + for _ = 1, count do + for _, v in ipairs(str_or_tbl) do + table.insert(rep, v) + end + end + return rep +end + +---Return the valu is empty or not. +---@param v any +---@return boolean +misc.empty = function(v) + if not v then + return true + end + if v == vim.NIL then + return true + end + if type(v) == 'string' and v == '' then + return true + end + if type(v) == 'table' and vim.tbl_isempty(v) then + return true + end + if type(v) == 'number' and v == 0 then + return true + end + return false +end + +---The symbol to remove key in misc.merge. +misc.none = vim.NIL + +---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) or vim.tbl_isempty(v1)) + local merge2 = type(v2) == 'table' and (not vim.tbl_islist(v2) or vim.tbl_isempty(v2)) + 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 and v ~= misc.none then + new_tbl[k] = v + end + end + return new_tbl + end + if v1 == misc.none then + return nil + end + if v1 == nil then + if v2 == misc.none then + return nil + else + return v2 + end + end + if v1 == true then + if merge2 then + return v2 + end + return {} + end + + return v1 +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 + +---Safe version of vim.str_utfindex +---@param text string +---@param vimindex number|nil +---@return number +misc.to_utfindex = function(text, vimindex) + vimindex = vimindex or #text + 1 + return vim.str_utfindex(text, math.max(0, math.min(vimindex - 1, #text))) +end + +---Safe version of vim.str_byteindex +---@param text string +---@param utfindex number +---@return number +misc.to_vimindex = function(text, utfindex) + utfindex = utfindex or #text + for i = utfindex, 1, -1 do + local s, v = pcall(function() + return vim.str_byteindex(text, i) + 1 + end) + if s then + return v + end + end + return utfindex + 1 +end + +---Mark the function as deprecated +misc.deprecated = function(fn, msg) + local printed = false + return function(...) + if not printed then + print(msg) + printed = true + end + return fn(...) + end +end + +--Redraw +misc.redraw = setmetatable({ + doing = false, + force = false, + termcode = vim.api.nvim_replace_termcodes('<C-r><Esc>', true, true, true), +}, { + __call = function(self, force) + if vim.tbl_contains({ '/', '?' }, vim.fn.getcmdtype()) then + if vim.o.incsearch then + return vim.api.nvim_feedkeys(self.termcode, 'in', true) + end + end + + if self.doing then + return + end + self.doing = true + self.force = not not force + vim.schedule(function() + if self.force then + vim.cmd([[redraw!]]) + else + vim.cmd([[redraw]]) + end + self.doing = false + self.force = false + end) + end, +}) + +return misc diff --git a/start/cmp/lua/cmp/utils/misc_spec.lua b/start/cmp/lua/cmp/utils/misc_spec.lua new file mode 100644 index 0000000..f687155 --- /dev/null +++ b/start/cmp/lua/cmp/utils/misc_spec.lua @@ -0,0 +1,63 @@ +local spec = require('cmp.utils.spec') + +local misc = require('cmp.utils.misc') + +describe('misc', function() + before_each(spec.before) + + it('merge', function() + local merged + merged = misc.merge({ + a = {}, + }, { + a = { + b = 1, + }, + }) + assert.are.equal(merged.a.b, 1) + + merged = misc.merge({ + a = { + i = 1, + }, + }, { + a = { + c = 2, + }, + }) + assert.are.equal(merged.a.i, 1) + assert.are.equal(merged.a.c, 2) + + merged = misc.merge({ + a = false, + }, { + a = { + b = 1, + }, + }) + assert.are.equal(merged.a, false) + + merged = misc.merge({ + a = misc.none, + }, { + a = { + b = 1, + }, + }) + assert.are.equal(merged.a, nil) + + merged = misc.merge({ + a = misc.none, + }, { + a = nil, + }) + assert.are.equal(merged.a, nil) + + merged = misc.merge({ + a = nil, + }, { + a = misc.none, + }) + assert.are.equal(merged.a, nil) + end) +end) diff --git a/start/cmp/lua/cmp/utils/pattern.lua b/start/cmp/lua/cmp/utils/pattern.lua new file mode 100644 index 0000000..1481e84 --- /dev/null +++ b/start/cmp/lua/cmp/utils/pattern.lua @@ -0,0 +1,28 @@ +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 + +pattern.matchstr = function(p, text) + local s, e = pattern.offset(p, text) + if s then + return string.sub(text, s, e) + end + return nil +end + +return pattern diff --git a/start/cmp/lua/cmp/utils/spec.lua b/start/cmp/lua/cmp/utils/spec.lua new file mode 100644 index 0000000..a4b2c83 --- /dev/null +++ b/start/cmp/lua/cmp/utils/spec.lua @@ -0,0 +1,92 @@ +local context = require('cmp.context') +local source = require('cmp.source') +local types = require('cmp.types') +local config = require('cmp.config') + +local spec = {} + +spec.before = function() + vim.cmd([[ + bdelete! + enew! + imapclear + imapclear <buffer> + cmapclear + cmapclear <buffer> + smapclear + smapclear <buffer> + xmapclear + xmapclear <buffer> + tmapclear + tmapclear <buffer> + setlocal noswapfile + setlocal virtualedit=all + setlocal completeopt=menu,menuone,noselect + ]]) + config.set_global({ + sources = { + { name = 'spec' }, + }, + snippet = { + expand = function(args) + local ctx = context.new() + vim.api.nvim_buf_set_text(ctx.bufnr, ctx.cursor.row - 1, ctx.cursor.col - 1, ctx.cursor.row - 1, ctx.cursor.col - 1, vim.split(string.gsub(args.body, '%$0', ''), '\n')) + for i, t in ipairs(vim.split(args.body, '\n')) do + local s = string.find(t, '$0', 1, true) + if s then + if i == 1 then + vim.api.nvim_win_set_cursor(0, { ctx.cursor.row, ctx.cursor.col + s - 2 }) + else + vim.api.nvim_win_set_cursor(0, { ctx.cursor.row + i - 1, s - 1 }) + end + break + end + end + end, + }, + }) + config.set_cmdline({ + sources = { + { name = 'spec' }, + }, + }, ':') +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, + backspace = function() + vim.fn.feedkeys('x', 'nx') + vim.fn.feedkeys('h', 'nx') + ctx = context.new(ctx, { reason = types.cmp.ContextReason.Auto }) + s:complete(ctx, function() end) + return ctx + end, + input = function(char) + vim.fn.feedkeys(('i%s'):format(char), 'nx') + vim.fn.feedkeys(string.rep('l', #char), 'nx') + ctx.prev_context = nil + ctx = context.new(ctx, { reason = types.cmp.ContextReason.Auto }) + s:complete(ctx, function() end) + return ctx + end, + manual = function() + ctx = context.new(ctx, { reason = types.cmp.ContextReason.Manual }) + s:complete(ctx, function() end) + return ctx + end, + } +end + +return spec diff --git a/start/cmp/lua/cmp/utils/str.lua b/start/cmp/lua/cmp/utils/str.lua new file mode 100644 index 0000000..bca210c --- /dev/null +++ b/start/cmp/lua/cmp/utils/str.lua @@ -0,0 +1,178 @@ +local char = require('cmp.utils.char') + +local str = {} + +local INVALIDS = {} +INVALIDS[string.byte("'")] = true +INVALIDS[string.byte('"')] = true +INVALIDS[string.byte('=')] = true +INVALIDS[string.byte('$')] = true +INVALIDS[string.byte('(')] = true +INVALIDS[string.byte('[')] = true +INVALIDS[string.byte('<')] = true +INVALIDS[string.byte('{')] = true +INVALIDS[string.byte(' ')] = true +INVALIDS[string.byte('\t')] = true +INVALIDS[string.byte('\n')] = true +INVALIDS[string.byte('\r')] = true + +local NR_BYTE = string.byte('\n') + +local PAIRS = {} +PAIRS[string.byte('<')] = string.byte('>') +PAIRS[string.byte('[')] = string.byte(']') +PAIRS[string.byte('(')] = string.byte(')') +PAIRS[string.byte('{')] = string.byte('}') +PAIRS[string.byte('"')] = string.byte('"') +PAIRS[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 + +---get_common_string +str.get_common_string = function(text1, text2) + local min = math.min(#text1, #text2) + for i = 1, min do + if not char.match(string.byte(text1, i), string.byte(text2, i)) then + return string.sub(text1, 1, i - 1) + end + end + return string.sub(text1, 1, min) +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 + +---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 +---@param stop_char number +---@param min_length number +---@return string +str.get_word = function(text, stop_char, min_length) + min_length = min_length or 0 + + local has_alnum = false + local stack = {} + local word = {} + local add = function(c) + table.insert(word, string.char(c)) + if stack[#stack] == c then + table.remove(stack, #stack) + else + if PAIRS[c] then + table.insert(stack, c) + end + end + end + for i = 1, #text do + local c = string.byte(text, i, i) + if #word < min_length then + table.insert(word, string.char(c)) + elseif not INVALIDS[c] then + add(c) + has_alnum = has_alnum or char.is_alnum(c) + elseif not has_alnum then + add(c) + elseif #stack ~= 0 then + add(c) + if has_alnum and #stack == 0 then + break + end + else + break + end + end + if stop_char and word[#word] == string.char(stop_char) then + table.remove(word, #word) + end + return table.concat(word, '') +end + +---Oneline +---@param text string +---@return string +str.oneline = function(text) + for i = 1, #text do + if string.byte(text, i) == NR_BYTE then + return string.sub(text, 1, i - 1) + end + end + return text +end + +---Escape special chars +---@param text string +---@param chars string[] +---@return string +str.escape = function(text, chars) + table.insert(chars, '\\') + local escaped = {} + local i = 1 + while i <= #text do + local c = string.sub(text, i, i) + if vim.tbl_contains(chars, c) then + table.insert(escaped, '\\') + table.insert(escaped, c) + else + table.insert(escaped, c) + end + i = i + 1 + end + return table.concat(escaped, '') +end + +return str diff --git a/start/cmp/lua/cmp/utils/str_spec.lua b/start/cmp/lua/cmp/utils/str_spec.lua new file mode 100644 index 0000000..1a21855 --- /dev/null +++ b/start/cmp/lua/cmp/utils/str_spec.lua @@ -0,0 +1,29 @@ +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') + assert.are.equal(str.get_word('"devDependencies": ${1},', string.byte('"')), '"devDependencies') + assert.are.equal(str.get_word('#[cfg(test)]'), '#[cfg(test)]') + assert.are.equal(str.get_word('import { GetStaticProps$1 } from "next";', nil, 9), 'import { GetStaticProps') + 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) + + it('escape', function() + assert.are.equal(str.escape('plain', {}), 'plain') + assert.are.equal(str.escape('plain\\', {}), 'plain\\\\') + assert.are.equal(str.escape('plain\\"', {}), 'plain\\\\"') + assert.are.equal(str.escape('pla"in', { '"' }), 'pla\\"in') + assert.are.equal(str.escape('call("")', { '"' }), 'call(\\"\\")') + end) +end) diff --git a/start/cmp/lua/cmp/utils/window.lua b/start/cmp/lua/cmp/utils/window.lua new file mode 100644 index 0000000..a8a271e --- /dev/null +++ b/start/cmp/lua/cmp/utils/window.lua @@ -0,0 +1,313 @@ +local cache = require('cmp.utils.cache') +local misc = require('cmp.utils.misc') +local buffer = require('cmp.utils.buffer') +local api = require('cmp.utils.api') + +---@class cmp.WindowStyle +---@field public relative string +---@field public row number +---@field public col number +---@field public width number +---@field public height number +---@field public border string|string[]|nil +---@field public zindex number|nil + +---@class cmp.Window +---@field public name string +---@field public win number|nil +---@field public thumb_win number|nil +---@field public sbar_win number|nil +---@field public style cmp.WindowStyle +---@field public opt table<string, any> +---@field public buffer_opt table<string, any> +---@field public cache cmp.Cache +local window = {} + +---new +---@return cmp.Window +window.new = function() + local self = setmetatable({}, { __index = window }) + self.name = misc.id('cmp.utils.window.new') + self.win = nil + self.sbar_win = nil + self.thumb_win = nil + self.style = {} + self.cache = cache.new() + self.opt = {} + self.buffer_opt = {} + return self +end + +---Set window option. +---NOTE: If the window already visible, immediately applied to it. +---@param key string +---@param value any +window.option = function(self, key, value) + if vim.fn.exists('+' .. key) == 0 then + return + end + + if value == nil then + return self.opt[key] + end + + self.opt[key] = value + if self:visible() then + vim.api.nvim_win_set_option(self.win, key, value) + end +end + +---Set buffer option. +---NOTE: If the buffer already visible, immediately applied to it. +---@param key string +---@param value any +window.buffer_option = function(self, key, value) + if vim.fn.exists('+' .. key) == 0 then + return + end + + if value == nil then + return self.buffer_opt[key] + end + + self.buffer_opt[key] = value + local existing_buf = buffer.get(self.name) + if existing_buf then + vim.api.nvim_buf_set_option(existing_buf, key, value) + end +end + +---Set style. +---@param style cmp.WindowStyle +window.set_style = function(self, style) + self.style = style + local info = self:info() + + if vim.o.lines and vim.o.lines <= info.row + info.height + 1 then + self.style.height = vim.o.lines - info.row - info.border_info.vert - 1 + end + + self.style.zindex = self.style.zindex or 1 +end + +---Return buffer id. +---@return number +window.get_buffer = function(self) + local buf, created_new = buffer.ensure(self.name) + if created_new then + for k, v in pairs(self.buffer_opt) do + vim.api.nvim_buf_set_option(buf, k, v) + end + end + return buf +end + +---Open window +---@param style cmp.WindowStyle +window.open = function(self, style) + if style then + self:set_style(style) + end + + if self.style.width < 1 or self.style.height < 1 then + return + end + + if self.win and vim.api.nvim_win_is_valid(self.win) then + vim.api.nvim_win_set_config(self.win, self.style) + else + local s = misc.copy(self.style) + s.noautocmd = true + self.win = vim.api.nvim_open_win(self:get_buffer(), false, s) + for k, v in pairs(self.opt) do + vim.api.nvim_win_set_option(self.win, k, v) + end + end + self:update() +end + +---Update +window.update = function(self) + local info = self:info() + if info.scrollable then + -- Draw the background of the scrollbar + + if not info.border_info.visible then + local style = { + relative = 'editor', + style = 'minimal', + width = 1, + height = self.style.height, + row = info.row, + col = info.col + info.width - info.scrollbar_offset, -- info.col was already contained the scrollbar offset. + zindex = (self.style.zindex and (self.style.zindex + 1) or 1), + } + if self.sbar_win and vim.api.nvim_win_is_valid(self.sbar_win) then + vim.api.nvim_win_set_config(self.sbar_win, style) + else + style.noautocmd = true + self.sbar_win = vim.api.nvim_open_win(buffer.ensure(self.name .. 'sbar_buf'), false, style) + vim.api.nvim_win_set_option(self.sbar_win, 'winhighlight', 'EndOfBuffer:PmenuSbar,NormalFloat:PmenuSbar') + end + end + + -- Draw the scrollbar thumb + local thumb_height = math.floor(info.inner_height * (info.inner_height / self:get_content_height()) + 0.5) + local thumb_offset = math.floor(info.inner_height * (vim.fn.getwininfo(self.win)[1].topline / self:get_content_height())) + + local style = { + relative = 'editor', + style = 'minimal', + width = 1, + height = math.max(1, thumb_height), + row = info.row + thumb_offset + (info.border_info.visible and info.border_info.top or 0), + col = info.col + info.width - 1, -- info.col was already added scrollbar offset. + zindex = (self.style.zindex and (self.style.zindex + 2) or 2), + } + if self.thumb_win and vim.api.nvim_win_is_valid(self.thumb_win) then + vim.api.nvim_win_set_config(self.thumb_win, style) + else + style.noautocmd = true + self.thumb_win = vim.api.nvim_open_win(buffer.ensure(self.name .. 'thumb_buf'), false, style) + vim.api.nvim_win_set_option(self.thumb_win, 'winhighlight', 'EndOfBuffer:PmenuThumb,NormalFloat:PmenuThumb') + end + else + if self.sbar_win and vim.api.nvim_win_is_valid(self.sbar_win) then + vim.api.nvim_win_hide(self.sbar_win) + self.sbar_win = nil + end + if self.thumb_win and vim.api.nvim_win_is_valid(self.thumb_win) then + vim.api.nvim_win_hide(self.thumb_win) + self.thumb_win = nil + end + end + + -- In cmdline, vim does not redraw automatically. + if api.is_cmdline_mode() then + vim.api.nvim_win_call(self.win, function() + misc.redraw() + end) + end +end + +---Close window +window.close = function(self) + if self.win and vim.api.nvim_win_is_valid(self.win) then + if self.win and vim.api.nvim_win_is_valid(self.win) then + vim.api.nvim_win_hide(self.win) + self.win = nil + end + if self.sbar_win and vim.api.nvim_win_is_valid(self.sbar_win) then + vim.api.nvim_win_hide(self.sbar_win) + self.sbar_win = nil + end + if self.thumb_win and vim.api.nvim_win_is_valid(self.thumb_win) then + vim.api.nvim_win_hide(self.thumb_win) + self.thumb_win = nil + end + end +end + +---Return the window is visible or not. +window.visible = function(self) + return self.win and vim.api.nvim_win_is_valid(self.win) +end + +---Return win info. +window.info = function(self) + local border_info = self:get_border_info() + local info = { + row = self.style.row, + col = self.style.col, + width = self.style.width + border_info.left + border_info.right, + height = self.style.height + border_info.top + border_info.bottom, + inner_width = self.style.width, + inner_height = self.style.height, + border_info = border_info, + scrollable = false, + scrollbar_offset = 0, + } + + if self:get_content_height() > info.inner_height then + info.scrollable = true + if not border_info.visible then + info.scrollbar_offset = 1 + info.width = info.width + 1 + end + end + + return info +end + +---Return border information. +---@return { top: number, left: number, right: number, bottom: number, vert: number, horiz: number, visible: boolean } +window.get_border_info = function(self) + local border = self.style.border + if not border or border == 'none' then + return { + top = 0, + left = 0, + right = 0, + bottom = 0, + vert = 0, + horiz = 0, + visible = false, + } + end + if type(border) == 'string' then + if border == 'shadow' then + return { + top = 0, + left = 0, + right = 1, + bottom = 1, + vert = 1, + horiz = 1, + visible = false, + } + end + return { + top = 1, + left = 1, + right = 1, + bottom = 1, + vert = 2, + horiz = 2, + visible = true, + } + end + + local new_border = {} + while #new_border <= 8 do + for _, b in ipairs(border) do + table.insert(new_border, type(b) == 'string' and b or b[1]) + end + end + local info = {} + info.top = new_border[2] == '' and 0 or 1 + info.right = new_border[4] == '' and 0 or 1 + info.bottom = new_border[6] == '' and 0 or 1 + info.left = new_border[8] == '' and 0 or 1 + info.vert = info.top + info.bottom + info.horiz = info.left + info.right + info.visible = not (vim.tbl_contains({ '', ' ' }, new_border[2]) and vim.tbl_contains({ '', ' ' }, new_border[4]) and vim.tbl_contains({ '', ' ' }, new_border[6]) and vim.tbl_contains({ '', ' ' }, new_border[8])) + return info +end + +---Get scroll height. +---NOTE: The result of vim.fn.strdisplaywidth depends on the buffer it was called in (see comment in cmp.Entry.get_view). +---@return number +window.get_content_height = function(self) + if not self:option('wrap') then + return vim.api.nvim_buf_line_count(self:get_buffer()) + end + local height = 0 + vim.api.nvim_buf_call(self:get_buffer(), function() + for _, text in ipairs(vim.api.nvim_buf_get_lines(self:get_buffer(), 0, -1, false)) do + height = height + math.max(1, math.ceil(vim.fn.strdisplaywidth(text) / self.style.width)) + end + end) + return height +end + +return window diff --git a/start/cmp/lua/cmp/view.lua b/start/cmp/lua/cmp/view.lua new file mode 100644 index 0000000..981378b --- /dev/null +++ b/start/cmp/lua/cmp/view.lua @@ -0,0 +1,243 @@ +local config = require('cmp.config') +local async = require('cmp.utils.async') +local event = require('cmp.utils.event') +local keymap = require('cmp.utils.keymap') +local docs_view = require('cmp.view.docs_view') +local custom_entries_view = require('cmp.view.custom_entries_view') +local wildmenu_entries_view = require('cmp.view.wildmenu_entries_view') +local native_entries_view = require('cmp.view.native_entries_view') +local ghost_text_view = require('cmp.view.ghost_text_view') + +---@class cmp.View +---@field public event cmp.Event +---@field private resolve_dedup cmp.AsyncDedup +---@field private native_entries_view cmp.NativeEntriesView +---@field private custom_entries_view cmp.CustomEntriesView +---@field private wildmenu_entries_view cmp.CustomEntriesView +---@field private change_dedup cmp.AsyncDedup +---@field private docs_view cmp.DocsView +---@field private ghost_text_view cmp.GhostTextView +local view = {} + +---Create menu +view.new = function() + local self = setmetatable({}, { __index = view }) + self.resolve_dedup = async.dedup() + self.custom_entries_view = custom_entries_view.new() + self.native_entries_view = native_entries_view.new() + self.wildmenu_entries_view = wildmenu_entries_view.new() + self.docs_view = docs_view.new() + self.ghost_text_view = ghost_text_view.new() + self.event = event.new() + + return self +end + +---Return the view components are available or not. +---@return boolean +view.ready = function(self) + return self:_get_entries_view():ready() +end + +---OnChange handler. +view.on_change = function(self) + self:_get_entries_view():on_change() +end + +---Open menu +---@param ctx cmp.Context +---@param sources cmp.Source[] +view.open = function(self, ctx, sources) + local source_group_map = {} + for _, s in ipairs(sources) do + local group_index = s:get_source_config().group_index or 0 + if not source_group_map[group_index] then + source_group_map[group_index] = {} + end + table.insert(source_group_map[group_index], s) + end + + local group_indexes = vim.tbl_keys(source_group_map) + table.sort(group_indexes, function(a, b) + return a ~= b and (a < b) or nil + end) + + local entries = {} + for _, group_index in ipairs(group_indexes) do + local source_group = source_group_map[group_index] or {} + + -- check the source triggered by character + local has_triggered_by_symbol_source = false + for _, s in ipairs(source_group) do + if #s:get_entries(ctx) > 0 then + if s.is_triggered_by_symbol then + has_triggered_by_symbol_source = true + break + end + end + end + + -- create filtered entries. + local offset = ctx.cursor.col + for i, s in ipairs(source_group) do + if s.offset <= ctx.cursor.col then + if not has_triggered_by_symbol_source or s.is_triggered_by_symbol then + -- source order priority bonus. + local priority = s:get_source_config().priority or ((#source_group - (i - 1)) * config.get().sorting.priority_weight) + + for _, e in ipairs(s:get_entries(ctx)) do + e.score = e.score + priority + table.insert(entries, e) + offset = math.min(offset, e:get_offset()) + end + end + end + end + + -- sort. + local comparetors = config.get().sorting.comparators + table.sort(entries, function(e1, e2) + for _, fn in ipairs(comparetors) do + local diff = fn(e1, e2) + if diff ~= nil then + return diff + end + end + end) + + -- open + if #entries > 0 then + self:_get_entries_view():open(offset, entries) + break + end + end + + -- complete_done. + if #entries == 0 then + self:close() + end +end + +---Close menu +view.close = function(self) + if self:visible() then + self.event:emit('complete_done', { + entry = self:_get_entries_view():get_selected_entry(), + }) + end + self:_get_entries_view():close() + self.docs_view:close() + self.ghost_text_view:hide() +end + +---Abort menu +view.abort = function(self) + self:_get_entries_view():abort() + self.docs_view:close() + self.ghost_text_view:hide() +end + +---Return the view is visible or not. +---@return boolean +view.visible = function(self) + return self:_get_entries_view():visible() +end + +---Scroll documentation window if possible. +---@param delta number +view.scroll_docs = function(self, delta) + self.docs_view:scroll(delta) +end + +---Select prev menu item. +---@param option cmp.SelectOption +view.select_next_item = function(self, option) + self:_get_entries_view():select_next_item(option) +end + +---Select prev menu item. +---@param option cmp.SelectOption +view.select_prev_item = function(self, option) + self:_get_entries_view():select_prev_item(option) +end + +---Get offset. +view.get_offset = function(self) + return self:_get_entries_view():get_offset() +end + +---Get entries. +---@return cmp.Entry[] +view.get_entries = function(self) + return self:_get_entries_view():get_entries() +end + +---Get first entry +---@param self cmp.Entry|nil +view.get_first_entry = function(self) + return self:_get_entries_view():get_first_entry() +end + +---Get current selected entry +---@return cmp.Entry|nil +view.get_selected_entry = function(self) + return self:_get_entries_view():get_selected_entry() +end + +---Get current active entry +---@return cmp.Entry|nil +view.get_active_entry = function(self) + return self:_get_entries_view():get_active_entry() +end + +---Return current configured entries_view +---@return cmp.CustomEntriesView|cmp.NativeEntriesView +view._get_entries_view = function(self) + self.native_entries_view.event:clear() + self.custom_entries_view.event:clear() + self.wildmenu_entries_view.event:clear() + + local c = config.get() + local v = self.custom_entries_view + if (c.view and c.view.entries and (c.view.entries.name or c.view.entries)) == 'wildmenu' then + v = self.wildmenu_entries_view + elseif (c.view and c.view.entries and (c.view.entries.name or c.view.entries)) == 'native' then + v = self.native_entries_view + end + v.event:on('change', function() + self:on_entry_change() + end) + return v +end + +---On entry change +view.on_entry_change = async.throttle(function(self) + if not self:visible() then + return + end + local e = self:get_selected_entry() + if e then + for _, c in ipairs(config.get().confirmation.get_commit_characters(e:get_commit_characters())) do + keymap.listen('i', c, function(...) + self.event:emit('keymap', ...) + end) + end + e:resolve(vim.schedule_wrap(self.resolve_dedup(function() + if not self:visible() then + return + end + self.docs_view:open(e, self:_get_entries_view():info()) + end))) + else + self.docs_view:close() + end + + e = e or self:get_first_entry() + if e then + self.ghost_text_view:show(e) + else + self.ghost_text_view:hide() + end +end, 20) + +return view diff --git a/start/cmp/lua/cmp/view/custom_entries_view.lua b/start/cmp/lua/cmp/view/custom_entries_view.lua new file mode 100644 index 0000000..8020a9b --- /dev/null +++ b/start/cmp/lua/cmp/view/custom_entries_view.lua @@ -0,0 +1,409 @@ +local event = require('cmp.utils.event') +local autocmd = require('cmp.utils.autocmd') +local feedkeys = require('cmp.utils.feedkeys') +local window = require('cmp.utils.window') +local config = require('cmp.config') +local types = require('cmp.types') +local keymap = require('cmp.utils.keymap') +local misc = require('cmp.utils.misc') +local api = require('cmp.utils.api') + +local SIDE_PADDING = 1 + +local DEFAULT_HEIGHT = 10 -- @see https://github.com/vim/vim/blob/master/src/popupmenu.c#L45 + +---@class cmp.CustomEntriesView +---@field private entries_win cmp.Window +---@field private offset number +---@field private active boolean +---@field private entries cmp.Entry[] +---@field private column_width any +---@field public event cmp.Event +local custom_entries_view = {} + +custom_entries_view.ns = vim.api.nvim_create_namespace('cmp.view.custom_entries_view') + +custom_entries_view.new = function() + local self = setmetatable({}, { __index = custom_entries_view }) + + self.entries_win = window.new() + self.entries_win:option('conceallevel', 2) + self.entries_win:option('concealcursor', 'n') + self.entries_win:option('cursorlineopt', 'line') + self.entries_win:option('foldenable', false) + self.entries_win:option('wrap', false) + self.entries_win:option('scrolloff', 0) + -- This is done so that strdisplaywidth calculations for lines in the + -- custom_entries_view window exactly match with what is really displayed, + -- see comment in cmp.Entry.get_view. Setting tabstop to 1 makes all tabs be + -- always rendered one column wide, which removes the unpredictability coming + -- from variable width of the tab character. + self.entries_win:buffer_option('tabstop', 1) + self.event = event.new() + self.offset = -1 + self.active = false + self.entries = {} + self.bottom_up = false + + autocmd.subscribe( + 'CompleteChanged', + vim.schedule_wrap(function() + if self:visible() and vim.fn.pumvisible() == 1 then + self:close() + end + end) + ) + + vim.api.nvim_set_decoration_provider(custom_entries_view.ns, { + on_win = function(_, win, buf, top, bot) + if win ~= self.entries_win.win or buf ~= self.entries_win:get_buffer() then + return + end + + local fields = config.get().formatting.fields + for i = top, bot do + local e = self.entries[i + 1] + if e then + local v = e:get_view(self.offset, buf) + local o = SIDE_PADDING + local a = 0 + for _, field in ipairs(fields) do + if field == types.cmp.ItemField.Abbr then + a = o + end + vim.api.nvim_buf_set_extmark(buf, custom_entries_view.ns, i, o, { + end_line = i, + end_col = o + v[field].bytes, + hl_group = v[field].hl_group, + hl_mode = 'combine', + ephemeral = true, + }) + o = o + v[field].bytes + (self.column_width[field] - v[field].width) + 1 + end + + for _, m in ipairs(e.matches or {}) do + vim.api.nvim_buf_set_extmark(buf, custom_entries_view.ns, i, a + m.word_match_start - 1, { + end_line = i, + end_col = a + m.word_match_end, + hl_group = m.fuzzy and 'CmpItemAbbrMatchFuzzy' or 'CmpItemAbbrMatch', + hl_mode = 'combine', + ephemeral = true, + }) + end + end + end + end, + }) + + return self +end + +custom_entries_view.ready = function() + return vim.fn.pumvisible() == 0 +end + +custom_entries_view.on_change = function(self) + self.active = false +end + +custom_entries_view.is_direction_top_down = function(self) + local c = config.get() + if (c.view and c.view.entries and c.view.entries.selection_order) == 'top_down' then + return true + elseif c.view.entries == nil or c.view.entries.selection_order == nil then + return true + else + return not self.bottom_up + end +end + +custom_entries_view.open = function(self, offset, entries) + local completion = config.get().window.completion + self.offset = offset + self.entries = {} + self.column_width = { abbr = 0, kind = 0, menu = 0 } + + local entries_buf = self.entries_win:get_buffer() + local lines = {} + local dedup = {} + local preselect = 0 + for _, e in ipairs(entries) do + local view = e:get_view(offset, entries_buf) + if view.dup == 1 or not dedup[e.completion_item.label] then + dedup[e.completion_item.label] = true + self.column_width.abbr = math.max(self.column_width.abbr, view.abbr.width) + self.column_width.kind = math.max(self.column_width.kind, view.kind.width) + self.column_width.menu = math.max(self.column_width.menu, view.menu.width) + table.insert(self.entries, e) + table.insert(lines, ' ') + if preselect == 0 and e.completion_item.preselect then + preselect = #self.entries + end + end + end + vim.api.nvim_buf_set_lines(entries_buf, 0, -1, false, lines) + vim.api.nvim_buf_set_option(entries_buf, 'modified', false) + + local width = 0 + width = width + 1 + width = width + self.column_width.abbr + (self.column_width.kind > 0 and 1 or 0) + width = width + self.column_width.kind + (self.column_width.menu > 0 and 1 or 0) + width = width + self.column_width.menu + 1 + + local height = vim.api.nvim_get_option('pumheight') + height = height ~= 0 and height or #self.entries + height = math.min(height, #self.entries) + + local pos = api.get_screen_cursor() + local cursor = api.get_cursor() + local delta = cursor[2] + 1 - self.offset + local row, col = pos[1], pos[2] - delta - 1 + + local border_info = window.get_border_info({ style = completion }) + local border_offset_row = border_info.top + border_info.bottom + local border_offset_col = border_info.left + border_info.right + if math.floor(vim.o.lines * 0.5) <= row + border_offset_row and vim.o.lines - row - border_offset_row <= math.min(DEFAULT_HEIGHT, height) then + height = math.min(height, row - 1) + row = row - height - border_offset_row - 1 + if row < 0 then + height = height + row + end + end + if math.floor(vim.o.columns * 0.5) <= col + border_offset_col and vim.o.columns - col - border_offset_col <= width then + width = math.min(width, vim.o.columns - 1) + col = vim.o.columns - width - border_offset_col - 1 + if col < 0 then + width = width + col + end + end + + if pos[1] > row then + self.bottom_up = true + else + self.bottom_up = false + end + + if not self:is_direction_top_down() then + local n = #self.entries + for i = 1, math.floor(n / 2) do + self.entries[i], self.entries[n - i + 1] = self.entries[n - i + 1], self.entries[i] + end + if preselect ~= 0 then + preselect = #self.entries - preselect + 1 + end + end + + -- Apply window options (that might be changed) on the custom completion menu. + self.entries_win:option('winblend', vim.o.pumblend) + self.entries_win:option('winhighlight', completion.winhighlight) + self.entries_win:open({ + relative = 'editor', + style = 'minimal', + row = math.max(0, row), + col = math.max(0, col), + width = width, + height = height, + border = completion.border, + zindex = completion.zindex or 1001, + }) + -- always set cursor when starting. It will be adjusted on the call to _select + vim.api.nvim_win_set_cursor(self.entries_win.win, { 1, 0 }) + if preselect > 0 and config.get().preselect == types.cmp.PreselectMode.Item then + self:_select(preselect, { behavior = types.cmp.SelectBehavior.Select }) + elseif not string.match(config.get().completion.completeopt, 'noselect') then + if self:is_direction_top_down() then + self:_select(1, { behavior = types.cmp.SelectBehavior.Select }) + else + self:_select(#self.entries - 1, { behavior = types.cmp.SelectBehavior.Select }) + end + else + if self:is_direction_top_down() then + self:_select(0, { behavior = types.cmp.SelectBehavior.Select }) + else + self:_select(#self.entries + 1, { behavior = types.cmp.SelectBehavior.Select }) + end + end +end + +custom_entries_view.close = function(self) + self.prefix = nil + self.offset = -1 + self.active = false + self.entries = {} + self.entries_win:close() + self.bottom_up = false +end + +custom_entries_view.abort = function(self) + if self.prefix then + self:_insert(self.prefix) + end + feedkeys.call('', 'n', function() + self:close() + end) +end + +custom_entries_view.draw = function(self) + local info = vim.fn.getwininfo(self.entries_win.win)[1] + local topline = info.topline - 1 + local botline = info.topline + info.height - 1 + local texts = {} + local fields = config.get().formatting.fields + local entries_buf = self.entries_win:get_buffer() + for i = topline, botline - 1 do + local e = self.entries[i + 1] + if e then + local view = e:get_view(self.offset, entries_buf) + local text = {} + table.insert(text, string.rep(' ', SIDE_PADDING)) + for _, field in ipairs(fields) do + table.insert(text, view[field].text) + table.insert(text, string.rep(' ', 1 + self.column_width[field] - view[field].width)) + end + table.insert(text, string.rep(' ', SIDE_PADDING)) + table.insert(texts, table.concat(text, '')) + end + end + vim.api.nvim_buf_set_lines(entries_buf, topline, botline, false, texts) + vim.api.nvim_buf_set_option(entries_buf, 'modified', false) + + if api.is_cmdline_mode() then + vim.api.nvim_win_call(self.entries_win.win, function() + misc.redraw() + end) + end +end + +custom_entries_view.visible = function(self) + return self.entries_win:visible() +end + +custom_entries_view.info = function(self) + return self.entries_win:info() +end + +custom_entries_view.select_next_item = function(self, option) + if self:visible() then + local cursor = vim.api.nvim_win_get_cursor(self.entries_win.win)[1] + if self:is_direction_top_down() then + cursor = cursor + 1 + else + cursor = cursor - 1 + end + if not self.entries_win:option('cursorline') then + cursor = (self:is_direction_top_down() and 1) or #self.entries + elseif #self.entries < cursor then + cursor = (not self:is_direction_top_down() and #self.entries + 1) or 0 + end + self:_select(cursor, option) + end +end + +custom_entries_view.select_prev_item = function(self, option) + if self:visible() then + local cursor = vim.api.nvim_win_get_cursor(self.entries_win.win)[1] + if self:is_direction_top_down() then + cursor = cursor - 1 + else + cursor = cursor + 1 + end + if not self.entries_win:option('cursorline') then + cursor = (self:is_direction_top_down() and #self.entries) or 1 + elseif #self.entries < cursor then + cursor = (not self:is_direction_top_down() and 0) or #self.entries + 1 + end + self:_select(cursor, option) + end +end + +custom_entries_view.get_offset = function(self) + if self:visible() then + return self.offset + end + return nil +end + +custom_entries_view.get_entries = function(self) + if self:visible() then + return self.entries + end + return {} +end + +custom_entries_view.get_first_entry = function(self) + if self:visible() then + return (self:is_direction_top_down() and self.entries[1]) or self.entries[#self.entries] + end +end + +custom_entries_view.get_selected_entry = function(self) + if self:visible() and self.entries_win:option('cursorline') then + return self.entries[vim.api.nvim_win_get_cursor(self.entries_win.win)[1]] + end +end + +custom_entries_view.get_active_entry = function(self) + if self:visible() and self.active then + return self:get_selected_entry() + end +end + +custom_entries_view._select = function(self, cursor, option) + local is_insert = (option.behavior or types.cmp.SelectBehavior.Insert) == types.cmp.SelectBehavior.Insert + if is_insert and not self.active then + self.prefix = string.sub(api.get_current_line(), self.offset, api.get_cursor()[2]) or '' + end + + self.active = cursor > 0 and cursor <= #self.entries and is_insert + self.entries_win:option('cursorline', cursor > 0 and cursor <= #self.entries) + + vim.api.nvim_win_set_cursor(self.entries_win.win, { + math.max(math.min(cursor, #self.entries), 1), + 0, + }) + + if is_insert then + self:_insert(self.entries[cursor] and self.entries[cursor]:get_vim_item(self.offset).word or self.prefix) + end + + self.entries_win:update() + self:draw() + self.event:emit('change') +end + +custom_entries_view._insert = setmetatable({ + pending = false, +}, { + __call = function(this, self, word) + word = word or '' + if api.is_cmdline_mode() then + local cursor = api.get_cursor() + vim.api.nvim_feedkeys(keymap.backspace(string.sub(api.get_current_line(), self.offset, cursor[2])) .. word, 'int', true) + else + if this.pending then + return + end + this.pending = true + + local release = require('cmp').suspend() + feedkeys.call('', '', function() + local cursor = api.get_cursor() + local keys = {} + table.insert(keys, keymap.indentkeys()) + table.insert(keys, keymap.backspace(string.sub(api.get_current_line(), self.offset, cursor[2]))) + table.insert(keys, word) + table.insert(keys, keymap.indentkeys(vim.bo.indentkeys)) + feedkeys.call( + table.concat(keys, ''), + 'int', + vim.schedule_wrap(function() + this.pending = false + release() + end) + ) + end) + end + end, +}) + +return custom_entries_view diff --git a/start/cmp/lua/cmp/view/docs_view.lua b/start/cmp/lua/cmp/view/docs_view.lua new file mode 100644 index 0000000..9d2cd3f --- /dev/null +++ b/start/cmp/lua/cmp/view/docs_view.lua @@ -0,0 +1,136 @@ +local window = require('cmp.utils.window') +local config = require('cmp.config') + +---@class cmp.DocsView +---@field public window cmp.Window +local docs_view = {} + +---Create new floating window module +docs_view.new = function() + local self = setmetatable({}, { __index = docs_view }) + self.entry = nil + self.window = window.new() + self.window:option('conceallevel', 2) + self.window:option('concealcursor', 'n') + self.window:option('foldenable', false) + self.window:option('linebreak', true) + self.window:option('scrolloff', 0) + self.window:option('wrap', true) + return self +end + +---Open documentation window +---@param e cmp.Entry +---@param view cmp.WindowStyle +docs_view.open = function(self, e, view) + local documentation = config.get().window.documentation + if not documentation then + return + end + + if not e or not view then + return self:close() + end + + local border_info = window.get_border_info({ style = documentation }) + local right_space = vim.o.columns - (view.col + view.width) - 1 + local left_space = view.col - 1 + local max_width = math.min(documentation.max_width, math.max(left_space, right_space)) + + -- Update buffer content if needed. + if not self.entry or e.id ~= self.entry.id then + local documents = e:get_documentation() + if #documents == 0 then + return self:close() + end + + self.entry = e + vim.api.nvim_buf_call(self.window:get_buffer(), function() + vim.cmd([[syntax clear]]) + vim.api.nvim_buf_set_lines(self.window:get_buffer(), 0, -1, false, {}) + end) + vim.lsp.util.stylize_markdown(self.window:get_buffer(), documents, { + max_width = max_width, + max_height = documentation.max_height, + }) + end + + -- Calculate window size. + local width, height = vim.lsp.util._make_floating_popup_size(vim.api.nvim_buf_get_lines(self.window:get_buffer(), 0, -1, false), { + max_width = max_width - border_info.horiz, + max_height = documentation.max_height - border_info.vert, + }) + if width <= 0 or height <= 0 then + return self:close() + end + + -- Calculate window position. + local right_col = view.col + view.width + local left_col = view.col - width - border_info.horiz + local col, left + if right_space >= width and left_space >= width then + if right_space < left_space then + col = left_col + left = true + else + col = right_col + end + elseif right_space >= width then + col = right_col + elseif left_space >= width then + col = left_col + left = true + else + return self:close() + end + + -- Render window. + self.window:option('winblend', vim.o.pumblend) + self.window:option('winhighlight', documentation.winhighlight) + local style = { + relative = 'editor', + style = 'minimal', + width = width, + height = height, + row = view.row, + col = col, + border = documentation.border, + zindex = documentation.zindex or 50, + } + self.window:open(style) + + -- Correct left-col for scrollbar existence. + if left then + style.col = style.col - self.window:info().scrollbar_offset + self.window:open(style) + end +end + +---Close floating window +docs_view.close = function(self) + self.window:close() + self.entry = nil +end + +docs_view.scroll = function(self, delta) + if self:visible() then + local info = vim.fn.getwininfo(self.window.win)[1] or {} + local top = info.topline or 1 + top = top + delta + top = math.max(top, 1) + top = math.min(top, self.window:get_content_height() - info.height + 1) + + vim.defer_fn(function() + vim.api.nvim_buf_call(self.window:get_buffer(), function() + vim.api.nvim_command('normal! ' .. top .. 'zt') + self.window:update() + end) + end, 0) + end +end + +docs_view.visible = function(self) + return self.window:visible() +end + +return docs_view diff --git a/start/cmp/lua/cmp/view/ghost_text_view.lua b/start/cmp/lua/cmp/view/ghost_text_view.lua new file mode 100644 index 0000000..b798ebb --- /dev/null +++ b/start/cmp/lua/cmp/view/ghost_text_view.lua @@ -0,0 +1,97 @@ +local config = require('cmp.config') +local misc = require('cmp.utils.misc') +local str = require('cmp.utils.str') +local types = require('cmp.types') +local api = require('cmp.utils.api') + +---@class cmp.GhostTextView +local ghost_text_view = {} + +ghost_text_view.ns = vim.api.nvim_create_namespace('cmp:GHOST_TEXT') + +ghost_text_view.new = function() + local self = setmetatable({}, { __index = ghost_text_view }) + self.win = nil + self.entry = nil + vim.api.nvim_set_decoration_provider(ghost_text_view.ns, { + on_win = function(_, win) + return win == self.win + end, + on_line = function(_) + local c = config.get().experimental.ghost_text + if not c then + return + end + + if not self.entry then + return + end + + local row, col = unpack(vim.api.nvim_win_get_cursor(0)) + local line = vim.api.nvim_get_current_line() + if string.sub(line, col + 1) ~= '' then + return + end + + local text = self.text_gen(self, line, col) + if #text > 0 then + vim.api.nvim_buf_set_extmark(0, ghost_text_view.ns, row - 1, col, { + right_gravity = false, + virt_text = { { text, c.hl_group or 'Comment' } }, + virt_text_pos = 'overlay', + hl_mode = 'combine', + ephemeral = true, + }) + end + end, + }) + return self +end + +---Generate the ghost text +--- This function calculates the bytes of the entry to display calculating the number +--- of character differences instead of just byte difference. +ghost_text_view.text_gen = function(self, line, cursor_col) + local word = self.entry:get_insert_text() + if self.entry.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then + word = vim.lsp.util.parse_snippet(word) + end + word = str.oneline(word) + local word_clen = vim.str_utfindex(word) + local cword = string.sub(line, self.entry:get_offset(), cursor_col) + local cword_clen = vim.str_utfindex(cword) + -- Number of characters from entry text (word) to be displayed as ghost thext + local nchars = word_clen - cword_clen + -- Missing characters to complete the entry text + local text + if nchars > 0 then + text = string.sub(word, vim.str_byteindex(word, word_clen - nchars) + 1) + else + text = '' + end + return text +end + +---Show ghost text +---@param e cmp.Entry +ghost_text_view.show = function(self, e) + if not api.is_insert_mode() then + return + end + local changed = e ~= self.entry + self.win = vim.api.nvim_get_current_win() + self.entry = e + if changed then + misc.redraw(true) -- force invoke decoration provider. + end +end + +ghost_text_view.hide = function(self) + if self.win and self.entry then + self.win = nil + self.entry = nil + misc.redraw(true) -- force invoke decoration provider. + end +end + +return ghost_text_view diff --git a/start/cmp/lua/cmp/view/native_entries_view.lua b/start/cmp/lua/cmp/view/native_entries_view.lua new file mode 100644 index 0000000..d8b0b1b --- /dev/null +++ b/start/cmp/lua/cmp/view/native_entries_view.lua @@ -0,0 +1,181 @@ +local event = require('cmp.utils.event') +local autocmd = require('cmp.utils.autocmd') +local keymap = require('cmp.utils.keymap') +local feedkeys = require('cmp.utils.feedkeys') +local types = require('cmp.types') +local config = require('cmp.config') +local api = require('cmp.utils.api') + +---@class cmp.NativeEntriesView +---@field private offset number +---@field private items vim.CompletedItem +---@field private entries cmp.Entry[] +---@field private preselect_index number +---@field public event cmp.Event +local native_entries_view = {} + +native_entries_view.new = function() + local self = setmetatable({}, { __index = native_entries_view }) + self.event = event.new() + self.offset = -1 + self.items = {} + self.entries = {} + self.preselect_index = 0 + autocmd.subscribe('CompleteChanged', function() + self.event:emit('change') + end) + return self +end + +native_entries_view.ready = function(_) + if vim.fn.pumvisible() == 0 then + return true + end + return vim.fn.complete_info({ 'mode' }).mode == 'eval' +end + +native_entries_view.on_change = function(self) + if #self.entries > 0 and self.offset <= vim.api.nvim_win_get_cursor(0)[2] + 1 then + local preselect_enabled = config.get().preselect == types.cmp.PreselectMode.Item + + local completeopt = vim.o.completeopt + if self.preselect_index == 1 and preselect_enabled then + vim.o.completeopt = 'menu,menuone,noinsert' + else + vim.o.completeopt = config.get().completion.completeopt + end + vim.fn.complete(self.offset, self.items) + vim.o.completeopt = completeopt + + if self.preselect_index > 1 and preselect_enabled then + self:preselect(self.preselect_index) + end + end +end + +native_entries_view.open = function(self, offset, entries) + local dedup = {} + local items = {} + local dedup_entries = {} + local preselect_index = 0 + for _, e in ipairs(entries) do + local item = e:get_vim_item(offset) + if item.dup == 1 or not dedup[item.abbr] then + dedup[item.abbr] = true + table.insert(items, item) + table.insert(dedup_entries, e) + if preselect_index == 0 and e.completion_item.preselect then + preselect_index = #dedup_entries + end + end + end + self.offset = offset + self.items = items + self.entries = dedup_entries + self.preselect_index = preselect_index + self:on_change() +end + +native_entries_view.close = function(self) + if api.is_suitable_mode() and self:visible() then + vim.fn.complete(1, {}) + vim.api.nvim_select_popupmenu_item(-1, false, true, {}) + end + self.offset = -1 + self.entries = {} + self.items = {} + self.preselect_index = 0 +end + +native_entries_view.abort = function(_) + if api.is_suitable_mode() then + vim.api.nvim_select_popupmenu_item(-1, true, true, {}) + end +end + +native_entries_view.visible = function(_) + return vim.fn.pumvisible() == 1 +end + +native_entries_view.info = function(self) + if self:visible() then + local info = vim.fn.pum_getpos() + return { + width = info.width + (info.scrollable and 1 or 0), + height = info.height, + row = info.row, + col = info.col, + } + end +end + +native_entries_view.preselect = function(self, index) + if self:visible() then + if index <= #self.entries then + vim.api.nvim_select_popupmenu_item(index - 1, false, false, {}) + end + end +end + +native_entries_view.select_next_item = function(self, option) + local callback = function() + self.event:emit('change') + end + if self:visible() then + if (option.behavior or types.cmp.SelectBehavior.Insert) == types.cmp.SelectBehavior.Insert then + feedkeys.call(keymap.t('<C-n>'), 'n', callback) + else + feedkeys.call(keymap.t('<Down>'), 'n', callback) + end + end +end + +native_entries_view.select_prev_item = function(self, option) + local callback = function() + self.event:emit('change') + end + if self:visible() then + if (option.behavior or types.cmp.SelectBehavior.Insert) == types.cmp.SelectBehavior.Insert then + feedkeys.call(keymap.t('<C-p>'), 'n', callback) + else + feedkeys.call(keymap.t('<Up>'), 'n', callback) + end + end +end + +native_entries_view.get_offset = function(self) + if self:visible() then + return self.offset + end + return nil +end + +native_entries_view.get_entries = function(self) + if self:visible() then + return self.entries + end + return {} +end + +native_entries_view.get_first_entry = function(self) + if self:visible() then + return self.entries[1] + end +end + +native_entries_view.get_selected_entry = function(self) + if self:visible() then + local idx = vim.fn.complete_info({ 'selected' }).selected + if idx > -1 then + return self.entries[math.max(0, idx) + 1] + end + end +end + +native_entries_view.get_active_entry = function(self) + if self:visible() and (vim.v.completed_item or {}).word then + return self:get_selected_entry() + end +end + +return native_entries_view diff --git a/start/cmp/lua/cmp/view/wildmenu_entries_view.lua b/start/cmp/lua/cmp/view/wildmenu_entries_view.lua new file mode 100644 index 0000000..3419164 --- /dev/null +++ b/start/cmp/lua/cmp/view/wildmenu_entries_view.lua @@ -0,0 +1,261 @@ +local event = require('cmp.utils.event') +local autocmd = require('cmp.utils.autocmd') +local feedkeys = require('cmp.utils.feedkeys') +local config = require('cmp.config') +local window = require('cmp.utils.window') +local types = require('cmp.types') +local keymap = require('cmp.utils.keymap') +local misc = require('cmp.utils.misc') +local api = require('cmp.utils.api') + +---@class cmp.CustomEntriesView +---@field private offset number +---@field private entries_win cmp.Window +---@field private active boolean +---@field private entries cmp.Entry[] +---@field public event cmp.Event +local wildmenu_entries_view = {} + +wildmenu_entries_view.ns = vim.api.nvim_create_namespace('cmp.view.statusline_entries_view') + +wildmenu_entries_view.new = function() + local self = setmetatable({}, { __index = wildmenu_entries_view }) + self.event = event.new() + self.offset = -1 + self.active = false + self.entries = {} + self.offsets = {} + self.selected_index = 0 + self.entries_win = window.new() + + self.entries_win:option('conceallevel', 2) + self.entries_win:option('concealcursor', 'n') + self.entries_win:option('cursorlineopt', 'line') + self.entries_win:option('foldenable', false) + self.entries_win:option('wrap', false) + self.entries_win:option('scrolloff', 0) + self.entries_win:option('sidescrolloff', 0) + self.entries_win:option('winhighlight', 'Normal:Pmenu,FloatBorder:Pmenu,CursorLine:PmenuSel,Search:None') + self.entries_win:buffer_option('tabstop', 1) + + autocmd.subscribe( + 'CompleteChanged', + vim.schedule_wrap(function() + if self:visible() and vim.fn.pumvisible() == 1 then + self:close() + end + end) + ) + + vim.api.nvim_set_decoration_provider(wildmenu_entries_view.ns, { + on_win = function(_, win, buf, _, _) + if win ~= self.entries_win.win or buf ~= self.entries_win:get_buffer() then + return + end + + for i, e in ipairs(self.entries) do + if e then + local view = e:get_view(self.offset, buf) + vim.api.nvim_buf_set_extmark(buf, wildmenu_entries_view.ns, 0, self.offsets[i], { + end_line = 0, + end_col = self.offsets[i] + view.abbr.bytes, + hl_group = view.abbr.hl_group, + hl_mode = 'combine', + ephemeral = true, + }) + + if i == self.selected_index then + vim.api.nvim_buf_set_extmark(buf, wildmenu_entries_view.ns, 0, self.offsets[i], { + end_line = 0, + end_col = self.offsets[i] + view.abbr.bytes, + hl_group = 'PmenuSel', + hl_mode = 'combine', + ephemeral = true, + }) + end + + for _, m in ipairs(e.matches or {}) do + vim.api.nvim_buf_set_extmark(buf, wildmenu_entries_view.ns, 0, self.offsets[i] + m.word_match_start - 1, { + end_line = 0, + end_col = self.offsets[i] + m.word_match_end, + hl_group = m.fuzzy and 'CmpItemAbbrMatchFuzzy' or 'CmpItemAbbrMatch', + hl_mode = 'combine', + ephemeral = true, + }) + end + end + end + end, + }) + return self +end + +wildmenu_entries_view.close = function(self) + self.entries_win:close() +end + +wildmenu_entries_view.ready = function() + return vim.fn.pumvisible() == 0 +end + +wildmenu_entries_view.on_change = function(self) + self.active = false +end + +wildmenu_entries_view.open = function(self, offset, entries) + self.offset = offset + self.entries = {} + + -- Apply window options (that might be changed) on the custom completion menu. + self.entries_win:option('winblend', vim.o.pumblend) + + local dedup = {} + local preselect = 0 + local i = 1 + for _, e in ipairs(entries) do + local view = e:get_view(offset, 0) + if view.dup == 1 or not dedup[e.completion_item.label] then + dedup[e.completion_item.label] = true + table.insert(self.entries, e) + if preselect == 0 and e.completion_item.preselect then + preselect = i + end + i = i + 1 + end + end + + self.entries_win:open({ + relative = 'editor', + style = 'minimal', + row = vim.o.lines - 2, + col = 0, + width = vim.o.columns, + height = 1, + zindex = 1001, + }) + self:draw() + + if preselect > 0 and config.get().preselect == types.cmp.PreselectMode.Item then + self:_select(preselect, { behavior = types.cmp.SelectBehavior.Select }) + elseif not string.match(config.get().completion.completeopt, 'noselect') then + self:_select(1, { behavior = types.cmp.SelectBehavior.Select }) + else + self:_select(0, { behavior = types.cmp.SelectBehavior.Select }) + end +end + +wildmenu_entries_view.abort = function(self) + feedkeys.call('', 'n', function() + self:close() + end) +end + +wildmenu_entries_view.draw = function(self) + self.offsets = {} + + local entries_buf = self.entries_win:get_buffer() + local texts = {} + local offset = 0 + for _, e in ipairs(self.entries) do + local view = e:get_view(self.offset, entries_buf) + table.insert(self.offsets, offset) + table.insert(texts, view.abbr.text) + offset = offset + view.abbr.bytes + #self:_get_separator() + end + + vim.api.nvim_buf_set_lines(entries_buf, 0, 1, false, { table.concat(texts, self:_get_separator()) }) + vim.api.nvim_buf_set_option(entries_buf, 'modified', false) + + vim.api.nvim_win_call(0, function() + misc.redraw() + end) +end + +wildmenu_entries_view.visible = function(self) + return self.entries_win:visible() +end + +wildmenu_entries_view.info = function(self) + return self.entries_win:info() +end + +wildmenu_entries_view.select_next_item = function(self, option) + if self:visible() then + if self.selected_index == 0 or self.selected_index == #self.entries then + self:_select(1, option) + else + self:_select(self.selected_index + 1, option) + end + end +end + +wildmenu_entries_view.select_prev_item = function(self, option) + if self:visible() then + if self.selected_index == 0 or self.selected_index <= 1 then + self:_select(#self.entries, option) + else + self:_select(self.selected_index - 1, option) + end + end +end + +wildmenu_entries_view.get_offset = function(self) + if self:visible() then + return self.offset + end + return nil +end + +wildmenu_entries_view.get_entries = function(self) + if self:visible() then + return self.entries + end + return {} +end + +wildmenu_entries_view.get_first_entry = function(self) + if self:visible() then + return self.entries[1] + end +end + +wildmenu_entries_view.get_selected_entry = function(self) + if self:visible() and self.active then + return self.entries[self.selected_index] + end +end + +wildmenu_entries_view.get_active_entry = function(self) + if self:visible() and self.active then + return self:get_selected_entry() + end +end + +wildmenu_entries_view._select = function(self, selected_index, option) + local is_next = self.selected_index < selected_index + self.selected_index = selected_index + self.active = (selected_index ~= 0) + + if self.active then + local e = self:get_active_entry() + if option.behavior == types.cmp.SelectBehavior.Insert then + local cursor = api.get_cursor() + local word = e:get_vim_item(self.offset).word + vim.api.nvim_feedkeys(keymap.backspace(string.sub(api.get_current_line(), self.offset, cursor[2])) .. word, 'int', true) + end + vim.api.nvim_win_call(self.entries_win.win, function() + local view = e:get_view(self.offset, self.entries_win:get_buffer()) + vim.api.nvim_win_set_cursor(0, { 1, self.offsets[selected_index] + (is_next and view.abbr.bytes or 0) }) + vim.cmd([[redraw!]]) -- Force refresh for vim.api.nvim_set_decoration_provider + end) + end + + self.event:emit('change') +end + +wildmenu_entries_view._get_separator = function() + local c = config.get() + return (c and c.view and c.view.entries and c.view.entries.separator) or ' ' +end + +return wildmenu_entries_view diff --git a/start/cmp/lua/cmp/vim_source.lua b/start/cmp/lua/cmp/vim_source.lua new file mode 100644 index 0000000..2ee8fbf --- /dev/null +++ b/start/cmp/lua/cmp/vim_source.lua @@ -0,0 +1,53 @@ +local misc = require('cmp.utils.misc') + +local vim_source = {} + +---@param id number +---@param args any[] +vim_source.on_callback = function(id, args) + if vim_source.to_callback.callbacks[id] then + vim_source.to_callback.callbacks[id](unpack(args)) + end +end + +---@param callback function +---@return number +vim_source.to_callback = setmetatable({ + callbacks = {}, +}, { + __call = function(self, callback) + local id = misc.id('cmp.vim_source.to_callback') + self.callbacks[id] = function(...) + callback(...) + self.callbacks[id] = nil + end + return id + end, +}) + +---Convert to serializable args. +---@param args any[] +vim_source.to_args = function(args) + for i, arg in ipairs(args) do + if type(arg) == 'function' then + args[i] = vim_source.to_callback(arg) + end + end + return args +end + +---@param bridge_id number +---@param methods string[] +vim_source.new = function(bridge_id, methods) + local self = {} + for _, method in ipairs(methods) do + self[method] = (function(m) + return function(_, ...) + return vim.fn['cmp#_method'](bridge_id, m, vim_source.to_args({ ... })) + end + end)(method) + end + return self +end + +return vim_source diff --git a/start/cmp/lua/cmp_nvim_lsp/init.lua b/start/cmp/lua/cmp_nvim_lsp/init.lua new file mode 100644 index 0000000..9feb93a --- /dev/null +++ b/start/cmp/lua/cmp_nvim_lsp/init.lua @@ -0,0 +1,84 @@ +local source = require('cmp_nvim_lsp.source') + +local M = {} + +---Registered client and source mapping. +M.client_source_map = {} + +---Setup cmp-nvim-lsp source. +M.setup = function() + vim.cmd([[ + augroup cmp_nvim_lsp + autocmd! + autocmd InsertEnter * lua require'cmp_nvim_lsp'._on_insert_enter() + augroup END + ]]) +end + +local if_nil = function(val, default) + if val == nil then return default end + return val +end + +M.update_capabilities = function(capabilities, override) + override = override or {} + + local completionItem = capabilities.textDocument.completion.completionItem + + completionItem.snippetSupport = if_nil(override.snippetSupport, true) + completionItem.preselectSupport = if_nil(override.preselectSupport, true) + completionItem.insertReplaceSupport = if_nil(override.insertReplaceSupport, true) + completionItem.labelDetailsSupport = if_nil(override.labelDetailsSupport, true) + completionItem.deprecatedSupport = if_nil(override.deprecatedSupport, true) + completionItem.commitCharactersSupport = if_nil(override.commitCharactersSupport, true) + completionItem.tagSupport = if_nil(override.tagSupport, { valueSet = { 1 } }) + completionItem.resolveSupport = if_nil(override.resolveSupport, { + properties = { + 'documentation', + 'detail', + 'additionalTextEdits', + } + }) + + return capabilities +end + +---Refresh sources on InsertEnter. +M._on_insert_enter = function() + local cmp = require('cmp') + + local allowed_clients = {} + + -- register all active clients. + for _, client in ipairs(vim.lsp.get_active_clients()) do + allowed_clients[client.id] = client + if not M.client_source_map[client.id] then + local s = source.new(client) + if s:is_available() then + M.client_source_map[client.id] = cmp.register_source('nvim_lsp', s) + end + end + end + + -- register all buffer clients (early register before activation) + for _, client in ipairs(vim.lsp.buf_get_clients(0)) do + allowed_clients[client.id] = client + if not M.client_source_map[client.id] then + local s = source.new(client) + if s:is_available() then + M.client_source_map[client.id] = cmp.register_source('nvim_lsp', s) + end + end + end + + -- unregister stopped/detached clients. + for client_id, source_id in pairs(M.client_source_map) do + if not allowed_clients[client_id] or allowed_clients[client_id]:is_stopped() then + cmp.unregister_source(source_id) + M.client_source_map[client_id] = nil + end + end +end + +return M + diff --git a/start/cmp/lua/cmp_nvim_lsp/source.lua b/start/cmp/lua/cmp_nvim_lsp/source.lua new file mode 100644 index 0000000..d4dd587 --- /dev/null +++ b/start/cmp/lua/cmp_nvim_lsp/source.lua @@ -0,0 +1,117 @@ +local source = {} + +source.new = function(client) + local self = setmetatable({}, { __index = source }) + self.client = client + self.request_ids = {} + return self +end + +source.get_debug_name = function(self) + return table.concat({ 'nvim_lsp', self.client.name }, ':') +end + +source.is_available = function(self) + -- client is stopped. + if self.client.is_stopped() then + return false + end + + -- client is not attached to current buffer. + if not vim.lsp.buf_get_clients(vim.api.nvim_get_current_buf())[self.client.id] then + return false + end + + -- client has no completion capability. + if not self:_get(self.client.server_capabilities, { 'completionProvider' }) then + return false + end + return true; +end + +source.get_trigger_characters = function(self) + return self:_get(self.client.server_capabilities, { 'completionProvider', 'triggerCharacters' }) or {} +end + +source.complete = function(self, request, callback) + local params = vim.lsp.util.make_position_params(0, self.client.offset_encoding) + params.context = {} + params.context.triggerKind = request.completion_context.triggerKind + params.context.triggerCharacter = request.completion_context.triggerCharacter + + self:_request('textDocument/completion', params, function(_, response) + callback(response) + end) +end + +source.resolve = function(self, completion_item, callback) + -- client is stopped. + if self.client.is_stopped() then + return callback() + end + + -- client has no completion capability. + if not self:_get(self.client.server_capabilities, { 'completionProvider', 'resolveProvider' }) then + return callback() + end + + self:_request('completionItem/resolve', completion_item, function(_, response) + callback(response or completion_item) + end) +end + +source.execute = function(self, completion_item, callback) + -- client is stopped. + if self.client.is_stopped() then + return callback() + end + + -- completion_item has no command. + if not completion_item.command then + return callback() + end + + self:_request('workspace/executeCommand', completion_item.command, function(_, _) + callback() + end) +end + +source._get = function(_, root, paths) + local c = root + for _, path in ipairs(paths) do + c = c[path] + if not c then + return nil + end + end + return c +end + +source._request = function(self, method, params, callback) + if self.request_ids[method] ~= nil then + self.client.cancel_request(self.request_ids[method]) + self.request_ids[method] = nil + end + local _, request_id + _, request_id = self.client.request(method, params, function(arg1, arg2, arg3) + if self.request_ids[method] ~= request_id then + return + end + self.request_ids[method] = nil + + -- Text changed, retry + if arg1 and arg1.code == -32801 then + self:_request(method, params, callback) + return + end + + if method == arg2 then + callback(arg1, arg3) -- old signature + else + callback(arg1, arg2) -- new signature + end + end) + self.request_ids[method] = request_id +end + +return source |