├── .gitignore ├── TODO.md ├── stylua.toml ├── .stylua.toml ├── .luacheckrc ├── lua └── cmp-kit │ ├── signature_help │ ├── ext │ │ ├── DefaultConfig.lua │ │ ├── source │ │ │ └── lsp │ │ │ │ └── signature_help.lua │ │ └── DefaultView.lua │ ├── init.lua │ ├── SignatureHelpProvider.lua │ └── SignatureHelpService.lua │ ├── kit │ ├── LSP │ │ ├── LanguageId.lua │ │ ├── Range.lua │ │ ├── Position.lua │ │ └── DocumentSelector.lua │ ├── Vim │ │ ├── WinSaveView.lua │ │ ├── RegExp.lua │ │ ├── Syntax.lua │ │ └── Keymap.lua │ ├── App │ │ ├── Cache.lua │ │ ├── Event.lua │ │ ├── Config.lua │ │ ├── Character.lua │ │ └── Command.lua │ ├── Spec │ │ └── init.lua │ ├── Async │ │ ├── Worker.lua │ │ ├── ScheduledTimer.lua │ │ ├── Timing.lua │ │ ├── init.lua │ │ └── AsyncTask.lua │ └── RPC │ │ └── JSON │ │ └── init.lua │ ├── completion │ ├── SnippetText.spec.lua │ ├── ext │ │ ├── DefaultConfig.lua │ │ ├── DefaultMatcher.spec.lua │ │ ├── source │ │ │ ├── emoji │ │ │ │ └── init.lua │ │ │ ├── calc.spec.lua │ │ │ ├── buffer.lua │ │ │ ├── calc.lua │ │ │ ├── lsp │ │ │ │ └── completion.lua │ │ │ ├── path.spec.lua │ │ │ ├── cmdline.lua │ │ │ ├── path.lua │ │ │ └── github │ │ │ │ └── init.lua │ │ ├── DefaultSorter.lua │ │ └── DefaultMatcher.lua │ ├── Hack.lua │ ├── CompletionService.spec.lua │ ├── init.lua │ ├── PreviewText.spec.lua │ ├── init.spec.lua │ ├── PreviewText.lua │ └── CompletionProvider.spec.lua │ ├── core │ ├── TriggerContext.spec.lua │ ├── debugger.lua │ ├── LinePatch.spec.lua │ ├── Buffer.spec.lua │ ├── LinePatch.lua │ └── Buffer.lua │ ├── init.lua │ └── spec │ ├── benchmark.spec.lua │ └── init.lua ├── Makefile ├── README.md ├── .github └── workflows │ └── ci.yml ├── scripts └── update-emoji.lua └── plugin └── cmp-kit.lua /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - [ ] Do use send macro keys. Use instead event system and handling macro keys in app layer. 2 | 3 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | indent_type = "Spaces" 2 | indent_width = 2 3 | column_width = 1200 4 | quote_style = "AutoPreferSingle" 5 | -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | indent_type = "Spaces" 2 | indent_width = 2 3 | column_width = 1200 4 | quote_style = "AutoPreferSingle" 5 | 6 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | globals = { 'vim', 'describe', 'it', 'before_each', 'after_each', 'assert', 'async' } 2 | max_line_length = false 3 | ignore = { '311', '421', '431' } 4 | 5 | -------------------------------------------------------------------------------- /lua/cmp-kit/signature_help/ext/DefaultConfig.lua: -------------------------------------------------------------------------------- 1 | local DefaultView = require('cmp-kit.signature_help.ext.DefaultView') 2 | 3 | ---@type cmp-kit.signature_help.SignatureHelpService.Config 4 | return { 5 | view = DefaultView.new(), 6 | } 7 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/LSP/LanguageId.lua: -------------------------------------------------------------------------------- 1 | local mapping = { 2 | ['sh'] = 'shellscript', 3 | ['javascript.tsx'] = 'javascriptreact', 4 | ['typescript.tsx'] = 'typescriptreact', 5 | } 6 | 7 | local LanguageId = {} 8 | 9 | function LanguageId.from_filetype(filetype) 10 | return mapping[filetype] or filetype 11 | end 12 | 13 | return LanguageId 14 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/SnippetText.spec.lua: -------------------------------------------------------------------------------- 1 | local SnippetText = require('cmp-kit.completion.SnippetText') 2 | 3 | describe('cmp-kit.completion', function() 4 | describe('SnippetText', function() 5 | describe('.parse', function() 6 | it('should return snippet text', function() 7 | assert.are.equal('a b c', tostring(SnippetText.parse('a ${1:b} c'))) 8 | end) 9 | end) 10 | end) 11 | end) 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DENO_DIR := ${PWD}/.deno_dir 2 | 3 | .PHONY: lint 4 | lint: 5 | docker run -v $(PWD):/code -i registry.gitlab.com/pipeline-components/luacheck:latest --codes /code/lua 6 | 7 | .PHONY: format 8 | format: 9 | docker run -v $(PWD):/src -i fnichol/stylua --config-path=/src/.stylua.toml -- /src/lua 10 | 11 | .PHONY: test 12 | test: 13 | TEST=1 vusted --output=gtest --pattern=.spec ./lua 14 | 15 | .PHONY: update-emoji 16 | update-emoji: 17 | nvim -l ./scripts/update-emoji.lua 18 | 19 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/DefaultConfig.lua: -------------------------------------------------------------------------------- 1 | local DefaultView = require('cmp-kit.completion.ext.DefaultView') 2 | local DefaultSorter = require('cmp-kit.completion.ext.DefaultSorter') 3 | local DefaultMatcher = require('cmp-kit.completion.ext.DefaultMatcher') 4 | 5 | ---@type cmp-kit.completion.CompletionService.Config 6 | return { 7 | view = DefaultView.new(), 8 | sorter = DefaultSorter, 9 | matcher = DefaultMatcher, 10 | is_macro_executing = function() 11 | return vim.fn.reg_executing() ~= '' 12 | end, 13 | is_macro_recording = function() 14 | return vim.fn.reg_recording() ~= '' 15 | end, 16 | preselect = true, 17 | performance = { 18 | fetching_timeout_ms = 300, 19 | menu_update_throttle_ms = 32, 20 | }, 21 | default_keyword_pattern = [[\%(-\?\d\+\%(\.\d\+\)\?\|\h\w*\%(-\w*\)*\)]], 22 | } 23 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/DefaultMatcher.spec.lua: -------------------------------------------------------------------------------- 1 | local DefaultMatcher = require('cmp-kit.completion.ext.DefaultMatcher') 2 | 3 | describe('cmp-kit.completion.ext', function() 4 | describe('DefaultMatcher', function() 5 | describe('.match', function() 6 | it('should match', function() 7 | assert.is_true(DefaultMatcher.match('', 'a') > 0) 8 | assert.is_true(DefaultMatcher.match('a', 'a') > 0) 9 | assert.is_true(DefaultMatcher.match('ab', 'a') == 0) 10 | assert.is_true(DefaultMatcher.match('ab', 'a_b_c') > 0) 11 | 12 | assert.is_true( 13 | DefaultMatcher.match('a', 'a') > DefaultMatcher.match('a', 'A') 14 | ) 15 | assert.is_true( 16 | DefaultMatcher.match('ab', 'ab') > DefaultMatcher.match('ab', 'Ab') 17 | ) 18 | end) 19 | end) 20 | end) 21 | end) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nvim-cmp-kit 2 | 3 | nvim's completion core module. 4 | 5 | ## Custom Extension 6 | 7 | `nvim-cmp-kit` implements some custom extensions that are not defined in LSP. 8 | 9 | - `LSP.CompletionItem.nvim_previewText` (string) 10 | - This value will be inserted as a preview text when the completion item is selected. 11 | 12 | ## Highlighting 13 | 14 | - markdown related highlights 15 | - CmpKitMarkdownAnnotateUnderlined 16 | - CmpKitMarkdownAnnotateBold 17 | - CmpKitMarkdownAnnotateEm 18 | - CmpKitMarkdownAnnotateStrong 19 | - CmpKitMarkdownAnnotateCode 20 | - CmpKitMarkdownAnnotateCodeBlock 21 | - CmpKitMarkdownAnnotateHeading{1,2,3,4,5,6} 22 | 23 | - completion related highlights 24 | - CmpKitDeprecated 25 | 26 | - default completion menu highlights 27 | - CmpKitCompletionItemLabel 28 | - CmpKitCompletionItemDescription 29 | - CmpKitCompletionItemMatch 30 | - CmpKitCompletionItemExtra 31 | 32 | ## TODO 33 | 34 | - [ ] Rename repository 35 | 36 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/Vim/WinSaveView.lua: -------------------------------------------------------------------------------- 1 | ---@class cmp-kit.kit.Vim.WinSaveView 2 | ---@field private _mode string 3 | ---@field private _view table 4 | ---@field private _cmd string 5 | ---@field private _win number 6 | ---@field private _cur table 7 | local WinSaveView = {} 8 | WinSaveView.__index = WinSaveView 9 | 10 | ---Create WinSaveView. 11 | function WinSaveView.new() 12 | return setmetatable({ 13 | _mode = vim.api.nvim_get_mode().mode, 14 | _view = vim.fn.winsaveview(), 15 | _cmd = vim.fn.winrestcmd(), 16 | _win = vim.api.nvim_get_current_win(), 17 | _cur = vim.api.nvim_win_get_cursor(0), 18 | }, WinSaveView) 19 | end 20 | 21 | ---Restore saved window. 22 | function WinSaveView:restore() 23 | if vim.api.nvim_win_is_valid(self._win) then 24 | vim.api.nvim_set_current_win(self._win) 25 | end 26 | 27 | -- restore modes. 28 | if vim.api.nvim_get_mode().mode ~= self._mode then 29 | if self._mode == 'i' then 30 | vim.cmd.startinsert() 31 | elseif vim.tbl_contains({ 'v', 'V', vim.keycode('') }, self._mode) then 32 | vim.cmd.normal({ 'gv', bang = true }) 33 | end 34 | end 35 | 36 | vim.api.nvim_win_set_cursor(0, self._cur) 37 | vim.cmd(self._cmd) 38 | vim.fn.winrestview(self._view) 39 | end 40 | 41 | return WinSaveView 42 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/Hack.lua: -------------------------------------------------------------------------------- 1 | local Character = require('cmp-kit.kit.App.Character') 2 | 3 | local Hack = {} 4 | 5 | Hack.clangd = {} 6 | 7 | ---The clangd does not respect VSCode implementation. 8 | ---In VSCode, the `vscode-clangd` fixes it, in `clangd` itself does not support VSCode compatible editors. 9 | ---@param item cmp-kit.completion.CompletionItem 10 | ---@param trigger_context cmp-kit.core.TriggerContext 11 | ---@param filter_text string 12 | ---@return string 13 | function Hack.clangd.get_filter_text( 14 | item, 15 | trigger_context, 16 | provider, 17 | filter_text 18 | ) 19 | if item:has_text_edit() then 20 | local offset = item:get_offset() -- NOTE: get_filter_text and get_offset reference each other, but calling get_offset here does NOT cause an infinite loop. 21 | if Character.is_symbol(trigger_context.text:byte(offset)) then 22 | local keyword_offset = provider:get_keyword_offset() 23 | local delta = keyword_offset - offset 24 | if delta > 0 then 25 | local prefix = trigger_context:substr(offset, keyword_offset - 1) 26 | if not vim.startswith(filter_text, prefix) then 27 | filter_text = prefix .. filter_text 28 | end 29 | end 30 | end 31 | end 32 | return filter_text 33 | end 34 | 35 | return Hack 36 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/source/emoji/init.lua: -------------------------------------------------------------------------------- 1 | local IO = require('cmp-kit.kit.IO') 2 | local Async = require('cmp-kit.kit.Async') 3 | local TriggerContext = require('cmp-kit.core.TriggerContext') 4 | 5 | local cache = {} 6 | 7 | local function script_path() 8 | return debug.getinfo(2, 'S').source:sub(2):match('(.*/)') 9 | end 10 | 11 | local function restore() 12 | if not cache.items then 13 | cache.items = vim.json.decode(IO.read_file( 14 | IO.join(script_path(), 'emoji.json') 15 | ):await()) 16 | end 17 | return cache.items 18 | end 19 | 20 | return function() 21 | ---@type cmp-kit.completion.CompletionSource 22 | return { 23 | name = 'emoji', 24 | get_configuration = function() 25 | return { 26 | trigger_characters = { ':' }, 27 | keyword_pattern = [=[\%(^\|\s\)\zs:\w\w*]=], 28 | } 29 | end, 30 | complete = function(_, _, callback) 31 | local trigger_context = TriggerContext.create() 32 | if not vim.regex([=[\%(^\|\s\)\zs:\w\w*$]=]):match_str(trigger_context.text_before) then 33 | return callback(nil, nil) 34 | end 35 | Async.run(function() 36 | callback(nil, { 37 | isIncomplete = false, 38 | items = restore(), 39 | }) 40 | end):dispatch(callback, function() 41 | callback(nil, nil) 42 | end) 43 | end 44 | } 45 | end 46 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/Vim/RegExp.lua: -------------------------------------------------------------------------------- 1 | local RegExp = {} 2 | 3 | ---@type table 4 | RegExp._cache = {} 5 | 6 | ---Create a RegExp object. 7 | ---@param pattern string 8 | ---@return { match_str: fun(self, text: string) } 9 | function RegExp.get(pattern) 10 | if not RegExp._cache[pattern] then 11 | RegExp._cache[pattern] = vim.regex(pattern) 12 | end 13 | return RegExp._cache[pattern] 14 | end 15 | 16 | ---Grep and substitute text. 17 | ---@param text string 18 | ---@param pattern string 19 | ---@param replacement string 20 | ---@return string 21 | function RegExp.gsub(text, pattern, replacement) 22 | return vim.fn.substitute(text, pattern, replacement, 'g') 23 | end 24 | 25 | ---Match pattern in text for specified position. 26 | ---@param text string 27 | ---@param pattern string 28 | ---@param pos integer 1-origin index 29 | ---@return string?, integer?, integer? 1-origin-index 30 | function RegExp.extract_at(text, pattern, pos) 31 | local before_text = text:sub(1, pos - 1) 32 | local after_text = text:sub(pos) 33 | local b_s, _ = RegExp.get(pattern .. '$'):match_str(before_text) 34 | local _, a_e = RegExp.get('^' .. pattern):match_str(after_text) 35 | if b_s or a_e then 36 | b_s = b_s or #before_text 37 | a_e = #before_text + (a_e or 0) 38 | return text:sub(b_s + 1, a_e), b_s + 1, a_e + 1 39 | end 40 | end 41 | 42 | return RegExp 43 | -------------------------------------------------------------------------------- /lua/cmp-kit/core/TriggerContext.spec.lua: -------------------------------------------------------------------------------- 1 | local spec = require('cmp-kit.spec') 2 | local Keymap = require('cmp-kit.kit.Vim.Keymap') 3 | local TriggerContext = require('cmp-kit.core.TriggerContext') 4 | 5 | describe('cmp-kit.completion', function() 6 | describe('TriggerContext', function() 7 | for _, mode in ipairs({ 'i', 'c' }) do 8 | describe(mode, function() 9 | describe('.create', function() 10 | it('should create trigger context', function() 11 | spec.reset() 12 | Keymap.spec(function() 13 | Keymap.send(mode == 'c' and ':' or 'i'):await() 14 | Keymap.send(Keymap.termcodes('foo+bar')):await() 15 | local trigger_context = TriggerContext.create({ trigger_character = '+' }) 16 | assert.are.equal(mode, trigger_context.mode) 17 | assert.are.equal(4, trigger_context.character) 18 | assert.are.equal('foo+bar', trigger_context.text) 19 | assert.are.equal('foo+', trigger_context.text_before) 20 | assert.are.equal('foo+', trigger_context:get_query(1)) 21 | assert.are.equal(false, trigger_context.force) 22 | assert.are.equal('+', trigger_context.trigger_character) 23 | Keymap.send(''):await() 24 | end) 25 | end) 26 | end) 27 | end) 28 | end 29 | end) 30 | end) 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: ~ 5 | push: 6 | branches: 7 | - main 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | check: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | nvim: 20 | - nightly 21 | - stable 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: rhysd/action-setup-vim@v1 26 | with: 27 | neovim: true 28 | version: ${{ matrix.nvim }} 29 | - uses: leafo/gh-actions-lua@v8 30 | with: 31 | luaVersion: "5.1" 32 | - uses: leafo/gh-actions-luarocks@v4 33 | 34 | - name: Luacheck 35 | uses: judaew/luacheck-action@v0.2.2 36 | with: 37 | targets: lua 38 | 39 | - run: | 40 | luarocks install vusted 41 | luarocks install luassert 42 | - run: make test 43 | 44 | 45 | auto-format: 46 | needs: check 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v4 50 | with: 51 | token: ${{ secrets.GITHUB_TOKEN }} 52 | fetch-depth: 0 53 | - uses: JohnnyMorganz/stylua-action@v4 54 | with: 55 | token: ${{ secrets.GITHUB_TOKEN }} 56 | version: latest 57 | args: "plugin lua" 58 | - uses: stefanzweifel/git-auto-commit-action@v6 59 | with: 60 | commit_message: "style: run stylua" 61 | file_pattern: | 62 | lua/**/*.lua 63 | -------------------------------------------------------------------------------- /scripts/update-emoji.lua: -------------------------------------------------------------------------------- 1 | if vim.fn.executable('curl') == 0 then 2 | print('curl is not installed. Please install curl to update emojis.') 3 | return 4 | end 5 | 6 | local function script_path() 7 | return debug.getinfo(2, 'S').source:sub(2):match('(.*/)') 8 | end 9 | 10 | local data_path = ('%s/../lua/cmp-kit/completion/ext/source/emoji/emoji.json'):format(script_path()) 11 | 12 | local function to_string(chars) 13 | local nrs = {} 14 | for _, char in ipairs(chars) do 15 | table.insert(nrs, vim.fn.eval(([[char2nr("\U%s")]]):format(char))) 16 | end 17 | return vim.fn.list2str(nrs, true) 18 | end 19 | 20 | local function fetch() 21 | vim.fn.system({ 22 | 'curl', 23 | '-s', 24 | 'https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json', 25 | '-o', 26 | data_path, 27 | }) 28 | end 29 | 30 | local function update() 31 | local data = vim.json.decode(table.concat(vim.fn.readfile(data_path), '\n')) 32 | 33 | local emojis = {} 34 | for _, emoji in ipairs(data) do 35 | local chars = to_string(vim.split(emoji.unified, '-')) 36 | if vim.api.nvim_strwidth(chars) <= 2 then 37 | emojis[#emojis + 1] = { 38 | kind = 21, 39 | label = (' %s :%s:'):format(chars, emoji.short_name), 40 | insertText = ('%s'):format(chars), 41 | filterText = (':%s:'):format(table.concat(emoji.short_names, ' ')), 42 | } 43 | end 44 | end 45 | vim.fn.writefile({ vim.json.encode(emojis) }, data_path) 46 | end 47 | 48 | local function main() 49 | fetch() 50 | update() 51 | end 52 | main() 53 | -------------------------------------------------------------------------------- /lua/cmp-kit/signature_help/init.lua: -------------------------------------------------------------------------------- 1 | ---@class cmp-kit.signature_help.SignatureHelpSource.Configuration 2 | ---@field public trigger_characters? string[] 3 | ---@field public retrigger_characters? string[] 4 | ---@field public position_encoding_kind? cmp-kit.kit.LSP.PositionEncodingKind 5 | 6 | ---@class cmp-kit.signature_help.SignatureHelpSource 7 | ---@field public name string 8 | ---@field public get_configuration? fun(self: unknown): cmp-kit.signature_help.SignatureHelpSource.Configuration 9 | ---@field public fetch fun(self: unknown, context: cmp-kit.kit.LSP.SignatureHelpContext, callback: fun(err?: unknown, response?: cmp-kit.kit.LSP.TextDocumentSignatureHelpResponse)): nil 10 | ---@field public capable? fun(self: unknown): boolean 11 | 12 | ---@class cmp-kit.signature_help.SignatureHelpView 13 | ---@field public show fun(self: cmp-kit.signature_help.SignatureHelpView, data: cmp-kit.signature_help.ActiveSignatureData) 14 | ---@field public hide fun(self: cmp-kit.signature_help.SignatureHelpView) 15 | ---@field public is_visible fun(): boolean 16 | ---@field public select fun(self: cmp-kit.signature_help.SignatureHelpView) 17 | ---@field public scroll fun(self: cmp-kit.signature_help.SignatureHelpView, delta: integer) 18 | ---@field public dispose fun(self: cmp-kit.signature_help.SignatureHelpView) 19 | 20 | ---@class cmp-kit.signature_help.ActiveSignatureData 21 | ---@field public signature cmp-kit.kit.LSP.SignatureInformation 22 | ---@field public parameter_index integer 1-origin index 23 | ---@field public signature_index integer 1-origin index 24 | ---@field public signature_count integer 25 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/App/Cache.lua: -------------------------------------------------------------------------------- 1 | ---Create cache key. 2 | ---@private 3 | ---@param key string[]|string 4 | ---@return string 5 | local function _key(key) 6 | if type(key) == 'table' then 7 | return table.concat(key, ':') 8 | end 9 | return key 10 | end 11 | 12 | ---@class cmp-kit.kit.App.Cache 13 | ---@field private keys table 14 | ---@field private entries table 15 | local Cache = {} 16 | Cache.__index = Cache 17 | 18 | ---Create new cache instance. 19 | function Cache.new() 20 | local self = setmetatable({}, Cache) 21 | self.keys = {} 22 | self.entries = {} 23 | return self 24 | end 25 | 26 | ---Get cache entry. 27 | ---@param key string[]|string 28 | ---@return any 29 | function Cache:get(key) 30 | return self.entries[_key(key)] 31 | end 32 | 33 | ---Set cache entry. 34 | ---@param key string[]|string 35 | ---@param val any 36 | function Cache:set(key, val) 37 | key = _key(key) 38 | self.keys[key] = true 39 | self.entries[key] = val 40 | end 41 | 42 | ---Delete cache entry. 43 | ---@param key string[]|string 44 | function Cache:del(key) 45 | key = _key(key) 46 | self.keys[key] = nil 47 | self.entries[key] = nil 48 | end 49 | 50 | ---Return this cache has the key entry or not. 51 | ---@param key string[]|string 52 | ---@return boolean 53 | function Cache:has(key) 54 | key = _key(key) 55 | return not not self.keys[key] 56 | end 57 | 58 | ---Ensure cache entry. 59 | ---@generic T 60 | ---@generic U 61 | ---@param key string[]|string 62 | ---@param callback function(...: U): T 63 | ---@param ... U 64 | ---@return T 65 | function Cache:ensure(key, callback, ...) 66 | if not self:has(key) then 67 | self:set(key, callback(...)) 68 | end 69 | return self:get(key) 70 | end 71 | 72 | return Cache 73 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/Spec/init.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local assert = require('luassert') 3 | 4 | ---@class cmp-kit.Spec.SetupOption 5 | ---@field filetype? string 6 | ---@field noexpandtab? boolean 7 | ---@field shiftwidth? integer 8 | ---@field tabstop? integer 9 | 10 | ---@param buffer string|string[] 11 | local function parse_buffer(buffer) 12 | buffer = kit.to_array(buffer) 13 | 14 | for i, line in ipairs(buffer) do 15 | local s = line:find('|', 1, true) 16 | if s then 17 | buffer[i] = line:gsub('|', '') 18 | return buffer, { i, s - 1 } 19 | end 20 | end 21 | error('cursor position is not found.') 22 | end 23 | 24 | local Spec = {} 25 | 26 | ---Setup buffer. 27 | ---@param buffer string|string[] 28 | ---@param option? cmp-kit.Spec.SetupOption 29 | function Spec.setup(buffer, option) 30 | option = option or {} 31 | 32 | vim.cmd.enew({ bang = true }) 33 | vim.cmd([[ set noswapfile ]]) 34 | vim.cmd([[ set virtualedit=onemore ]]) 35 | vim.cmd(([[ set shiftwidth=%s ]]):format(option.shiftwidth or 2)) 36 | vim.cmd(([[ set tabstop=%s ]]):format(option.tabstop or 2)) 37 | if option.noexpandtab then 38 | vim.cmd([[ set noexpandtab ]]) 39 | else 40 | vim.cmd([[ set expandtab ]]) 41 | end 42 | if option.filetype then 43 | vim.cmd(([[ set filetype=%s ]]):format(option.filetype)) 44 | end 45 | 46 | local lines, cursor = parse_buffer(buffer) 47 | vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) 48 | vim.api.nvim_win_set_cursor(0, cursor) 49 | end 50 | 51 | ---Expect buffer. 52 | function Spec.expect(buffer) 53 | local lines, cursor = parse_buffer(buffer) 54 | assert.are.same(lines, vim.api.nvim_buf_get_lines(0, 0, -1, false)) 55 | assert.are.same(cursor, vim.api.nvim_win_get_cursor(0)) 56 | end 57 | 58 | return Spec 59 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/source/calc.spec.lua: -------------------------------------------------------------------------------- 1 | local calc_source = require('cmp-kit.completion.ext.source.calc') 2 | local spec = require('cmp-kit.spec') 3 | local LSP = require('cmp-kit.kit.LSP') 4 | local Async = require('cmp-kit.kit.Async') 5 | 6 | describe('cmp-kit.completion.ext.source.calc', function() 7 | local source = calc_source() 8 | 9 | ---@param text string 10 | ---@param output number? 11 | local function assert_output(text, output) 12 | for _, buffer_text in ipairs({ text, ('%s '):format(text) }) do 13 | spec.setup({ buffer_text = { buffer_text } }) 14 | local response = Async.new(function(resolve, reject) 15 | source:complete({ triggerKind = LSP.CompletionTriggerKind.Invoked }, function(err, res) 16 | if err then 17 | reject(err) 18 | else 19 | resolve(res) 20 | end 21 | end) 22 | end):sync(2 * 1000) 23 | if output == nil then 24 | assert.is_not_nil(response) 25 | assert.are_equal(#response.items, 0) 26 | else 27 | assert.is_not_nil(response) 28 | assert.are_equal(#response.items, 2) 29 | assert.is_truthy(response.items[1].label:match(('= %s$'):format(vim.pesc(tostring(output))))) 30 | assert.is_truthy(response.items[2].label:match(('= %s$'):format(vim.pesc(tostring(output))))) 31 | end 32 | end 33 | end 34 | 35 | it('basic usage', function() 36 | assert_output('5|', nil) 37 | assert_output('5 * 100|', 5 * 100) 38 | assert_output('5 * math.pow(1, 2)|', 5 * math.pow(1, 2)) 39 | assert_output('5 * math.pow(1, 2)|', 5 * math.pow(1, 2)) 40 | assert_output('5 * math.pow(1, 2) = |', 5 * math.pow(1, 2)) 41 | assert_output('1 + 2 * (3 - 1)|', 1 + 2 * (3 - 1)) 42 | assert_output('1 + 2 * (3 - 1) = |', 1 + 2 * (3 - 1)) 43 | end) 44 | end) 45 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/Async/Worker.lua: -------------------------------------------------------------------------------- 1 | local uv = require('luv') 2 | local AsyncTask = require('cmp-kit.kit.Async.AsyncTask') 3 | 4 | ---@class cmp-kit.kit.Async.WorkerOption 5 | ---@field public runtimepath string[] 6 | 7 | ---@class cmp-kit.kit.Async.Worker 8 | ---@field private runner string 9 | local Worker = {} 10 | Worker.__index = Worker 11 | 12 | ---Create a new worker. 13 | ---@param runner function 14 | function Worker.new(runner) 15 | local self = setmetatable({}, Worker) 16 | self.runner = string.dump(runner) 17 | return self 18 | end 19 | 20 | ---Call worker function. 21 | ---@return cmp-kit.kit.Async.AsyncTask 22 | function Worker:__call(...) 23 | local args_ = { ... } 24 | return AsyncTask.new(function(resolve, reject) 25 | uv.new_work(function(runner, args, option) 26 | args = vim.mpack.decode(args) 27 | option = vim.mpack.decode(option) 28 | 29 | --Initialize cwd. 30 | require('luv').chdir(option.cwd) 31 | 32 | --Initialize package.loaders. 33 | table.insert(package.loaders, 2, vim._load_package) 34 | 35 | --Run runner function. 36 | local ok, res = pcall(function() 37 | return require('cmp-kit.kit.Async.AsyncTask').resolve(assert(loadstring(runner))(unpack(args))):sync(5000) 38 | end) 39 | 40 | res = vim.mpack.encode({ res }) 41 | 42 | --Return error or result. 43 | if not ok then 44 | return res, nil 45 | else 46 | return nil, res 47 | end 48 | end, function(err, res) 49 | if err then 50 | reject(vim.mpack.decode(err)[1]) 51 | else 52 | resolve(vim.mpack.decode(res)[1]) 53 | end 54 | end):queue( 55 | self.runner, 56 | vim.mpack.encode(args_), 57 | vim.mpack.encode({ 58 | cwd = uv.cwd(), 59 | }) 60 | ) 61 | end) 62 | end 63 | 64 | return Worker 65 | -------------------------------------------------------------------------------- /lua/cmp-kit/init.lua: -------------------------------------------------------------------------------- 1 | local cmp_kit = {} 2 | 3 | ---Return completion related capabilities. 4 | ---@return table 5 | function cmp_kit.get_completion_capabilities() 6 | return { 7 | textDocument = { 8 | completion = { 9 | dynamicRegistration = true, 10 | completionItem = { 11 | snippetSupport = true, 12 | commitCharactersSupport = true, 13 | deprecatedSupport = true, 14 | preselectSupport = true, 15 | tagSupport = { 16 | valueSet = { 1 }, 17 | }, 18 | insertReplaceSupport = true, 19 | resolveSupport = { 20 | properties = { 21 | 'documentation', 22 | 'additionalTextEdits', 23 | 'insertTextFormat', 24 | 'insertTextMode', 25 | 'command', 26 | }, 27 | }, 28 | insertTextModeSupport = { 29 | valueSet = { 1, 2 }, 30 | }, 31 | labelDetailsSupport = true, 32 | }, 33 | contextSupport = true, 34 | insertTextMode = 1, 35 | completionList = { 36 | itemDefaults = { 37 | 'commitCharacters', 38 | 'editRange', 39 | 'insertTextFormat', 40 | 'insertTextMode', 41 | 'data', 42 | }, 43 | }, 44 | }, 45 | }, 46 | } 47 | end 48 | 49 | ---Return completion related capabilities. 50 | ---@return table 51 | function cmp_kit.get_signature_help_capabilities() 52 | return { 53 | textDocument = { 54 | signatureHelp = { 55 | dynamicRegistration = true, 56 | signatureInformation = { 57 | documentationFormat = { 'markdown', 'plaintext' }, 58 | parameterInformation = { 59 | labelOffsetSupport = true, 60 | }, 61 | activeParameterSupport = true, 62 | }, 63 | contextSupport = true, 64 | }, 65 | }, 66 | } 67 | end 68 | 69 | return cmp_kit 70 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/App/Event.lua: -------------------------------------------------------------------------------- 1 | ---@class cmp-kit.kit.App.Event 2 | ---@field private _events table 3 | local Event = {} 4 | Event.__index = Event 5 | 6 | ---Create new Event. 7 | function Event.new() 8 | local self = setmetatable({}, Event) 9 | self._events = {} 10 | return self 11 | end 12 | 13 | ---Register listener. 14 | ---@param name string 15 | ---@param listener function 16 | ---@return function 17 | function Event:on(name, listener) 18 | self._events[name] = self._events[name] or {} 19 | table.insert(self._events[name], listener) 20 | return function() 21 | self:off(name, listener) 22 | end 23 | end 24 | 25 | ---Register once listener. 26 | ---@param name string 27 | ---@param listener function 28 | function Event:once(name, listener) 29 | local off 30 | off = self:on(name, function(...) 31 | listener(...) 32 | off() 33 | end) 34 | end 35 | 36 | ---Off specified listener from event. 37 | ---@param name string 38 | ---@param listener function 39 | function Event:off(name, listener) 40 | self._events[name] = self._events[name] or {} 41 | if not listener then 42 | self._events[name] = nil 43 | else 44 | for i = #self._events[name], 1, -1 do 45 | if self._events[name][i] == listener then 46 | table.remove(self._events[name], i) 47 | break 48 | end 49 | end 50 | end 51 | end 52 | 53 | ---Return if the listener is registered. 54 | ---@param name string 55 | ---@param listener? function 56 | ---@return boolean 57 | function Event:has(name, listener) 58 | self._events[name] = self._events[name] or {} 59 | for _, v in ipairs(self._events[name]) do 60 | if v == listener then 61 | return true 62 | end 63 | end 64 | return false 65 | end 66 | 67 | ---Emit event. 68 | ---@param name string 69 | ---@vararg any 70 | function Event:emit(name, ...) 71 | self._events[name] = self._events[name] or {} 72 | for _, v in ipairs(self._events[name]) do 73 | v(...) 74 | end 75 | end 76 | 77 | return Event 78 | -------------------------------------------------------------------------------- /lua/cmp-kit/core/debugger.lua: -------------------------------------------------------------------------------- 1 | local debugger = {} 2 | 3 | local private = { 4 | ---@type integer 5 | ns = vim.api.nvim_create_namespace('cmp-kit.core.debugger'), 6 | ---@type integer 7 | buf = vim.api.nvim_create_buf(false, true), 8 | ---@type integer 9 | win = nil, 10 | ---@type boolean 11 | enabled = false, 12 | } 13 | 14 | ---Enable or disable debugger. 15 | ---@param enabled? boolean 16 | ---@return boolean 17 | function debugger.enable(enabled) 18 | if type(enabled) == 'boolean' then 19 | private.enabled = enabled 20 | end 21 | return private.enabled 22 | end 23 | 24 | ---Add debugger entry. 25 | ---@param name string 26 | ---@param entry any 27 | function debugger.add(name, entry) 28 | if not private.enabled then 29 | return 30 | end 31 | 32 | local botrow = vim.api.nvim_buf_line_count(private.buf) 33 | local lines = {} 34 | local marks = {} 35 | 36 | -- insert name. 37 | table.insert(lines, name) 38 | table.insert(marks, { 39 | row = #lines - 1, 40 | col = 0, 41 | mark = { 42 | end_row = #lines - 1, 43 | end_col = #name, 44 | hl_group = 'Special', 45 | }, 46 | }) 47 | 48 | -- insert inspected values. 49 | local inspected = vim.inspect(vim.deepcopy(entry, true)) 50 | for _, s in ipairs(vim.split(inspected, '\n')) do 51 | table.insert(lines, s) 52 | end 53 | table.insert(lines, '') 54 | 55 | -- set lines. 56 | vim.api.nvim_buf_set_lines(private.buf, -1, -1, false, lines) 57 | 58 | -- set marks. 59 | for _, extmark in ipairs(marks) do 60 | pcall(vim.api.nvim_buf_set_extmark, private.buf, private.ns, extmark.row, extmark.col, extmark.mark) 61 | end 62 | 63 | -- locate cursor. 64 | if private.win and vim.api.nvim_win_is_valid(private.win) then 65 | vim.api.nvim_win_set_cursor(private.win, { botrow, 0 }) 66 | end 67 | end 68 | 69 | ---Open debugger logs. 70 | function debugger.open() 71 | debugger.enable(true) 72 | if private.win then 73 | pcall(vim.api.nvim_win_close, private.win, true) 74 | end 75 | private.win = vim.api.nvim_open_win(private.buf, true, { 76 | vertical = true, 77 | split = 'right', 78 | }) 79 | end 80 | 81 | return debugger 82 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/Vim/Syntax.lua: -------------------------------------------------------------------------------- 1 | local Syntax = {} 2 | 3 | ---Return the specified position is in the specified syntax. 4 | ---@param cursor { [1]: integer, [2]: integer } 5 | ---@param groups string[] 6 | function Syntax.within(cursor, groups) 7 | for _, group in ipairs(Syntax.get_syntax_groups(cursor)) do 8 | if vim.tbl_contains(groups, group) then 9 | return true 10 | end 11 | end 12 | return false 13 | end 14 | 15 | ---Get all syntax groups for specified position. 16 | ---NOTE: This function accepts 0-origin cursor position. 17 | ---@param cursor { [1]: integer, [2]: integer } 18 | ---@return string[] 19 | function Syntax.get_syntax_groups(cursor) 20 | local treesitter = Syntax.get_treesitter_syntax_groups(cursor) 21 | if #treesitter > 0 then 22 | return treesitter 23 | end 24 | return Syntax.get_vim_syntax_groups(cursor) -- it might be heavy. 25 | end 26 | 27 | ---Get vim's syntax groups for specified position. 28 | ---NOTE: This function accepts 0-origin cursor position. 29 | ---@param cursor { [1]: integer, [2]: integer } 30 | ---@return string[] 31 | function Syntax.get_vim_syntax_groups(cursor) 32 | local unique = {} 33 | local groups = {} 34 | for _, syntax_id in ipairs(vim.fn.synstack(cursor[1] + 1, cursor[2] + 1)) do 35 | local name = vim.fn.synIDattr(vim.fn.synIDtrans(syntax_id), 'name') 36 | if not unique[name] then 37 | unique[name] = true 38 | table.insert(groups, name) 39 | end 40 | end 41 | for _, syntax_id in ipairs(vim.fn.synstack(cursor[1] + 1, cursor[2] + 1)) do 42 | local name = vim.fn.synIDattr(syntax_id, 'name') 43 | if not unique[name] then 44 | unique[name] = true 45 | table.insert(groups, name) 46 | end 47 | end 48 | return groups 49 | end 50 | 51 | ---Get tree-sitter's syntax groups for specified position. 52 | ---NOTE: This function accepts 0-origin cursor position. 53 | ---@param cursor { [1]: integer, [2]: integer } 54 | ---@return string[] 55 | function Syntax.get_treesitter_syntax_groups(cursor) 56 | local groups = {} 57 | for _, capture in ipairs(vim.treesitter.get_captures_at_pos(0, cursor[1], cursor[2])) do 58 | table.insert(groups, ('@%s'):format(capture.capture)) 59 | end 60 | return groups 61 | end 62 | 63 | return Syntax 64 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/Async/ScheduledTimer.lua: -------------------------------------------------------------------------------- 1 | ---@class cmp-kit.kit.Async.ScheduledTimer 2 | ---@field private _timer uv.uv_timer_t 3 | ---@field private _start_time integer 4 | ---@field private _running boolean 5 | ---@field private _revision integer 6 | local ScheduledTimer = {} 7 | ScheduledTimer.__index = ScheduledTimer 8 | 9 | ---Create new timer. 10 | function ScheduledTimer.new() 11 | return setmetatable({ 12 | _timer = assert(vim.uv.new_timer()), 13 | _schedule_fn = function(callback) 14 | if vim.in_fast_event() then 15 | vim.schedule(callback) 16 | else 17 | callback() 18 | end 19 | end, 20 | _start_time = 0, 21 | _running = false, 22 | _revision = 0, 23 | }, ScheduledTimer) 24 | end 25 | 26 | ---Check if timer is running. 27 | ---@return boolean 28 | function ScheduledTimer:is_running() 29 | return self._running 30 | end 31 | 32 | ---Get recent start time. 33 | ---@return integer 34 | function ScheduledTimer:start_time() 35 | return self._start_time 36 | end 37 | 38 | ---Set schedule function. 39 | ---@param schedule_fn fun(callback: fun()): nil 40 | function ScheduledTimer:set_schedule_fn(schedule_fn) 41 | self._schedule_fn = schedule_fn 42 | end 43 | 44 | ---Start timer. 45 | function ScheduledTimer:start(ms, repeat_ms, callback) 46 | self._timer:stop() 47 | self._running = true 48 | self._revision = self._revision + 1 49 | local revision = self._revision 50 | 51 | local on_tick 52 | local tick 53 | 54 | on_tick = function() 55 | if revision ~= self._revision then 56 | return 57 | end 58 | self._schedule_fn(tick) 59 | end 60 | 61 | tick = function() 62 | if revision ~= self._revision then 63 | return 64 | end 65 | callback() -- `callback()` can restart timer, so it need to check revision here again. 66 | if revision ~= self._revision then 67 | return 68 | end 69 | if repeat_ms ~= 0 then 70 | self._timer:start(repeat_ms, 0, on_tick) 71 | else 72 | self._running = false 73 | end 74 | end 75 | 76 | self._start_time = vim.uv.hrtime() / 1e6 77 | self._timer:stop() 78 | if ms == 0 then 79 | on_tick() 80 | else 81 | self._timer:start(ms, 0, on_tick) 82 | end 83 | end 84 | 85 | ---Stop timer. 86 | function ScheduledTimer:stop() 87 | self._timer:stop() 88 | self._running = false 89 | self._revision = self._revision + 1 90 | end 91 | 92 | return ScheduledTimer 93 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/source/buffer.lua: -------------------------------------------------------------------------------- 1 | local Async = require('cmp-kit.kit.Async') 2 | local Buffer = require('cmp-kit.core.Buffer') 3 | local DefaultConfig = require('cmp-kit.completion.ext.DefaultConfig') 4 | 5 | ---@class cmp-kit.completion.ext.source.buffer.Option 6 | ---@field public keyword_pattern? string 7 | ---@field public gather_keyword_length? integer 8 | ---@field public get_bufnrs? fun(): integer[] 9 | ---@field public label_details? cmp-kit.kit.LSP.CompletionItemLabelDetails 10 | ---@param option? cmp-kit.completion.ext.source.buffer.Option 11 | return function(option) 12 | local keyword_pattern = option and option.keyword_pattern or DefaultConfig.default_keyword_pattern 13 | local gather_keyword_length = option and option.gather_keyword_length or 3 14 | local get_bufnrs = option and option.get_bufnrs or function() 15 | return vim.iter(vim.api.nvim_list_wins()):map(vim.api.nvim_win_get_buf):totable() 16 | end 17 | 18 | ---@param bufs integer[] 19 | ---@return cmp-kit.kit.LSP.CompletionList 20 | local function get_items(bufs) 21 | local is_indexing = false 22 | local uniq = {} 23 | local items = {} 24 | for _, buf in ipairs(bufs) do 25 | local max = vim.api.nvim_buf_line_count(buf) 26 | for i = 0, max do 27 | for _, word in ipairs(Buffer.ensure(buf):get_words(keyword_pattern, i)) do 28 | if not uniq[word] then 29 | uniq[word] = true 30 | if #word >= gather_keyword_length then 31 | table.insert(items, { 32 | label = word, 33 | labelDetails = option and option.label_details, 34 | } --[[@as cmp-kit.kit.LSP.CompletionItem]]) 35 | end 36 | Async.interrupt(8, 16) 37 | end 38 | end 39 | end 40 | is_indexing = is_indexing or Buffer.ensure(buf):is_indexing(keyword_pattern) 41 | end 42 | return { 43 | isIncomplete = is_indexing, 44 | items = items, 45 | } 46 | end 47 | 48 | ---@type cmp-kit.completion.CompletionSource 49 | return { 50 | name = 'buffer', 51 | get_configuration = function() 52 | return { 53 | keyword_pattern = keyword_pattern, 54 | } 55 | end, 56 | complete = function(_, _, callback) 57 | Async.run(function() 58 | return get_items(get_bufnrs()) 59 | end):dispatch(function(res) 60 | callback(nil, res) 61 | end, function(err) 62 | callback(err, nil) 63 | end) 64 | end, 65 | } 66 | end 67 | -------------------------------------------------------------------------------- /lua/cmp-kit/spec/benchmark.spec.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local tailwindcss_fixture = require('cmp-kit.spec.fixtures.tailwindcss') 3 | 4 | -- luacheck: ignore 511 5 | if false then 6 | ---@diagnostic disable-next-line: duplicate-set-field 7 | _G.describe = function(_, fn) 8 | fn() 9 | end 10 | ---@diagnostic disable-next-line: duplicate-set-field 11 | _G.it = function(_, fn) 12 | fn() 13 | end 14 | end 15 | 16 | local spec = require('cmp-kit.spec') 17 | 18 | local function run(name, fn) 19 | collectgarbage('collect') 20 | collectgarbage('stop') 21 | local s = vim.uv.hrtime() / 1000000 22 | fn() 23 | local e = vim.uv.hrtime() / 1000000 24 | print(('[%s]: elapsed time: %sms, memory: %skb'):format(name, e - s, collectgarbage('count'))) 25 | print('\n') 26 | collectgarbage('restart') 27 | end 28 | 29 | describe('cmp-kit.misc.spec.benchmark', function() 30 | local input = function(text) 31 | local cursor = vim.api.nvim_win_get_cursor(0) 32 | vim.api.nvim_buf_set_text(0, cursor[1] - 1, cursor[2], cursor[1] - 1, cursor[2], { text }) 33 | vim.api.nvim_win_set_cursor(0, { cursor[1], cursor[2] + #text }) 34 | end 35 | for _, isIncomplete in ipairs({ true, false }) do 36 | it(('isIncomplete=%s'):format(isIncomplete), function() 37 | local response = tailwindcss_fixture() 38 | local _, _, service = spec.setup({ 39 | buffer_text = { 40 | '|', 41 | }, 42 | item_defaults = response.itemDefaults, 43 | is_incomplete = response.isIncomplete, 44 | items = response.items, 45 | }) 46 | service:set_config(kit.merge({ 47 | performance = { 48 | fetching_timeout_ms = 0, 49 | }, 50 | }, service:get_config())) 51 | for i = 1, 3 do 52 | vim.cmd.enew() 53 | run(('isIncomplete=%s: %s'):format(isIncomplete, i), function() 54 | require('cmp-kit.spec').start_profile() 55 | input('') 56 | service:complete({ force = true }):sync(1 * 1000) 57 | input('g') 58 | service:complete():sync(1 * 1000) 59 | input('r') 60 | service:complete():sync(1 * 1000) 61 | input('o') 62 | service:complete():sync(1 * 1000) 63 | input('u') 64 | service:complete():sync(1 * 1000) 65 | input('p') 66 | service:complete():sync(1 * 1000) 67 | require('cmp-kit.spec').print_profile() 68 | end) 69 | end 70 | end) 71 | end 72 | end) 73 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/Async/Timing.lua: -------------------------------------------------------------------------------- 1 | local ScheduledTimer = require('cmp-kit.kit.Async.ScheduledTimer') 2 | 3 | ---Get current ms. 4 | ---@return integer 5 | local function now_ms() 6 | return vim.uv.hrtime() / 1e6 7 | end 8 | 9 | ---@class cmp-kit.kit.Async.Timing.Handle 10 | ---@field public timeout_ms integer 11 | ---@field public timer cmp-kit.kit.Async.ScheduledTimer 12 | 13 | ---@alias cmp-kit.kit.Async.Timing.TimingFunction fun() | cmp-kit.kit.Async.Timing.Handle 14 | 15 | local Timing = {} 16 | 17 | ---Create debounced callback. 18 | ---@param callback fun() 19 | ---@param option { timeout_ms: integer, } 20 | ---@return cmp-kit.kit.Async.Timing.TimingFunction 21 | function Timing.debounce(callback, option) 22 | local timer = ScheduledTimer.new() 23 | local arguments --[=[@as unknown[]?]=] 24 | return setmetatable({ 25 | timeout_ms = option.timeout_ms, 26 | timer = timer, 27 | flush = function() 28 | if arguments then 29 | timer:stop() 30 | callback(unpack(arguments)) 31 | arguments = nil 32 | end 33 | end, 34 | }, { 35 | __call = function(self, ...) 36 | arguments = { ... } 37 | 38 | timer:stop() 39 | timer:start(self.timeout_ms, 0, function() 40 | timer:stop() 41 | callback(unpack(arguments)) 42 | arguments = nil 43 | end) 44 | end, 45 | }) 46 | end 47 | 48 | ---Create throttled callback. 49 | ---First call will be called immediately. 50 | ---@param callback fun() 51 | ---@param option { timeout_ms: integer, leading: boolean? } 52 | ---@return cmp-kit.kit.Async.Timing.TimingFunction 53 | function Timing.throttle(callback, option) 54 | local leading = option.leading or false 55 | 56 | local timer = ScheduledTimer.new() 57 | local arguments --[=[@as unknown[]?]=] 58 | local last_ms = 0 59 | return setmetatable({ 60 | timeout_ms = option.timeout_ms, 61 | timer = timer, 62 | flush = function() 63 | if arguments then 64 | timer:stop() 65 | callback(unpack(arguments)) 66 | arguments = nil 67 | end 68 | end 69 | }, { 70 | __call = function(self, ...) 71 | arguments = { ... } 72 | 73 | if not leading and not timer:is_running() then 74 | last_ms = now_ms() 75 | end 76 | 77 | local delay_ms = self.timeout_ms - (now_ms() - last_ms) 78 | timer:stop() 79 | timer:start(math.max(delay_ms, 0), 0, function() 80 | timer:stop() 81 | last_ms = now_ms() 82 | callback(unpack(arguments)) 83 | arguments = nil 84 | end) 85 | end, 86 | }) 87 | end 88 | 89 | return Timing 90 | -------------------------------------------------------------------------------- /lua/cmp-kit/signature_help/ext/source/lsp/signature_help.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local Client = require('cmp-kit.kit.LSP.Client') 3 | local Async = require('cmp-kit.kit.Async') 4 | 5 | ---@class cmp-kit.signature_help.ext.source.lsp.signature_help.Option 6 | 7 | ---@class cmp-kit.signature_help.ext.source.lsp.signature_help.OptionWithClient: cmp-kit.signature_help.ext.source.lsp.signature_help.Option 8 | ---@field public client vim.lsp.Client 9 | 10 | ---Create a signature help source for LSP client. 11 | ---@param option cmp-kit.signature_help.ext.source.lsp.signature_help.OptionWithClient 12 | return function(option) 13 | local client = Client.new(option.client) 14 | 15 | local request = nil ---@type (cmp-kit.kit.Async.AsyncTask|{ cancel: fun(): nil })? 16 | 17 | ---@type cmp-kit.signature_help.SignatureHelpSource 18 | return { 19 | name = option.client.name, 20 | get_configuration = function() 21 | local trigger_characters = kit.get(option.client, { 22 | 'server_capabilities', 23 | 'signatureHelpProvider', 24 | 'triggerCharacters', 25 | }, {}) 26 | local retrigger_characters = kit.get(option.client, { 27 | 'server_capabilities', 28 | 'signatureHelpProvider', 29 | 'retriggerCharacters', 30 | }, {}) 31 | return { 32 | position_encoding_kind = option.client.offset_encoding, 33 | trigger_characters = trigger_characters, 34 | retrigger_characters = kit.concat(trigger_characters, retrigger_characters), 35 | } 36 | end, 37 | capable = function(_) 38 | return option.client:supports_method('textDocument/signatureHelp', vim.api.nvim_get_current_buf()) 39 | end, 40 | fetch = function(_, signature_help_context, callback) 41 | if request then 42 | request.cancel() 43 | request = nil 44 | end 45 | 46 | local position_params = vim.lsp.util.make_position_params(0, option.client.offset_encoding) 47 | Async.run(function() 48 | request = client:textDocument_signatureHelp({ 49 | textDocument = { 50 | uri = position_params.textDocument.uri, 51 | }, 52 | position = { 53 | line = position_params.position.line, 54 | character = position_params.position.character, 55 | }, 56 | context = signature_help_context, 57 | }) 58 | return request:catch(function() 59 | return nil 60 | end) 61 | end):dispatch(function(res) 62 | callback(nil, res) 63 | end, function(err) 64 | callback(err, nil) 65 | end) 66 | end, 67 | } 68 | end 69 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/LSP/Range.lua: -------------------------------------------------------------------------------- 1 | local Position = require('cmp-kit.kit.LSP.Position') 2 | 3 | local Range = {} 4 | 5 | ---Return the value is range or not. 6 | ---@param v any 7 | ---@return boolean 8 | function Range.is(v) 9 | return type(v) == 'table' and Position.is(v.start) and Position.is(v['end']) 10 | end 11 | 12 | ---Return the range is empty or not. 13 | ---@param range cmp-kit.kit.LSP.Range 14 | ---@return boolean 15 | function Range.empty(range) 16 | return range.start.line == range['end'].line and range.start.character == range['end'].character 17 | end 18 | 19 | ---Return the range is empty or not. 20 | ---@param range cmp-kit.kit.LSP.Range 21 | ---@return boolean 22 | function Range.contains(range) 23 | return range.start.line == range['end'].line and range.start.character == range['end'].character 24 | end 25 | 26 | ---Convert range to buffer range from specified encoding. 27 | ---@param bufnr integer 28 | ---@param range cmp-kit.kit.LSP.Range 29 | ---@param from_encoding? cmp-kit.kit.LSP.PositionEncodingKind 30 | ---@return cmp-kit.kit.LSP.Range 31 | function Range.to_buf(bufnr, range, from_encoding) 32 | return { 33 | start = Position.to_buf(bufnr, range.start, from_encoding), 34 | ['end'] = Position.to_buf(bufnr, range['end'], from_encoding), 35 | } 36 | end 37 | 38 | ---Convert range to utf8 from specified encoding. 39 | ---@param text_start string 40 | ---@param range cmp-kit.kit.LSP.Range 41 | ---@param from_encoding? cmp-kit.kit.LSP.PositionEncodingKind 42 | ---@return cmp-kit.kit.LSP.Range 43 | function Range.to_utf8(text_start, text_end, range, from_encoding) 44 | return { 45 | start = Position.to_utf8(text_start, range.start, from_encoding), 46 | ['end'] = Position.to_utf8(text_end, range['end'], from_encoding), 47 | } 48 | end 49 | 50 | ---Convert range to utf16 from specified encoding. 51 | ---@param text_start string 52 | ---@param range cmp-kit.kit.LSP.Range 53 | ---@param from_encoding? cmp-kit.kit.LSP.PositionEncodingKind 54 | ---@return cmp-kit.kit.LSP.Range 55 | function Range.to_utf16(text_start, text_end, range, from_encoding) 56 | return { 57 | start = Position.to_utf16(text_start, range.start, from_encoding), 58 | ['end'] = Position.to_utf16(text_end, range['end'], from_encoding), 59 | } 60 | end 61 | 62 | ---Convert range to utf32 from specified encoding. 63 | ---@param text_start string 64 | ---@param range cmp-kit.kit.LSP.Range 65 | ---@param from_encoding? cmp-kit.kit.LSP.PositionEncodingKind 66 | ---@return cmp-kit.kit.LSP.Range 67 | function Range.to_utf32(text_start, text_end, range, from_encoding) 68 | return { 69 | start = Position.to_utf32(text_start, range.start, from_encoding), 70 | ['end'] = Position.to_utf32(text_end, range['end'], from_encoding), 71 | } 72 | end 73 | 74 | return Range 75 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/CompletionService.spec.lua: -------------------------------------------------------------------------------- 1 | local spec = require('cmp-kit.spec') 2 | local CompletionService = require('cmp-kit.completion.CompletionService') 3 | 4 | describe('cmp-kit.completion', function() 5 | describe('CompletionService', function() 6 | it('should work on basic case', function() 7 | local _, source = spec.setup({ 8 | input = 'w', 9 | buffer_text = { 10 | 'key|', 11 | }, 12 | items = { 13 | { label = 'keyword' }, 14 | { label = 'dummy' }, 15 | }, 16 | }) 17 | local state = {} 18 | local service = CompletionService.new({ 19 | view = { 20 | show = function(_, params) 21 | state.matches = params.matches 22 | end, 23 | hide = function() end, 24 | is_menu_visible = function() 25 | return true 26 | end, 27 | is_docs_visible = function() 28 | return false 29 | end, 30 | show_docs = function() end, 31 | hide_docs = function() end, 32 | select = function() end, 33 | scroll_docs = function() end, 34 | dispose = function() end, 35 | }, 36 | }) 37 | service:register_source(source) 38 | service:complete() 39 | vim.wait(500, function() 40 | return not not state.matches 41 | end) 42 | assert.are.equal(#state.matches, 1) 43 | assert.are.equal(state.matches[1].item:get_insert_text(), 'keyword') 44 | end) 45 | 46 | it('should update view on new response', function() 47 | local _, source = spec.setup({ 48 | input = 'w', 49 | buffer_text = { 50 | 'key|', 51 | }, 52 | items = { 53 | { label = 'keyword' }, 54 | }, 55 | }) 56 | local state = {} 57 | local service = CompletionService.new({ 58 | view = { 59 | show = function() 60 | state.show_count = (state.show_count or 0) + 1 61 | end, 62 | hide = function() end, 63 | is_menu_visible = function() 64 | return true 65 | end, 66 | is_docs_visible = function() 67 | return false 68 | end, 69 | show_docs = function() end, 70 | hide_docs = function() end, 71 | select = function() end, 72 | scroll_docs = function() end, 73 | dispose = function() end, 74 | }, 75 | }) 76 | service:register_source(source) 77 | service:complete() 78 | vim.wait(500, function() 79 | return state.show_count == 1 80 | end) 81 | service:complete({ force = true }) 82 | vim.wait(500, function() 83 | return state.show_count == 2 84 | end) 85 | assert.are.equal(state.show_count, 2) 86 | end) 87 | end) 88 | end) 89 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/init.lua: -------------------------------------------------------------------------------- 1 | ---@class cmp-kit.completion.Match 2 | ---@field trigger_context cmp-kit.core.TriggerContext 3 | ---@field provider cmp-kit.completion.CompletionProvider 4 | ---@field item cmp-kit.completion.CompletionItem 5 | ---@field score integer 6 | ---@field index integer 7 | 8 | ---@class cmp-kit.completion.Matcher 9 | ---@field match fun(input: string, text: string): integer 10 | ---@field decor fun(input: string, text: string): { [1]: integer, [2]: integer }[] 11 | 12 | ---@class cmp-kit.completion.Sorter 13 | ---@field sort fun(matches: cmp-kit.completion.Match[], context: cmp-kit.completion.SorterContext): cmp-kit.completion.Match[] 14 | 15 | ---@class cmp-kit.completion.SorterContext 16 | ---@field public locality_map table 17 | ---@field public trigger_context cmp-kit.core.TriggerContext 18 | 19 | ---@class cmp-kit.completion.CompletionSource.Configuration 20 | ---@field public keyword_pattern? string 21 | ---@field public trigger_characters? string[] 22 | ---@field public all_commit_characters? string[] 23 | ---@field public position_encoding_kind? cmp-kit.kit.LSP.PositionEncodingKind 24 | 25 | ---@class cmp-kit.completion.CompletionSource 26 | ---@field public name string 27 | ---@field public get_configuration? fun(self: unknown): cmp-kit.completion.CompletionSource.Configuration 28 | ---@field public resolve? fun(self: unknown, item: cmp-kit.kit.LSP.CompletionItem, callback: fun(err?: unknown, response?: cmp-kit.kit.LSP.CompletionItemResolveResponse)): nil 29 | ---@field public execute? fun(self: unknown, command: cmp-kit.kit.LSP.Command, callback: fun(err?: unknown, response?: cmp-kit.kit.LSP.WorkspaceExecuteCommandResponse)): nil 30 | ---@field public capable? fun(self: unknown): boolean 31 | ---@field public complete fun(self: unknown, completion_context: cmp-kit.kit.LSP.CompletionContext, callback: fun(err?: unknown, res?: cmp-kit.kit.LSP.TextDocumentCompletionResponse)): nil 32 | 33 | ---@class cmp-kit.completion.Selection 34 | ---@field public index integer 35 | ---@field public preselect boolean 36 | ---@field public text_before string 37 | 38 | ---@class cmp-kit.completion.CompletionView 39 | ---@field public show fun(self: cmp-kit.completion.CompletionView, params: { matches: cmp-kit.completion.Match[], selection: cmp-kit.completion.Selection }) 40 | ---@field public hide fun(self: cmp-kit.completion.CompletionView) 41 | ---@field public show_docs fun(self: cmp-kit.completion.CompletionView) 42 | ---@field public hide_docs fun(self: cmp-kit.completion.CompletionView) 43 | ---@field public scroll_docs fun(self: cmp-kit.completion.CompletionView, delta: integer) 44 | ---@field public is_menu_visible fun(): boolean 45 | ---@field public is_docs_visible fun(): boolean 46 | ---@field public select fun(self: cmp-kit.completion.CompletionView, params: { selection: cmp-kit.completion.Selection }) 47 | ---@field public dispose fun(self: cmp-kit.completion.CompletionView) 48 | -------------------------------------------------------------------------------- /lua/cmp-kit/core/LinePatch.spec.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: invisible 2 | local spec = require('cmp-kit.spec') 3 | local Keymap = require('cmp-kit.kit.Vim.Keymap') 4 | local TriggerContext = require('cmp-kit.core.TriggerContext') 5 | local LinePatch = require('cmp-kit.core.LinePatch') 6 | 7 | describe('cmp-kit.completion', function() 8 | describe('LinePatch', function() 9 | for _, mode in ipairs({ 'i', 'c' }) do 10 | for _, fn in ipairs({ 'apply_by_func', 'apply_by_keys' }) do 11 | describe(('[%s] .%s'):format(mode, fn), function() 12 | it('should apply the insert-range patch', function() 13 | Keymap.spec(function() 14 | Keymap.send(mode == 'i' and 'i' or ':'):await() 15 | local trigger_context, _, service = spec.setup({ 16 | mode = mode, 17 | buffer_text = { 18 | '(ins|ert)', 19 | }, 20 | items = { { 21 | label = 'inserted', 22 | } }, 23 | }) 24 | local bufnr = vim.api.nvim_get_current_buf() 25 | local match = service:get_matches()[1] 26 | local range = match.item:get_insert_range() 27 | local before = trigger_context.character - range.start.character 28 | local after = range['end'].character - trigger_context.character 29 | LinePatch[fn](bufnr, before, after, match.item:get_insert_text()):await() 30 | 31 | trigger_context = TriggerContext.create() 32 | assert.are.equal(trigger_context.text, '(insertedert)') 33 | assert.are.same({ trigger_context.line, trigger_context.character }, { 0, 9 }) 34 | end) 35 | end) 36 | 37 | it('should apply the replace-range patch', function() 38 | Keymap.spec(function() 39 | Keymap.send(mode == 'i' and 'i' or ':'):await() 40 | local trigger_context, _, service = spec.setup({ 41 | mode = mode, 42 | buffer_text = { 43 | '(ins|ert)', 44 | }, 45 | items = { { 46 | label = 'inserted', 47 | } }, 48 | }) 49 | local bufnr = vim.api.nvim_get_current_buf() 50 | local match = service:get_matches()[1] 51 | local range = (match.item:get_replace_range() or match.item._provider:get_default_replace_range()) 52 | local before = trigger_context.character - range.start.character 53 | local after = range['end'].character - trigger_context.character 54 | LinePatch[fn](bufnr, before, after, match.item:get_insert_text()):await() 55 | 56 | trigger_context = TriggerContext.create() 57 | assert.are.equal(trigger_context.text, '(inserted)') 58 | assert.are.same({ trigger_context.line, trigger_context.character }, { 0, 9 }) 59 | end) 60 | end) 61 | end) 62 | end 63 | end 64 | end) 65 | end) 66 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/source/calc.lua: -------------------------------------------------------------------------------- 1 | local Async = require('cmp-kit.kit.Async') 2 | local TriggerContext = require('cmp-kit.core.TriggerContext') 3 | 4 | local PATTERN = [=[\%(^\|\s\)\zs\%( \|math\.\w\+\|\d\+\%(\.\d\+\)\?\|[()*/+\-,]\)\+\s*\%(\s*=\s*\)\?]=] 5 | 6 | local DIGIT_ONLY = [=[^\s*\d\+\%(\.\d\+\)\?\s*$]=] 7 | 8 | local INVALID = { 9 | isIncomplete = false, 10 | items = {}, 11 | } 12 | 13 | ---@class cmp-kit.completion.ext.source.calc.Option 14 | return function() 15 | ---@type cmp-kit.completion.CompletionSource 16 | return { 17 | name = 'calc', 18 | get_configuration = function() 19 | return { 20 | keyword_pattern = PATTERN, 21 | trigger_characters = { ')', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '.', ' ', '=' }, 22 | } 23 | end, 24 | complete = function(_, _, callback) 25 | Async.run(function() 26 | local ctx = TriggerContext.create() 27 | local off = ctx:get_keyword_offset(PATTERN) 28 | if not off then 29 | return INVALID 30 | end 31 | local leading_text = ctx.text_before:sub(off) 32 | local candidate_text = leading_text:gsub('%s*=%s*$', '') 33 | 34 | local stack = {} 35 | for i = #candidate_text, 1, -1 do 36 | local char = candidate_text:sub(i, i) 37 | if char == ')' then 38 | table.insert(stack, { 39 | idx = i, 40 | char = char, 41 | }) 42 | elseif char == '(' then 43 | if #stack == 0 then 44 | return INVALID 45 | end 46 | table.remove(stack) 47 | end 48 | end 49 | 50 | local program = candidate_text 51 | if #stack > 0 then 52 | program = candidate_text:sub(stack[#stack].idx) 53 | end 54 | 55 | program = (program:gsub('^%s*', '')) 56 | 57 | if vim.regex(DIGIT_ONLY):match_str(program) then 58 | return INVALID 59 | end 60 | 61 | local output = assert(loadstring(('return %s'):format(program), 'calc'))() 62 | if type(output) ~= 'number' then 63 | return INVALID 64 | end 65 | 66 | return { 67 | isIncomplete = true, 68 | items = { 69 | { 70 | label = ('= %s'):format(output), 71 | insertText = tostring(output), 72 | filterText = leading_text, 73 | sortText = '1', 74 | nvim_previewText = tostring(output), 75 | }, 76 | { 77 | label = ('%s = %s'):format((candidate_text:gsub('%s*$', '')), output), 78 | insertText = ('%s = %s'):format((candidate_text:gsub('%s*$', '')), output), 79 | filterText = leading_text, 80 | sortText = '2', 81 | nvim_previewText = ('%s = %s'):format((candidate_text:gsub('%s*$', '')), output), 82 | commitCharacters = { '=' } 83 | }, 84 | }, 85 | } 86 | end):dispatch(function(res) 87 | callback(nil, res) 88 | end, function(err) 89 | callback(err, nil) 90 | end) 91 | end, 92 | } 93 | end 94 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/PreviewText.spec.lua: -------------------------------------------------------------------------------- 1 | local PreviewText = require('cmp-kit.completion.PreviewText') 2 | 3 | describe('cmp-kit.completion', function() 4 | describe('PreviewText', function() 5 | describe('.create', function() 6 | it('should return preview text', function() 7 | -- simple: num only. 8 | assert.are.equal( 9 | '16', 10 | PreviewText.create({ 11 | offset = 1, 12 | insert_text = '16', 13 | before_text = '', 14 | after_text = '', 15 | }) 16 | ) 17 | -- simple: keyword only. 18 | assert.are.equal( 19 | 'insert', 20 | PreviewText.create({ 21 | offset = 1, 22 | insert_text = 'insert', 23 | before_text = '', 24 | after_text = '', 25 | }) 26 | ) 27 | -- pairs. 28 | assert.are.equal( 29 | '"true"', 30 | PreviewText.create({ 31 | offset = 1, 32 | insert_text = '"true"', 33 | before_text = '', 34 | after_text = '', 35 | }) 36 | ) 37 | -- pairs stack. 38 | assert.are.equal( 39 | '(insert)', 40 | PreviewText.create({ 41 | offset = 1, 42 | insert_text = '(insert))', 43 | before_text = '', 44 | after_text = '', 45 | }) 46 | ) 47 | -- after_text overlap: symbolic chars. 48 | assert.are.equal( 49 | '"repository', 50 | PreviewText.create({ 51 | offset = 1, 52 | insert_text = '"repository"', 53 | before_text = '', 54 | after_text = '"', 55 | }) 56 | ) 57 | -- after_text overlap: symbolic chars only. 58 | assert.are.equal( 59 | '"', 60 | PreviewText.create({ 61 | offset = 1, 62 | insert_text = '""', 63 | before_text = '', 64 | after_text = '"', 65 | }) 66 | ) 67 | -- after_text overlap: alphabetical chars. 68 | assert.are.equal( 69 | 'signature', 70 | PreviewText.create({ 71 | offset = 1, 72 | insert_text = 'signature', 73 | before_text = '', 74 | after_text = 'exit', 75 | }) 76 | ) 77 | -- don't consume pairs after is_alnum_consumed=true 78 | assert.are.equal( 79 | 'insert', 80 | PreviewText.create({ 81 | offset = 1, 82 | insert_text = 'insert(list, pos, value)', 83 | before_text = '', 84 | after_text = '', 85 | }) 86 | ) 87 | -- realworld: 1 88 | assert.are.equal( 89 | 'import { Directory }', 90 | PreviewText.create({ 91 | offset = 1, 92 | insert_text = 'import { Directory } from \'cmd-ts/batteries/fs\';', 93 | before_text = 'import ', 94 | after_text = '', 95 | }) 96 | ) 97 | end) 98 | end) 99 | end) 100 | end) 101 | -------------------------------------------------------------------------------- /plugin/cmp-kit.lua: -------------------------------------------------------------------------------- 1 | local debugger = require('cmp-kit.core.debugger') 2 | 3 | vim.api.nvim_create_user_command('CmpKitDebuggerOpen', function() 4 | debugger.open() 5 | end, { 6 | nargs = '*' 7 | }) 8 | 9 | ---@param name string 10 | ---@param parents string[] 11 | ---@param keys string[] 12 | ---@param opts vim.api.keyset.highlight 13 | local function inherit_hl(name, parents, keys, opts) 14 | local parent = vim.iter(parents):find(function(parent_name) 15 | if vim.fn.hlexists(parent_name) == 1 then 16 | return true 17 | end 18 | return false 19 | end) 20 | if parent then 21 | local synid = vim.fn.synIDtrans(vim.fn.hlID(parent)) 22 | for _, key in ipairs(keys) do 23 | if not opts[key] then 24 | local v = vim.fn.synIDattr(synid, key) --[[@as string|boolean]] 25 | if key == 'fg' or key == 'bg' or key == 'sp' then 26 | local n = tonumber(tostring(v), 10) 27 | v = type(n) == 'number' and tostring(n) or v 28 | else 29 | v = v == 1 30 | end 31 | opts[key] = v == '' and 'NONE' or v 32 | end 33 | end 34 | end 35 | if vim.fn.hlexists(name) == 0 then 36 | vim.api.nvim_set_hl(0, name, opts) 37 | end 38 | end 39 | 40 | local function on_color_scheme() 41 | -- markdown rendering utilities. 42 | inherit_hl('CmpKitMarkdownAnnotateUnderlined', { 'Special' }, { 'fg', 'bg' }, { 43 | default = true, 44 | sp = 'fg', 45 | underline = true, 46 | }) 47 | inherit_hl('CmpKitMarkdownAnnotateBold', { 'Special' }, { 'fg', 'bg' }, { 48 | default = true, 49 | bold = true, 50 | }) 51 | inherit_hl('CmpKitMarkdownAnnotateEm', { 'Special' }, { 'fg', 'bg' }, { 52 | default = true, 53 | sp = 'fg', 54 | bold = true, 55 | underline = true, 56 | }) 57 | inherit_hl('CmpKitMarkdownAnnotateStrong', { 'Visual' }, { 'fg', 'bg' }, { 58 | default = true, 59 | sp = 'fg', 60 | bold = true, 61 | }) 62 | inherit_hl('CmpKitMarkdownAnnotateCodeBlock', { 'CursorColumn' }, { 'bg' }, { 63 | default = true, 64 | blend = 30, 65 | }) 66 | for _, i in ipairs({ 1, 2, 3, 4, 5, 6 }) do 67 | inherit_hl(('CmpKitMarkdownAnnotateHeading%s'):format(i), { 'Title' }, { 'fg', 'bg' }, { 68 | default = true, 69 | blend = 30, 70 | }) 71 | end 72 | 73 | -- completion utilities. 74 | inherit_hl('CmpKitDeprecated', { 'CmpItemAbbrDeprecated', 'Comment' }, { 'fg' }, { 75 | default = true, 76 | sp = 'fg', 77 | strikethrough = true, 78 | }) 79 | inherit_hl('CmpKitCompletionItemLabel', { 'CmpItemAbbr', 'Pmenu' }, { 'fg' }, { 80 | default = true, 81 | }) 82 | inherit_hl('CmpKitCompletionItemDescription', { 'CmpItemMenu', 'PmenuExtra' }, { 'fg' }, { 83 | default = true, 84 | }) 85 | inherit_hl('CmpKitCompletionItemMatch', { 'CmpItemAbbrMatch', 'PmenuMatch' }, { 'fg' }, { 86 | default = true, 87 | }) 88 | inherit_hl('CmpKitCompletionItemExtra', { 'CmpItemMenu', 'PmenuExtra' }, { 'fg' }, { 89 | default = true, 90 | }) 91 | 92 | -- completion item kinds. 93 | local LSP = require('cmp-kit.kit.LSP') 94 | for name in pairs(LSP.CompletionItemKind) do 95 | local kit_name = ('CmpKitCompletionItemKind_%s'):format(name) 96 | local cmp_name = ('CmpItemKind%s'):format(name) 97 | inherit_hl(kit_name, { cmp_name, 'PmenuKind' }, { 'fg', 'bg' }, { 98 | default = true 99 | }) 100 | end 101 | end 102 | vim.api.nvim_create_autocmd({ 'ColorScheme', 'UIEnter' }, { 103 | pattern = '*', 104 | callback = on_color_scheme, 105 | }) 106 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/source/lsp/completion.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local Client = require('cmp-kit.kit.LSP.Client') 3 | local Async = require('cmp-kit.kit.Async') 4 | 5 | ---@class cmp-kit.completion.ext.source.lsp.completion.Option 6 | ---@field public keyword_pattern? string 7 | 8 | ---@class cmp-kit.completion.ext.source.lsp.completion.OptionWithClient: cmp-kit.completion.ext.source.lsp.completion.Option 9 | ---@field public client vim.lsp.Client 10 | ---@param option cmp-kit.completion.ext.source.lsp.completion.OptionWithClient 11 | return function(option) 12 | option = option or {} 13 | option.client = option.client 14 | option.keyword_pattern = option.keyword_pattern or nil 15 | 16 | local client = Client.new(option.client) 17 | 18 | local request = nil ---@type (cmp-kit.kit.Async.AsyncTask|{ cancel: fun(): nil })? 19 | 20 | ---@type cmp-kit.completion.CompletionSource 21 | return { 22 | name = option.client.name, 23 | get_configuration = function() 24 | local capabilities = option.client.dynamic_capabilities:get('textDocument/completion', { 25 | bufnr = vim.api.nvim_get_current_buf(), 26 | }) 27 | return { 28 | position_encoding_kind = option.client.offset_encoding, 29 | keyword_pattern = option.keyword_pattern, 30 | trigger_characters = kit.concat( 31 | kit.get(capabilities, { 'registerOptions', 'triggerCharacters' }, {}), 32 | kit.get(option.client.server_capabilities, { 'completionProvider', 'triggerCharacters' }, {}) 33 | ), 34 | } 35 | end, 36 | capable = function(_) 37 | return option.client:supports_method('textDocument/completion', vim.api.nvim_get_current_buf()) 38 | end, 39 | resolve = function(_, item, callback) 40 | Async.run(function() 41 | local capabilities = option.client.dynamic_capabilities:get('textDocument/completion', { 42 | bufnr = vim.api.nvim_get_current_buf(), 43 | }) 44 | if kit.get(capabilities, { 'registerOptions', 'resolveProvider' }) or kit.get(option.client.server_capabilities, { 'completionProvider', 'resolveProvider' }) then 45 | return client:completionItem_resolve(item):await() 46 | end 47 | return item 48 | end):dispatch(function(res) 49 | callback(nil, res) 50 | end, function(err) 51 | callback(err, nil) 52 | end) 53 | end, 54 | execute = function(_, command, callback) 55 | Async.new(function(resolve, reject) 56 | option.client:exec_cmd(command --[[@as lsp.Command]], { 57 | bufnr = vim.api.nvim_get_current_buf(), 58 | }, function(err, result) 59 | if err then 60 | reject(err) 61 | else 62 | resolve(result) 63 | end 64 | end) 65 | end):dispatch(function(res) 66 | callback(nil, res) 67 | end, function(err) 68 | callback(err, nil) 69 | end) 70 | end, 71 | complete = function(_, completion_context, callback) 72 | if request then 73 | request.cancel() 74 | end 75 | 76 | local position_params = vim.lsp.util.make_position_params(0, option.client.offset_encoding) 77 | request = client:textDocument_completion({ 78 | textDocument = { 79 | uri = position_params.textDocument.uri, 80 | }, 81 | position = { 82 | line = position_params.position.line, 83 | character = position_params.position.character, 84 | }, 85 | context = completion_context, 86 | }) 87 | request:dispatch(function(res) 88 | request = nil 89 | callback(nil, res) 90 | end, function(err) 91 | request = nil 92 | callback(err, nil) 93 | end) 94 | end, 95 | } 96 | end 97 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/init.spec.lua: -------------------------------------------------------------------------------- 1 | local Async = require('cmp-kit.kit.Async') 2 | local CompletionService = require('cmp-kit.completion.CompletionService') 3 | 4 | describe('cmp-kit.completion', function() 5 | describe('init', function() 6 | local function create_source(name, delay) 7 | return { 8 | name = name, 9 | complete = function(_, _, callback) 10 | vim.print('complete\n') 11 | Async.run(function() 12 | Async.timeout(delay):await() 13 | return { 14 | { label = ('label-%s'):format(name) }, 15 | } 16 | end):dispatch(function(res) 17 | callback(nil, res) 18 | end, function(err) 19 | callback(err, nil) 20 | end) 21 | end, 22 | } 23 | end 24 | 25 | it('should work with macro', function() 26 | vim.cmd.enew({ bang = true }) 27 | 28 | local service = CompletionService.new({ 29 | performance = { 30 | fetch_waiting_ms = 120, 31 | }, 32 | }) 33 | service:register_source(create_source('group1-slow-high', 80), { 34 | group = 1, 35 | priority = 1000, 36 | }) 37 | service:register_source(create_source('group1-fast-low', 20), { 38 | group = 1, 39 | priority = 1, 40 | }) 41 | vim.keymap.set('i', '(complete)', function() 42 | if vim.fn.reg_executing() ~= '' then 43 | return 44 | end 45 | service:complete({ force = true }) 46 | end, { buffer = 0 }) 47 | vim.keymap.set('i', '(select_next)', function() 48 | local selection = service:get_selection() 49 | service:select(selection.index + 1, false) 50 | end, { buffer = 0 }) 51 | vim.keymap.set('i', '(wait:200)', function() 52 | if vim.fn.reg_executing() ~= '' then 53 | return 54 | end 55 | vim.wait(200) 56 | end, { buffer = 0 }) 57 | vim.keymap.set('n', '(clear)', function() 58 | service:clear() 59 | end, { buffer = 0 }) 60 | vim.keymap.set('i', '(show_state)', function() 61 | vim.print(('reg_exec="%s", count="%s", line="%s"\n'):format(vim.fn.reg_executing(), #service:get_matches(), vim.api.nvim_get_current_line())) 62 | end, { buffer = 0 }) 63 | 64 | vim.api.nvim_feedkeys('qx', 'n', true) 65 | vim.api.nvim_feedkeys(vim.keycode('o'), 'nt', true) 66 | vim.api.nvim_feedkeys(vim.keycode('l'), 'nt', true) 67 | vim.api.nvim_feedkeys(vim.keycode('(complete)'), 'n', true) 68 | vim.api.nvim_feedkeys(vim.keycode('(wait:200)'), 'n', true) 69 | vim.api.nvim_feedkeys(vim.keycode('(select_next)(show_state)'), 'nt', true) 70 | vim.api.nvim_feedkeys(vim.keycode('(select_next)(show_state)'), 'nt', true) 71 | vim.api.nvim_feedkeys(vim.keycode('(select_next)(show_state)'), 'nt', true) 72 | vim.api.nvim_feedkeys(vim.keycode('(select_next)(show_state)'), 'nt', true) 73 | vim.api.nvim_feedkeys(vim.keycode(''), 'nt', true) 74 | vim.api.nvim_feedkeys('q', 'n', true) 75 | vim.api.nvim_feedkeys(vim.keycode('(clear)'), 'n', true) -- 1st 76 | vim.api.nvim_feedkeys('', 'x', true) 77 | assert.are.same({ 78 | '', 79 | 'label-group1-slow-high', 80 | }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) 81 | 82 | -- force release prevension. 83 | vim.api.nvim_exec_autocmds('SafeState', { modeline = false }) 84 | 85 | vim.api.nvim_feedkeys('@x', 'nx', true) 86 | assert.are.same({ 87 | '', 88 | 'label-group1-slow-high', 89 | 'label-group1-slow-high', 90 | }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) 91 | end) 92 | end) 93 | end) 94 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/App/Config.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local Cache = require('cmp-kit.kit.App.Cache') 3 | 4 | ---@class cmp-kit.kit.App.Config.Schema 5 | 6 | ---@alias cmp-kit.kit.App.Config.SchemaInternal cmp-kit.kit.App.Config.Schema|{ revision: integer } 7 | 8 | ---@class cmp-kit.kit.App.Config 9 | ---@field private _cache cmp-kit.kit.App.Cache 10 | ---@field private _default cmp-kit.kit.App.Config.SchemaInternal 11 | ---@field private _global cmp-kit.kit.App.Config.SchemaInternal 12 | ---@field private _filetype table 13 | ---@field private _buffer table 14 | local Config = {} 15 | Config.__index = Config 16 | 17 | ---Create new config instance. 18 | ---@param default cmp-kit.kit.App.Config.Schema 19 | function Config.new(default) 20 | local self = setmetatable({}, Config) 21 | self._cache = Cache.new() 22 | self._default = default 23 | self._global = {} 24 | self._filetype = {} 25 | self._buffer = {} 26 | return self 27 | end 28 | 29 | ---Update global config. 30 | ---@param config cmp-kit.kit.App.Config.Schema 31 | function Config:global(config) 32 | local revision = (self._global.revision or 1) + 1 33 | self._global = config or {} 34 | self._global.revision = revision 35 | end 36 | 37 | ---Update filetype config. 38 | ---@param filetypes string|string[] 39 | ---@param config cmp-kit.kit.App.Config.Schema 40 | function Config:filetype(filetypes, config) 41 | for _, filetype in ipairs(kit.to_array(filetypes)) do 42 | local revision = ((self._filetype[filetype] or {}).revision or 1) + 1 43 | self._filetype[filetype] = config or {} 44 | self._filetype[filetype].revision = revision 45 | end 46 | end 47 | 48 | ---Update filetype config. 49 | ---@param bufnr integer 50 | ---@param config cmp-kit.kit.App.Config.Schema 51 | function Config:buffer(bufnr, config) 52 | bufnr = bufnr == 0 and vim.api.nvim_get_current_buf() or bufnr 53 | local revision = ((self._buffer[bufnr] or {}).revision or 1) + 1 54 | self._buffer[bufnr] = config or {} 55 | self._buffer[bufnr].revision = revision 56 | end 57 | 58 | ---Get current configuration. 59 | ---@return cmp-kit.kit.App.Config.Schema 60 | function Config:get() 61 | local filetype = vim.api.nvim_get_option_value('filetype', { buf = 0 }) 62 | local bufnr = vim.api.nvim_get_current_buf() 63 | return self._cache:ensure({ 64 | tostring(self._global.revision or 0), 65 | tostring((self._buffer[bufnr] or {}).revision or 0), 66 | tostring((self._filetype[filetype] or {}).revision or 0), 67 | }, function() 68 | local config = self._default 69 | config = kit.merge(self._global, config) 70 | config = kit.merge(self._filetype[filetype] or {}, config) 71 | config = kit.merge(self._buffer[bufnr] or {}, config) 72 | config.revision = nil 73 | return config 74 | end) 75 | end 76 | 77 | ---Create setup interface. 78 | ---@return fun(config: cmp-kit.kit.App.Config.Schema)|{ filetype: fun(filetypes: string|string[], config: cmp-kit.kit.App.Config.Schema), buffer: fun(bufnr: integer, config: cmp-kit.kit.App.Config.Schema) } 79 | function Config:create_setup_interface() 80 | return setmetatable({ 81 | ---@param filetypes string|string[] 82 | ---@param config cmp-kit.kit.App.Config.Schema 83 | filetype = function(filetypes, config) 84 | self:filetype(filetypes, config) 85 | end, 86 | ---@param bufnr integer 87 | ---@param config cmp-kit.kit.App.Config.Schema 88 | buffer = function(bufnr, config) 89 | self:buffer(bufnr, config) 90 | end, 91 | }, { 92 | ---@param config cmp-kit.kit.App.Config.Schema 93 | __call = function(_, config) 94 | self:global(config) 95 | end, 96 | }) 97 | end 98 | 99 | return Config 100 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/PreviewText.lua: -------------------------------------------------------------------------------- 1 | local Character = require('cmp-kit.kit.App.Character') 2 | 3 | local PreviewText = {} 4 | 5 | ---@type table 6 | PreviewText.StopCharacters = { 7 | [string.byte("'")] = true, 8 | [string.byte('"')] = true, 9 | [string.byte('=')] = true, 10 | [string.byte('(')] = true, 11 | [string.byte(')')] = true, 12 | [string.byte('[')] = true, 13 | [string.byte(']')] = true, 14 | [string.byte('<')] = true, 15 | [string.byte('>')] = true, 16 | [string.byte('{')] = true, 17 | [string.byte('}')] = true, 18 | [string.byte('\t')] = true, 19 | [string.byte(' ')] = true, 20 | } 21 | 22 | ---@type table 23 | PreviewText.ForceStopCharacters = { 24 | [string.byte('\n')] = true, 25 | [string.byte('\r')] = true, 26 | } 27 | 28 | ---@type table 29 | PreviewText.Pairs = { 30 | [string.byte('(')] = string.byte(')'), 31 | [string.byte('[')] = string.byte(']'), 32 | [string.byte('{')] = string.byte('}'), 33 | [string.byte('"')] = string.byte('"'), 34 | [string.byte("'")] = string.byte("'"), 35 | [string.byte('<')] = string.byte('>'), 36 | } 37 | 38 | ---Create preview text. 39 | ---@param params { offset: integer, insert_text: string, before_text: string, after_text: string, in_string: boolean } 40 | ---@return string 41 | function PreviewText.create(params) 42 | local insert_text = params.insert_text 43 | local after_text = params.after_text 44 | local is_alnum_consumed = false 45 | 46 | local is_after_symbol = Character.is_symbol(after_text:byte(1)) 47 | 48 | -- consume if before text is same as the inser text parts in ingnoring white spaces. 49 | local before_idx = params.offset 50 | local insert_idx = 1 51 | while insert_idx <= #insert_text do 52 | while before_idx <= #params.before_text and Character.is_white(params.before_text:byte(before_idx)) do 53 | before_idx = before_idx + 1 54 | end 55 | while insert_idx <= #insert_text and Character.is_white(insert_text:byte(insert_idx)) do 56 | insert_idx = insert_idx + 1 57 | end 58 | if before_idx > #params.before_text or insert_idx > #insert_text then 59 | break 60 | end 61 | if params.before_text:byte(before_idx) ~= insert_text:byte(insert_idx) then 62 | break 63 | end 64 | before_idx = before_idx + 1 65 | insert_idx = insert_idx + 1 66 | end 67 | 68 | if not params.in_string then 69 | local pairs_stack = {} 70 | for i = insert_idx, #insert_text do 71 | local byte = insert_text:byte(i) 72 | if PreviewText.ForceStopCharacters[byte] then 73 | return insert_text:sub(1, i - 1) 74 | end 75 | local is_alnum = Character.is_alnum(byte) 76 | 77 | if is_alnum_consumed and is_after_symbol and after_text:byte(1) == byte then 78 | return insert_text:sub(1, i - 1) 79 | end 80 | 81 | if byte == pairs_stack[#pairs_stack] then 82 | table.remove(pairs_stack, #pairs_stack) 83 | elseif not is_alnum_consumed and PreviewText.Pairs[byte] then 84 | table.insert(pairs_stack, PreviewText.Pairs[byte]) 85 | elseif is_alnum_consumed and not is_alnum and #pairs_stack == 0 then 86 | if PreviewText.StopCharacters[byte] then 87 | return insert_text:sub(1, i - 1) 88 | end 89 | else 90 | is_alnum_consumed = is_alnum_consumed or is_alnum 91 | end 92 | end 93 | end 94 | 95 | -- check after symbol. 96 | local skip_suffix_idx = 1 97 | if not is_alnum_consumed then 98 | if insert_text:byte(-1) == after_text:byte(1) then 99 | skip_suffix_idx = 2 100 | end 101 | end 102 | 103 | if skip_suffix_idx ~= 1 then 104 | return insert_text:sub(1, #insert_text - (skip_suffix_idx - 1)) 105 | end 106 | return insert_text 107 | end 108 | 109 | return PreviewText 110 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/Vim/Keymap.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local Async = require('cmp-kit.kit.Async') 3 | 4 | local buf = vim.api.nvim_create_buf(false, true) 5 | 6 | local resolve_key = vim.api.nvim_replace_termcodes('lua require("cmp-kit.kit.Vim.Keymap")._resolve(%s)', true, true, true) 7 | 8 | ---@alias cmp-kit.kit.Vim.Keymap.Keys { keys: string, remap?: boolean } 9 | ---@alias cmp-kit.kit.Vim.Keymap.KeysSpecifier string|cmp-kit.kit.Vim.Keymap.Keys 10 | 11 | ---@param keys cmp-kit.kit.Vim.Keymap.KeysSpecifier 12 | ---@return cmp-kit.kit.Vim.Keymap.Keys 13 | local function to_keys(keys) 14 | if type(keys) == 'table' then 15 | return keys 16 | end 17 | return { keys = keys, remap = false } 18 | end 19 | 20 | local Keymap = {} 21 | 22 | _G.kit = _G.kit or {} 23 | _G.kit.Vim = _G.kit.Vim or {} 24 | _G.kit.Vim.Keymap = _G.kit.Vim.Keymap or {} 25 | _G.kit.Vim.Keymap.callbacks = _G.kit.Vim.Keymap.callbacks or {} 26 | 27 | ---Replace termcodes. 28 | ---@param keys string 29 | ---@return string 30 | function Keymap.termcodes(keys) 31 | return vim.api.nvim_replace_termcodes(keys, true, true, true) 32 | end 33 | 34 | ---Normalize keycode. 35 | function Keymap.normalize(s) 36 | local desc = 'cmp-kit.kit.Vim.Keymap.normalize' 37 | vim.api.nvim_buf_set_keymap(buf, 't', s, '.', { desc = desc }) 38 | for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, 't')) do 39 | if map.desc == desc then 40 | vim.api.nvim_buf_del_keymap(buf, 't', s) 41 | return map.lhs --[[@as string]] 42 | end 43 | end 44 | vim.api.nvim_buf_del_keymap(buf, 't', s) 45 | return s 46 | end 47 | 48 | ---Set callback for consuming next typeahead. 49 | ---@param callback fun() 50 | ---@return cmp-kit.kit.Async.AsyncTask 51 | function Keymap.next(callback) 52 | return Keymap.send(''):next(callback) 53 | end 54 | 55 | ---Send keys. 56 | ---@param keys cmp-kit.kit.Vim.Keymap.KeysSpecifier|cmp-kit.kit.Vim.Keymap.KeysSpecifier[] 57 | ---@param no_insert? boolean 58 | ---@return cmp-kit.kit.Async.AsyncTask 59 | function Keymap.send(keys, no_insert) 60 | local unique_id = kit.unique_id() 61 | return Async.new(function(resolve, _) 62 | _G.kit.Vim.Keymap.callbacks[unique_id] = resolve 63 | 64 | local callback = resolve_key:format(unique_id) 65 | if no_insert then 66 | for _, keys_ in ipairs(kit.to_array(keys)) do 67 | keys_ = to_keys(keys_) 68 | vim.api.nvim_feedkeys(keys_.keys, keys_.remap and 'm' or 'n', true) 69 | end 70 | vim.api.nvim_feedkeys(callback, 'n', true) 71 | else 72 | vim.api.nvim_feedkeys(callback, 'in', true) 73 | for _, keys_ in ipairs(kit.reverse(kit.to_array(keys))) do 74 | keys_ = to_keys(keys_) 75 | vim.api.nvim_feedkeys(keys_.keys, 'i' .. (keys_.remap and 'm' or 'n'), true) 76 | end 77 | end 78 | end):catch(function() 79 | _G.kit.Vim.Keymap.callbacks[unique_id] = nil 80 | end) 81 | end 82 | 83 | ---Return sendabke keys with callback function. 84 | ---@param callback fun(...: any): any 85 | ---@return string 86 | function Keymap.to_sendable(callback) 87 | local unique_id = kit.unique_id() 88 | _G.kit.Vim.Keymap.callbacks[unique_id] = function() 89 | Async.run(callback) 90 | end 91 | return resolve_key:format(unique_id) 92 | end 93 | 94 | ---Test spec helper. 95 | ---@param spec fun(): any 96 | function Keymap.spec(spec) 97 | local task = Async.resolve():next(function() 98 | return Async.run(spec) 99 | end) 100 | vim.api.nvim_feedkeys('', 'x', true) 101 | task:sync(5000) 102 | collectgarbage('collect') 103 | vim.wait(200) 104 | end 105 | 106 | ---Resolve running keys. 107 | ---@param unique_id integer 108 | function Keymap._resolve(unique_id) 109 | _G.kit.Vim.Keymap.callbacks[unique_id]() 110 | _G.kit.Vim.Keymap.callbacks[unique_id] = nil 111 | end 112 | 113 | return Keymap 114 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/App/Character.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: discard-returns 2 | 3 | local Character = {} 4 | 5 | ---@type table 6 | Character.alpha = {} 7 | string.gsub('abcdefghijklmnopqrstuvwxyz', '.', function(char) 8 | Character.alpha[string.byte(char)] = char 9 | end) 10 | 11 | ---@type table 12 | Character.upper = {} 13 | string.gsub('ABCDEFGHIJKLMNOPQRSTUVWXYZ', '.', function(char) 14 | Character.upper[string.byte(char)] = char 15 | end) 16 | 17 | ---@type table 18 | Character.digit = {} 19 | string.gsub('1234567890', '.', function(char) 20 | Character.digit[string.byte(char)] = char 21 | end) 22 | 23 | ---@type table 24 | Character.white = {} 25 | string.gsub(' \t\n', '.', function(char) 26 | Character.white[string.byte(char)] = char 27 | end) 28 | 29 | ---Return specified byte is alpha or not. 30 | ---@param byte integer 31 | ---@return boolean 32 | function Character.is_alpha(byte) 33 | return not not (Character.upper[byte] or Character.alpha[byte]) 34 | end 35 | 36 | ---Return specified byte is digit or not. 37 | ---@param byte integer 38 | ---@return boolean 39 | function Character.is_digit(byte) 40 | return Character.digit[byte] ~= nil 41 | end 42 | 43 | ---Return specified byte is alpha or not. 44 | ---@param byte integer 45 | ---@return boolean 46 | function Character.is_alnum(byte) 47 | return Character.is_alpha(byte) or Character.is_digit(byte) 48 | end 49 | 50 | ---Return specified byte is alpha or not. 51 | ---@param byte integer 52 | ---@return boolean 53 | function Character.is_upper(byte) 54 | return Character.upper[byte] ~= nil 55 | end 56 | 57 | ---Return specified byte is alpha or not. 58 | ---@param byte integer 59 | ---@return boolean 60 | function Character.is_lower(byte) 61 | return Character.alpha[byte] ~= nil 62 | end 63 | 64 | ---Return specified byte is white or not. 65 | ---@param byte integer 66 | ---@return boolean 67 | function Character.is_white(byte) 68 | return Character.white[byte] ~= nil 69 | end 70 | 71 | ---Return specified byte is symbol or not. 72 | ---@param byte integer 73 | ---@return boolean 74 | function Character.is_symbol(byte) 75 | return not Character.is_wordlike(byte) and not Character.is_white(byte) 76 | end 77 | 78 | ---Return specified byte is wordlike or not. 79 | ---@param byte integer 80 | ---@return boolean 81 | function Character.is_wordlike(byte) 82 | return Character.is_alnum(byte) or Character.is_utf8_part(byte) 83 | end 84 | 85 | ---Return specified byte is utf8 part or not. 86 | ---@param byte integer 87 | ---@return boolean 88 | function Character.is_utf8_part(byte) 89 | return byte and byte >= 128 90 | end 91 | 92 | ---@param a integer 93 | ---@param b integer 94 | function Character.match_icase(a, b) 95 | if a == b then 96 | return true 97 | elseif math.abs(a - b) == 32 and Character.is_alpha(a) and Character.is_alpha(b) then 98 | return true 99 | end 100 | return false 101 | end 102 | 103 | ---@param text string 104 | ---@param index integer 105 | ---@return boolean 106 | function Character.is_semantic_index(text, index) 107 | if index <= 1 then 108 | return true 109 | end 110 | 111 | local curr = string.byte(text, index) 112 | local prev = string.byte(text, index - 1) 113 | if Character.is_symbol(curr) or Character.is_white(curr) then 114 | return true 115 | end 116 | if not Character.is_wordlike(prev) and Character.is_wordlike(curr) then 117 | return true 118 | end 119 | if Character.is_lower(prev) and Character.is_upper(curr) then 120 | return true 121 | end 122 | if not Character.is_digit(prev) and Character.is_digit(curr) then 123 | return true 124 | end 125 | return false 126 | end 127 | 128 | ---@param text string 129 | ---@param current_index integer 130 | ---@return integer 131 | function Character.get_next_semantic_index(text, current_index) 132 | for i = current_index + 1, #text do 133 | if Character.is_semantic_index(text, i) then 134 | return i 135 | end 136 | end 137 | return #text + 1 138 | end 139 | 140 | return Character 141 | -------------------------------------------------------------------------------- /lua/cmp-kit/core/Buffer.spec.lua: -------------------------------------------------------------------------------- 1 | local Buffer = require('cmp-kit.core.Buffer') 2 | 3 | ---@return string 4 | local function get_script_dir() 5 | return vim.fs.dirname(vim.fs.joinpath(vim.uv.cwd(), vim.fs.normalize(debug.getinfo(2, 'S').source:sub(2)))) 6 | end 7 | 8 | local pattern = [=[[[:keyword:]:]\+]=] 9 | 10 | describe('cmp-kit.completion', function() 11 | describe('Buffer', function() 12 | local function setup() 13 | vim.cmd.bdelete({ bang = true }) 14 | local fixture_path = vim.fn.fnamemodify(vim.fs.joinpath(get_script_dir(), '../spec/fixtures/buffer.txt'), ':p') 15 | vim.cmd.edit(fixture_path) 16 | vim.cmd('setlocal noswapfile') 17 | local bufnr = vim.api.nvim_get_current_buf() 18 | local buffer = Buffer.new(bufnr) 19 | vim.wait(16) 20 | vim.wait(1000, function() 21 | return not buffer:is_indexing(pattern) 22 | end, 1) 23 | assert.are.equal(false, buffer:is_indexing(pattern)) 24 | return buffer 25 | end 26 | 27 | it('.get_words (init)', function() 28 | local buffer = setup() 29 | for i = 1, vim.api.nvim_buf_line_count(buffer:get_buf()) do 30 | assert.are.same({ 31 | ('word:%s:1'):format(i), 32 | ('word:%s:2'):format(i), 33 | ('word:%s:3'):format(i), 34 | ('word:%s:4'):format(i), 35 | ('word:%s:5'):format(i), 36 | }, buffer:get_words(pattern, i - 1)) 37 | end 38 | end) 39 | 40 | it('.get_words (remove)', function() 41 | local buffer = setup() 42 | vim.api.nvim_buf_set_lines(buffer:get_buf(), 120, 121, false, {}) 43 | vim.wait(16) 44 | vim.wait(1000, function() 45 | return not buffer:is_indexing(pattern) 46 | end, 1) 47 | assert.are.equal(false, buffer:is_indexing(pattern)) 48 | 49 | for i = 1, vim.api.nvim_buf_line_count(buffer:get_buf()) do 50 | assert.are.same({ 51 | ('word:%s:1'):format(i + (i > 120 and 1 or 0)), 52 | ('word:%s:2'):format(i + (i > 120 and 1 or 0)), 53 | ('word:%s:3'):format(i + (i > 120 and 1 or 0)), 54 | ('word:%s:4'):format(i + (i > 120 and 1 or 0)), 55 | ('word:%s:5'):format(i + (i > 120 and 1 or 0)), 56 | }, buffer:get_words(pattern, i - 1)) 57 | end 58 | end) 59 | 60 | it('.get_words (add)', function() 61 | local buffer = setup() 62 | vim.api.nvim_buf_set_lines(buffer:get_buf(), 120, 120, false, { 'add' }) 63 | vim.wait(16) 64 | vim.wait(1000, function() 65 | return not buffer:is_indexing(pattern) 66 | end, 1) 67 | assert.are.equal(false, buffer:is_indexing(pattern)) 68 | 69 | for i = 1, vim.api.nvim_buf_line_count(buffer:get_buf()) do 70 | if i == 121 then 71 | assert.are.same({ 'add' }, buffer:get_words(pattern, i - 1)) 72 | else 73 | assert.are.same({ 74 | ('word:%s:1'):format(i + (i > 120 and -1 or 0)), 75 | ('word:%s:2'):format(i + (i > 120 and -1 or 0)), 76 | ('word:%s:3'):format(i + (i > 120 and -1 or 0)), 77 | ('word:%s:4'):format(i + (i > 120 and -1 or 0)), 78 | ('word:%s:5'):format(i + (i > 120 and -1 or 0)), 79 | }, buffer:get_words(pattern, i - 1)) 80 | end 81 | end 82 | end) 83 | 84 | it('.get_words (modify)', function() 85 | local buffer = setup() 86 | vim.api.nvim_buf_set_lines(buffer:get_buf(), 120, 121, false, { 'modify' }) 87 | vim.wait(16) 88 | vim.wait(1000, function() 89 | return not buffer:is_indexing(pattern) 90 | end, 1) 91 | assert.are.equal(false, buffer:is_indexing(pattern)) 92 | 93 | for i = 1, vim.api.nvim_buf_line_count(buffer:get_buf()) do 94 | if i == 121 then 95 | assert.are.same({ 'modify' }, buffer:get_words(pattern, i - 1)) 96 | else 97 | assert.are.same({ 98 | ('word:%s:1'):format(i), 99 | ('word:%s:2'):format(i), 100 | ('word:%s:3'):format(i), 101 | ('word:%s:4'):format(i), 102 | ('word:%s:5'):format(i), 103 | }, buffer:get_words(pattern, i - 1)) 104 | end 105 | end 106 | end) 107 | end) 108 | end) 109 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/source/path.spec.lua: -------------------------------------------------------------------------------- 1 | local path_source = require('cmp-kit.completion.ext.source.path') 2 | local spec = require('cmp-kit.spec') 3 | local LSP = require('cmp-kit.kit.LSP') 4 | local Async = require('cmp-kit.kit.Async') 5 | 6 | describe('cmp-kit.completion.ext.source.path', function() 7 | local source = path_source() 8 | 9 | ---Invoke completion. 10 | ---@param source cmp-kit.completion.CompletionSource 11 | ---@param completion_context cmp-kit.kit.LSP.CompletionContext 12 | ---@return cmp-kit.kit.LSP.TextDocumentCompletionResponse 13 | local function complete(source, completion_context) 14 | return Async.new(function(resolve, reject) 15 | source:complete(completion_context, function(err, res) 16 | if err then 17 | reject(err) 18 | else 19 | resolve(res) 20 | end 21 | end) 22 | end):sync(2 * 1000) 23 | end 24 | 25 | describe('skip unexpected absolute path completion', function() 26 | it('any symbols', function() 27 | spec.setup({ buffer_text = { '#|' } }) 28 | assert.are_same( 29 | complete(source, { 30 | triggerKind = LSP.CompletionTriggerKind.Invoked, 31 | triggerCharacter = '/', 32 | }), 33 | {} 34 | ) 35 | end) 36 | 37 | it('protocol scheme', function() 38 | spec.setup({ buffer_text = { 'https://|' } }) 39 | assert.are_same( 40 | complete(source, { 41 | triggerKind = LSP.CompletionTriggerKind.Invoked, 42 | triggerCharacter = '/', 43 | }), 44 | {} 45 | ) 46 | 47 | spec.setup({ buffer_text = { 'file:///|' } }) 48 | assert.are_not.same( 49 | complete(source, { 50 | triggerKind = LSP.CompletionTriggerKind.Invoked, 51 | triggerCharacter = '/', 52 | }), 53 | {} 54 | ) 55 | end) 56 | 57 | it('html closing tag', function() 58 | spec.setup({ buffer_text = { ' 16 | ---@param Bonus cmp-kit.completion.ext.DefaultSorter.BonusConfig 17 | ---@return boolean 18 | local function compare(a, b, context, cache, Bonus) 19 | if not cache[a.item] then 20 | local offset = a.provider:get_keyword_offset() or a.provider:get_completion_offset() 21 | local offset_diff = offset - a.item:get_offset() 22 | cache[a.item] = { 23 | offset_diff = offset_diff, 24 | preselect = a.item:is_preselect(), 25 | exact = context.trigger_context:get_query(a.item:get_offset()) == a.item:get_filter_text(), 26 | locality = context.locality_map[a.item:get_label_text()] or math.huge, 27 | sort_text = a.item:get_sort_text(), 28 | label_text = a.item:get_label_text(), 29 | } 30 | end 31 | local a_cache = cache[a.item] 32 | 33 | if not cache[b.item] then 34 | local offset = b.provider:get_keyword_offset() or b.provider:get_completion_offset() 35 | local offset_diff = offset - b.item:get_offset() 36 | cache[b.item] = { 37 | offset_diff = offset_diff, 38 | preselect = b.item:is_preselect(), 39 | exact = context.trigger_context:get_query(b.item:get_offset()) == b.item:get_filter_text(), 40 | locality = context.locality_map[b.item:get_label_text()] or math.huge, 41 | sort_text = b.item:get_sort_text(), 42 | label_text = b.item:get_label_text(), 43 | } 44 | end 45 | local b_cache = cache[b.item] 46 | 47 | if a_cache.offset_diff ~= b_cache.offset_diff then 48 | return a_cache.offset_diff < b_cache.offset_diff 49 | end 50 | 51 | local sort_text_bonus_a = 0 52 | local sort_text_bonus_b = 0 53 | if a_cache.sort_text and not b_cache.sort_text then 54 | sort_text_bonus_a = Bonus.sort_text 55 | end 56 | if not a_cache.sort_text and b_cache.sort_text then 57 | sort_text_bonus_b = Bonus.sort_text 58 | end 59 | if a_cache.sort_text and b_cache.sort_text and a_cache.sort_text ~= b_cache.sort_text then 60 | if a_cache.sort_text < b_cache.sort_text then 61 | sort_text_bonus_a = Bonus.sort_text 62 | elseif a_cache.sort_text > b_cache.sort_text then 63 | sort_text_bonus_b = Bonus.sort_text 64 | end 65 | end 66 | 67 | local score_bonus_a = 0 68 | local score_bonus_b = 0 69 | score_bonus_a = score_bonus_a + (a_cache.preselect and Bonus.preselect or 0) 70 | score_bonus_b = score_bonus_b + (b_cache.preselect and Bonus.preselect or 0) 71 | score_bonus_a = score_bonus_a + a_cache.locality < b_cache.locality and Bonus.locality or 0 72 | score_bonus_b = score_bonus_b + a_cache.locality > b_cache.locality and Bonus.locality or 0 73 | score_bonus_a = score_bonus_a + (a_cache.exact and Bonus.exact or 0) 74 | score_bonus_b = score_bonus_b + (b_cache.exact and Bonus.exact or 0) 75 | score_bonus_a = score_bonus_a + sort_text_bonus_a 76 | score_bonus_b = score_bonus_b + sort_text_bonus_b 77 | 78 | local score_a = a.score + score_bonus_a 79 | local score_b = b.score + score_bonus_b 80 | if score_a ~= score_b then 81 | return score_a > score_b 82 | end 83 | 84 | if a_cache.label_text ~= b_cache.label_text then 85 | return a_cache.label_text < b_cache.label_text 86 | end 87 | return a.index < b.index 88 | end 89 | 90 | local DefaultSorter = {} 91 | 92 | ---Sort matches. 93 | ---@param matches cmp-kit.completion.Match[] 94 | ---@param context cmp-kit.completion.SorterContext 95 | ---@return cmp-kit.completion.Match[] 96 | function DefaultSorter.sort(matches, context) 97 | -- sort matches. 98 | local cache = {} 99 | table.sort(matches, function(a, b) 100 | return compare(a, b, context, cache, BonusConfig) 101 | end) 102 | 103 | return matches 104 | end 105 | 106 | return DefaultSorter 107 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/LSP/DocumentSelector.lua: -------------------------------------------------------------------------------- 1 | local LanguageId = require('cmp-kit.kit.LSP.LanguageId') 2 | 3 | -- NOTE 4 | --@alias cmp-kit.kit.LSP.DocumentSelector cmp-kit.kit.LSP.DocumentFilter[] 5 | --@alias cmp-kit.kit.LSP.DocumentFilter (cmp-kit.kit.LSP.TextDocumentFilter | cmp-kit.kit.LSP.NotebookCellTextDocumentFilter) 6 | --@alias cmp-kit.kit.LSP.TextDocumentFilter ({ language: string, scheme?: string, pattern?: string } | { language?: string, scheme: string, pattern?: string } | { language?: string, scheme?: string, pattern: string }) 7 | --@class cmp-kit.kit.LSP.NotebookCellTextDocumentFilter 8 | --@field public notebook (string | cmp-kit.kit.LSP.NotebookDocumentFilter) A filter that matches against the notebook
containing the notebook cell. If a string
value is provided it matches against the
notebook type. '*' matches every notebook. 9 | --@field public language? string A language id like `python`.

Will be matched against the language id of the
notebook cell document. '*' matches every language. 10 | --@alias cmp-kit.kit.LSP.NotebookDocumentFilter ({ notebookType: string, scheme?: string, pattern?: string } | { notebookType?: string, scheme: string, pattern?: string } | { notebookType?: string, scheme?: string, pattern: string }) 11 | 12 | ---@alias cmp-kit.kit.LSP.DocumentSelector.NormalizedFilter { notebook_type: string?, scheme: string?, pattern: string, language: string? } 13 | 14 | ---Normalize the filter. 15 | ---@param document_filter cmp-kit.kit.LSP.DocumentFilter 16 | ---@return cmp-kit.kit.LSP.DocumentSelector.NormalizedFilter | nil 17 | local function normalize_filter(document_filter) 18 | if document_filter.notebook then 19 | local filter = document_filter --[[@as cmp-kit.kit.LSP.NotebookCellTextDocumentFilter]] 20 | if type(filter.notebook) == 'string' then 21 | return { 22 | notebook_type = nil, 23 | scheme = nil, 24 | pattern = filter.notebook, 25 | language = filter.language, 26 | } 27 | elseif filter.notebook then 28 | return { 29 | notebook_type = filter.notebook.notebookType, 30 | scheme = filter.notebook.scheme, 31 | pattern = filter.notebook.pattern, 32 | language = filter.language, 33 | } 34 | end 35 | else 36 | local filter = document_filter --[[@as cmp-kit.kit.LSP.TextDocumentFilter]] 37 | return { 38 | notebook_type = nil, 39 | scheme = filter.scheme, 40 | pattern = filter.pattern, 41 | language = filter.language, 42 | } 43 | end 44 | end 45 | 46 | ---Return the document filter score. 47 | ---TODO: file-related buffer check is not implemented... 48 | ---TODO: notebook related function is not implemented... 49 | ---@param filter? cmp-kit.kit.LSP.DocumentSelector.NormalizedFilter 50 | ---@param uri string 51 | ---@param language string 52 | ---@return integer 53 | local function score(filter, uri, language) 54 | if not filter then 55 | return 0 56 | end 57 | 58 | local s = 0 59 | 60 | if filter.scheme then 61 | if filter.scheme == '*' then 62 | s = 5 63 | elseif filter.scheme == uri:sub(1, #filter.scheme) then 64 | s = 10 65 | else 66 | return 0 67 | end 68 | end 69 | 70 | if filter.language then 71 | if filter.language == '*' then 72 | s = math.max(s, 5) 73 | elseif filter.language == language then 74 | s = 10 75 | else 76 | return 0 77 | end 78 | end 79 | 80 | if filter.pattern then 81 | if vim.glob.to_lpeg(filter.pattern):match(uri) ~= nil then 82 | s = 10 83 | else 84 | return 0 85 | end 86 | end 87 | 88 | return s 89 | end 90 | 91 | local DocumentSelector = {} 92 | 93 | ---Check buffer matches the selector. 94 | ---@see https://github.com/microsoft/vscode/blob/7241eea61021db926c052b657d577ef0d98f7dc7/src/vs/editor/common/languageSelector.ts#L29 95 | ---@param bufnr integer 96 | ---@param document_selector cmp-kit.kit.LSP.DocumentSelector 97 | function DocumentSelector.score(bufnr, document_selector) 98 | local uri = vim.uri_from_bufnr(bufnr) 99 | local language = LanguageId.from_filetype(vim.api.nvim_buf_get_option(bufnr, 'filetype')) 100 | local r = 0 101 | for _, document_filter in ipairs(document_selector) do 102 | local filter = normalize_filter(document_filter) 103 | if filter then 104 | local s = score(filter, uri, language) 105 | if s == 10 then 106 | return 10 107 | end 108 | r = math.max(r, s) 109 | end 110 | end 111 | return r 112 | end 113 | 114 | return DocumentSelector 115 | -------------------------------------------------------------------------------- /lua/cmp-kit/core/LinePatch.lua: -------------------------------------------------------------------------------- 1 | local LSP = require('cmp-kit.kit.LSP') 2 | local Async = require('cmp-kit.kit.Async') 3 | local Keymap = require('cmp-kit.kit.Vim.Keymap') 4 | local Position = require('cmp-kit.kit.LSP.Position') 5 | 6 | local BS = Keymap.termcodes('U') 7 | local DEL = Keymap.termcodes('') 8 | 9 | local wrap_keys 10 | do 11 | local set_options = Keymap.termcodes(table.concat({ 12 | 'noautocmd setlocal backspace=2', 13 | 'noautocmd setlocal textwidth=0', 14 | 'noautocmd setlocal indentkeys=', 15 | }, '')) 16 | local reset_options = Keymap.termcodes(table.concat({ 17 | 'noautocmd setlocal textwidth=%s', 18 | 'noautocmd setlocal backspace=%s', 19 | 'noautocmd setlocal indentkeys=%s', 20 | }, '')) 21 | wrap_keys = function(keys) 22 | return table.concat({ 23 | set_options, 24 | keys, 25 | reset_options:format( 26 | vim.bo.textwidth or 0, 27 | vim.go.backspace or 2, 28 | vim.bo.indentkeys or '' 29 | ), 30 | }, '') 31 | end 32 | end 33 | 34 | ---Move position by delta with consider buffer text and line changes. 35 | ---@param bufnr integer 36 | ---@param position cmp-kit.kit.LSP.Position 37 | ---@param delta integer 38 | ---@return cmp-kit.kit.LSP.Position 39 | local function shift_position(bufnr, position, delta) 40 | local new_character = position.character + delta 41 | if new_character < 0 then 42 | if position.line == 0 then 43 | error('can not shift to the new position.') 44 | end 45 | local above_line = vim.api.nvim_buf_get_lines(bufnr, position.line - 1, position.line, false)[1] 46 | return shift_position(bufnr, { 47 | line = position.line - 1, 48 | character = #above_line, 49 | }, new_character + 1) 50 | end 51 | local curr_line = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)[position.line + 1] or '' 52 | if #curr_line < new_character then 53 | return shift_position(bufnr, { 54 | line = position.line + 1, 55 | character = 0, 56 | }, new_character - #curr_line - 1) 57 | end 58 | return { 59 | line = position.line, 60 | character = new_character, 61 | } 62 | end 63 | 64 | local LinePatch = {} 65 | 66 | ---Apply oneline text patch by func (without dot-repeat). 67 | ---@param bufnr integer 68 | ---@param before integer 0-origin utf8 byte count 69 | ---@param after integer 0-origin utf8 byte count 70 | ---@param insert_text string 71 | function LinePatch.apply_by_func(bufnr, before, after, insert_text) 72 | bufnr = bufnr == 0 and vim.api.nvim_get_current_buf() or bufnr 73 | 74 | return Async.run(function() 75 | local mode = vim.api.nvim_get_mode().mode --[[@as string]] 76 | if mode == 'c' then 77 | local cursor_col = vim.fn.getcmdpos() - 1 78 | local cmdline = vim.fn.getcmdline() 79 | local before_text = string.sub(cmdline, 1, cursor_col - before) 80 | local after_text = string.sub(cmdline, cursor_col + after + 1) 81 | vim.fn.setcmdline(before_text .. insert_text .. after_text, #before_text + #insert_text + 1) 82 | else 83 | local cursor_position = Position.cursor(LSP.PositionEncodingKind.UTF8) 84 | local text_edit = { 85 | range = { 86 | start = shift_position(bufnr, cursor_position, -before), 87 | ['end'] = shift_position(bufnr, cursor_position, after), 88 | }, 89 | newText = insert_text, 90 | } 91 | vim.lsp.util.apply_text_edits({ text_edit }, bufnr, LSP.PositionEncodingKind.UTF8) 92 | 93 | local insert_lines = vim.split(insert_text, '\n', { plain = true }) 94 | if #insert_lines == 1 then 95 | vim.api.nvim_win_set_cursor(0, { 96 | (text_edit.range.start.line + 1), 97 | text_edit.range.start.character + #insert_lines[1], 98 | }) 99 | else 100 | vim.api.nvim_win_set_cursor(0, { 101 | (text_edit.range.start.line + 1) + (#insert_lines - 1), 102 | #insert_lines[#insert_lines], 103 | }) 104 | end 105 | end 106 | end) 107 | end 108 | 109 | ---Apply oneline text patch by keys (with dot-repeat). 110 | ---@param bufnr integer 111 | ---@param before integer 0-origin utf8 byte count 112 | ---@param after integer 0-origin utf8 byte count 113 | ---@param insert_text string 114 | function LinePatch.apply_by_keys(bufnr, before, after, insert_text) 115 | local mode = vim.api.nvim_get_mode().mode 116 | if mode == 'c' then 117 | return LinePatch.apply_by_func(bufnr, before, after, insert_text):next(function() 118 | return Keymap.send('') 119 | end) 120 | end 121 | 122 | local cursor = vim.api.nvim_win_get_cursor(0) 123 | local line = vim.api.nvim_buf_get_lines(bufnr, cursor[1] - 1, cursor[1], false)[1] 124 | local character = cursor[2] 125 | local before_text = line:sub(1 + character - before, character) 126 | local after_text = line:sub(character + 1, character + after) 127 | 128 | return Keymap.send(wrap_keys(table.concat({ 129 | BS:rep(vim.fn.strchars(before_text, true)), 130 | DEL:rep(vim.fn.strchars(after_text, true)), 131 | insert_text, 132 | }, ''))) 133 | end 134 | 135 | return LinePatch 136 | -------------------------------------------------------------------------------- /lua/cmp-kit/spec/init.lua: -------------------------------------------------------------------------------- 1 | local LSP = require('cmp-kit.kit.LSP') 2 | local kit = require('cmp-kit.kit') 3 | local Async = require('cmp-kit.kit.Async') 4 | local assert = select(2, pcall(require, 'luassert')) or _G.assert 5 | local TriggerContext = require('cmp-kit.core.TriggerContext') 6 | local LinePatch = require('cmp-kit.core.LinePatch') 7 | local CompletionService = require('cmp-kit.completion.CompletionService') 8 | local DefaultConfig = require('cmp-kit.completion.ext.DefaultConfig') 9 | 10 | local profiling = {} 11 | 12 | local spec = {} 13 | 14 | ---Reset test environment. 15 | function spec.reset() 16 | --Create buffer. 17 | vim.cmd.enew({ bang = true, args = {} }) 18 | vim.o.virtualedit = 'onemore' 19 | vim.o.swapfile = false 20 | end 21 | 22 | ---@class cmp-kit.completion.spec.setup.Option 23 | ---@field public buffer_text string[] 24 | ---@field public mode? 'i' | 'c' 25 | ---@field public input? string 26 | ---@field public provider_name? string 27 | ---@field public keyword_pattern? string 28 | ---@field public position_encoding_kind? cmp-kit.kit.LSP.PositionEncodingKind 29 | ---@field public resolve? fun(item: cmp-kit.kit.LSP.CompletionItem): cmp-kit.kit.Async.AsyncTask cmp-kit.kit.LSP.CompletionItem 30 | ---@field public item_defaults? cmp-kit.kit.LSP.CompletionItemDefaults 31 | ---@field public is_incomplete? boolean 32 | ---@field public items? cmp-kit.kit.LSP.CompletionItem[] 33 | 34 | ---Setup for spec. 35 | ---@param option cmp-kit.completion.spec.setup.Option 36 | ---@return cmp-kit.core.TriggerContext, cmp-kit.completion.CompletionSource, cmp-kit.completion.CompletionService 37 | function spec.setup(option) 38 | option.mode = option.mode or 'i' 39 | 40 | --Reset test environment. 41 | spec.reset() 42 | 43 | --Setup context and buffer text and cursor position. 44 | if option.mode == 'i' then 45 | vim.api.nvim_buf_set_lines(0, 0, -1, false, option.buffer_text) 46 | for i = 1, #option.buffer_text do 47 | local s = option.buffer_text[i]:find('|', 1, true) 48 | if s then 49 | vim.api.nvim_win_set_cursor(0, { i, s - 1 }) 50 | vim.api.nvim_set_current_line((option.buffer_text[i]:gsub('|', ''))) 51 | break 52 | end 53 | end 54 | elseif option.mode == 'c' then 55 | local pos = option.buffer_text[1]:find('|', 1, true) 56 | local text = option.buffer_text[1]:gsub('|', '') 57 | vim.fn.setcmdline(text, pos) 58 | end 59 | 60 | local target_items = option.items or { { label = 'dummy' } } 61 | 62 | ---Create source. 63 | ---@type cmp-kit.completion.CompletionSource 64 | local source = { 65 | name = option.provider_name or 'test', 66 | get_configuration = function() 67 | return { 68 | keyword_pattern = option.keyword_pattern or DefaultConfig.default_keyword_pattern, 69 | trigger_characters = { '.' }, 70 | } 71 | end, 72 | get_position_encoding_kind = function(_) 73 | return option.position_encoding_kind or LSP.PositionEncodingKind.UTF8 74 | end, 75 | resolve = function(_, item, callback) 76 | Async.run(function() 77 | if not option.resolve then 78 | return Async.resolve(item) 79 | end 80 | return option.resolve(item) 81 | end):dispatch(function(res) 82 | callback(nil, res) 83 | end, function(err) 84 | callback(err, nil) 85 | end) 86 | end, 87 | complete = function(_, _, callback) 88 | callback(nil, { 89 | items = target_items, 90 | itemDefaults = option.item_defaults, 91 | isIncomplete = option.is_incomplete or false, 92 | }) 93 | end, 94 | } 95 | 96 | -- Create service. 97 | local service = CompletionService.new({}) 98 | service:register_source(source, { 99 | group = 1, 100 | }) 101 | 102 | service:set_config(kit.merge({ 103 | performance = { 104 | fetching_timeout_ms = 0, 105 | }, 106 | } --[[@as cmp-kit.completion.CompletionService.Config|{}]], service:get_config())) 107 | service:complete({ force = true }):sync(5000) 108 | service:matching() 109 | 110 | -- Insert filtering query after request. 111 | if option.input then 112 | LinePatch.apply_by_func(vim.api.nvim_get_current_buf(), 0, 0, option.input):sync(5000) 113 | end 114 | 115 | return TriggerContext.create(), source, service 116 | end 117 | 118 | ---@param buffer_text string[] 119 | function spec.assert(buffer_text) 120 | ---@type { [1]: integer, [2]: integer } 121 | local cursor = vim.api.nvim_win_get_cursor(0) 122 | for i = 1, #buffer_text do 123 | local s = buffer_text[i]:find('|', 1, true) 124 | if s then 125 | cursor[1] = i 126 | cursor[2] = s - 1 127 | buffer_text[i] = buffer_text[i]:gsub('|', '') 128 | break 129 | end 130 | end 131 | 132 | local ok1, err1 = pcall(function() 133 | assert.are.same(buffer_text, vim.api.nvim_buf_get_lines(0, 0, -1, false)) 134 | end) 135 | local ok2, err2 = pcall(function() 136 | assert.are.same(cursor, vim.api.nvim_win_get_cursor(0)) 137 | end) 138 | if not ok1 or not ok2 then 139 | local err = '' 140 | if err1 then 141 | if type(err1) == 'string' then 142 | err = err .. '\n' .. err1 143 | else 144 | ---@diagnostic disable-next-line: need-check-nil 145 | err = err .. err1.message 146 | end 147 | end 148 | if err2 then 149 | if type(err2) == 'string' then 150 | err = err .. '\n' .. err2 151 | else 152 | ---@diagnostic disable-next-line: need-check-nil 153 | err = err .. err2.message 154 | end 155 | end 156 | error(err, 2) 157 | end 158 | end 159 | 160 | function spec.start_profile() 161 | profiling = {} 162 | end 163 | 164 | function spec.on_call(name) 165 | if not profiling then 166 | return 167 | end 168 | if not profiling[name] then 169 | profiling[name] = 0 170 | end 171 | profiling[name] = profiling[name] + 1 172 | end 173 | 174 | function spec.print_profile() 175 | vim.print(vim.inspect(profiling)) 176 | end 177 | 178 | return spec 179 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/DefaultMatcher.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local Character = require('cmp-kit.kit.App.Character') 3 | 4 | local Config = { 5 | score_adjuster = 0.001, 6 | max_semantic_indexes = 200, 7 | } 8 | 9 | local cache = { 10 | score_memo = {}, 11 | semantic_indexes = {}, 12 | } 13 | 14 | ---Get semantic indexes for the text. 15 | ---@param text string 16 | ---@param char_map table 17 | ---@return integer[] 18 | local function parse_semantic_indexes(text, char_map) 19 | local is_semantic_index = Character.is_semantic_index 20 | 21 | local M = math.min(#text, Config.max_semantic_indexes) 22 | local semantic_indexes = kit.clear(cache.semantic_indexes) 23 | for ti = 1, M do 24 | if char_map[text:byte(ti)] and is_semantic_index(text, ti) then 25 | semantic_indexes[#semantic_indexes + 1] = ti 26 | end 27 | end 28 | return semantic_indexes 29 | end 30 | 31 | ---Find best match with dynamic programming. 32 | ---@param query string 33 | ---@param text string 34 | ---@param semantic_indexes integer[] 35 | ---@param with_ranges boolean 36 | ---@return integer, { [1]: integer, [2]: integer }[]? 37 | local function compute( 38 | query, 39 | text, 40 | semantic_indexes, 41 | with_ranges 42 | ) 43 | local Q = #query 44 | local T = #text 45 | local S = #semantic_indexes 46 | 47 | local run_id = kit.unique_id() 48 | local score_memo = cache.score_memo 49 | local match_icase = Character.match_icase 50 | local score_adjuster = Config.score_adjuster 51 | 52 | local function dfs(qi, si, prev_ti, part_score, part_chunks) 53 | -- match 54 | if qi > Q then 55 | local score = part_score - part_chunks * score_adjuster 56 | if with_ranges then 57 | return score, {} 58 | end 59 | return score 60 | end 61 | 62 | -- no match 63 | if si > S then 64 | return -1 / 0, nil 65 | end 66 | 67 | -- memo 68 | local idx = ((qi - 1) * S + si - 1) * 3 + 1 69 | if score_memo[idx + 0] == run_id then 70 | return score_memo[idx + 1], score_memo[idx + 2] 71 | end 72 | 73 | -- compute. 74 | local best_score = -1 / 0 75 | local best_range_s 76 | local best_range_e 77 | local best_ranges --[[@as { [1]: integer, [2]: integer }[]?]] 78 | while si <= S do 79 | local ti = semantic_indexes[si] 80 | 81 | local strict_bonus = 0 82 | local mi = 0 83 | while ti + mi <= T and qi + mi <= Q do 84 | local t_char = text:byte(ti + mi) 85 | local q_char = query:byte(qi + mi) 86 | if not match_icase(t_char, q_char) then 87 | break 88 | end 89 | mi = mi + 1 90 | strict_bonus = strict_bonus + (t_char == q_char and score_adjuster * 0.1 or 0) 91 | 92 | local inner_score, inner_ranges = dfs( 93 | qi + mi, 94 | si + 1, 95 | ti + mi, 96 | part_score + mi + strict_bonus, 97 | part_chunks + 1 98 | ) 99 | 100 | -- custom 101 | do 102 | -- prefix unmatch penalty 103 | if qi == 1 and ti ~= 1 then 104 | inner_score = inner_score - score_adjuster * T 105 | end 106 | 107 | -- gap length penalty 108 | if ti - prev_ti > 0 then 109 | inner_score = inner_score - (score_adjuster * math.max(0, (ti - prev_ti))) 110 | end 111 | end 112 | 113 | if inner_score > best_score then 114 | best_score = inner_score 115 | best_range_s = ti 116 | best_range_e = ti + mi 117 | best_ranges = inner_ranges 118 | end 119 | end 120 | si = si + 1 121 | end 122 | 123 | if best_ranges then 124 | best_ranges[#best_ranges + 1] = { best_range_s, best_range_e } 125 | end 126 | 127 | score_memo[idx + 0] = run_id 128 | score_memo[idx + 1] = best_score 129 | score_memo[idx + 2] = best_ranges 130 | 131 | return best_score, best_ranges 132 | end 133 | return dfs(1, 1, math.huge, 0, -1) 134 | end 135 | 136 | ---Parse a query string into parts. 137 | ---@type table|(fun(query: string): string, table) 138 | local parse_query = setmetatable({ 139 | cache_query = nil, 140 | cache_char_map = {}, 141 | }, { 142 | __call = function(self, query) 143 | if self.cache_query == query then 144 | return query, self.cache_char_map 145 | end 146 | self.cache_query = query 147 | 148 | local char_map = {} 149 | for i = 1, #query do 150 | local c = query:byte(i) 151 | char_map[c] = true 152 | if Character.is_upper(c) then 153 | char_map[c + 32] = true 154 | elseif Character.is_lower(c) then 155 | char_map[c - 32] = true 156 | end 157 | end 158 | self.cache_char_map = char_map 159 | 160 | return query, self.cache_char_map 161 | end, 162 | }) 163 | 164 | local DefaultMatcher = {} 165 | 166 | DefaultMatcher.Config = Config 167 | 168 | ---Match query against text and return a score. 169 | ---@param input string 170 | ---@param text string 171 | ---@return integer 172 | function DefaultMatcher.match(input, text) 173 | if input == '' then 174 | return 1 175 | end 176 | 177 | local query, char_map = parse_query(input) 178 | local semantic_indexes = parse_semantic_indexes(text, char_map) 179 | local score = compute(query, text, semantic_indexes, false) 180 | if score <= 0 then 181 | return 0 182 | end 183 | return score 184 | end 185 | 186 | ---Match query against text and return a score. 187 | ---@param input string 188 | ---@param text string 189 | ---@return { [1]: integer, [2]: integer }[] 190 | function DefaultMatcher.decor(input, text) 191 | if input == '' then 192 | return {} 193 | end 194 | 195 | local query, char_map = parse_query(input) 196 | local semantic_indexes = parse_semantic_indexes(text, char_map) 197 | local score, ranges = compute(query, text, semantic_indexes, true) 198 | if score <= 0 then 199 | return {} 200 | end 201 | return ranges or {} 202 | end 203 | 204 | return DefaultMatcher 205 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/CompletionProvider.spec.lua: -------------------------------------------------------------------------------- 1 | local spec = require('cmp-kit.spec') 2 | local Keymap = require('cmp-kit.kit.Vim.Keymap') 3 | local TriggerContext = require('cmp-kit.core.TriggerContext') 4 | local DefaultConfig = require('cmp-kit.completion.ext.DefaultConfig') 5 | local CompletionProvider = require('cmp-kit.completion.CompletionProvider') 6 | 7 | ---@class cmp-kit.completion.CompletionProvider.spec.Option 8 | ---@field public keyword_pattern? string 9 | 10 | ---@param option? cmp-kit.completion.CompletionProvider.spec.Option 11 | ---@return cmp-kit.completion.CompletionProvider, { set_response: fun(response: cmp-kit.kit.LSP.CompletionList) } 12 | local function create_provider(option) 13 | option = option or {} 14 | 15 | local response ---@type cmp-kit.kit.LSP.CompletionList 16 | local provider = CompletionProvider.new({ 17 | name = 'dummy', 18 | get_configuration = function() 19 | return { 20 | keyword_pattern = option.keyword_pattern or DefaultConfig.default_keyword_pattern, 21 | trigger_characters = { '.' }, 22 | } 23 | end, 24 | complete = function(_, _, callback) 25 | callback(nil, response) 26 | end, 27 | }) 28 | return provider, { 29 | ---@param response_ cmp-kit.kit.LSP.CompletionList 30 | set_response = function(response_) 31 | response = response_ 32 | end, 33 | } 34 | end 35 | 36 | describe('cmp-kit.completion', function() 37 | describe('CompletionProvider', function() 38 | it('should determine completion timing', function() 39 | spec.reset() 40 | 41 | local provider, ctx = create_provider() 42 | Keymap.spec(function() 43 | Keymap.send('i'):await() 44 | 45 | -- no_completion: keyword_pattern=false. 46 | Keymap.send(' '):await() 47 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 48 | assert.is_nil(provider:complete(TriggerContext.create()):await()) 49 | 50 | -- completion: keyword_pattern=true. 51 | Keymap.send('f'):await() 52 | ctx.set_response({ isIncomplete = true, items = { { label = 'foobarbaz' } } }) 53 | assert.are_not.is_nil(provider:complete(TriggerContext.create()):await()) 54 | 55 | -- completion: keyword_pattern=true, alreadyCompleted=true, prevIsIncomplete=true 56 | Keymap.send('o'):await() 57 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 58 | assert.are_not.is_nil(provider:complete(TriggerContext.create()):await()) 59 | 60 | -- no_completion: keyword_pattern=true, alreadyCompleted=true, prevIsIncomplete=false 61 | Keymap.send('o'):await() 62 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 63 | assert.is_nil(provider:complete(TriggerContext.create()):await()) 64 | 65 | -- completion: force. 66 | Keymap.send('b'):await() 67 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 68 | assert.are_not.is_nil(provider:complete(TriggerContext.create({ force = true })):await()) 69 | 70 | -- completion: trigger_char 71 | Keymap.send('.'):await() 72 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 73 | assert.are_not.is_nil(provider:complete(TriggerContext.create()):await()) 74 | end) 75 | end) 76 | 77 | it('completion state: keyword pattern will be cleared if keyword_pattern=false', function() 78 | spec.reset() 79 | 80 | local provider, ctx = create_provider() 81 | Keymap.spec(function() 82 | Keymap.send('i'):await() 83 | 84 | -- completion. 85 | Keymap.send('f'):await() 86 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 87 | provider:complete(TriggerContext.create()):await() 88 | assert.are_not.same({}, provider:get_items()) 89 | 90 | -- keep: keyword_pattern=true. 91 | Keymap.send('o'):await() 92 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 93 | provider:complete(TriggerContext.create()):await() 94 | assert.are_not.same({}, provider:get_items()) 95 | 96 | -- clear: keyword_pattern=false. 97 | Keymap.send(Keymap.termcodes('')):await() 98 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 99 | provider:complete(TriggerContext.create()):await() 100 | assert.are.same({}, provider:get_items()) 101 | end) 102 | end) 103 | 104 | it('completion state: trigger_characters does not clear even if keyword_pattern=false', function() 105 | spec.reset() 106 | 107 | local provider, ctx = create_provider() 108 | Keymap.spec(function() 109 | Keymap.send('i'):await() 110 | 111 | -- completion. 112 | Keymap.send('.'):await() 113 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 114 | provider:complete(TriggerContext.create()):await() 115 | assert.is_true(provider:in_trigger_character_completion()) 116 | assert.are_not.same({}, provider:get_items()) 117 | 118 | -- keep: keyword_pattern=true. 119 | Keymap.send('f'):await() 120 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 121 | provider:complete(TriggerContext.create()):await() 122 | assert.is_true(provider:in_trigger_character_completion()) 123 | assert.are_not.same({}, provider:get_items()) 124 | 125 | -- keep: keyword_pattern=true. 126 | Keymap.send('o'):await() 127 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 128 | provider:complete(TriggerContext.create()):await() 129 | assert.is_true(provider:in_trigger_character_completion()) 130 | assert.are_not.same({}, provider:get_items()) 131 | 132 | -- keep: keyword_pattern=false. 133 | Keymap.send(Keymap.termcodes('')):await() 134 | ctx.set_response({ isIncomplete = false, items = { { label = 'foobarbaz' } } }) 135 | provider:complete(TriggerContext.create()):await() 136 | assert.is_true(provider:in_trigger_character_completion()) 137 | assert.are_not.same({}, provider:get_items()) 138 | end) 139 | end) 140 | end) 141 | end) 142 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/Async/init.lua: -------------------------------------------------------------------------------- 1 | local AsyncTask = require('cmp-kit.kit.Async.AsyncTask') 2 | 3 | local Interrupt = {} 4 | 5 | local Async = {} 6 | 7 | _G.kit = _G.kit or {} 8 | _G.kit.Async = _G.kit.Async or {} 9 | _G.kit.Async.___threads___ = _G.kit.Async.___threads___ or {} 10 | 11 | ---Alias of AsyncTask.all. 12 | ---@param tasks cmp-kit.kit.Async.AsyncTask[] 13 | ---@return cmp-kit.kit.Async.AsyncTask 14 | function Async.all(tasks) 15 | return AsyncTask.all(tasks) 16 | end 17 | 18 | ---Alias of AsyncTask.race. 19 | ---@param tasks cmp-kit.kit.Async.AsyncTask[] 20 | ---@return cmp-kit.kit.Async.AsyncTask 21 | function Async.race(tasks) 22 | return AsyncTask.race(tasks) 23 | end 24 | 25 | ---Alias of AsyncTask.resolve(v). 26 | ---@param v any 27 | ---@return cmp-kit.kit.Async.AsyncTask 28 | function Async.resolve(v) 29 | return AsyncTask.resolve(v) 30 | end 31 | 32 | ---Alias of AsyncTask.reject(v). 33 | ---@param v any 34 | ---@return cmp-kit.kit.Async.AsyncTask 35 | function Async.reject(v) 36 | return AsyncTask.reject(v) 37 | end 38 | 39 | ---Alias of AsyncTask.new(...). 40 | ---@param runner fun(resolve: fun(value: any), reject: fun(err: any)) 41 | ---@return cmp-kit.kit.Async.AsyncTask 42 | function Async.new(runner) 43 | return AsyncTask.new(runner) 44 | end 45 | 46 | do 47 | ---@param resolve fun(v: unknown) 48 | ---@param reject fun(e: unknown) 49 | ---@param thread thread 50 | ---@param ok boolean 51 | ---@param v unknown 52 | local function next_step(resolve, reject, thread, ok, v) 53 | if getmetatable(v) == Interrupt then 54 | vim.defer_fn(function() 55 | next_step(resolve, reject, thread, coroutine.resume(thread)) 56 | end, v.timeout) 57 | return 58 | end 59 | 60 | if coroutine.status(thread) == 'dead' then 61 | if AsyncTask.is(v) then 62 | v:dispatch(resolve, reject) 63 | else 64 | if ok then 65 | resolve(v) 66 | else 67 | reject(v) 68 | end 69 | end 70 | _G.kit.Async.___threads___[thread] = nil 71 | return 72 | end 73 | 74 | v:dispatch(function(...) 75 | next_step(resolve, reject, thread, coroutine.resume(thread, true, ...)) 76 | end, function(...) 77 | next_step(resolve, reject, thread, coroutine.resume(thread, false, ...)) 78 | end) 79 | end 80 | 81 | ---Run async function immediately. 82 | ---@generic A: ... 83 | ---@param runner fun(...: A): any 84 | ---@param ...? A 85 | ---@return cmp-kit.kit.Async.AsyncTask 86 | function Async.run(runner, ...) 87 | local args = { ... } 88 | 89 | local thread_parent = Async.in_context() and coroutine.running() or nil 90 | 91 | local thread = coroutine.create(runner) 92 | _G.kit.Async.___threads___[thread] = { 93 | thread = thread, 94 | thread_parent = thread_parent, 95 | now = vim.uv.hrtime() / 1000000, 96 | } 97 | return AsyncTask.new(function(resolve, reject) 98 | next_step(resolve, reject, thread, coroutine.resume(thread, unpack(args))) 99 | end) 100 | end 101 | end 102 | 103 | ---Return current context is async coroutine or not. 104 | ---@return boolean 105 | function Async.in_context() 106 | return _G.kit.Async.___threads___[coroutine.running()] ~= nil 107 | end 108 | 109 | ---Await async task. 110 | ---@param task cmp-kit.kit.Async.AsyncTask 111 | ---@return any 112 | function Async.await(task) 113 | if not _G.kit.Async.___threads___[coroutine.running()] then 114 | error('`Async.await` must be called in async context.') 115 | end 116 | if not AsyncTask.is(task) then 117 | error('`Async.await` must be called with AsyncTask.') 118 | end 119 | 120 | local ok, res = coroutine.yield(task) 121 | if not ok then 122 | error(res, 2) 123 | end 124 | return res 125 | end 126 | 127 | ---Interrupt sync process. 128 | ---@param interval integer 129 | ---@param timeout? integer 130 | function Async.interrupt(interval, timeout) 131 | local thread = coroutine.running() 132 | if not _G.kit.Async.___threads___[thread] then 133 | error('`Async.interrupt` must be called in async context.') 134 | end 135 | 136 | local thread_parent = thread 137 | while true do 138 | local next_thread_parent = _G.kit.Async.___threads___[thread_parent].thread_parent 139 | if not next_thread_parent then 140 | break 141 | end 142 | if not _G.kit.Async.___threads___[next_thread_parent] then 143 | break 144 | end 145 | thread_parent = next_thread_parent 146 | end 147 | 148 | local prev_now = _G.kit.Async.___threads___[thread_parent].now 149 | local curr_now = vim.uv.hrtime() / 1000000 150 | if (curr_now - prev_now) > interval then 151 | coroutine.yield(setmetatable({ timeout = timeout or 16 }, Interrupt)) 152 | if _G.kit.Async.___threads___[thread_parent] then 153 | _G.kit.Async.___threads___[thread_parent].now = vim.uv.hrtime() / 1000000 154 | end 155 | end 156 | end 157 | 158 | ---Create vim.schedule task. 159 | ---@return cmp-kit.kit.Async.AsyncTask 160 | function Async.schedule() 161 | return AsyncTask.new(function(resolve) 162 | vim.schedule(resolve) 163 | end) 164 | end 165 | 166 | ---Create vim.defer_fn task. 167 | ---@param timeout integer 168 | ---@return cmp-kit.kit.Async.AsyncTask 169 | function Async.timeout(timeout) 170 | return AsyncTask.new(function(resolve) 171 | vim.defer_fn(resolve, timeout) 172 | end) 173 | end 174 | 175 | ---Create async function from callback function. 176 | ---@generic T: ... 177 | ---@param runner fun(...: T) 178 | ---@param option? { schedule?: boolean, callback?: integer } 179 | ---@return fun(...: T): cmp-kit.kit.Async.AsyncTask 180 | function Async.promisify(runner, option) 181 | option = option or {} 182 | option.schedule = not vim.is_thread() and (option.schedule or false) 183 | option.callback = option.callback or nil 184 | return function(...) 185 | local args = { ... } 186 | return AsyncTask.new(function(resolve, reject) 187 | local max = #args + 1 188 | local pos = math.min(option.callback or max, max) 189 | table.insert(args, pos, function(err, ...) 190 | if option.schedule and vim.in_fast_event() then 191 | resolve = vim.schedule_wrap(resolve) 192 | reject = vim.schedule_wrap(reject) 193 | end 194 | if err then 195 | reject(err) 196 | else 197 | resolve(...) 198 | end 199 | end) 200 | runner(unpack(args)) 201 | end) 202 | end 203 | end 204 | 205 | return Async 206 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/source/cmdline.lua: -------------------------------------------------------------------------------- 1 | local Async = require('cmp-kit.kit.Async') 2 | local TriggerContext = require('cmp-kit.core.TriggerContext') 3 | 4 | ---@param patterns string[] 5 | ---@return vim.regex 6 | local function create_head_regex(patterns) 7 | return vim.regex([[^\%(]] .. table.concat(patterns, [[\|]]) .. [[\)]]) 8 | end 9 | 10 | ---Remove by regex. 11 | ---@param text string 12 | ---@param regex vim.regex 13 | ---@return string 14 | local function remove_regex(text, regex) 15 | local s, e = regex:match_str(text) 16 | if s and e then 17 | return text:sub(1, s) .. text:sub(e + 1) 18 | end 19 | return text 20 | end 21 | 22 | ---Check if the option is boolean. 23 | ---@param o string 24 | ---@return boolean 25 | local function is_boolean_option(o) 26 | local ok, v = pcall(function() 27 | return vim.o[o] 28 | end) 29 | if ok then 30 | return type(v) == 'boolean' 31 | end 32 | return false 33 | end 34 | 35 | local modifier_regex = create_head_regex({ 36 | [=[\s*abo\%[veleft]\s*]=], 37 | [=[\s*bel\%[owright]\s*]=], 38 | [=[\s*bo\%[tright]\s*]=], 39 | [=[\s*bro\%[wse]\s*]=], 40 | [=[\s*conf\%[irm]\s*]=], 41 | [=[\s*hid\%[e]\s*]=], 42 | [=[\s*keepalt\s*]=], 43 | [=[\s*keeppa\%[tterns]\s*]=], 44 | [=[\s*lefta\%[bove]\s*]=], 45 | [=[\s*loc\%[kmarks]\s*]=], 46 | [=[\s*nos\%[wapfile]\s*]=], 47 | [=[\s*rightb\%[elow]\s*]=], 48 | [=[\s*sil\%[ent]\s*]=], 49 | [=[\s*tab\s*]=], 50 | [=[\s*to\%[pleft]\s*]=], 51 | [=[\s*verb\%[ose]\s*]=], 52 | [=[\s*vert\%[ical]\s*]=], 53 | }) 54 | 55 | local count_range_regex = create_head_regex({ 56 | [=[\s*\%(\d\+\|\$\)\%[,\%(\d\+\|\$\)]\s*]=], 57 | [=[\s*'\%[<,'>]\s*]=], 58 | [=[\s*\%(\d\+\|\$\)\s*]=], 59 | }) 60 | 61 | local range_only_regex = create_head_regex({ 62 | [=[\s*\%(\d\+\|\$\)\%[,\%(\d\+\|\$\)]\s*]=], 63 | [=[\s*'\%[<,'>]\s*]=], 64 | [=[\s*\%(\d\+\|\$\)\s*]=], 65 | [=[\s*%\s*]=], 66 | }) 67 | 68 | local set_option_cmd_regex = create_head_regex({ 69 | [=[\s*se\%[tlocal][^=]*]=], 70 | }) 71 | 72 | local lua_expression_cmd_regex = create_head_regex({ 73 | [=[\s*lua]=], 74 | [=[\s*lua=]=], 75 | [=[\s*luado]=], 76 | }) 77 | 78 | ---@class cmp-kit.completion.ext.source.cmdline.Option 79 | ---@param option? cmp-kit.completion.ext.source.cmdline.Option 80 | return function(option) 81 | option = option or {} 82 | 83 | local dedup = {} --[=[@as table]=] 84 | local items = {} --[=[@as cmp-kit.kit.LSP.CompletionItem[]]=] 85 | 86 | ---@type cmp-kit.completion.CompletionSource 87 | return { 88 | name = 'cmdline', 89 | get_configuration = function() 90 | return { 91 | keyword_pattern = [==[[^[:blank:]=]\+]==], 92 | trigger_characters = { ' ', '=', '.', ':' }, 93 | } 94 | end, 95 | capable = function() 96 | return vim.api.nvim_get_mode().mode == 'c' 97 | end, 98 | complete = function(_, completion_context, callback) 99 | if completion_context.triggerKind ~= vim.lsp.protocol.CompletionTriggerKind.TriggerForIncompleteCompletions then 100 | dedup = {} 101 | items = {} 102 | end 103 | 104 | Async.run(function() 105 | -- create normalized cmdline. 106 | -- - remove modifiers 107 | -- - `keepalt bufdo` -> bufdo` 108 | -- - remove count range. 109 | -- - `1,$delete` -> `delete` 110 | -- - remove range only. 111 | -- - `'<,>'delete` -> `delete` 112 | local cmdline = TriggerContext.create().text_before 113 | while true do 114 | local prev = cmdline 115 | for _, regex in ipairs({ modifier_regex, count_range_regex, range_only_regex }) do 116 | cmdline = remove_regex(cmdline, regex) 117 | end 118 | if cmdline == prev then 119 | break 120 | end 121 | end 122 | cmdline = (cmdline:gsub('^%s+', '')) 123 | 124 | -- if cmd is not determined, return empty. 125 | local cmd = cmdline:match('^%S+') or '' 126 | if cmd == '' then 127 | return {} 128 | end 129 | 130 | -- get and fix arguments part for specific commands. 131 | local arg = cmdline:sub(#cmd + 1) 132 | do 133 | if lua_expression_cmd_regex:match_str(cmd) then 134 | -- - remove in-complete identifier. 135 | -- - `lua vim.api.nivmbuf` -> `lua vim.api.` 136 | arg = arg:match('%.') and (arg:gsub('%w*$', '')) or arg 137 | elseif set_option_cmd_regex:match_str(cmd) then 138 | -- - remove `no` prefix. 139 | -- - `set nonumber` -> `set number` 140 | arg = (arg:gsub('^%s*no', '')) 141 | end 142 | end 143 | 144 | -- invoke completion. 145 | local query_parts = { cmd } 146 | if arg ~= '' then 147 | table.insert(query_parts, arg) 148 | end 149 | local query = table.concat(query_parts, ' ') 150 | local completions = vim.fn.getcompletion(query, 'cmdline', false) 151 | 152 | -- get last argment for fixing lua expression completion. 153 | local offset = 0 154 | for i = #arg, 1, -1 do 155 | if arg:sub(i, i) == ' ' and arg:sub(i - 1, i - 1) ~= '\\' then 156 | offset = i 157 | break 158 | end 159 | end 160 | local last_arg = arg:sub(offset + 1) 161 | 162 | -- convert to LSP items. 163 | for _, completion in ipairs(completions) do 164 | local label = completion 165 | 166 | -- fix lua expression completion. 167 | if lua_expression_cmd_regex:match_str(cmd) then 168 | label = label:find(last_arg, 1, true) and label or last_arg .. label 169 | end 170 | 171 | if not dedup[label] then 172 | table.insert(items, { 173 | label = label, 174 | }) 175 | dedup[label] = true 176 | end 177 | 178 | -- add `no` prefix for boolean options. 179 | if set_option_cmd_regex:match_str(cmd) and is_boolean_option(label) then 180 | if not dedup[label] then 181 | table.insert(items, { 182 | label = 'no' .. completion, 183 | filterText = completion, 184 | }) 185 | dedup[label] = true 186 | end 187 | end 188 | end 189 | 190 | return { 191 | isIncomplete = true, 192 | items = items, 193 | } 194 | end):dispatch(function(res) 195 | callback(nil, res) 196 | end, function(err) 197 | callback(err, nil) 198 | end) 199 | end, 200 | } 201 | end 202 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/App/Command.lua: -------------------------------------------------------------------------------- 1 | ---@class cmp-kit.kit.App.Command.SubCommand.Argument 2 | ---@field public complete? fun(prefix: string):string[] 3 | ---@field public required? boolean 4 | 5 | ---@class cmp-kit.kit.App.Command.SubCommandSpecifier 6 | ---@field public desc? string 7 | ---@field public args? table 8 | ---@field public execute fun(params: cmp-kit.kit.App.Command.ExecuteParams, arguments: table) 9 | 10 | ---@class cmp-kit.kit.App.Command.SubCommand: cmp-kit.kit.App.Command.SubCommandSpecifier 11 | ---@field public name string 12 | ---@field public args table 13 | 14 | ---@class cmp-kit.kit.App.Command 15 | ---@field public name string 16 | ---@field public subcommands table 17 | local Command = {} 18 | Command.__index = Command 19 | 20 | ---Create a new command. 21 | ---@param name string 22 | ---@param subcommand_specifiers table 23 | function Command.new(name, subcommand_specifiers) 24 | -- normalize subcommand specifiers. 25 | local subcommands = {} 26 | for subcommand_name, subcommand_specifier in pairs(subcommand_specifiers) do 27 | subcommands[subcommand_name] = { 28 | name = subcommand_name, 29 | args = subcommand_specifier.args or {}, 30 | execute = subcommand_specifier.execute, 31 | } 32 | end 33 | 34 | -- create command. 35 | return setmetatable({ 36 | name = name, 37 | subcommands = subcommands, 38 | }, Command) 39 | end 40 | 41 | ---@class cmp-kit.kit.App.Command.ExecuteParams 42 | ---@field public name string 43 | ---@field public args string 44 | ---@field public fargs string[] 45 | ---@field public nargs string 46 | ---@field public bang boolean 47 | ---@field public line1 integer 48 | ---@field public line2 integer 49 | ---@field public range 0|1|2 50 | ---@field public count integer 51 | ---@field public req string 52 | ---@field public mods string 53 | ---@field public smods string[] 54 | ---Execute command. 55 | ---@param params cmp-kit.kit.App.Command.ExecuteParams 56 | function Command:execute(params) 57 | local parsed = self._parse(params.args) 58 | 59 | local subcommand = self.subcommands[parsed[1].text] 60 | if not subcommand then 61 | error(('Unknown subcommand: %s'):format(parsed[1].text)) 62 | end 63 | 64 | local arguments = {} 65 | 66 | local pos = 1 67 | for i, part in ipairs(parsed) do 68 | if i > 1 then 69 | local is_named_argument = vim.iter(pairs(subcommand.args)):any(function(name) 70 | return type(name) == 'string' and part.text:sub(1, #name + 1) == ('%s='):format(name) 71 | end) 72 | if is_named_argument then 73 | local s = part.text:find('=', 1, true) 74 | if s then 75 | local name = part.text:sub(1, s - 1) 76 | local value = part.text:sub(s + 1) 77 | arguments[name] = value 78 | end 79 | else 80 | arguments[pos] = part.text 81 | pos = pos + 1 82 | end 83 | end 84 | end 85 | 86 | -- check required arguments. 87 | for name, arg in pairs(subcommand.args or {}) do 88 | if arg.required and not arguments[name] then 89 | error(('Argument %s is required.'):format(name)) 90 | end 91 | end 92 | 93 | subcommand.execute(params, arguments) 94 | end 95 | 96 | ---Complete command. 97 | ---@param cmdline string 98 | ---@param cursor integer 99 | function Command:complete(cmdline, cursor) 100 | local parsed = self._parse(cmdline) 101 | 102 | -- check command. 103 | if parsed[1].text ~= self.name then 104 | return {} 105 | end 106 | 107 | -- complete subcommand names. 108 | if parsed[2] and parsed[2].s <= cursor and cursor <= parsed[2].e then 109 | return vim 110 | .iter(pairs(self.subcommands)) 111 | :map(function(_, subcommand) 112 | return subcommand.name 113 | end) 114 | :totable() 115 | end 116 | 117 | -- check subcommand is exists. 118 | local subcommand = self.subcommands[parsed[2].text] 119 | if not subcommand then 120 | return {} 121 | end 122 | 123 | -- check subcommand arguments. 124 | local pos = 1 125 | for i, part in ipairs(parsed) do 126 | if i > 2 then 127 | local is_named_argument_name = vim.regex([=[^--\?[^=]*$]=]):match_str(part.text) ~= nil 128 | local is_named_argument_value = vim.iter(pairs(subcommand.args)):any(function(name) 129 | name = tostring(name) 130 | return part.text:sub(1, #name + 1) == ('%s='):format(name) 131 | end) 132 | 133 | -- current cursor argument. 134 | if part.s <= cursor and cursor <= part.e then 135 | if is_named_argument_name then 136 | -- return named-argument completion. 137 | return vim 138 | .iter(pairs(subcommand.args)) 139 | :map(function(name) 140 | return name 141 | end) 142 | :filter(function(name) 143 | return type(name) == 'string' 144 | end) 145 | :totable() 146 | elseif is_named_argument_value then 147 | -- return specific named-argument value completion. 148 | for name, argument in pairs(subcommand.args) do 149 | if type(name) == 'string' then 150 | if part.text:sub(1, #name + 1) == ('%s='):format(name) then 151 | if argument.complete then 152 | return argument.complete(part.text:sub(#name + 2)) 153 | end 154 | return {} 155 | end 156 | end 157 | end 158 | elseif subcommand.args[pos] then 159 | local argument = subcommand.args[pos] 160 | if argument.complete then 161 | return argument.complete(part.text) 162 | end 163 | return {} 164 | end 165 | end 166 | 167 | -- increment positional argument. 168 | if not is_named_argument_name and not is_named_argument_value then 169 | pos = pos + 1 170 | end 171 | end 172 | end 173 | end 174 | 175 | ---Parse command line. 176 | ---@param cmdline string 177 | ---@return { text: string, s: integer, e: integer }[] 178 | function Command._parse(cmdline) 179 | ---@type { text: string, s: integer, e: integer }[] 180 | local parsed = {} 181 | 182 | local part = {} 183 | local s = 1 184 | local i = 1 185 | while i <= #cmdline do 186 | local c = cmdline:sub(i, i) 187 | if c == '\\' then 188 | table.insert(part, cmdline:sub(i + 1, i + 1)) 189 | i = i + 1 190 | elseif c == ' ' then 191 | if #part > 0 then 192 | table.insert(parsed, { 193 | text = table.concat(part), 194 | s = s - 1, 195 | e = i - 1, 196 | }) 197 | part = {} 198 | s = i + 1 199 | end 200 | else 201 | table.insert(part, c) 202 | end 203 | i = i + 1 204 | end 205 | 206 | if #part then 207 | table.insert(parsed, { 208 | text = table.concat(part), 209 | s = s - 1, 210 | e = i - 1, 211 | }) 212 | return parsed 213 | end 214 | 215 | table.insert(parsed, { 216 | text = '', 217 | s = #cmdline, 218 | e = #cmdline + 1, 219 | }) 220 | 221 | return parsed 222 | end 223 | 224 | return Command 225 | -------------------------------------------------------------------------------- /lua/cmp-kit/signature_help/SignatureHelpProvider.lua: -------------------------------------------------------------------------------- 1 | local LSP = require('cmp-kit.kit.LSP') 2 | local Async = require('cmp-kit.kit.Async') 3 | 4 | ---@enum cmp-kit.signature_help.SignatureHelpProvider.RequestState 5 | local RequestState = { 6 | Waiting = 'Waiting', 7 | Fetching = 'Fetching', 8 | Completed = 'Completed', 9 | } 10 | 11 | ---@class cmp-kit.signature_help.SignatureHelpProvider.State 12 | ---@field public request_state cmp-kit.signature_help.SignatureHelpProvider.RequestState 13 | ---@field public trigger_context? cmp-kit.core.TriggerContext 14 | ---@field public active_signature_help? cmp-kit.kit.LSP.SignatureHelp 15 | 16 | ---@class cmp-kit.signature_help.SignatureHelpProvider 17 | ---@field private _source cmp-kit.signature_help.SignatureHelpSource 18 | ---@field private _state cmp-kit.signature_help.SignatureHelpProvider.State 19 | local SignatureHelpProvider = {} 20 | SignatureHelpProvider.__index = SignatureHelpProvider 21 | SignatureHelpProvider.RequestState = RequestState 22 | 23 | ---Create SignatureHelpProvider. 24 | ---@param source cmp-kit.signature_help.SignatureHelpSource 25 | function SignatureHelpProvider.new(source) 26 | return setmetatable({ 27 | _source = source, 28 | _state = { 29 | request_state = RequestState.Waiting, 30 | trigger_context = nil, 31 | active_signature_help = nil, 32 | }, 33 | }, SignatureHelpProvider) 34 | end 35 | 36 | ---Fetch signature help. 37 | ---@param trigger_context cmp-kit.core.TriggerContext 38 | ---@return cmp-kit.kit.Async.AsyncTask cmp-kit.kit.LSP.CompletionContext? 39 | function SignatureHelpProvider:fetch(trigger_context) 40 | return Async.run(function() 41 | self._state.trigger_context = trigger_context 42 | 43 | local is_retrigger = self._state.request_state ~= RequestState.Waiting 44 | local is_triggered = is_retrigger or not not self._state.active_signature_help 45 | 46 | local trigger_kind --[[@as cmp-kit.kit.LSP.SignatureHelpTriggerKind]] 47 | local trigger_character --[[@as string?]] 48 | if trigger_context.force then 49 | trigger_kind = LSP.SignatureHelpTriggerKind.Invoked 50 | elseif vim.tbl_contains(self:get_trigger_characters(), trigger_context.trigger_character) or (is_triggered and vim.tbl_contains(self:get_retrigger_characters(), trigger_context.trigger_character)) then 51 | trigger_kind = LSP.SignatureHelpTriggerKind.TriggerCharacter 52 | trigger_character = trigger_context.trigger_character 53 | elseif is_triggered then 54 | trigger_kind = LSP.SignatureHelpTriggerKind.ContentChange 55 | end 56 | 57 | if not trigger_kind then 58 | return 59 | end 60 | 61 | local context = { 62 | triggerKind = trigger_kind, 63 | triggerCharacter = trigger_character, 64 | isRetrigger = is_retrigger, 65 | activeSignatureHelp = self._state.active_signature_help, 66 | } --[[@as cmp-kit.kit.LSP.SignatureHelpContext]] 67 | self._state.request_state = RequestState.Fetching 68 | local response = Async.new(function(resolve) 69 | self._source:fetch(context, function(err, res) 70 | if err then 71 | resolve(nil) 72 | else 73 | resolve(res) 74 | end 75 | end) 76 | end):await() --[[@as cmp-kit.kit.LSP.TextDocumentSignatureHelpResponse]] 77 | if self._state.trigger_context ~= trigger_context then 78 | return 79 | end 80 | if not response or not response.signatures or #response.signatures == 0 then 81 | self._state.request_state = RequestState.Waiting 82 | self._state.active_signature_help = nil 83 | return 84 | end 85 | self._state.request_state = RequestState.Completed 86 | self._state.active_signature_help = response 87 | 88 | return context 89 | end) 90 | end 91 | 92 | ---Clear signature help. 93 | function SignatureHelpProvider:clear() 94 | self._state = { 95 | request_state = RequestState.Waiting, 96 | trigger_context = nil, 97 | active_signature_help = nil, 98 | } 99 | end 100 | 101 | ---Check if the provider is capable for the trigger context. 102 | ---@param trigger_context cmp-kit.core.TriggerContext 103 | ---@return boolean 104 | function SignatureHelpProvider:capable(trigger_context) 105 | if self._source.capable and not self._source:capable(trigger_context) then 106 | return false 107 | end 108 | return true 109 | end 110 | 111 | ---Select specified signature. 112 | ---@param index integer # 1-origin 113 | function SignatureHelpProvider:select(index) 114 | if not self._state.active_signature_help then 115 | return 116 | end 117 | index = index - 1 -- to 0-origin 118 | index = math.max(index, 0) 119 | index = math.min(index, #self._state.active_signature_help.signatures - 1) 120 | self._state.active_signature_help.activeSignature = index 121 | end 122 | 123 | ---Get active signature data. 124 | ---@return cmp-kit.signature_help.ActiveSignatureData? 125 | function SignatureHelpProvider:get_active_signature_data() 126 | local active_signature_help = self._state.active_signature_help 127 | if not active_signature_help then 128 | return 129 | end 130 | local active_signature_index = self:get_active_signature_index() 131 | if not active_signature_index then 132 | return 133 | end 134 | local signature = active_signature_help.signatures[active_signature_index] 135 | if not signature then 136 | return 137 | end 138 | return { 139 | signature = signature, 140 | parameter_index = self:get_active_parameter_index(), 141 | signature_index = active_signature_index, 142 | signature_count = #active_signature_help.signatures, 143 | } 144 | end 145 | 146 | ---Return active signature index. 147 | ---@return integer? 1-origin index 148 | function SignatureHelpProvider:get_active_signature_index() 149 | if not self._state.active_signature_help then 150 | return 151 | end 152 | local index = self._state.active_signature_help.activeSignature or 0 153 | index = math.max(index, 0) 154 | index = math.min(index, #self._state.active_signature_help.signatures) 155 | return index + 1 156 | end 157 | 158 | ---Return active parameter index. 159 | ---@return integer? 1-origin index 160 | function SignatureHelpProvider:get_active_parameter_index() 161 | local active_signature_index = self:get_active_signature_index() 162 | if not active_signature_index then 163 | return 164 | end 165 | local signature = self._state.active_signature_help.signatures[active_signature_index] 166 | if not signature then 167 | return 168 | end 169 | local index = signature.activeParameter or self._state.active_signature_help.activeParameter or 0 170 | index = math.max(index, 0) 171 | index = math.min(index, #signature.parameters) 172 | return index + 1 173 | end 174 | 175 | ---Get trigger_characters. 176 | ---@return string[] 177 | function SignatureHelpProvider:get_trigger_characters() 178 | if not self._source.get_configuration then 179 | return {} 180 | end 181 | return self._source:get_configuration().trigger_characters or {} 182 | end 183 | 184 | ---Get retrigger_characters. 185 | ---@return string[] 186 | function SignatureHelpProvider:get_retrigger_characters() 187 | if not self._source.get_configuration then 188 | return {} 189 | end 190 | return self._source:get_configuration().retrigger_characters or {} 191 | end 192 | 193 | return SignatureHelpProvider 194 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/Async/AsyncTask.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: invisible 2 | local uv = require('luv') 3 | local kit = require('cmp-kit.kit') 4 | 5 | local is_thread = vim.is_thread() 6 | 7 | ---@class cmp-kit.kit.Async.AsyncTask 8 | ---@field private value any 9 | ---@field private status cmp-kit.kit.Async.AsyncTask.Status 10 | ---@field private synced boolean 11 | ---@field private chained boolean 12 | ---@field private children (fun(): any)[]? 13 | local AsyncTask = {} 14 | AsyncTask.__index = AsyncTask 15 | 16 | ---Settle the specified task. 17 | ---@param task cmp-kit.kit.Async.AsyncTask 18 | ---@param status cmp-kit.kit.Async.AsyncTask.Status 19 | ---@param value any 20 | local function settle(task, status, value) 21 | task.status = status 22 | task.value = value 23 | if task.children then 24 | for _, c in ipairs(task.children) do 25 | c() 26 | end 27 | end 28 | 29 | if status == AsyncTask.Status.Rejected then 30 | if not task.chained and not task.synced then 31 | local timer = uv.new_timer() 32 | timer:start( 33 | 0, 34 | 0, 35 | kit.safe_schedule_wrap(function() 36 | timer:stop() 37 | timer:close() 38 | if not task.chained and not task.synced then 39 | AsyncTask.on_unhandled_rejection(value) 40 | end 41 | end) 42 | ) 43 | end 44 | end 45 | end 46 | 47 | ---@enum cmp-kit.kit.Async.AsyncTask.Status 48 | AsyncTask.Status = { 49 | Pending = 0, 50 | Fulfilled = 1, 51 | Rejected = 2, 52 | } 53 | 54 | ---Handle unhandled rejection. 55 | ---@param err any 56 | function AsyncTask.on_unhandled_rejection(err) 57 | error('AsyncTask.on_unhandled_rejection: ' .. (type(err) == 'table' and vim.inspect(err) or tostring(err)), 2) 58 | end 59 | 60 | ---Return the value is AsyncTask or not. 61 | ---@param value any 62 | ---@return boolean 63 | function AsyncTask.is(value) 64 | return getmetatable(value) == AsyncTask 65 | end 66 | 67 | ---Resolve all tasks. 68 | ---@param tasks any[] 69 | ---@return cmp-kit.kit.Async.AsyncTask 70 | function AsyncTask.all(tasks) 71 | return AsyncTask.new(function(resolve, reject) 72 | if #tasks == 0 then 73 | resolve({}) 74 | return 75 | end 76 | 77 | local values = {} 78 | local count = 0 79 | for i, task in ipairs(tasks) do 80 | task:dispatch(function(value) 81 | values[i] = value 82 | count = count + 1 83 | if #tasks == count then 84 | resolve(values) 85 | end 86 | end, reject) 87 | end 88 | end) 89 | end 90 | 91 | ---Resolve first resolved task. 92 | ---@param tasks any[] 93 | ---@return cmp-kit.kit.Async.AsyncTask 94 | function AsyncTask.race(tasks) 95 | return AsyncTask.new(function(resolve, reject) 96 | for _, task in ipairs(tasks) do 97 | task:dispatch(resolve, reject) 98 | end 99 | end) 100 | end 101 | 102 | ---Create resolved AsyncTask. 103 | ---@param v any 104 | ---@return cmp-kit.kit.Async.AsyncTask 105 | function AsyncTask.resolve(v) 106 | if AsyncTask.is(v) then 107 | return v 108 | end 109 | return AsyncTask.new(function(resolve) 110 | resolve(v) 111 | end) 112 | end 113 | 114 | ---Create new AsyncTask. 115 | ---@NOET: The AsyncTask has similar interface to JavaScript Promise but the AsyncTask can be worked as synchronous. 116 | ---@param v any 117 | ---@return cmp-kit.kit.Async.AsyncTask 118 | function AsyncTask.reject(v) 119 | if AsyncTask.is(v) then 120 | return v 121 | end 122 | return AsyncTask.new(function(_, reject) 123 | reject(v) 124 | end) 125 | end 126 | 127 | ---Create new async task object. 128 | ---@param runner fun(resolve?: fun(value: any?), reject?: fun(err: any?)) 129 | function AsyncTask.new(runner) 130 | local self = setmetatable({ 131 | value = nil, 132 | status = AsyncTask.Status.Pending, 133 | synced = false, 134 | chained = false, 135 | children = nil, 136 | }, AsyncTask) 137 | local ok, err = pcall(runner, function(res) 138 | if self.status == AsyncTask.Status.Pending then 139 | settle(self, AsyncTask.Status.Fulfilled, res) 140 | end 141 | end, function(err) 142 | if self.status == AsyncTask.Status.Pending then 143 | settle(self, AsyncTask.Status.Rejected, err) 144 | end 145 | end) 146 | if not ok then 147 | settle(self, AsyncTask.Status.Rejected, err) 148 | end 149 | return self 150 | end 151 | 152 | ---Sync async task. 153 | ---@NOTE: This method uses `vim.wait` so that this can't wait the typeahead to be empty. 154 | ---@param timeout integer 155 | ---@return any 156 | function AsyncTask:sync(timeout) 157 | self.synced = true 158 | 159 | local time = uv.now() 160 | while uv.now() - time <= timeout do 161 | if self.status ~= AsyncTask.Status.Pending then 162 | break 163 | end 164 | if is_thread then 165 | uv.run('once') 166 | else 167 | vim.wait(0) 168 | end 169 | end 170 | if self.status == AsyncTask.Status.Pending then 171 | error('AsyncTask:sync is timeout.', 2) 172 | end 173 | if self.status == AsyncTask.Status.Rejected then 174 | error(self.value, 2) 175 | end 176 | if self.status ~= AsyncTask.Status.Fulfilled then 177 | error('AsyncTask:sync is timeout.', 2) 178 | end 179 | return self.value 180 | end 181 | 182 | ---Await async task. 183 | ---@return any 184 | function AsyncTask:await() 185 | local Async = require('cmp-kit.kit.Async') 186 | local in_fast_event = vim.in_fast_event() 187 | local ok, res = pcall(Async.await, self) 188 | if not ok then 189 | error(res, 2) 190 | end 191 | if not in_fast_event and vim.in_fast_event() then 192 | Async.schedule():await() 193 | end 194 | return res 195 | end 196 | 197 | ---Return current state of task. 198 | ---@return { status: cmp-kit.kit.Async.AsyncTask.Status, value: any } 199 | function AsyncTask:state() 200 | return { 201 | status = self.status, 202 | value = self.value, 203 | } 204 | end 205 | 206 | do 207 | local default_on_rejected = function(err) 208 | error(err, 2) 209 | end 210 | ---Register next step. 211 | ---@param on_fulfilled fun(value: any): any 212 | function AsyncTask:next(on_fulfilled) 213 | return self:dispatch(on_fulfilled, default_on_rejected) 214 | end 215 | end 216 | 217 | do 218 | local default_on_fulfilled = function(value) 219 | return value 220 | end 221 | ---Register catch step. 222 | ---@param on_rejected fun(value: any): any 223 | ---@return cmp-kit.kit.Async.AsyncTask 224 | function AsyncTask:catch(on_rejected) 225 | return self:dispatch(default_on_fulfilled, on_rejected) 226 | end 227 | end 228 | 229 | ---Dispatch task state. 230 | ---@param on_fulfilled fun(value: any): any 231 | ---@param on_rejected fun(err: any): any 232 | ---@return cmp-kit.kit.Async.AsyncTask 233 | function AsyncTask:dispatch(on_fulfilled, on_rejected) 234 | self.chained = true 235 | 236 | local function dispatch(resolve, reject) 237 | local on_next = self.status == AsyncTask.Status.Fulfilled and on_fulfilled or on_rejected 238 | local ok, res = pcall(on_next, self.value) 239 | if AsyncTask.is(res) then 240 | res:dispatch(resolve, reject) 241 | else 242 | if ok then 243 | resolve(res) 244 | else 245 | reject(res) 246 | end 247 | end 248 | end 249 | 250 | if self.status == AsyncTask.Status.Pending then 251 | return AsyncTask.new(function(resolve, reject) 252 | local function dispatcher() 253 | return dispatch(resolve, reject) 254 | end 255 | self.children = self.children or {} 256 | table.insert(self.children, dispatcher) 257 | end) 258 | end 259 | return AsyncTask.new(dispatch) 260 | end 261 | 262 | return AsyncTask 263 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/source/path.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local IO = require('cmp-kit.kit.IO') 3 | local Async = require('cmp-kit.kit.Async') 4 | local TriggerContext = require('cmp-kit.core.TriggerContext') 5 | 6 | local escape_chars = { 7 | [' '] = true, 8 | ['\\'] = true, 9 | ['*'] = true, 10 | ['?'] = true, 11 | ['['] = true, 12 | [']'] = true, 13 | ['('] = true, 14 | [')'] = true, 15 | ['{'] = true, 16 | ['}'] = true, 17 | ['|'] = true, 18 | ['<'] = true, 19 | ['>'] = true, 20 | [';'] = true, 21 | ['&'] = true, 22 | ['"'] = true, 23 | ["'"] = true, 24 | ['`'] = true, 25 | ['#'] = true, 26 | ['!'] = true, 27 | } 28 | 29 | ---Return path components before the cursor. 30 | ---@param before_text string 31 | ---@return string[], string 32 | local function parse_components(before_text) 33 | local chars = vim 34 | .iter(vim.fn.str2list(before_text, true)) 35 | :map(function(n) 36 | return vim.fn.nr2char(n, true) 37 | end) 38 | :totable() 39 | 40 | local path_parts = {} 41 | local name_chars = {} 42 | local i = #chars 43 | while i > 0 do 44 | local prev_char = chars[i - 1] or '' 45 | local curr_char = chars[i] 46 | if curr_char == '/' then 47 | table.insert(path_parts, 1, table.concat(name_chars)) 48 | name_chars = {} 49 | elseif escape_chars[curr_char] then 50 | if prev_char == '\\' then 51 | table.insert(name_chars, 1, curr_char) 52 | table.insert(name_chars, 1, '\\') 53 | i = i - 1 54 | else 55 | break 56 | end 57 | else 58 | table.insert(name_chars, 1, curr_char) 59 | end 60 | i = i - 1 61 | end 62 | if #name_chars > 0 then 63 | table.insert(path_parts, 1, table.concat(name_chars)) 64 | end 65 | return path_parts, table.concat(kit.slice(chars, 1, i), '') 66 | end 67 | 68 | ---@class cmp-kit.completion.ext.source.path.Option 69 | ---@field public get_cwd? fun(): string 70 | ---@field public enable_file_document? boolean 71 | ---@param option? cmp-kit.completion.ext.source.path.Option 72 | return function(option) 73 | option = option or {} 74 | option.get_cwd = option.get_cwd or function() 75 | local bufname = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ':p') 76 | if vim.fn.filereadable(bufname) == 1 then 77 | return vim.fn.fnamemodify(bufname, ':h') 78 | end 79 | if vim.fn.isdirectory(bufname) == 1 then 80 | return bufname 81 | end 82 | return vim.fn.getcwd() 83 | end 84 | option.enable_file_document = option.enable_file_document == nil and true or option.enable_file_document 85 | 86 | ---@type cmp-kit.completion.CompletionSource 87 | return { 88 | name = 'path', 89 | get_configuration = function() 90 | return { 91 | keyword_pattern = [=[[^/]*]=], 92 | trigger_characters = { '/' }, 93 | } 94 | end, 95 | complete = function(_, _, callback) 96 | Async.run(function() 97 | local trigger_context = TriggerContext.create() 98 | 99 | -- parse path components. 100 | local path_components, prefix = parse_components(trigger_context.text_before) 101 | if #path_components <= 0 then 102 | return {} 103 | end 104 | 105 | -- check path_components is valid. 106 | local is_valid_path = false 107 | is_valid_path = is_valid_path or path_components[1] == '' 108 | is_valid_path = is_valid_path or path_components[1] == 'file:' 109 | is_valid_path = is_valid_path or path_components[1]:match('%$[%w_]+') 110 | is_valid_path = is_valid_path or path_components[1]:match('%${[%w_]}+') 111 | is_valid_path = is_valid_path or path_components[1] == '.' 112 | is_valid_path = is_valid_path or path_components[1] == '..' 113 | is_valid_path = is_valid_path or path_components[1] == '~' 114 | if not is_valid_path then 115 | return {} 116 | end 117 | 118 | local dirname = table.concat(kit.slice(path_components, 1, #path_components - 1), '/') .. '/' 119 | 120 | -- skip or convert by condition. 121 | do 122 | -- html tag. 123 | if prefix:match('<$') then 124 | return {} 125 | end 126 | -- slash comments. 127 | if vim.o.commentstring:gsub('^%s*', ''):sub(1, 1) == '/' then 128 | -- / 129 | if prefix:match('^%s*$') and dirname:match('^/') then 130 | return {} 131 | end 132 | -- */ 133 | if prefix:match('^%s*%*$') and dirname:match('^/') then 134 | return {} 135 | end 136 | end 137 | -- math expression. 138 | if prefix:match('[)%d]%s*$') and dirname:match('^/') then 139 | return {} 140 | end 141 | -- fix file://. 142 | if dirname:match('^file://') then 143 | dirname = dirname:sub(8) 144 | end 145 | end 146 | 147 | -- normalize dirname. 148 | if dirname:match('^%./') or dirname:match('^%.%./') then 149 | dirname = vim.fn.fnamemodify(option.get_cwd() .. '/' .. dirname, ':p') 150 | end 151 | dirname = vim.fn.expand(dirname) 152 | 153 | -- invalid dirname. 154 | if vim.fn.isdirectory(dirname) == 0 then 155 | return {} 156 | end 157 | 158 | -- convert to LSP items. 159 | local items = {} 160 | for entry in IO.iter_scandir(dirname):await() do 161 | local kind = vim.lsp.protocol.CompletionItemKind.File 162 | if entry.type == 'directory' then 163 | kind = vim.lsp.protocol.CompletionItemKind.Folder 164 | end 165 | table.insert(items, { 166 | label = vim.fs.basename(entry.path) .. (entry.type == 'directory' and '/' or ''), 167 | insertText = vim.fs.basename(entry.path), 168 | kind = kind, 169 | data = entry, 170 | }) 171 | Async.interrupt(8, 16) 172 | end 173 | table.sort(items, function(a, b) 174 | local is_directory_a = a.data.type == 'directory' 175 | local is_directory_b = b.data.type == 'directory' 176 | if is_directory_b ~= is_directory_a then 177 | return is_directory_a 178 | end 179 | return a.label < b.label 180 | end) 181 | return items 182 | end):dispatch(function(res) 183 | callback(nil, res) 184 | end, function(err) 185 | callback(err, nil) 186 | end) 187 | end, 188 | resolve = function(_, item, callback) 189 | Async.run(function() 190 | if item.data.type == 'file' and option.enable_file_document then 191 | -- read file. 192 | local contents = vim.split( 193 | IO.read_file(item.data.path) 194 | :catch(function() 195 | return '' 196 | end) 197 | :await(), 198 | '\n' 199 | ) 200 | 201 | -- resolve filetype 202 | local filetype = vim.filetype.match({ 203 | contents = contents, 204 | filename = item.data.path, 205 | }) 206 | 207 | -- trim contents. 208 | if #contents > 120 then 209 | contents = vim.list_slice(contents, 1, 10) 210 | table.insert(contents, '...') 211 | end 212 | 213 | -- markdown. 214 | table.insert(contents, 1, ('```%s'):format(filetype)) 215 | table.insert(contents, '```') 216 | 217 | return kit.merge(item, { 218 | documentation = { 219 | kind = vim.lsp.protocol.MarkupKind.Markdown, 220 | value = table.concat(contents, '\n'), 221 | }, 222 | }) 223 | end 224 | end):dispatch(function(res) 225 | callback(nil, res) 226 | end, function(err) 227 | callback(err, nil) 228 | end) 229 | end, 230 | } 231 | end 232 | -------------------------------------------------------------------------------- /lua/cmp-kit/kit/RPC/JSON/init.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local Async = require('cmp-kit.kit.Async') 3 | 4 | ---@class cmp-kit.kit.RPC.JSON.Transport 5 | ---@field send fun(self: cmp-kit.kit.RPC.JSON.Transport, data: table): cmp-kit.kit.Async.AsyncTask 6 | ---@field on_message fun(self: cmp-kit.kit.RPC.JSON.Transport, callback: fun(data: table)) 7 | ---@field start fun(self: cmp-kit.kit.RPC.JSON.Transport) 8 | ---@field close fun(self: cmp-kit.kit.RPC.JSON.Transport): cmp-kit.kit.Async.AsyncTask 9 | 10 | ---@class cmp-kit.kit.RPC.JSON.Transport.LineDelimitedPipe: cmp-kit.kit.RPC.JSON.Transport 11 | ---@field private _buffer cmp-kit.kit.buffer.Buffer 12 | ---@field private _reader uv.uv_pipe_t 13 | ---@field private _writer uv.uv_pipe_t 14 | ---@field private _on_message fun(data: table) 15 | local LineDelimitedPipe = {} 16 | LineDelimitedPipe.__index = LineDelimitedPipe 17 | 18 | ---Create new LineDelimitedPipe instance. 19 | ---@param reader uv.uv_pipe_t 20 | ---@param writer uv.uv_pipe_t 21 | function LineDelimitedPipe.new(reader, writer) 22 | return setmetatable({ 23 | _buffer = kit.buffer(), 24 | _reader = reader, 25 | _writer = writer, 26 | _on_message = nil, 27 | }, LineDelimitedPipe) 28 | end 29 | 30 | ---Send data. 31 | ---@param message table 32 | ---@return cmp-kit.kit.Async.AsyncTask 33 | function LineDelimitedPipe:send(message) 34 | return Async.new(function(resolve, reject) 35 | self._writer:write(vim.json.encode(message) .. '\n', function(err) 36 | if err then 37 | return reject(err) 38 | else 39 | resolve() 40 | end 41 | end) 42 | end) 43 | end 44 | 45 | ---Set message callback. 46 | ---@param callback fun(data: table) 47 | function LineDelimitedPipe:on_message(callback) 48 | self._on_message = callback 49 | end 50 | 51 | ---Start transport. 52 | function LineDelimitedPipe:start() 53 | self._reader:read_start(function(err, data) 54 | if err then 55 | return 56 | end 57 | self._buffer.put(data) 58 | 59 | local found = data:find('\n', 1, true) 60 | if found then 61 | for i, byte in self._buffer.iter_bytes() do 62 | if byte == 10 then 63 | local message = vim.json.decode(self._buffer.get(i - 1), { object = true, array = true }) 64 | self._buffer.skip(1) 65 | self._on_message(message) 66 | end 67 | end 68 | end 69 | end) 70 | end 71 | 72 | ---Close transport. 73 | ---@return cmp-kit.kit.Async.AsyncTask 74 | function LineDelimitedPipe:close() 75 | self._reader:read_stop() 76 | 77 | local p = Async.resolve() 78 | p = p:next(function() 79 | if not self._reader:is_closing() and self._reader:is_active() then 80 | return Async.new(function(resolve) 81 | self._reader:close(resolve) 82 | end) 83 | end 84 | end) 85 | p = p:next(function() 86 | if not self._writer:is_closing() and self._writer:is_active() then 87 | return Async.new(function(resolve) 88 | self._writer:close(resolve) 89 | end) 90 | end 91 | end) 92 | return p 93 | end 94 | 95 | ---@class cmp-kit.kit.RPC.JSON.RPC 96 | ---@field private _transport cmp-kit.kit.RPC.JSON.Transport 97 | ---@field private _next_requet_id number 98 | ---@field private _pending_callbacks table 99 | ---@field private _on_request_map table 100 | ---@field private _on_notification_map table 101 | local RPC = { 102 | Transport = { 103 | LineDelimitedPipe = LineDelimitedPipe, 104 | }, 105 | } 106 | RPC.__index = RPC 107 | 108 | ---Create new RPC instance. 109 | ---@param params { transport: cmp-kit.kit.RPC.JSON.Transport } 110 | function RPC.new(params) 111 | return setmetatable({ 112 | _transport = params.transport, 113 | _next_requet_id = 0, 114 | _pending_callbacks = {}, 115 | _on_request_map = {}, 116 | _on_notification_map = {}, 117 | }, RPC) 118 | end 119 | 120 | ---Start RPC. 121 | function RPC:start() 122 | self._transport:on_message(function(data) 123 | if data.id then 124 | if data.method then 125 | -- request. 126 | local request_callback = self._on_request_map[data.method] 127 | if request_callback then 128 | Async.resolve():next(function() 129 | return request_callback(data) 130 | end):dispatch(function(res) 131 | -- request success. 132 | self._transport:send({ 133 | jsonrpc = '2.0', 134 | id = data.id, 135 | result = res, 136 | }) 137 | end, function(err) 138 | -- request failure. 139 | self._transport:send({ 140 | jsonrpc = '2.0', 141 | id = data.id, 142 | error = { 143 | code = -32603, 144 | message = tostring(err), 145 | }, 146 | }) 147 | end) 148 | else 149 | -- request not found. 150 | self._transport:send({ 151 | jsonrpc = "2.0", 152 | id = data.id, 153 | error = { 154 | code = -32601, 155 | message = ('Method not found: %s'):format(data.method), 156 | }, 157 | }) 158 | end 159 | else 160 | -- response. 161 | local pending_callback = self._pending_callbacks[data.id] 162 | if pending_callback then 163 | pending_callback(data) 164 | self._pending_callbacks[data.id] = nil 165 | end 166 | end 167 | else 168 | -- notification. 169 | local notification_callbacks = self._on_notification_map[data.method] 170 | if notification_callbacks then 171 | for _, callback in ipairs(notification_callbacks) do 172 | pcall(callback, { params = data.params }) 173 | end 174 | end 175 | end 176 | end) 177 | self._transport:start() 178 | end 179 | 180 | ---Close RPC. 181 | ---@return cmp-kit.kit.Async.AsyncTask 182 | function RPC:close() 183 | return self._transport:close() 184 | end 185 | 186 | ---Set request callback. 187 | ---@param method string 188 | ---@param callback fun(ctx: { params: table }): table 189 | function RPC:on_request(method, callback) 190 | if self._on_request_map[method] then 191 | error('Method already exists: ' .. method) 192 | end 193 | self._on_request_map[method] = callback 194 | end 195 | 196 | ---Set notification callback. 197 | ---@param method string 198 | ---@param callback fun(ctx: { params: table }) 199 | function RPC:on_notification(method, callback) 200 | if not self._on_notification_map[method] then 201 | self._on_notification_map[method] = {} 202 | end 203 | table.insert(self._on_notification_map[method], callback) 204 | end 205 | 206 | ---Request. 207 | ---@param method string 208 | ---@param params table 209 | ---@return cmp-kit.kit.Async.AsyncTask| { cancel: fun() } 210 | function RPC:request(method, params) 211 | self._next_requet_id = self._next_requet_id + 1 212 | 213 | local request_id = self._next_requet_id 214 | 215 | local p = Async.new(function(resolve, reject) 216 | self._pending_callbacks[request_id] = function(response) 217 | if response.error then 218 | reject(response.error) 219 | else 220 | resolve(response.result) 221 | end 222 | end 223 | self._transport:send({ 224 | jsonrpc = '2.0', 225 | id = request_id, 226 | method = method, 227 | params = params, 228 | }) 229 | end) 230 | 231 | ---@diagnostic disable-next-line: inject-field 232 | p.cancel = function() 233 | self._pending_callbacks[request_id] = nil 234 | end 235 | 236 | return p 237 | end 238 | 239 | ---Notify. 240 | ---@param method string 241 | ---@param params table 242 | function RPC:notify(method, params) 243 | self._transport:send({ 244 | jsonrpc = '2.0', 245 | method = method, 246 | params = params, 247 | }) 248 | end 249 | 250 | return RPC 251 | -------------------------------------------------------------------------------- /lua/cmp-kit/completion/ext/source/github/init.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local Async = require('cmp-kit.kit.Async') 3 | local System = require('cmp-kit.kit.System') 4 | 5 | local gh_executable = vim.fn.executable('gh') == 1 6 | 7 | local stat_format = table.concat({ 8 | '# %s: #%s', 9 | '------------------------------------------------------------', 10 | 'title: %s', 11 | 'author: %s', 12 | '------------------------------------------------------------', 13 | '', 14 | }, '\n') 15 | 16 | ---Find the root directory of the git repository. 17 | ---@type table|fun(): string|nil 18 | local try_get_git_root = setmetatable({ 19 | cache = {} 20 | }, { 21 | __call = function(self) 22 | local candidates = { vim.fn.expand('%:p:h'), vim.fn.getcwd() } 23 | for _, candidate in ipairs(candidates) do 24 | if not self.cache[candidate] then 25 | self.cache[candidate] = { 26 | git_root = kit.findup(vim.fs.normalize(candidate), { '.git' }) 27 | } 28 | end 29 | if self.cache[candidate].git_root then 30 | return self.cache[candidate].git_root 31 | end 32 | end 33 | end 34 | }) 35 | 36 | ---Run gh command. 37 | ---@param args string[] 38 | ---@param option { cwd: string } 39 | ---@return cmp-kit.kit.Async.AsyncTask 40 | local function gh_command(args, option) 41 | return Async.new(function(resolve, reject) 42 | local stdout = {} 43 | local stderr = {} 44 | System.spawn(kit.concat({ 'gh' }, args), { 45 | cwd = option.cwd, 46 | on_stdout = function(data) 47 | if data then 48 | table.insert(stdout, data) 49 | end 50 | end, 51 | on_stderr = function(data) 52 | if data then 53 | table.insert(stderr, data) 54 | end 55 | end, 56 | on_exit = function(code) 57 | if code == 0 then 58 | resolve(table.concat(stdout, '')) 59 | else 60 | reject('gh command failed with code: ' .. table.concat(stderr, '')) 61 | end 62 | end, 63 | }) 64 | end) 65 | end 66 | 67 | ---Get repository information from the current directory. 68 | ---@return { owner: string, name: string } | nil 69 | local function get_repo_info() 70 | local repo = vim.json.decode(gh_command({ 71 | 'repo', 72 | 'view', 73 | '--json', 74 | 'owner,name', 75 | '--jq', 76 | '{ owner: .owner.login, name: .name }' 77 | }, { 78 | cwd = assert(try_get_git_root()) 79 | }):await()) 80 | return repo and repo.owner and repo.name and { owner = repo.owner, name = repo.name } or nil 81 | end 82 | 83 | ---Get issues and pull requests from the current repository. 84 | ---@async 85 | ---@return cmp-kit.kit.LSP.CompletionItem[] 86 | local function get_prs_and_issues() 87 | local items = {} 88 | 89 | -- fetch prs. 90 | local prs = vim.json.decode(gh_command({ 91 | 'pr', 92 | 'list', 93 | '--search', 94 | 'is:pr is:open', 95 | '--json', 96 | 'number,title,body,author' 97 | }, { 98 | cwd = assert(try_get_git_root()) 99 | }):await()) 100 | for _, pr in ipairs(prs) do 101 | local stat = stat_format:format('Pull Request', pr.number, pr.title, pr.author.login) 102 | table.insert(items, { 103 | label = ('#%s %s'):format(pr.number, pr.title), 104 | insertText = ('#%s'):format(pr.number), 105 | nvim_previewText = ('#%s'):format(pr.number), 106 | filterText = ('#%s %s %s'):format(pr.number, pr.title, pr.author.login), 107 | labelDetails = { description = 'Pull Request', }, 108 | documentation = { 109 | kind = 'markdown', 110 | value = stat .. (pr.body ~= '' and pr.body or 'empty body'), 111 | }, 112 | sortText = #items + 1 113 | }) 114 | end 115 | 116 | -- fetch issues. 117 | local issues = vim.json.decode(gh_command({ 118 | 'issue', 119 | 'list', 120 | '--search', 121 | 'is:open', 122 | '--json', 123 | 'number,title,body,author' 124 | }, { 125 | cwd = assert(try_get_git_root()) 126 | }):await()) 127 | for _, issue in ipairs(issues) do 128 | local stat = stat_format:format('Issue', issue.number, issue.title, issue.author.login) 129 | table.insert(items, { 130 | label = ('#%s %s'):format(issue.number, issue.title), 131 | insertText = ('#%s'):format(issue.number), 132 | nvim_previewText = ('#%s'):format(issue.number), 133 | filterText = ('#%s %s %s'):format(issue.number, issue.title, issue.author.login), 134 | labelDetails = { description = 'Issue', }, 135 | documentation = { 136 | kind = 'markdown', 137 | value = stat .. (issue.body ~= '' and issue.body or 'empty body'), 138 | }, 139 | sortText = #items + 1 140 | }) 141 | end 142 | 143 | return items 144 | end 145 | 146 | ---Get mentionable users from the current repository. 147 | ---@async 148 | ---@param owner string 149 | ---@param name string 150 | ---@param member_type 'collaborators' | 'contributors' 151 | ---@return cmp-kit.kit.LSP.CompletionItem[] 152 | local function get_mentionable_users(owner, name, member_type) 153 | local items = {} 154 | 155 | local users = vim.json.decode(gh_command({ 156 | 'api', 157 | ('/repos/%s/%s/%s'):format(owner, name, member_type), 158 | '--paginate', 159 | '--jq', 160 | '[.[] | {login: .login, name: .name}]' 161 | }, { 162 | cwd = assert(try_get_git_root()) 163 | }):await()) 164 | 165 | for _, user in ipairs(users) do 166 | if user.login and user.name then 167 | table.insert(items, { 168 | label = ('@%s'):format(user.login), 169 | insertText = ('@%s'):format(user.login), 170 | nvim_previewText = ('@%s'):format(user.login), 171 | filterText = ('@%s %s'):format(user.login, user.name), 172 | sortText = #items + 1 173 | }) 174 | end 175 | end 176 | 177 | return items 178 | end 179 | 180 | return setmetatable({ 181 | checkhealth = function() 182 | Async.run(function() 183 | if not gh_executable then 184 | vim.notify('[NG] `gh` command is not executable', vim.log.levels.ERROR) 185 | else 186 | vim.notify('[OK] `gh` command is executable', vim.log.levels.INFO) 187 | end 188 | local auth_status = gh_command({ 'auth', 'status' }, { 189 | cwd = assert(try_get_git_root()) 190 | }):await() 191 | vim.notify('[INFO] GitHub CLI authentication status: ' .. auth_status, vim.log.levels.INFO) 192 | end) 193 | end 194 | }, { 195 | __call = function() 196 | ---@type cmp-kit.completion.CompletionSource 197 | return { 198 | name = 'github', 199 | get_configuration = function() 200 | return { 201 | trigger_characters = { '#', '@' }, 202 | keyword_pattern = [=[\%(#\|@\).*]=], 203 | } 204 | end, 205 | capable = function() 206 | if not gh_executable then 207 | return false 208 | end 209 | 210 | if vim.api.nvim_get_option_value('filetype', { buf = 0 }) ~= 'gitcommit' then 211 | return false 212 | end 213 | 214 | return try_get_git_root() ~= nil 215 | end, 216 | complete = function(_, completion_context, callback) 217 | if not vim.regex([=[\%(#\|@\).*]=]):match_str(vim.api.nvim_get_current_line()) then 218 | return callback(nil, {}) 219 | end 220 | 221 | Async.run(function() 222 | local items = {} 223 | if completion_context.triggerCharacter == '#' then 224 | for _, item in ipairs(get_prs_and_issues()) do 225 | table.insert(items, item) 226 | end 227 | elseif completion_context.triggerCharacter == '@' then 228 | local repo = get_repo_info() 229 | if repo and repo.owner and repo.name then 230 | local ok = false 231 | if not ok then 232 | ok = pcall(function() 233 | for _, item in ipairs(get_mentionable_users(repo.owner, repo.name, 'collaborators')) do 234 | table.insert(items, item) 235 | end 236 | end) 237 | end 238 | if not ok then 239 | ok = pcall(function() 240 | for _, item in ipairs(get_mentionable_users(repo.owner, repo.name, 'contributors')) do 241 | table.insert(items, item) 242 | end 243 | end) 244 | end 245 | end 246 | end 247 | callback(nil, items) 248 | end):dispatch(function(res) 249 | callback(nil, res) 250 | end, function() 251 | callback(nil, nil) 252 | end) 253 | end 254 | } 255 | end 256 | }) 257 | -------------------------------------------------------------------------------- /lua/cmp-kit/signature_help/SignatureHelpService.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local Async = require('cmp-kit.kit.Async') 3 | local TriggerContext = require('cmp-kit.core.TriggerContext') 4 | local DefaultConfig = require('cmp-kit.signature_help.ext.DefaultConfig') 5 | local SignatureHelpProvider = require('cmp-kit.signature_help.SignatureHelpProvider') 6 | 7 | ---Emit events. 8 | ---@generic T 9 | ---@param events fun(payload: T)[] 10 | ---@param payload T 11 | local function emit(events, payload) 12 | for _, event in ipairs(events or {}) do 13 | event(payload) 14 | end 15 | end 16 | 17 | ---@class cmp-kit.signature_help.SignatureHelpService.Config 18 | ---@field public view cmp-kit.signature_help.SignatureHelpView 19 | 20 | ---@class cmp-kit.signature_help.SignatureHelpService.ProviderConfiguration 21 | ---@field public provider cmp-kit.signature_help.SignatureHelpProvider 22 | ---@field public priority? integer 23 | 24 | ---@class cmp-kit.signature_help.SignatureHelpService.State 25 | ---@field public trigger_context cmp-kit.core.TriggerContext 26 | ---@field public active_provider? cmp-kit.signature_help.SignatureHelpProvider 27 | 28 | ---@class cmp-kit.signature_help.SignatureHelpService 29 | ---@field private _disposed boolean 30 | ---@field private _preventing integer 31 | ---@field private _events table 32 | ---@field private _config cmp-kit.signature_help.SignatureHelpService.Config 33 | ---@field private _provider_configurations (cmp-kit.completion.CompletionService.ProviderConfiguration|{ index: integer })[] 34 | ---@field private _state cmp-kit.signature_help.SignatureHelpService.State 35 | local SignatureHelpService = {} 36 | SignatureHelpService.__index = SignatureHelpService 37 | 38 | ---Create a new SignatureHelpService. 39 | ---@param config? cmp-kit.signature_help.SignatureHelpService.Config|{} 40 | ---@return cmp-kit.signature_help.SignatureHelpService 41 | function SignatureHelpService.new(config) 42 | return setmetatable({ 43 | _disposed = false, 44 | _preventing = 0, 45 | _events = {}, 46 | _config = kit.merge(config or {}, DefaultConfig), 47 | _provider_configurations = {}, 48 | _state = { 49 | trigger_context = TriggerContext.create_empty_context(), 50 | active_provider = nil, 51 | }, 52 | }, SignatureHelpService) 53 | end 54 | 55 | ---Register source. 56 | ---@param source cmp-kit.signature_help.SignatureHelpSource 57 | ---@param config? { priority?: integer, dedup?: boolean, keyword_length?: integer, item_count?: integer } 58 | ---@return fun(): nil 59 | function SignatureHelpService:register_source(source, config) 60 | ---@type cmp-kit.signature_help.SignatureHelpService.ProviderConfiguration|{ index: integer } 61 | local provider_configuration = { 62 | index = #self._provider_configurations + 1, 63 | priority = config and config.priority or 0, 64 | provider = SignatureHelpProvider.new(source), 65 | } 66 | table.insert(self._provider_configurations, provider_configuration) 67 | return function() 68 | for i, c in ipairs(self._provider_configurations) do 69 | if c == provider_configuration then 70 | table.remove(self._provider_configurations, i) 71 | if self._state.active_provider == provider_configuration.provider then 72 | self._state.active_provider = nil 73 | end 74 | break 75 | end 76 | end 77 | end 78 | end 79 | 80 | ---Trigger. 81 | ---@param params? { force?: boolean } 82 | ---@return cmp-kit.kit.Async.AsyncTask 83 | function SignatureHelpService:trigger(params) 84 | params = params or {} 85 | params.force = params.force or false 86 | 87 | if self._disposed then 88 | return Async.resolve() 89 | end 90 | if self._preventing > 0 then 91 | return Async.resolve() 92 | end 93 | 94 | local trigger_context = TriggerContext.create({ force = params.force }) 95 | if not self._state.trigger_context:changed(trigger_context) then 96 | return Async.run(function() end) 97 | end 98 | self._state.trigger_context = trigger_context 99 | 100 | if self:is_visible() then 101 | self:_update_signature_help(self._state.active_provider) 102 | end 103 | 104 | return Async.run(function() 105 | for _, cfg in ipairs(self:_get_providers()) do 106 | if cfg.provider:capable(trigger_context) then 107 | local context = cfg.provider:fetch(trigger_context):await() 108 | if self._state.trigger_context ~= trigger_context then 109 | return 110 | end 111 | if context then 112 | self:_update_signature_help(cfg.provider) 113 | return 114 | end 115 | end 116 | end 117 | self:_update_signature_help(self._state.active_provider) 118 | end) 119 | end 120 | 121 | ---Select specific signature. 122 | ---@param index integer 1-origin index 123 | function SignatureHelpService:select(index) 124 | if not self._state.active_provider then 125 | return 126 | end 127 | self._state.active_provider:select(index) 128 | self:_update_signature_help(self._state.active_provider) 129 | end 130 | 131 | ---Scroll signature help. 132 | ---@param delta integer 133 | function SignatureHelpService:scroll(delta) 134 | if self._config.view:is_visible() then 135 | self._config.view:scroll(delta) 136 | end 137 | end 138 | 139 | ---Return if the signature help is visible. 140 | ---@return boolean 141 | function SignatureHelpService:is_visible() 142 | return self._config.view:is_visible() 143 | end 144 | 145 | ---Get active signature data. 146 | ---@return cmp-kit.signature_help.ActiveSignatureData|nil 147 | function SignatureHelpService:get_active_signature_data() 148 | if not self._state.active_provider then 149 | return nil 150 | end 151 | return self._state.active_provider:get_active_signature_data() 152 | end 153 | 154 | ---Prevent signature help. 155 | ---@return fun(): cmp-kit.kit.Async.AsyncTask 156 | function SignatureHelpService:prevent() 157 | self._preventing = self._preventing + 1 158 | return function() 159 | return Async.run(function() 160 | Async.new(function(resolve) 161 | vim.api.nvim_create_autocmd('SafeState', { 162 | once = true, 163 | callback = resolve, 164 | }) 165 | end):await() 166 | self._state.trigger_context = TriggerContext.create() 167 | self._preventing = self._preventing - 1 168 | end) 169 | end 170 | end 171 | 172 | ---Clear signature help. 173 | function SignatureHelpService:clear() 174 | for _, cfg in ipairs(self:_get_providers()) do 175 | cfg.provider:clear() 176 | end 177 | self._state = { 178 | trigger_context = TriggerContext.create(), 179 | active_provider = nil, 180 | } 181 | self._config.view:hide() 182 | end 183 | 184 | ---Register on_dispose event. 185 | ---@param callback fun(payload: { service: cmp-kit.completion.CompletionService }) 186 | ---@return fun() 187 | function SignatureHelpService:on_dispose(callback) 188 | self._events = self._events or {} 189 | self._events.on_dispose = self._events.on_dispose or {} 190 | table.insert(self._events.on_dispose, callback) 191 | return function() 192 | for i, c in ipairs(self._events.on_dispose) do 193 | if c == callback then 194 | table.remove(self._events.on_dispose, i) 195 | break 196 | end 197 | end 198 | end 199 | end 200 | 201 | ---Dispose the service. 202 | function SignatureHelpService:dispose() 203 | if self._disposed then 204 | return 205 | end 206 | self._disposed = true 207 | 208 | -- Clear state. 209 | self:clear() 210 | 211 | -- Emit dispose event. 212 | emit(self._events.on_dispose, { service = self }) 213 | end 214 | 215 | ---Update_signature_help 216 | ---@param provider? cmp-kit.signature_help.SignatureHelpProvider 217 | function SignatureHelpService:_update_signature_help(provider) 218 | if not provider then 219 | self._state.active_provider = nil 220 | self._config.view:hide() 221 | return 222 | end 223 | local active_signature_data = provider:get_active_signature_data() 224 | if not active_signature_data then 225 | self._state.active_provider = nil 226 | self._config.view:hide() 227 | return 228 | end 229 | 230 | self._state.active_provider = provider 231 | self._config.view:show(active_signature_data) 232 | end 233 | 234 | ---Get providers. 235 | ---@return cmp-kit.signature_help.SignatureHelpService.ProviderConfiguration[] 236 | function SignatureHelpService:_get_providers() 237 | table.sort(self._provider_configurations, function(a, b) 238 | if a.priority ~= b.priority then 239 | return a.priority > b.priority 240 | end 241 | return a.index < b.index 242 | end) 243 | return self._provider_configurations 244 | end 245 | 246 | return SignatureHelpService 247 | -------------------------------------------------------------------------------- /lua/cmp-kit/signature_help/ext/DefaultView.lua: -------------------------------------------------------------------------------- 1 | local kit = require('cmp-kit.kit') 2 | local LSP = require('cmp-kit.kit.LSP') 3 | local FloatingWindow = require('cmp-kit.kit.Vim.FloatingWindow') 4 | local TriggerContext = require('cmp-kit.core.TriggerContext') 5 | local Markdown = require('cmp-kit.core.Markdown') 6 | 7 | ---Create winhighlight. 8 | ---@param map table 9 | ---@return string 10 | local function winhighlight(map) 11 | return vim 12 | .iter(pairs(map)) 13 | :map(function(k, v) 14 | return ('%s:%s'):format(k, v) 15 | end) 16 | :join(',') 17 | end 18 | local winhl_bordered = winhighlight({ 19 | CursorLine = 'Visual', 20 | Search = 'None', 21 | EndOfBuffer = '', 22 | }) 23 | local winhl_pum = winhighlight({ 24 | NormalFloat = 'Pmenu', 25 | Normal = 'Pmenu', 26 | FloatBorder = 'Pmenu', 27 | CursorLine = 'PmenuSel', 28 | Search = 'None', 29 | EndOfBuffer = '', 30 | }) 31 | 32 | ---Convert documentation to string. 33 | ---@param doc cmp-kit.kit.LSP.MarkupContent|string|nil 34 | ---@return string 35 | local function doc_to_string(doc) 36 | if doc then 37 | if type(doc) == 'string' then 38 | return doc 39 | elseif type(doc) == 'table' and doc.value then 40 | return doc.value 41 | end 42 | end 43 | return '' 44 | end 45 | 46 | ---side padding border. 47 | local border_padding_side = { '', '', '', ' ', '', '', '', ' ' } 48 | 49 | ---@class cmp-kit.signature_help.ext.DefaultView.Config 50 | local default_config = { 51 | max_width_ratio = 0.8, 52 | max_height_ratio = 8 / vim.o.lines, 53 | } 54 | 55 | ---@class cmp-kit.signature_help.ext.DefaultView: cmp-kit.signature_help.SignatureHelpView 56 | ---@field private _ns integer 57 | ---@field private _window cmp-kit.kit.Vim.FloatingWindow 58 | local DefaultView = {} 59 | DefaultView.__index = DefaultView 60 | 61 | ---Create a new DefaultView instance. 62 | ---@param config? cmp-kit.signature_help.ext.DefaultView.Config|{} 63 | ---@return cmp-kit.signature_help.ext.DefaultView 64 | function DefaultView.new(config) 65 | local self = setmetatable({ 66 | _ns = vim.api.nvim_create_namespace('cmp-kit.signature_help.ext.DefaultView'), 67 | _window = FloatingWindow.new(), 68 | _config = kit.merge(config or {}, default_config), 69 | }, DefaultView) 70 | 71 | self._window:set_buf_option('buftype', 'nofile') 72 | self._window:set_buf_option('tabstop', 1) 73 | self._window:set_buf_option('shiftwidth', 1) 74 | self._window:set_win_option('scrolloff', 0) 75 | self._window:set_win_option('conceallevel', 2) 76 | self._window:set_win_option('concealcursor', 'n') 77 | self._window:set_win_option('cursorlineopt', 'line') 78 | self._window:set_win_option('foldenable', false) 79 | self._window:set_win_option('wrap', true) 80 | 81 | self._window:set_win_option( 82 | 'winhighlight', 83 | winhighlight({ 84 | NormalFloat = 'PmenuSbar', 85 | Normal = 'PmenuSbar', 86 | EndOfBuffer = 'PmenuSbar', 87 | Search = 'None', 88 | }), 89 | 'scrollbar_track' 90 | ) 91 | self._window:set_win_option( 92 | 'winhighlight', 93 | winhighlight({ 94 | NormalFloat = 'PmenuThumb', 95 | Normal = 'PmenuThumb', 96 | EndOfBuffer = 'PmenuThumb', 97 | Search = 'None', 98 | }), 99 | 'scrollbar_thumb' 100 | ) 101 | 102 | return self 103 | end 104 | 105 | ---Return if the window is visible. 106 | ---@return boolean 107 | function DefaultView:is_visible() 108 | return self._window:is_visible() 109 | end 110 | 111 | ---@param data cmp-kit.signature_help.ActiveSignatureData 112 | function DefaultView:show(data) 113 | local contents = {} --[=[@as cmp-kit.kit.LSP.MarkupContent[]]=] 114 | -- Create signature label. 115 | do 116 | local label = data.signature.label 117 | local parameter = data.signature.parameters[data.parameter_index] 118 | if parameter then 119 | local pos = parameter.label 120 | if type(pos) == 'string' then 121 | local s, e = label:find(pos, 1, true) 122 | if s and e then 123 | pos = { s - 1, e - 1 } 124 | end 125 | end 126 | if kit.is_array(pos) then 127 | local pos1 = pos[1] 128 | local pos2 = pos[2] or pos1 129 | local before = label:sub(1, pos1) 130 | local middle = label:sub(pos1 + 1, pos2) 131 | local after = label:sub(pos2 + 1) 132 | label = ('```%s\n%s%s%s\n```'):format(vim.bo.filetype, before, middle, after) 133 | end 134 | end 135 | table.insert(contents, { 136 | kind = LSP.MarkupKind.Markdown, 137 | value = label, 138 | }) 139 | end 140 | 141 | -- Create parameter documentation. 142 | do 143 | local parameter = data.signature.parameters[data.parameter_index] 144 | if parameter then 145 | local doc_str = doc_to_string(parameter.documentation) 146 | if doc_str ~= '' then 147 | if #contents > 0 then 148 | table.insert(contents, { 149 | kind = LSP.MarkupKind.Markdown, 150 | value = '-----', 151 | }) 152 | end 153 | table.insert(contents, { 154 | kind = LSP.MarkupKind.Markdown, 155 | value = doc_str, 156 | }) 157 | end 158 | end 159 | end 160 | 161 | -- Create signature documentation. 162 | do 163 | local doc_str = doc_to_string(data.signature.documentation) 164 | if doc_str ~= '' then 165 | if #contents > 0 then 166 | table.insert(contents, { 167 | kind = LSP.MarkupKind.Markdown, 168 | value = '-----', 169 | }) 170 | end 171 | table.insert(contents, { 172 | kind = LSP.MarkupKind.Markdown, 173 | value = doc_str, 174 | }) 175 | end 176 | end 177 | 178 | if #contents == 0 then 179 | return self:hide() 180 | end 181 | 182 | -- Update buffer contents. 183 | Markdown.set( 184 | self._window:get_buf('main'), 185 | self._ns, 186 | vim.iter(contents):fold({}, function(acc, v) 187 | for _, t in ipairs(vim.split(v.value, '\n')) do 188 | table.insert(acc, t) 189 | end 190 | return acc 191 | end) 192 | ) 193 | 194 | -- Compute screen position. 195 | local trigger_context = TriggerContext.create() 196 | local border = (vim.o.winborder ~= '' and vim.o.winborder ~= 'none') and vim.o.winborder or border_padding_side 197 | local border_size = FloatingWindow.get_border_size(border) 198 | local content_size = FloatingWindow.get_content_size({ 199 | bufnr = self._window:get_buf('main'), 200 | wrap = true, 201 | max_inner_width = math.floor(vim.o.columns * default_config.max_width_ratio) - border_size.h, 202 | markdown = true, 203 | }) 204 | local pos --[[@as { row: integer, col: integer }]] 205 | if vim.api.nvim_get_mode().mode ~= 'c' then 206 | pos = vim.fn.screenpos(0, trigger_context.line + 1, trigger_context.character + 1) 207 | else 208 | pos = {} 209 | pos.row = vim.o.lines 210 | pos.col = vim.fn.getcmdscreenpos() 211 | end 212 | local row = pos.row -- default row should be below the cursor. so we use 1-origin as-is. 213 | local col = pos.col 214 | local row_off = -1 215 | local col_off = -1 216 | local anchor = 'SW' 217 | local width = content_size.width 218 | width = math.min(width, math.floor(default_config.max_width_ratio * vim.o.columns)) 219 | local height = math.min(math.floor(default_config.max_height_ratio * vim.o.lines), content_size.height) 220 | height = math.min(height, (row + row_off) - border_size.v) 221 | 222 | -- Check row space is enough. 223 | if (row + row_off - border_size.v) < 1 then 224 | return self:hide() 225 | end 226 | 227 | -- update border config. 228 | if vim.o.winborder ~= '' and vim.o.winborder ~= 'none' then 229 | self._window:set_win_option('winhighlight', winhl_bordered) 230 | else 231 | self._window:set_win_option('winhighlight', winhl_pum) 232 | end 233 | self._window:set_win_option('winblend', vim.o.pumblend ~= 0 and vim.o.pumblend or vim.o.winblend) 234 | 235 | self._window:show({ 236 | row = row + row_off, 237 | col = col + col_off, 238 | anchor = anchor, 239 | width = width, 240 | height = height, 241 | border = border, 242 | footer = ('%s / %s'):format(data.signature_index, data.signature_count), 243 | footer_pos = 'right', 244 | style = 'minimal', 245 | }) 246 | vim.api.nvim_win_set_cursor(self._window:get_win('main') --[[@as integer]], { 1, 0 }) 247 | end 248 | 249 | ---Hide the signature help view. 250 | function DefaultView:hide() 251 | self._window:hide() 252 | end 253 | 254 | ---Scroll the signature help view. 255 | ---@param delta integer 256 | function DefaultView:scroll(delta) 257 | if self:is_visible() then 258 | self._window:scroll(delta) 259 | end 260 | end 261 | 262 | return DefaultView 263 | -------------------------------------------------------------------------------- /lua/cmp-kit/core/Buffer.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: invisible 2 | 3 | local ScheduledTimer = require('cmp-kit.kit.Async.ScheduledTimer') 4 | local debugger = require('cmp-kit.core.debugger') 5 | 6 | ---@type fun(regex: string): vim.regex 7 | local get_regex 8 | do 9 | local cache = {} ---@type table 10 | get_regex = function(regex) 11 | if not cache[regex] then 12 | cache[regex] = vim.regex(regex) 13 | end 14 | return cache[regex] --[[@as vim.regex]] 15 | end 16 | end 17 | 18 | ---@class cmp-kit.completion.Buffer.Indexer 19 | ---@field private _bufnr integer 20 | ---@field private _regex string 21 | ---@field private _words string[][] 22 | ---@field private _timer cmp-kit.kit.Async.ScheduledTimer 23 | ---@field private _s_idx integer? 24 | ---@field private _e_idx integer? 25 | ---@field private _disposed boolean 26 | local Indexer = {} 27 | Indexer.__index = Indexer 28 | 29 | ---@param bufnr integer 30 | ---@param regex string 31 | function Indexer.new(bufnr, regex) 32 | local self = setmetatable({ 33 | _bufnr = bufnr, 34 | _regex = regex, 35 | _words = {}, 36 | _timer = ScheduledTimer.new(), 37 | _s_idx = nil, 38 | _e_idx = nil, 39 | _rev = 0, 40 | _disposed = false, 41 | }, Indexer) 42 | vim.api.nvim_buf_attach(bufnr, false, { 43 | on_lines = function(_, _, _, toprow, botrow, botrow_updated) 44 | if not self._disposed then 45 | self:_update(toprow, botrow, botrow_updated) 46 | end 47 | return self._disposed 48 | end, 49 | on_reload = function() 50 | if not self._disposed then 51 | local max = vim.api.nvim_buf_line_count(bufnr) 52 | self:_update(0, max, max) 53 | end 54 | end, 55 | on_detach = function() 56 | self:dispose() 57 | end, 58 | }) 59 | 60 | do 61 | local is_current_win = bufnr == vim.api.nvim_get_current_buf() 62 | if is_current_win then 63 | self._s_idx = vim.fn.line('w0') 64 | self._e_idx = vim.fn.line('w$') 65 | self:_run_index() 66 | end 67 | end 68 | self._s_idx = 1 69 | self._e_idx = vim.api.nvim_buf_line_count(bufnr) + 1 70 | self:_run_index() 71 | return self 72 | end 73 | 74 | ---Get indexed words for specified row. 75 | ---@param row integer 76 | ---@return string[] 77 | function Indexer:get_words(row) 78 | return self._words[row + 1] or {} 79 | end 80 | 81 | ---Return is indexing or not. 82 | ---@return boolean 83 | function Indexer:is_indexing() 84 | return self._timer:is_running() 85 | end 86 | 87 | ---Dispose. 88 | function Indexer:dispose() 89 | self._disposed = true 90 | end 91 | 92 | ---Update range and start indexing. 93 | ---@param toprow integer 94 | ---@param botrow integer 95 | ---@param botrow_updated integer 96 | function Indexer:_update(toprow, botrow, botrow_updated) 97 | local s = nil --[[@as integer?]] 98 | local e = nil --[[@as integer?]] 99 | if botrow < botrow_updated then 100 | local add_count = botrow_updated - botrow 101 | for i = botrow + 1, botrow + add_count do 102 | table.insert(self._words, i + 1, {}) 103 | end 104 | for i = toprow + 1, botrow + 1 + add_count do 105 | self._words[i] = nil 106 | s = s or i 107 | e = i 108 | end 109 | elseif botrow_updated < botrow then 110 | local del_count = botrow - botrow_updated 111 | for i = botrow, botrow + 1 - del_count, -1 do 112 | table.remove(self._words, i) 113 | end 114 | for i = toprow + 1, botrow + 1 - del_count do 115 | self._words[i] = nil 116 | s = s or i 117 | e = i 118 | end 119 | else 120 | for i = toprow + 1, botrow + 1 do 121 | self._words[i] = nil 122 | s = s or i 123 | e = i 124 | end 125 | end 126 | self._s_idx = self._s_idx and math.min(self._s_idx, s) or s 127 | self._e_idx = self._e_idx and math.max(self._e_idx, e) or e 128 | if self._s_idx and self._e_idx then 129 | self:_run_index() 130 | end 131 | end 132 | 133 | ---Run indexing. 134 | ---NOTE: Extract anonymous functions because they impact LuaJIT performance. 135 | function Indexer:_run_index() 136 | if self._disposed then 137 | return 138 | end 139 | if not self._s_idx or not self._e_idx then 140 | return 141 | end 142 | 143 | local s = vim.uv.hrtime() / 1e6 144 | local c = 0 145 | local regex = get_regex(self._regex) 146 | local cursor = vim.api.nvim_win_get_cursor(0) 147 | cursor[2] = cursor[2] + 1 -- Convert to 1-based index 148 | local is_inserting = vim.api.nvim_get_mode().mode == 'i' 149 | for i = self._s_idx, self._e_idx do 150 | if self._words[i] == nil then 151 | self._words[i] = {} 152 | local text = vim.api.nvim_buf_get_lines(self._bufnr, i - 1, i, false)[1] or '' 153 | local off = 0 154 | while true do 155 | local sidx, eidx = regex:match_str(text) 156 | if sidx and eidx then 157 | if not is_inserting or cursor[1] ~= i or cursor[2] < (off + sidx) or (off + eidx) < cursor[2] then 158 | local word = text:sub(sidx + 1, eidx) 159 | table.insert(self._words[i], word) 160 | 161 | -- → neovim-completion-engine 162 | -- → neovim 163 | -- → neovim-completion 164 | -- → completion-engine 165 | -- → engine 166 | local p = 1 167 | while true do 168 | local s_pos, e_pos = word:find('[_-]', p) 169 | if not s_pos or not e_pos then 170 | break 171 | end 172 | table.insert(self._words[i], word:sub(1, s_pos - 1)) 173 | table.insert(self._words[i], word:sub(e_pos + 1)) 174 | p = e_pos + 1 175 | end 176 | end 177 | off = off + eidx 178 | 179 | local prev = text 180 | text = text:sub(eidx + 1) 181 | if text == prev then 182 | break 183 | end 184 | else 185 | break 186 | end 187 | end 188 | self._s_idx = i + 1 189 | 190 | c = c + 1 191 | if c >= 100 then 192 | c = 0 193 | local n = vim.uv.hrtime() / 1e6 194 | if n - s > 10 then 195 | self._timer:start(16, 0, function() 196 | self:_run_index() 197 | end) 198 | return 199 | end 200 | end 201 | end 202 | end 203 | 204 | self._s_idx = nil 205 | self._e_idx = nil 206 | self:_finish_index() 207 | end 208 | 209 | ---Finish indexing. 210 | function Indexer:_finish_index() 211 | if debugger.enable() then 212 | for i, words in ipairs(self._words) do 213 | local text = vim.api.nvim_buf_get_lines(self._bufnr, i - 1, i, false)[1] or '' 214 | for _, word in ipairs(words) do 215 | if not text:match(vim.pesc(word)) then 216 | debugger.add('cmp-kit.completion.Buffer.Indexer', { 217 | desc = 'buffer is not synced collectly', 218 | bufnr = self._bufnr, 219 | regex = self._regex, 220 | row = i, 221 | text = text, 222 | words = words, 223 | }) 224 | end 225 | end 226 | end 227 | end 228 | end 229 | 230 | ---@class cmp-kit.completion.Buffer 231 | ---@field private _bufnr integer 232 | ---@field private _indexers table 233 | ---@field private _disposed boolean 234 | local Buffer = {} 235 | Buffer.__index = Buffer 236 | 237 | local internal = { 238 | bufs = {},--[[@as table]] 239 | } 240 | 241 | ---Get or create buffer instance. 242 | ---@param bufnr integer 243 | ---@return cmp-kit.completion.Buffer 244 | function Buffer.ensure(bufnr) 245 | bufnr = bufnr == 0 and vim.api.nvim_get_current_buf() or bufnr 246 | if not internal.bufs[bufnr] or internal.bufs[bufnr]:is_disposed() then 247 | internal.bufs[bufnr] = Buffer.new(bufnr) 248 | end 249 | return internal.bufs[bufnr] 250 | end 251 | 252 | ---Create new Buffer. 253 | function Buffer.new(bufnr) 254 | local self = setmetatable({ 255 | _bufnr = bufnr, 256 | _indexers = {}, 257 | _disposed = false, 258 | }, Buffer) 259 | vim.api.nvim_create_autocmd('BufDelete', { 260 | once = true, 261 | pattern = (''):format(bufnr), 262 | callback = function() 263 | self:dispose() 264 | end, 265 | }) 266 | return self 267 | end 268 | 269 | ---Return bufnr. 270 | ---@return integer 271 | function Buffer:get_buf() 272 | return self._bufnr 273 | end 274 | 275 | ---Get words in row. 276 | ---@param regex string 277 | ---@param row integer 278 | ---@return string[] 279 | function Buffer:get_words(regex, row) 280 | if not self._indexers[regex] then 281 | self._indexers[regex] = Indexer.new(self._bufnr, regex) 282 | end 283 | return self._indexers[regex]:get_words(row) 284 | end 285 | 286 | ---Return is indexing or not. 287 | ---@param regex string 288 | ---@return boolean 289 | function Buffer:is_indexing(regex) 290 | if not self._indexers[regex] then 291 | self._indexers[regex] = Indexer.new(self._bufnr, regex) 292 | end 293 | return self._indexers[regex]:is_indexing() 294 | end 295 | 296 | ---Return if buffer is disposed. 297 | ---@return boolean 298 | function Buffer:is_disposed() 299 | return self._disposed 300 | end 301 | 302 | ---Dispose. 303 | function Buffer:dispose() 304 | self._disposed = true 305 | for _, indexer in pairs(self._indexers) do 306 | indexer:dispose() 307 | end 308 | self._indexers = {} 309 | end 310 | 311 | return Buffer 312 | --------------------------------------------------------------------------------