From d23d3533cfa69b9690346b2b846462576c05d273 Mon Sep 17 00:00:00 2001 From: hrsh7th Date: Wed, 4 Aug 2021 01:07:12 +0900 Subject: [PATCH] dev (#1) * dev * Improve sync design * Support buffer local mapping * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * stylua * tmp * tmp * tmp * tmp * tmp * tmp * tmp * integration * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * update * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp * tmp --- .githooks/pre-commit | 6 + .github/workflows/integration.yaml | 49 +++ .luacheckrc | 2 + Makefile | 24 ++ README.md | 91 +++++ autoload/cmp.vim | 32 ++ autoload/vital/_cmp.vim | 9 + autoload/vital/_cmp/VS/LSP/CompletionItem.vim | 178 ++++++++++ autoload/vital/_cmp/VS/LSP/Position.vim | 62 ++++ autoload/vital/_cmp/VS/LSP/Text.vim | 23 ++ autoload/vital/_cmp/VS/LSP/TextEdit.vim | 185 ++++++++++ autoload/vital/_cmp/VS/Vim/Buffer.vim | 126 +++++++ autoload/vital/_cmp/VS/Vim/Option.vim | 21 ++ autoload/vital/cmp.vim | 330 ++++++++++++++++++ autoload/vital/cmp.vital | 5 + init.sh | 7 + lua/cmp/autocmd.lua | 34 ++ lua/cmp/config.lua | 63 ++++ lua/cmp/config/compare.lua | 88 +++++ lua/cmp/config/default.lua | 119 +++++++ lua/cmp/context.lua | 142 ++++++++ lua/cmp/context_spec.lua | 31 ++ lua/cmp/core.lua | 255 ++++++++++++++ lua/cmp/entry.lua | 321 +++++++++++++++++ lua/cmp/entry_spec.lua | 253 ++++++++++++++ lua/cmp/float.lua | 112 ++++++ lua/cmp/init.lua | 79 +++++ lua/cmp/matcher.lua | 248 +++++++++++++ lua/cmp/matcher_spec.lua | 33 ++ lua/cmp/menu.lua | 242 +++++++++++++ lua/cmp/source.lua | 281 +++++++++++++++ lua/cmp/source_spec.lua | 11 + lua/cmp/types/cmp.lua | 84 +++++ lua/cmp/types/init.lua | 8 + lua/cmp/types/lsp.lua | 205 +++++++++++ lua/cmp/types/lsp_spec.lua | 47 +++ lua/cmp/types/vim.lua | 18 + lua/cmp/utils/async.lua | 55 +++ lua/cmp/utils/async_spec.lua | 40 +++ lua/cmp/utils/cache.lua | 57 +++ lua/cmp/utils/cache_spec.lua | 0 lua/cmp/utils/char.lua | 113 ++++++ lua/cmp/utils/debug.lua | 20 ++ lua/cmp/utils/keymap.lua | 134 +++++++ lua/cmp/utils/keymap_spec.lua | 13 + lua/cmp/utils/misc.lua | 112 ++++++ lua/cmp/utils/patch.lua | 32 ++ lua/cmp/utils/pattern.lua | 21 ++ lua/cmp/utils/spec.lua | 42 +++ lua/cmp/utils/str.lua | 150 ++++++++ lua/cmp/utils/str_spec.lua | 26 ++ plugin/cmp.lua | 38 ++ stylua.toml | 4 + 53 files changed, 4681 insertions(+) create mode 100755 .githooks/pre-commit create mode 100644 .github/workflows/integration.yaml create mode 100644 .luacheckrc create mode 100644 Makefile create mode 100644 autoload/cmp.vim create mode 100644 autoload/vital/_cmp.vim create mode 100644 autoload/vital/_cmp/VS/LSP/CompletionItem.vim create mode 100644 autoload/vital/_cmp/VS/LSP/Position.vim create mode 100644 autoload/vital/_cmp/VS/LSP/Text.vim create mode 100644 autoload/vital/_cmp/VS/LSP/TextEdit.vim create mode 100644 autoload/vital/_cmp/VS/Vim/Buffer.vim create mode 100644 autoload/vital/_cmp/VS/Vim/Option.vim create mode 100644 autoload/vital/cmp.vim create mode 100644 autoload/vital/cmp.vital create mode 100755 init.sh create mode 100644 lua/cmp/autocmd.lua create mode 100644 lua/cmp/config.lua create mode 100644 lua/cmp/config/compare.lua create mode 100644 lua/cmp/config/default.lua create mode 100644 lua/cmp/context.lua create mode 100644 lua/cmp/context_spec.lua create mode 100644 lua/cmp/core.lua create mode 100644 lua/cmp/entry.lua create mode 100644 lua/cmp/entry_spec.lua create mode 100644 lua/cmp/float.lua create mode 100644 lua/cmp/init.lua create mode 100644 lua/cmp/matcher.lua create mode 100644 lua/cmp/matcher_spec.lua create mode 100644 lua/cmp/menu.lua create mode 100644 lua/cmp/source.lua create mode 100644 lua/cmp/source_spec.lua create mode 100644 lua/cmp/types/cmp.lua create mode 100644 lua/cmp/types/init.lua create mode 100644 lua/cmp/types/lsp.lua create mode 100644 lua/cmp/types/lsp_spec.lua create mode 100644 lua/cmp/types/vim.lua create mode 100644 lua/cmp/utils/async.lua create mode 100644 lua/cmp/utils/async_spec.lua create mode 100644 lua/cmp/utils/cache.lua create mode 100644 lua/cmp/utils/cache_spec.lua create mode 100644 lua/cmp/utils/char.lua create mode 100644 lua/cmp/utils/debug.lua create mode 100644 lua/cmp/utils/keymap.lua create mode 100644 lua/cmp/utils/keymap_spec.lua create mode 100644 lua/cmp/utils/misc.lua create mode 100644 lua/cmp/utils/patch.lua create mode 100644 lua/cmp/utils/pattern.lua create mode 100644 lua/cmp/utils/spec.lua create mode 100644 lua/cmp/utils/str.lua create mode 100644 lua/cmp/utils/str_spec.lua create mode 100644 plugin/cmp.lua create mode 100644 stylua.toml diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..793266a --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,6 @@ +#!/bin/sh + +DIR="$(dirname $(dirname $( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )))" + +cd $DIR +make pre-commit diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml new file mode 100644 index 0000000..0a10135 --- /dev/null +++ b/.github/workflows/integration.yaml @@ -0,0 +1,49 @@ +name: integration + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + integration: + runs-on: ubuntu-latest + steps: + + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup rust + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + default: true + override: true + + - name: Setup neovim + uses: rhysd/action-setup-vim@v1 + with: + neovim: true + + - name: Setup lua + uses: leafo/gh-actions-lua@v8 + with: + luaVersion: "luajit-2.1.0-beta3" + + - name: Setup luarocks + uses: leafo/gh-actions-luarocks@v4 + + - name: Setup tools + shell: bash + run: | + cargo install stylua + luarocks install luacheck + luarocks install vusted + + - name: Run tests + shell: bash + run: make integration + diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..004e8c1 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,2 @@ +globals = { 'vim', 'describe', 'it', 'before_each', 'after_each', 'assert', 'async' } +max_line_length = false diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..900f10f --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: fmt +fmt: + stylua --glob lua/**/*.lua -- lua + +.PHONY: lint +lint: + luacheck ./lua + +.PHONY: test +test: + vusted ./lua + +.PHONY: pre-commit +pre-commit: + stylua --check --glob lua/**/*.lua -- lua + luacheck lua + vusted lua + +.PHONY: integration +integration: + stylua --check --glob lua/**/*.lua -- lua + luacheck lua + vusted lua + diff --git a/README.md b/README.md index 8911e12..d9b0756 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,93 @@ # nvim-cmp + A completion plugin for neovim written in Lua. + + +Status +==================== + +design and development + + +Development +==================== + +You should read [type definitions](/lua/cmp/types) and [LSP spec](https://microsoft.github.io/language-server-protocol/specifications/specification-current/) to develop core or sources. + +### Overview + +`nvim-cmp` emphasizes compatibility with the VSCode behavior and the LSP specification but there are some little differences. + +1. In `nvim-cmp`, the `CompletionItem` can have `word` and `dup` property that introduced by vim's completion mechanism. + + +### Create custom source + +The example source is here. + +- The `complete` function is required but others can be omitted. +- The `callback` argument must always be called. + +```lua +local source = {} + +---Create source. +source.new = function() + local self = setmetatable({}, { __index = source }) + self.your_awesome_variable = 1 + return self +end + +---Return keyword pattern which will be used by the followings. +--- 1. Trigger keyword completion +--- 2. Detect menu start offset +--- 3. Reset completion state +---@return string +function source:get_keyword_pattern() + return '???' +end + +---Return trigger characters. +---@return string[] +function source:get_trigger_characters() + return { ??? } +end + +---Invoke completion. +---@param request cmp.CompletionRequest +---@param callback fun(response: lsp.CompletionResponse|nil) +---NOTE: This method is required. +function source:complete(request, callback) + callback({ + { label = 'January' }, + { label = 'February' }, + { label = 'March' }, + { label = 'April' }, + { label = 'May' }, + { label = 'June' }, + { label = 'July' }, + { label = 'August' }, + { label = 'September' }, + { label = 'October' }, + { label = 'November' }, + { label = 'December' }, + }) +end + +---Resolve completion item that will be called when the item selected or before the item confirmation. +---@param completion_item lsp.CompletionItem +---@param callback fun(completion_item: lsp.CompletionItem|nil) +function source:resolve(completion_item, callback) + callback(completion_item) +end + +---Execute command that will be called when after the item confirmation. +---@param completion_item lsp.CompletionItem +---@param callback fun(completion_item: lsp.CompletionItem|nil) +function source:execute(completion_item, callback) + callback(completion_item) +end + +return source +``` + diff --git a/autoload/cmp.vim b/autoload/cmp.vim new file mode 100644 index 0000000..02b71de --- /dev/null +++ b/autoload/cmp.vim @@ -0,0 +1,32 @@ +let s:Position = vital#cmp#import('VS.LSP.Position') +let s:TextEdit = vital#cmp#import('VS.LSP.TextEdit') +let s:CompletionItem = vital#cmp#import('VS.LSP.CompletionItem') + +" +" cmp#apply_text_edits +" +function! cmp#apply_text_edits(bufnr, text_edits) abort + call s:TextEdit.apply(a:bufnr, a:text_edits) +endfunction + +" +" cmp#confirm +" +function! cmp#confirm(args) abort + call s:CompletionItem.confirm({ + \ 'suggest_position': s:Position.vim_to_lsp('%', [line('.'), a:args.suggest_offset]), + \ 'request_position': s:Position.vim_to_lsp('%', [line('.'), a:args.request_offset]), + \ 'current_position': s:Position.vim_to_lsp('%', [line('.'), col('.')]), + \ 'current_line': getline('.'), + \ 'completion_item': a:args.completion_item, + \ 'expand_snippet': s:get_expand_snippet(), + \ }) +endfunction + +" +" get_expand_snippet +" +function! s:get_expand_snippet() abort + return { args -> luaeval('require"cmp"._expand_snippet(_A)', args) } +endfunction + diff --git a/autoload/vital/_cmp.vim b/autoload/vital/_cmp.vim new file mode 100644 index 0000000..5510495 --- /dev/null +++ b/autoload/vital/_cmp.vim @@ -0,0 +1,9 @@ +let s:_plugin_name = expand(':t:r') + +function! vital#{s:_plugin_name}#new() abort + return vital#{s:_plugin_name[1:]}#new() +endfunction + +function! vital#{s:_plugin_name}#function(funcname) abort + silent! return function(a:funcname) +endfunction diff --git a/autoload/vital/_cmp/VS/LSP/CompletionItem.vim b/autoload/vital/_cmp/VS/LSP/CompletionItem.vim new file mode 100644 index 0000000..8ed5834 --- /dev/null +++ b/autoload/vital/_cmp/VS/LSP/CompletionItem.vim @@ -0,0 +1,178 @@ +" ___vital___ +" NOTE: lines between '" ___vital___' is generated by :Vitalize. +" Do not modify the code nor insert new lines before '" ___vital___' +function! s:_SID() abort + return matchstr(expand(''), '\zs\d\+\ze__SID$') +endfunction +execute join(['function! vital#_cmp#VS#LSP#CompletionItem#import() abort', printf("return map({'_vital_depends': '', 'confirm': '', '_vital_loaded': ''}, \"vital#_cmp#function('%s_' . v:key)\")", s:_SID()), 'endfunction'], "\n") +delfunction s:_SID +" ___vital___ +" +" _vital_loaded +" +function! s:_vital_loaded(V) abort + let s:Position = a:V.import('VS.LSP.Position') + let s:TextEdit = a:V.import('VS.LSP.TextEdit') + let s:Text = a:V.import('VS.LSP.Text') +endfunction + +" +" _vital_depends +" +function! s:_vital_depends() abort + return ['VS.LSP.Position', 'VS.LSP.TextEdit', 'VS.LSP.Text'] +endfunction + +" +" confirm +" +" @param {LSP.Position} args.suggest_position +" @param {LSP.Position} args.request_position +" @param {LSP.Position} args.current_position +" @param {string} args.current_line +" @param {LSP.CompletionItem} args.completion_item +" @param {(args: { body: string; insert_text_mode: number; }) => void?} args.expand_snippet +" +" # Pre-condition +" +" - You must pass `current_position` that represents the position when `CompleteDone` was fired. +" - You must pass `current_line` that represents the line when `CompleteDone` was fired. +" - You must call this function after the commit characters has been inserted. +" +" # The positoins +" +" 0. The example case +" +" call getbufl| -> call getbufline| +" +" 1. suggest_position +" +" call |getbufline +" +" 2. request_position +" +" call getbufl|ine +" +" 3. current_position +" +" call getbufline| +" +" +function! s:confirm(args) abort + let l:suggest_position = a:args.suggest_position + let l:request_position = a:args.request_position + let l:current_position = a:args.current_position + let l:current_line = a:args.current_line + let l:completion_item = a:args.completion_item + let l:ExpandSnippet = get(a:args, 'expand_snippet', v:null) + + " 1. Prepare for alignment to VSCode behavior. + let l:expansion = s:_get_expansion({ + \ 'suggest_position': l:suggest_position, + \ 'request_position': l:request_position, + \ 'current_position': l:current_position, + \ 'current_line': l:current_line, + \ 'completion_item': l:completion_item, + \ }) + if !empty(l:expansion) + " Remove commit characters if expansion is needed. + if getline('.') !=# l:current_line + call setline(l:current_position.line + 1, l:current_line) + call cursor(s:Position.lsp_to_vim('%', l:current_position)) + endif + + " Restore state of the timing when `textDocument/completion` was sent. + call s:TextEdit.apply('%', [{ + \ 'range': { 'start': l:request_position, 'end': l:current_position }, + \ 'newText': '' + \ }]) + endif + + " 2. Apply additionalTextEdits + if type(get(l:completion_item, 'additionalTextEdits', v:null)) == type([]) + call s:TextEdit.apply('%', l:completion_item.additionalTextEdits) + endif + + " 3. Apply expansion + if !empty(l:expansion) + let l:current_position = s:Position.cursor() " Update current_position to after additionalTextEdits. + let l:range = { + \ 'start': extend({ + \ 'character': l:current_position.character - l:expansion.overflow_before, + \ }, l:current_position, 'keep'), + \ 'end': extend({ + \ 'character': l:current_position.character + l:expansion.overflow_after, + \ }, l:current_position, 'keep') + \ } + + " Snippet. + if l:expansion.is_snippet && !empty(l:ExpandSnippet) + call s:TextEdit.apply('%', [{ 'range': l:range, 'newText': '' }]) + call cursor(s:Position.lsp_to_vim('%', l:range.start)) + call l:ExpandSnippet({ 'body': l:expansion.new_text, 'insert_text_mode': get(l:completion_item, 'insertTextMode', 2) }) + + " TextEdit. + else + call s:TextEdit.apply('%', [{ 'range': l:range, 'newText': l:expansion.new_text }]) + + " Move cursor position to end of new_text like as snippet. + let l:lines = s:Text.split_by_eol(l:expansion.new_text) + let l:cursor = copy(l:range.start) + let l:cursor.line += len(l:lines) - 1 + let l:cursor.character = strchars(l:lines[-1]) + (len(l:lines) == 1 ? l:cursor.character : 0) + call cursor(s:Position.lsp_to_vim('%', l:cursor)) + endif + endif +endfunction + +" +" _get_expansion +" +function! s:_get_expansion(args) abort + let l:current_line = a:args.current_line + let l:suggest_position = a:args.suggest_position + let l:request_position = a:args.request_position + let l:current_position = a:args.current_position + let l:completion_item = a:args.completion_item + + let l:is_snippet = get(l:completion_item, 'insertTextFormat', 1) == 2 + if type(get(l:completion_item, 'textEdit', v:null)) == type({}) + let l:inserted_text = strcharpart(l:current_line, l:request_position.character, l:current_position.character - l:request_position.character) + let l:overflow_before = l:request_position.character - l:completion_item.textEdit.range.start.character + let l:overflow_after = l:completion_item.textEdit.range.end.character - l:request_position.character + let l:inserted = '' + \ . strcharpart(l:current_line, l:request_position.character - l:overflow_before, l:overflow_before) + \ . strcharpart(l:current_line, l:request_position.character, strchars(l:inserted_text) + l:overflow_after) + let l:new_text = l:completion_item.textEdit.newText + if s:_trim_tabstop(l:new_text) !=# l:inserted + " The LSP spec says `textEdit range must contain the request position.` + return { + \ 'overflow_before': max([0, l:overflow_before]), + \ 'overflow_after': max([0, l:overflow_after]), + \ 'new_text': l:new_text, + \ 'is_snippet': l:is_snippet, + \ } + endif + else + let l:inserted = strcharpart(l:current_line, l:suggest_position.character, l:current_position.character - l:suggest_position.character) + let l:new_text = get(l:completion_item, 'insertText', v:null) + let l:new_text = !empty(l:new_text) ? l:new_text : l:completion_item.label + if s:_trim_tabstop(l:new_text) !=# l:inserted + return { + \ 'overflow_before': l:request_position.character - l:suggest_position.character, + \ 'overflow_after': 0, + \ 'new_text': l:new_text, + \ 'is_snippet': l:is_snippet, + \ } + endif + endif + return {} +endfunction + +" +" _trim_tabstop +" +function! s:_trim_tabstop(text) abort + return substitute(a:text, '\%(\$0\|\${0}\)$', '', 'g') +endfunction + diff --git a/autoload/vital/_cmp/VS/LSP/Position.vim b/autoload/vital/_cmp/VS/LSP/Position.vim new file mode 100644 index 0000000..f53c76a --- /dev/null +++ b/autoload/vital/_cmp/VS/LSP/Position.vim @@ -0,0 +1,62 @@ +" ___vital___ +" NOTE: lines between '" ___vital___' is generated by :Vitalize. +" Do not modify the code nor insert new lines before '" ___vital___' +function! s:_SID() abort + return matchstr(expand(''), '\zs\d\+\ze__SID$') +endfunction +execute join(['function! vital#_cmp#VS#LSP#Position#import() abort', printf("return map({'cursor': '', 'vim_to_lsp': '', 'lsp_to_vim': ''}, \"vital#_cmp#function('%s_' . v:key)\")", s:_SID()), 'endfunction'], "\n") +delfunction s:_SID +" ___vital___ +" +" cursor +" +function! s:cursor() abort + return s:vim_to_lsp('%', getpos('.')[1 : 3]) +endfunction + +" +" vim_to_lsp +" +function! s:vim_to_lsp(expr, pos) abort + let l:line = s:_get_buffer_line(a:expr, a:pos[0]) + if l:line is v:null + return { + \ 'line': a:pos[0] - 1, + \ 'character': a:pos[1] - 1 + \ } + endif + + return { + \ 'line': a:pos[0] - 1, + \ 'character': strchars(strpart(l:line, 0, a:pos[1] - 1)) + \ } +endfunction + +" +" lsp_to_vim +" +function! s:lsp_to_vim(expr, position) abort + let l:line = s:_get_buffer_line(a:expr, a:position.line + 1) + if l:line is v:null + return [a:position.line + 1, a:position.character + 1] + endif + return [a:position.line + 1, byteidx(l:line, a:position.character) + 1] +endfunction + +" +" _get_buffer_line +" +function! s:_get_buffer_line(expr, lnum) abort + try + let l:expr = bufnr(a:expr) + catch /.*/ + let l:expr = a:expr + endtry + if bufloaded(l:expr) + return get(getbufline(l:expr, a:lnum), 0, v:null) + elseif filereadable(a:expr) + return get(readfile(a:expr, '', a:lnum), 0, v:null) + endif + return v:null +endfunction + diff --git a/autoload/vital/_cmp/VS/LSP/Text.vim b/autoload/vital/_cmp/VS/LSP/Text.vim new file mode 100644 index 0000000..0c09314 --- /dev/null +++ b/autoload/vital/_cmp/VS/LSP/Text.vim @@ -0,0 +1,23 @@ +" ___vital___ +" NOTE: lines between '" ___vital___' is generated by :Vitalize. +" Do not modify the code nor insert new lines before '" ___vital___' +function! s:_SID() abort + return matchstr(expand(''), '\zs\d\+\ze__SID$') +endfunction +execute join(['function! vital#_cmp#VS#LSP#Text#import() abort', printf("return map({'normalize_eol': '', 'split_by_eol': ''}, \"vital#_cmp#function('%s_' . v:key)\")", s:_SID()), 'endfunction'], "\n") +delfunction s:_SID +" ___vital___ +" +" normalize_eol +" +function! s:normalize_eol(text) abort + return substitute(a:text, "\r\n\\|\r", "\n", 'g') +endfunction + +" +" split_by_eol +" +function! s:split_by_eol(text) abort + return split(a:text, "\r\n\\|\r\\|\n", v:true) +endfunction + diff --git a/autoload/vital/_cmp/VS/LSP/TextEdit.vim b/autoload/vital/_cmp/VS/LSP/TextEdit.vim new file mode 100644 index 0000000..09a8df8 --- /dev/null +++ b/autoload/vital/_cmp/VS/LSP/TextEdit.vim @@ -0,0 +1,185 @@ +" ___vital___ +" NOTE: lines between '" ___vital___' is generated by :Vitalize. +" Do not modify the code nor insert new lines before '" ___vital___' +function! s:_SID() abort + return matchstr(expand(''), '\zs\d\+\ze__SID$') +endfunction +execute join(['function! vital#_cmp#VS#LSP#TextEdit#import() abort', printf("return map({'_vital_depends': '', 'apply': '', '_vital_loaded': ''}, \"vital#_cmp#function('%s_' . v:key)\")", s:_SID()), 'endfunction'], "\n") +delfunction s:_SID +" ___vital___ +" +" _vital_loaded +" +function! s:_vital_loaded(V) abort + let s:Text = a:V.import('VS.LSP.Text') + let s:Position = a:V.import('VS.LSP.Position') + let s:Buffer = a:V.import('VS.Vim.Buffer') + let s:Option = a:V.import('VS.Vim.Option') +endfunction + +" +" _vital_depends +" +function! s:_vital_depends() abort + return ['VS.LSP.Text', 'VS.LSP.Position', 'VS.Vim.Buffer', 'VS.Vim.Option'] +endfunction + +" +" apply +" +function! s:apply(path, text_edits) abort + let l:current_bufname = bufname('%') + let l:current_position = s:Position.cursor() + + let l:target_bufnr = s:_switch(a:path) + call s:_substitute(l:target_bufnr, a:text_edits, l:current_position) + let l:current_bufnr = s:_switch(l:current_bufname) + + if l:current_bufnr == l:target_bufnr + call cursor(s:Position.lsp_to_vim('%', l:current_position)) + endif +endfunction + +" +" _substitute +" +function! s:_substitute(bufnr, text_edits, current_position) abort + try + " Save state. + let l:Restore = s:Option.define({ + \ 'foldenable': '0', + \ }) + let l:view = winsaveview() + + " Apply substitute. + let [l:fixeol, l:text_edits] = s:_normalize(a:bufnr, a:text_edits) + for l:text_edit in l:text_edits + let l:start = s:Position.lsp_to_vim(a:bufnr, l:text_edit.range.start) + let l:end = s:Position.lsp_to_vim(a:bufnr, l:text_edit.range.end) + let l:text = s:Text.normalize_eol(l:text_edit.newText) + execute printf('noautocmd keeppatterns keepjumps silent %ssubstitute/\%%%sl\%%%sc\_.\{-}\%%%sl\%%%sc/\=l:text/%se', + \ l:start[0], + \ l:start[0], + \ l:start[1], + \ l:end[0], + \ l:end[1], + \ &gdefault ? 'g' : '' + \ ) + call s:_fix_cursor_position(a:current_position, l:text_edit, s:Text.split_by_eol(l:text)) + endfor + + " Remove last empty line if fixeol enabled. + if l:fixeol && getline('$') ==# '' + noautocmd keeppatterns keepjumps silent $delete _ + endif + catch /.*/ + echomsg string({ 'exception': v:exception, 'throwpoint': v:throwpoint }) + finally + " Restore state. + call l:Restore() + call winrestview(l:view) + endtry +endfunction + +" +" _fix_cursor_position +" +function! s:_fix_cursor_position(position, text_edit, lines) abort + let l:lines_len = len(a:lines) + let l:range_len = (a:text_edit.range.end.line - a:text_edit.range.start.line) + 1 + + if a:text_edit.range.end.line < a:position.line + let a:position.line += l:lines_len - l:range_len + elseif a:text_edit.range.end.line == a:position.line && a:text_edit.range.end.character <= a:position.character + let a:position.line += l:lines_len - l:range_len + let a:position.character = strchars(a:lines[-1]) + (a:position.character - a:text_edit.range.end.character) + if l:lines_len == 1 + let a:position.character += a:text_edit.range.start.character + endif + endif +endfunction + +" +" _normalize +" +function! s:_normalize(bufnr, text_edits) abort + let l:text_edits = type(a:text_edits) == type([]) ? a:text_edits : [a:text_edits] + let l:text_edits = s:_range(l:text_edits) + let l:text_edits = sort(l:text_edits, function('s:_compare')) + let l:text_edits = reverse(l:text_edits) + return s:_fix_text_edits(a:bufnr, l:text_edits) +endfunction + +" +" _range +" +function! s:_range(text_edits) abort + let l:text_edits = [] + for l:text_edit in a:text_edits + if type(l:text_edit) != type({}) + continue + endif + if l:text_edit.range.start.line > l:text_edit.range.end.line || ( + \ l:text_edit.range.start.line == l:text_edit.range.end.line && + \ l:text_edit.range.start.character > l:text_edit.range.end.character + \ ) + let l:text_edit.range = { 'start': l:text_edit.range.end, 'end': l:text_edit.range.start } + endif + let l:text_edits += [l:text_edit] + endfor + return l:text_edits +endfunction + +" +" _compare +" +function! s:_compare(text_edit1, text_edit2) abort + let l:diff = a:text_edit1.range.start.line - a:text_edit2.range.start.line + if l:diff == 0 + return a:text_edit1.range.start.character - a:text_edit2.range.start.character + endif + return l:diff +endfunction + +" +" _fix_text_edits +" +function! s:_fix_text_edits(bufnr, text_edits) abort + let l:max = s:Buffer.get_line_count(a:bufnr) + + let l:fixeol = v:false + let l:text_edits = [] + for l:text_edit in a:text_edits + if l:max <= l:text_edit.range.start.line + let l:text_edit.range.start.line = l:max - 1 + let l:text_edit.range.start.character = strchars(get(getbufline(a:bufnr, '$'), 0, '')) + let l:text_edit.newText = "\n" . l:text_edit.newText + let l:fixeol = &fixendofline && !&binary + endif + if l:max <= l:text_edit.range.end.line + let l:text_edit.range.end.line = l:max - 1 + let l:text_edit.range.end.character = strchars(get(getbufline(a:bufnr, '$'), 0, '')) + let l:fixeol = &fixendofline && !&binary + endif + call add(l:text_edits, l:text_edit) + endfor + + return [l:fixeol, l:text_edits] +endfunction + +" +" _switch +" +function! s:_switch(path) abort + let l:curr = bufnr('%') + let l:next = bufnr(a:path) + if l:next >= 0 + if l:curr != l:next + execute printf('noautocmd keepalt keepjumps %sbuffer!', bufnr(a:path)) + endif + else + execute printf('noautocmd keepalt keepjumps edit! %s', fnameescape(a:path)) + endif + return bufnr('%') +endfunction + diff --git a/autoload/vital/_cmp/VS/Vim/Buffer.vim b/autoload/vital/_cmp/VS/Vim/Buffer.vim new file mode 100644 index 0000000..f5ace6f --- /dev/null +++ b/autoload/vital/_cmp/VS/Vim/Buffer.vim @@ -0,0 +1,126 @@ +" ___vital___ +" NOTE: lines between '" ___vital___' is generated by :Vitalize. +" Do not modify the code nor insert new lines before '" ___vital___' +function! s:_SID() abort + return matchstr(expand(''), '\zs\d\+\ze__SID$') +endfunction +execute join(['function! vital#_cmp#VS#Vim#Buffer#import() abort', printf("return map({'get_line_count': '', 'do': '', 'create': '', 'pseudo': '', 'ensure': '', 'load': ''}, \"vital#_cmp#function('%s_' . v:key)\")", s:_SID()), 'endfunction'], "\n") +delfunction s:_SID +" ___vital___ +let s:Do = { -> {} } + +let g:___VS_Vim_Buffer_id = get(g:, '___VS_Vim_Buffer_id', 0) + +" +" get_line_count +" +if exists('*nvim_buf_line_count') + function! s:get_line_count(bufnr) abort + return nvim_buf_line_count(a:bufnr) + endfunction +elseif has('patch-8.2.0019') + function! s:get_line_count(bufnr) abort + return getbufinfo(a:bufnr)[0].linecount + endfunction +else + function! s:get_line_count(bufnr) abort + if bufnr('%') == bufnr(a:bufnr) + return line('$') + endif + return len(getbufline(a:bufnr, '^', '$')) + endfunction +endif + +" +" create +" +function! s:create(...) abort + let g:___VS_Vim_Buffer_id += 1 + let l:bufname = printf('VS.Vim.Buffer: %s: %s', + \ g:___VS_Vim_Buffer_id, + \ get(a:000, 0, 'VS.Vim.Buffer.Default') + \ ) + return s:load(l:bufname) +endfunction + +" +" ensure +" +function! s:ensure(expr) abort + if !bufexists(a:expr) + if type(a:expr) == type(0) + throw printf('VS.Vim.Buffer: `%s` is not valid expr.', l:bufnr) + endif + badd `=a:expr` + endif + return bufnr(a:expr) +endfunction + +" +" load +" +if exists('*bufload') + function! s:load(expr) abort + let l:bufnr = s:ensure(a:expr) + if !bufloaded(l:bufnr) + call bufload(l:bufnr) + endif + return l:bufnr + endfunction +else + function! s:load(expr) abort + let l:curr_bufnr = bufnr('%') + try + let l:bufnr = s:ensure(a:expr) + execute printf('keepalt keepjumps silent %sbuffer', l:bufnr) + catch /.*/ + echomsg string({ 'exception': v:exception, 'throwpoint': v:throwpoint }) + finally + execute printf('noautocmd keepalt keepjumps silent %sbuffer', l:curr_bufnr) + endtry + return l:bufnr + endfunction +endif + +" +" do +" +function! s:do(bufnr, func) abort + let l:curr_bufnr = bufnr('%') + if l:curr_bufnr == a:bufnr + call a:func() + return + endif + + try + execute printf('noautocmd keepalt keepjumps silent %sbuffer', a:bufnr) + call a:func() + catch /.*/ + echomsg string({ 'exception': v:exception, 'throwpoint': v:throwpoint }) + finally + execute printf('noautocmd keepalt keepjumps silent %sbuffer', l:curr_bufnr) + endtry +endfunction + +" +" pseudo +" +function! s:pseudo(filepath) abort + if !filereadable(a:filepath) + throw printf('VS.Vim.Buffer: `%s` is not valid filepath.', a:filepath) + endif + + " create pseudo buffer + let l:bufname = printf('VSVimBufferPseudo://%s', a:filepath) + if bufexists(l:bufname) + return s:ensure(l:bufname) + endif + + let l:bufnr = s:ensure(l:bufname) + let l:group = printf('VS_Vim_Buffer_pseudo:%s', l:bufnr) + execute printf('augroup %s', l:group) + execute printf('autocmd BufReadCmd call setline(1, readfile(bufname("%")[20 : -1])) | try | filetype detect | catch /.*/ | endtry | augroup %s | autocmd! | augroup END', l:bufnr, l:group) + augroup END + return l:bufnr +endfunction + diff --git a/autoload/vital/_cmp/VS/Vim/Option.vim b/autoload/vital/_cmp/VS/Vim/Option.vim new file mode 100644 index 0000000..0435133 --- /dev/null +++ b/autoload/vital/_cmp/VS/Vim/Option.vim @@ -0,0 +1,21 @@ +" ___vital___ +" NOTE: lines between '" ___vital___' is generated by :Vitalize. +" Do not modify the code nor insert new lines before '" ___vital___' +function! s:_SID() abort + return matchstr(expand(''), '\zs\d\+\ze__SID$') +endfunction +execute join(['function! vital#_cmp#VS#Vim#Option#import() abort', printf("return map({'define': ''}, \"vital#_cmp#function('%s_' . v:key)\")", s:_SID()), 'endfunction'], "\n") +delfunction s:_SID +" ___vital___ +" +" define +" +function! s:define(map) abort + let l:old = {} + for [l:key, l:value] in items(a:map) + let l:old[l:key] = eval(printf('&%s', l:key)) + execute printf('let &%s = "%s"', l:key, l:value) + endfor + return { -> s:define(l:old) } +endfunction + diff --git a/autoload/vital/cmp.vim b/autoload/vital/cmp.vim new file mode 100644 index 0000000..6730f4e --- /dev/null +++ b/autoload/vital/cmp.vim @@ -0,0 +1,330 @@ +let s:plugin_name = expand(':t:r') +let s:vital_base_dir = expand(':h') +let s:project_root = expand(':h:h:h') +let s:is_vital_vim = s:plugin_name is# 'vital' + +let s:loaded = {} +let s:cache_sid = {} + +function! vital#{s:plugin_name}#new() abort + return s:new(s:plugin_name) +endfunction + +function! vital#{s:plugin_name}#import(...) abort + if !exists('s:V') + let s:V = s:new(s:plugin_name) + endif + return call(s:V.import, a:000, s:V) +endfunction + +let s:Vital = {} + +function! s:new(plugin_name) abort + let base = deepcopy(s:Vital) + let base._plugin_name = a:plugin_name + return base +endfunction + +function! s:vital_files() abort + if !exists('s:vital_files') + let s:vital_files = map( + \ s:is_vital_vim ? s:_global_vital_files() : s:_self_vital_files(), + \ 'fnamemodify(v:val, ":p:gs?[\\\\/]?/?")') + endif + return copy(s:vital_files) +endfunction +let s:Vital.vital_files = function('s:vital_files') + +function! s:import(name, ...) abort dict + let target = {} + let functions = [] + for a in a:000 + if type(a) == type({}) + let target = a + elseif type(a) == type([]) + let functions = a + endif + unlet a + endfor + let module = self._import(a:name) + if empty(functions) + call extend(target, module, 'keep') + else + for f in functions + if has_key(module, f) && !has_key(target, f) + let target[f] = module[f] + endif + endfor + endif + return target +endfunction +let s:Vital.import = function('s:import') + +function! s:load(...) abort dict + for arg in a:000 + let [name; as] = type(arg) == type([]) ? arg[: 1] : [arg, arg] + let target = split(join(as, ''), '\W\+') + let dict = self + let dict_type = type({}) + while !empty(target) + let ns = remove(target, 0) + if !has_key(dict, ns) + let dict[ns] = {} + endif + if type(dict[ns]) == dict_type + let dict = dict[ns] + else + unlet dict + break + endif + endwhile + if exists('dict') + call extend(dict, self._import(name)) + endif + unlet arg + endfor + return self +endfunction +let s:Vital.load = function('s:load') + +function! s:unload() abort dict + let s:loaded = {} + let s:cache_sid = {} + unlet! s:vital_files +endfunction +let s:Vital.unload = function('s:unload') + +function! s:exists(name) abort dict + if a:name !~# '\v^\u\w*%(\.\u\w*)*$' + throw 'vital: Invalid module name: ' . a:name + endif + return s:_module_path(a:name) isnot# '' +endfunction +let s:Vital.exists = function('s:exists') + +function! s:search(pattern) abort dict + let paths = s:_extract_files(a:pattern, self.vital_files()) + let modules = sort(map(paths, 's:_file2module(v:val)')) + return uniq(modules) +endfunction +let s:Vital.search = function('s:search') + +function! s:plugin_name() abort dict + return self._plugin_name +endfunction +let s:Vital.plugin_name = function('s:plugin_name') + +function! s:_self_vital_files() abort + let builtin = printf('%s/__%s__/', s:vital_base_dir, s:plugin_name) + let installed = printf('%s/_%s/', s:vital_base_dir, s:plugin_name) + let base = builtin . ',' . installed + return split(globpath(base, '**/*.vim', 1), "\n") +endfunction + +function! s:_global_vital_files() abort + let pattern = 'autoload/vital/__*__/**/*.vim' + return split(globpath(&runtimepath, pattern, 1), "\n") +endfunction + +function! s:_extract_files(pattern, files) abort + let tr = {'.': '/', '*': '[^/]*', '**': '.*'} + let target = substitute(a:pattern, '\.\|\*\*\?', '\=tr[submatch(0)]', 'g') + let regexp = printf('autoload/vital/[^/]\+/%s.vim$', target) + return filter(a:files, 'v:val =~# regexp') +endfunction + +function! s:_file2module(file) abort + let filename = fnamemodify(a:file, ':p:gs?[\\/]?/?') + let tail = matchstr(filename, 'autoload/vital/_\w\+/\zs.*\ze\.vim$') + return join(split(tail, '[\\/]\+'), '.') +endfunction + +" @param {string} name e.g. Data.List +function! s:_import(name) abort dict + if has_key(s:loaded, a:name) + return copy(s:loaded[a:name]) + endif + let module = self._get_module(a:name) + if has_key(module, '_vital_created') + call module._vital_created(module) + endif + let export_module = filter(copy(module), 'v:key =~# "^\\a"') + " Cache module before calling module._vital_loaded() to avoid cyclic + " dependences but remove the cache if module._vital_loaded() fails. + " let s:loaded[a:name] = export_module + let s:loaded[a:name] = export_module + if has_key(module, '_vital_loaded') + try + call module._vital_loaded(vital#{s:plugin_name}#new()) + catch + unlet s:loaded[a:name] + throw 'vital: fail to call ._vital_loaded(): ' . v:exception . " from:\n" . s:_format_throwpoint(v:throwpoint) + endtry + endif + return copy(s:loaded[a:name]) +endfunction +let s:Vital._import = function('s:_import') + +function! s:_format_throwpoint(throwpoint) abort + let funcs = [] + let stack = matchstr(a:throwpoint, '^function \zs.*, .\{-} \d\+$') + for line in split(stack, '\.\.') + let m = matchlist(line, '^\(.\+\)\%(\[\(\d\+\)\]\|, .\{-} \(\d\+\)\)$') + if !empty(m) + let [name, lnum, lnum2] = m[1:3] + if empty(lnum) + let lnum = lnum2 + endif + let info = s:_get_func_info(name) + if !empty(info) + let attrs = empty(info.attrs) ? '' : join([''] + info.attrs) + let flnum = info.lnum == 0 ? '' : printf(' Line:%d', info.lnum + lnum) + call add(funcs, printf('function %s(...)%s Line:%d (%s%s)', + \ info.funcname, attrs, lnum, info.filename, flnum)) + continue + endif + endif + " fallback when function information cannot be detected + call add(funcs, line) + endfor + return join(funcs, "\n") +endfunction + +function! s:_get_func_info(name) abort + let name = a:name + if a:name =~# '^\d\+$' " is anonymous-function + let name = printf('{%s}', a:name) + elseif a:name =~# '^\d\+$' " is lambda-function + let name = printf("{'%s'}", a:name) + endif + if !exists('*' . name) + return {} + endif + let body = execute(printf('verbose function %s', name)) + let lines = split(body, "\n") + let signature = matchstr(lines[0], '^\s*\zs.*') + let [_, file, lnum; __] = matchlist(lines[1], + \ '^\t\%(Last set from\|.\{-}:\)\s*\zs\(.\{-}\)\%( \S\+ \(\d\+\)\)\?$') + return { + \ 'filename': substitute(file, '[/\\]\+', '/', 'g'), + \ 'lnum': 0 + lnum, + \ 'funcname': a:name, + \ 'arguments': split(matchstr(signature, '(\zs.*\ze)'), '\s*,\s*'), + \ 'attrs': filter(['dict', 'abort', 'range', 'closure'], 'signature =~# (").*" . v:val)'), + \ } +endfunction + +" s:_get_module() returns module object wihch has all script local functions. +function! s:_get_module(name) abort dict + let funcname = s:_import_func_name(self.plugin_name(), a:name) + try + return call(funcname, []) + catch /^Vim\%((\a\+)\)\?:E117:/ + return s:_get_builtin_module(a:name) + endtry +endfunction + +function! s:_get_builtin_module(name) abort + return s:sid2sfuncs(s:_module_sid(a:name)) +endfunction + +if s:is_vital_vim + " For vital.vim, we can use s:_get_builtin_module directly + let s:Vital._get_module = function('s:_get_builtin_module') +else + let s:Vital._get_module = function('s:_get_module') +endif + +function! s:_import_func_name(plugin_name, module_name) abort + return printf('vital#_%s#%s#import', a:plugin_name, s:_dot_to_sharp(a:module_name)) +endfunction + +function! s:_module_sid(name) abort + let path = s:_module_path(a:name) + if !filereadable(path) + throw 'vital: module not found: ' . a:name + endif + let vital_dir = s:is_vital_vim ? '__\w\+__' : printf('_\{1,2}%s\%%(__\)\?', s:plugin_name) + let base = join([vital_dir, ''], '[/\\]\+') + let p = base . substitute('' . a:name, '\.', '[/\\\\]\\+', 'g') + let sid = s:_sid(path, p) + if !sid + call s:_source(path) + let sid = s:_sid(path, p) + if !sid + throw printf('vital: cannot get from path: %s', path) + endif + endif + return sid +endfunction + +function! s:_module_path(name) abort + return get(s:_extract_files(a:name, s:vital_files()), 0, '') +endfunction + +function! s:_module_sid_base_dir() abort + return s:is_vital_vim ? &rtp : s:project_root +endfunction + +function! s:_dot_to_sharp(name) abort + return substitute(a:name, '\.', '#', 'g') +endfunction + +function! s:_source(path) abort + execute 'source' fnameescape(a:path) +endfunction + +" @vimlint(EVL102, 1, l:_) +" @vimlint(EVL102, 1, l:__) +function! s:_sid(path, filter_pattern) abort + let unified_path = s:_unify_path(a:path) + if has_key(s:cache_sid, unified_path) + return s:cache_sid[unified_path] + endif + for line in filter(split(execute(':scriptnames'), "\n"), 'v:val =~# a:filter_pattern') + let [_, sid, path; __] = matchlist(line, '^\s*\(\d\+\):\s\+\(.\+\)\s*$') + if s:_unify_path(path) is# unified_path + let s:cache_sid[unified_path] = sid + return s:cache_sid[unified_path] + endif + endfor + return 0 +endfunction + +if filereadable(expand(':r') . '.VIM') " is case-insensitive or not + let s:_unify_path_cache = {} + " resolve() is slow, so we cache results. + " Note: On windows, vim can't expand path names from 8.3 formats. + " So if getting full path via and $HOME was set as 8.3 format, + " vital load duplicated scripts. Below's :~ avoid this issue. + function! s:_unify_path(path) abort + if has_key(s:_unify_path_cache, a:path) + return s:_unify_path_cache[a:path] + endif + let value = tolower(fnamemodify(resolve(fnamemodify( + \ a:path, ':p')), ':~:gs?[\\/]?/?')) + let s:_unify_path_cache[a:path] = value + return value + endfunction +else + function! s:_unify_path(path) abort + return resolve(fnamemodify(a:path, ':p:gs?[\\/]?/?')) + endfunction +endif + +" copied and modified from Vim.ScriptLocal +let s:SNR = join(map(range(len("\")), '"[\\x" . printf("%0x", char2nr("\"[v:val])) . "]"'), '') +function! s:sid2sfuncs(sid) abort + let fs = split(execute(printf(':function /^%s%s_', s:SNR, a:sid)), "\n") + let r = {} + let pattern = printf('\m^function\s%d_\zs\w\{-}\ze(', a:sid) + for fname in map(fs, 'matchstr(v:val, pattern)') + let r[fname] = function(s:_sfuncname(a:sid, fname)) + endfor + return r +endfunction + +"" Return funcname of script local functions with SID +function! s:_sfuncname(sid, funcname) abort + return printf('%s_%s', a:sid, a:funcname) +endfunction diff --git a/autoload/vital/cmp.vital b/autoload/vital/cmp.vital new file mode 100644 index 0000000..42822c4 --- /dev/null +++ b/autoload/vital/cmp.vital @@ -0,0 +1,5 @@ +cmp +5828301d6bae0858e9ea21012913544f5ef8e375 + +VS.LSP.CompletionItem +VS.LSP.Position diff --git a/init.sh b/init.sh new file mode 100755 index 0000000..ea6b543 --- /dev/null +++ b/init.sh @@ -0,0 +1,7 @@ +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +rm $DIR/.git/hooks/* +cp $DIR/.githooks/* $DIR/.git/hooks/ +chmod 755 $DIR/.git/hooks/* + + diff --git a/lua/cmp/autocmd.lua b/lua/cmp/autocmd.lua new file mode 100644 index 0000000..3d37407 --- /dev/null +++ b/lua/cmp/autocmd.lua @@ -0,0 +1,34 @@ +local debug = require('cmp.utils.debug') + +local autocmd = {} + +autocmd.events = {} + +---Subscribe autocmd +---@param event string +---@param callback function +---@return function +autocmd.subscribe = function(event, callback) + autocmd.events[event] = autocmd.events[event] or {} + table.insert(autocmd.events[event], callback) + return function() + for i, callback_ in ipairs(autocmd.events[event]) do + if callback_ == callback then + table.remove(autocmd.events[event], i) + break + end + end + end +end + +---Emit autocmd +---@param event string +autocmd.emit = function(event) + debug.log(string.format('>>> %s', event)) + autocmd.events[event] = autocmd.events[event] or {} + for _, callback in ipairs(autocmd.events[event]) do + callback() + end +end + +return autocmd diff --git a/lua/cmp/config.lua b/lua/cmp/config.lua new file mode 100644 index 0000000..80ff750 --- /dev/null +++ b/lua/cmp/config.lua @@ -0,0 +1,63 @@ +local cache = require('cmp.utils.cache') +local misc = require('cmp.utils.misc') + +---@class cmp.Config +---@field public g cmp.ConfigSchema +local config = {} + +---@type cmp.Cache +config.cache = cache.new() + +---@type cmp.ConfigSchema +config.global = require('cmp.config.default')() + +---@type table +config.buffers = {} + +---Set configuration for global. +---@param c cmp.ConfigSchema +config.set_global = function(c) + config.global = misc.merge(c, config.global) + config.global.revision = config.global.revision or 1 + config.global.revision = config.global.revision + 1 +end + +---Set configuration for buffer +---@param c cmp.ConfigSchema +---@param bufnr number|nil +config.set_buffer = function(c, bufnr) + config.buffers[bufnr] = c + config.buffers[bufnr].revision = config.buffers[bufnr].revision or 1 + config.buffers[bufnr].revision = config.buffers[bufnr].revision + 1 +end + +---@return cmp.ConfigSchema +config.get = function() + local global = config.global + local buffer = config.buffers[vim.api.nvim_get_current_buf()] or { revision = 1 } + return config.cache:ensure({ 'get', global.revision or 0, buffer.revision or 0 }, function() + return misc.merge(buffer, global) + end) +end + +---Return source option +---@param name string +---@return table +config.get_source_option = function(name) + local global = config.global + local buffer = config.buffers[vim.api.nvim_get_current_buf()] or { revision = 1 } + return config.cache:ensure({ 'get_source_config', global.revision or 0, buffer.revision or 0, name }, function() + local c = config.get() + for _, s in ipairs(c.sources) do + if s.name == name then + if type(s.opts) == 'table' then + return s.opts + end + return {} + end + end + return nil + end) +end + +return config diff --git a/lua/cmp/config/compare.lua b/lua/cmp/config/compare.lua new file mode 100644 index 0000000..73e4730 --- /dev/null +++ b/lua/cmp/config/compare.lua @@ -0,0 +1,88 @@ +local types = require'cmp.types' +local misc = require 'cmp.utils.misc' + +local compare = {} + +-- offset +compare.offset = function(entry1, entry2) + local diff = entry1:get_offset() - entry2:get_offset() + if diff < 0 then + return true + elseif diff > 0 then + return false + end +end + +-- exact +compare.exact = function(entry1, entry2) + if entry1.exact ~= entry2.exact then + return entry1.exact + end +end + +-- score +compare.score = function(entry1, entry2) + local diff = entry2.score - entry1.score + if diff < 0 then + return true + elseif diff > 0 then + return false + end +end + +-- kind +compare.kind = function(entry1, entry2) + local kind1 = entry1:get_kind() + kind1 = kind1 == types.lsp.CompletionItemKind.Text and 100 or kind1 + local kind2 = entry2:get_kind() + kind2 = kind2 == types.lsp.CompletionItemKind.Text and 100 or kind2 + if kind1 ~= kind2 then + if kind1 == types.lsp.CompletionItemKind.Snippet then + return true + end + if kind2 == types.lsp.CompletionItemKind.Snippet then + return false + end + local diff = kind1 - kind2 + if diff < 0 then + return true + elseif diff > 0 then + return false + end + end +end + +-- sortText +compare.sort_text = function(entry1, entry2) + if misc.safe(entry1.completion_item.sortText) and misc.safe(entry2.completion_item.sortText) then + local diff = vim.stricmp(entry1.completion_item.sortText, entry2.completion_item.sortText) + if diff < 0 then + return true + elseif diff > 0 then + return false + end + end +end + +-- length +compare.length = function(entry1, entry2) + local diff = #entry1.completion_item.label - #entry2.completion_item.label + if diff < 0 then + return true + elseif diff > 0 then + return false + end +end + +-- order +compare.order = function(entry1, entry2) + local diff = entry1.id - entry2.id + if diff < 0 then + return true + elseif diff > 0 then + return false + end +end + +return compare + diff --git a/lua/cmp/config/default.lua b/lua/cmp/config/default.lua new file mode 100644 index 0000000..4db1b5d --- /dev/null +++ b/lua/cmp/config/default.lua @@ -0,0 +1,119 @@ +local str = require('cmp.utils.str') +local misc = require('cmp.utils.misc') +local compare = require('cmp.config.compare') +local types = require('cmp.types') + +local WIDE_HEIGHT = 40 + +---@return cmp.ConfigSchema +return function() + return { + completion = { + autocomplete = { + types.cmp.TriggerEvent.InsertEnter, + types.cmp.TriggerEvent.TextChanged, + }, + completeopt = 'menu,menuone,noselect', + keyword_pattern = [[\%(-\?\d\+\%(\.\d\+\)\?\|\h\w*\%(-\w*\)*\)]], + keyword_length = 1, + }, + + snippet = { + expand = function() + error('snippet engine does not configured.') + end, + }, + + documentation = { + border = { '', '', '', ' ', '', '', '', ' ' }, + winhighlight = 'NormalFloat:CmpDocumentation,FloatBorder:CmpDocumentationBorder', + maxwidth = math.floor((WIDE_HEIGHT * 2) * (vim.o.columns / (WIDE_HEIGHT * 2 * 16 / 9))), + maxheight = math.floor(WIDE_HEIGHT * (WIDE_HEIGHT / vim.o.lines)), + }, + + confirmation = { + default_behavior = types.cmp.ConfirmBehavior.Replace, + mapping = { + [''] = { + behavior = types.cmp.ConfirmBehavior.Replace, + select = true, + }, + } + }, + + sorting = { + sort = function(entries) + table.sort(entries, function(entry1, entry2) + for _, fn in ipairs({ + compare.offset, + compare.exact, + compare.score, + compare.kind, + compare.sort_text, + compare.length, + compare.order, + }) do + local diff = fn(entry1, entry2) + if diff ~= nil then + return diff + end + end + return true + end) + return entries + end + }, + + formatting = { + format = function(e, suggest_offset) + local item = e:get_completion_item() + local word = e:get_word() + local abbr = str.trim(item.label) + + -- ~ indicator + if #(misc.safe(item.additionalTextEdits) or {}) > 0 then + abbr = abbr .. '~' + elseif item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then + local insert_text = e:get_insert_text() + if word ~= insert_text then + abbr = abbr .. '~' + end + end + + -- deprecated + if item.deprecated or vim.tbl_contains(item.tags or {}, types.lsp.CompletionItemTag.Deprecated) then + abbr = str.strikethrough(abbr) + end + + -- append delta text + if suggest_offset < e:get_offset() then + word = string.sub(e.context.cursor_before_line, suggest_offset, e:get_offset() - 1) .. word + end + + -- labelDetails. + local menu = nil + if misc.safe(item.labelDetails) then + menu = '' + if misc.safe(item.labelDetails.parameters) then + menu = menu .. item.labelDetails.parameters + end + if misc.safe(item.labelDetails.type) then + menu = menu .. item.labelDetails.type + end + if misc.safe(item.labelDetails.qualifier) then + menu = menu .. item.labelDetails.qualifier + end + end + + return { + word = word, + abbr = abbr, + kind = types.lsp.CompletionItemKind[e:get_kind()] or types.lsp.CompletionItemKind[1], + menu = menu, + } + end + }, + + sources = {}, + } +end diff --git a/lua/cmp/context.lua b/lua/cmp/context.lua new file mode 100644 index 0000000..f0adb1f --- /dev/null +++ b/lua/cmp/context.lua @@ -0,0 +1,142 @@ +local misc = require('cmp.utils.misc') +local pattern = require('cmp.utils.pattern') +local types = require('cmp.types') +local cache = require('cmp.utils.cache') + +---@class cmp.Context +---@field public id string +---@field public cache cmp.Cache +---@field public prev_context cmp.Context +---@field public option cmp.ContextOption +---@field public pumvisible boolean +---@field public pumselect boolean +---@field public filetype string +---@field public time number +---@field public mode string +---@field public bufnr number +---@field public cursor vim.Position +---@field public cursor_line string +---@field public cursor_after_line string +---@field public cursor_before_line string +---@field public before_char string +local context = {} + +---Create new empty context +---@return cmp.Context +context.empty = function() + local ctx = context.new({}) -- dirty hack to prevent recursive call `context.empty`. + ctx.bufnr = -1 + ctx.input = '' + ctx.cursor = {} + ctx.cursor.row = -1 + ctx.cursor.col = -1 + return ctx +end + +---Create new context +---@param prev_context cmp.Context +---@param option cmp.ContextOption +---@return cmp.Context +context.new = function(prev_context, option) + option = option or {} + + local self = setmetatable({}, { __index = context }) + local completeinfo = vim.fn.complete_info({ 'selected', 'mode', 'pum_visible' }) + self.id = misc.id('context') + self.cache = cache.new() + self.prev_context = prev_context or context.empty() + self.option = option or { reason = types.cmp.ContextReason.None } + self.pumvisible = completeinfo.pum_visible ~= 0 + self.pumselect = completeinfo.selected ~= -1 + self.filetype = vim.api.nvim_buf_get_option(0, 'filetype') + self.time = vim.loop.now() + self.mode = vim.api.nvim_get_mode().mode + self.bufnr = vim.api.nvim_get_current_buf() + self.cursor = {} + self.cursor.row = vim.api.nvim_win_get_cursor(0)[1] + self.cursor.col = vim.api.nvim_win_get_cursor(0)[2] + 1 + self.cursor_line = vim.api.nvim_get_current_line() + self.cursor_before_line = string.sub(self.cursor_line, 1, self.cursor.col - 1) + self.cursor_after_line = string.sub(self.cursor_line, self.cursor.col) + self.before_char = string.sub(self.cursor_line, self.cursor.col - 1, self.cursor.col - 1) + return self +end + +---Return context creation reason. +---@return cmp.ContextReason +context.get_reason = function(self) + return self.option.reason +end + +---Get keyword pattern offset +---@return number|nil +context.get_offset = function(self, keyword_pattern) + return self.cache:ensure({ 'get_offset', keyword_pattern, self.cursor_before_line }, function() + return pattern.offset(keyword_pattern .. '$', self.cursor_before_line) or self.cursor.col + end) +end + +---if cursor moves from left to right. +---@param self cmp.Context +context.is_forwarding = function(self) + local prev = self.prev_context + local curr = self + + return prev.bufnr == curr.bufnr and prev.cursor.row == curr.cursor.row and prev.cursor.col < curr.cursor.col +end + +---Return if this context is continueing previous context. +context.continue = function(self, offset) + local prev = self.prev_context + local curr = self + + if curr.bufnr ~= prev.bufnr then + return false + end + if curr.cursor.row ~= prev.cursor.row then + return false + end + if curr.cursor.col < offset then + return false + end + return true +end + +---Return if this context is changed from previous context or not. +---@return boolean +context.changed = function(self, ctx) + local curr = self + + if self.pumvisible then + local completed_item = vim.v.completed_item or {} + if completed_item.word then + return false + end + end + + if curr.bufnr ~= ctx.bufnr then + return true + end + if curr.cursor.row ~= ctx.cursor.row then + return true + end + if curr.cursor.col ~= ctx.cursor.col then + return true + end + if curr:get_reason() == types.cmp.ContextReason.Manual then + return true + end + + return false +end + +---Shallow clone +context.clone = function(self) + local cloned = {} + for k, v in pairs(self) do + cloned[k] = v + end + return cloned +end + +return context diff --git a/lua/cmp/context_spec.lua b/lua/cmp/context_spec.lua new file mode 100644 index 0000000..976e194 --- /dev/null +++ b/lua/cmp/context_spec.lua @@ -0,0 +1,31 @@ +local spec = require('cmp.utils.spec') + +local context = require('cmp.context') + +describe('context', function() + before_each(spec.before) + + describe('new', function() + it('middle of text', function() + vim.fn.setline('1', 'function! s:name() abort') + vim.bo.filetype = 'vim' + vim.fn.execute('normal! fm') + local ctx = context.new() + assert.are.equal(ctx.filetype, 'vim') + assert.are.equal(ctx.cursor.row, 1) + assert.are.equal(ctx.cursor.col, 15) + assert.are.equal(ctx.cursor_line, 'function! s:name() abort') + end) + + it('tab indent', function() + vim.fn.setline('1', '\t\tab') + vim.bo.filetype = 'vim' + vim.fn.execute('normal! fb') + local ctx = context.new() + assert.are.equal(ctx.filetype, 'vim') + assert.are.equal(ctx.cursor.row, 1) + assert.are.equal(ctx.cursor.col, 4) + assert.are.equal(ctx.cursor_line, '\t\tab') + end) + end) +end) diff --git a/lua/cmp/core.lua b/lua/cmp/core.lua new file mode 100644 index 0000000..c4cd26d --- /dev/null +++ b/lua/cmp/core.lua @@ -0,0 +1,255 @@ +local debug = require('cmp.utils.debug') +local char = require('cmp.utils.char') +local async = require('cmp.utils.async') +local keymap = require('cmp.utils.keymap') +local context = require('cmp.context') +local source = require('cmp.source') +local menu = require('cmp.menu') +local misc = require('cmp.utils.misc') +local config = require('cmp.config') +local types = require('cmp.types') +local patch = require('cmp.utils.patch') + +local core = {} + +core.SOURCE_TIMEOUT = 500 + +---@type cmp.Menu +core.menu = menu.new() + +---@type table +core.sources = {} + +---@type cmp.Context +core.context = context.new() + +---Register source +---@param s cmp.Source +core.register_source = function(s) + core.sources[s.id] = s +end + +---Unregister source +---@param source_id string +core.unregister_source = function(source_id) + core.sources[source_id] = nil +end + +---Get new context +---@param option cmp.ContextOption +---@return cmp.Context +core.get_context = function(option) + local prev = core.context:clone() + prev.prev_context = nil + core.context = context.new(prev, option) + return core.context +end + +---Get sources that sorted by priority +---@param statuses cmp.SourceStatus[] +---@return cmp.Source[] +core.get_sources = function(statuses) + local sources = {} + for _, c in pairs(config.get().sources) do + for _, s in pairs(core.sources) do + if c.name == s.name then + if not statuses or vim.tbl_contains(statuses, s.status) then + table.insert(sources, s) + end + end + end + end + return sources +end + +---Keypress handler +core.on_keymap = function(keys, fallback) + -- Confirm character + if config.get().confirmation.mapping[keys] then + local c = config.get().confirmation.mapping[keys] + local e = core.menu:get_selected_entry() or (c.select and core.menu:get_first_entry()) + if not e then + return fallback() + end + return core.confirm(e, { + behavior = c.behavior, + }) + end + + --Commit character. NOTE: This has a lot of cmp specific implementation to make more user-friendly. + local chars = keymap.t(keys) + local e = core.menu:get_selected_entry() + if e and vim.tbl_contains(e:get_commit_characters(), chars) then + local is_printable = char.is_printable(string.byte(chars, 1)) + return core.confirm(e, { + behavior = is_printable and 'insert' or 'replace', + }, function() + local ctx = core.get_context() + local word = e:get_word() + if string.sub(ctx.cursor_before_line, -#word, ctx.cursor.col - 1) == word and is_printable then + fallback() + end + end) + end + + fallback() +end + +---Prepare completion +core.prepare = function() + for keys in pairs(config.get().confirmation.mapping) do + keymap.listen(keys, core.on_keymap) + end +end + +---Check auto-completion +core.autocomplete = function(event) + local ctx = core.get_context({ reason = types.cmp.ContextReason.Auto }) + + -- Skip autocompletion when the item is selected manually. + if ctx.pumvisible and not vim.tbl_isempty(vim.v.completed_item) then + return + end + + debug.log(('ctx: `%s`'):format(ctx.cursor_before_line)) + if ctx:is_forwarding() then + debug.log('changed') + core.menu:restore(ctx) + + if vim.tbl_contains(config.get().completion.autocomplete, event) then + core.complete(ctx) + else + core.filter.timeout = 50 + core.filter() + end + else + debug.log('unchanged') + end +end + +---Invoke completion +---@param ctx cmp.Context +core.complete = function(ctx) + for _, s in ipairs(core.get_sources({ source.SourceStatus.WAITING, source.SourceStatus.COMPLETED })) do + s:complete(ctx, function() + local new = context.new(ctx) + if new:changed(new.prev_context) then + core.complete(new) + else + core.filter.timeout = 50 + core.filter() + end + end) + end + + core.filter.timeout = ctx.pumvisible and 50 or 0 + core.filter() +end + +---Update completion menu +core.filter = async.throttle(function() + local ctx = core.get_context() + + -- To wait for processing source for that's timeout. + for _, s in ipairs(core.get_sources({ source.SourceStatus.FETCHING })) do + local time = core.SOURCE_TIMEOUT - s:get_fetching_time() + if time > 0 then + core.filter.stop() + core.filter.timeout = time + 1 + core.filter() + return + end + end + + core.menu:update(ctx, core.get_sources()) +end, 50) + +---Confirm completion. +---@param e cmp.Entry +---@param option cmp.ConfirmOption +---@param callback function +core.confirm = vim.schedule_wrap(function(e, option, callback) + if not (e and not e.confirmed) then + return + end + e.confirmed = true + + debug.log('entry.confirm', e:get_completion_item()) + + --@see https://github.com/microsoft/vscode/blob/main/src/vs/editor/contrib/suggest/suggestController.ts#L334 + local pre = context.new() + if #(misc.safe(e:get_completion_item().additionalTextEdits) or {}) == 0 then + local new = context.new(pre) + e:resolve(function() + local text_edits = misc.safe(e:get_completion_item().additionalTextEdits) or {} + if #text_edits == 0 then + return + end + + local has_cursor_line_text_edit = (function() + local minrow = math.min(pre.cursor.row, new.cursor.row) + local maxrow = math.max(pre.cursor.row, new.cursor.row) + for _, text_edit in ipairs(text_edits) do + local srow = text_edit.range.start.line + 1 + local erow = text_edit.range['end'].line + 1 + if srow <= minrow and maxrow <= erow then + return true + end + end + return false + end)() + if has_cursor_line_text_edit then + return + end + + vim.fn['cmp#apply_text_edits'](new.bufnr, text_edits) + end) + end + + -- Prepare completion item for confirmation + local completion_item = misc.copy(e:get_completion_item()) + if not misc.safe(completion_item.textEdit) then + completion_item.textEdit = {} + completion_item.textEdit.newText = misc.safe(completion_item.insertText) or completion_item.label + end + local behavior = option.behavior or config.get().confirmation.default_behavior + if behavior == types.cmp.ConfirmBehavior.Replace then + completion_item.textEdit.range = e:get_replace_range() + else + completion_item.textEdit.range = e:get_insert_range() + end + + -- First, emulates vim's `` behavior and then confirms LSP functionalities. + patch.apply( + pre, + completion_item.textEdit.range, + e:get_word(), + vim.schedule_wrap(function() + vim.fn['cmp#confirm']({ + request_offset = e.context.cursor.col, + suggest_offset = e:get_offset(), + completion_item = completion_item, + }) + + -- execute + e:execute(function() + core.reset() + if callback then + callback() + end + end) + end) + ) +end) + +---Reset current completion state +core.reset = function() + for _, s in pairs(core.sources) do + s:reset() + end + core.menu:reset() + + core.get_context() -- To prevent new event +end + +return core diff --git a/lua/cmp/entry.lua b/lua/cmp/entry.lua new file mode 100644 index 0000000..8b21a38 --- /dev/null +++ b/lua/cmp/entry.lua @@ -0,0 +1,321 @@ +local cache = require('cmp.utils.cache') +local char = require('cmp.utils.char') +local misc = require('cmp.utils.misc') +local str = require('cmp.utils.str') +local config = require('cmp.config') +local types = require('cmp.types') + +---@class cmp.Entry +---@field public id number +---@field public cache cmp.Cache +---@field public score number +---@field public exact boolean +---@field public context cmp.Context +---@field public source cmp.Source +---@field public source_offset number +---@field public source_insert_range lsp.Range +---@field public source_replace_range lsp.Range +---@field public completion_item lsp.CompletionItem +---@field public resolved_completion_item lsp.CompletionItem|nil +---@field public resolved_callbacks fun()[] +---@field public resolving boolean +---@field public confirmed boolean +local entry = {} + +---Create new entry +---@param ctx cmp.Context +---@param source cmp.Source +---@param completion_item lsp.CompletionItem +---@return cmp.Entry +entry.new = function(ctx, source, completion_item) + local self = setmetatable({}, { __index = entry }) + self.id = misc.id('entry') + self.cache = cache.new() + self.score = 0 + self.context = ctx + self.source = source + self.source_offset = source.offset + self.source_insert_range = source:get_default_insert_range() + self.source_replace_range = source:get_default_replace_range() + self.completion_item = completion_item + self.resolved_completion_item = nil + self.resolved_callbacks = {} + self.resolving = false + self.confirmed = false + return self +end + +---Make offset value +---@return number +entry.get_offset = function(self) + return self.cache:ensure('get_offset', function() + local offset = self.source_offset + if misc.safe(self.completion_item.textEdit) then + local range = misc.safe(self.completion_item.textEdit.insert) or misc.safe(self.completion_item.textEdit.range) + if range then + local c = vim.str_byteindex(self.context.cursor_line, range.start.character) + 1 + for idx = c, self.source_offset do + if not char.is_white(string.byte(self.context.cursor_line, idx)) then + offset = math.min(offset, idx) + break + end + end + end + else + -- NOTE + -- The VSCode does not implement this but it's useful if the server does not care about word patterns. + -- We should care about this performance. + local word = self:get_word() + for idx = self.source_offset - 1, self.source_offset - #word, -1 do + if char.is_semantic_index(self.context.cursor_line, idx) then + local c = string.byte(self.context.cursor_line, idx) + if char.is_white(c) then + break + end + local match = true + for i = 1, self.source_offset - idx do + local c1 = string.byte(word, i) + local c2 = string.byte(self.context.cursor_line, idx + i - 1) + if not c1 or not c2 or c1 ~= c2 then + match = false + break + end + end + if match then + offset = math.min(offset, idx) + end + end + end + end + return offset + end) +end + +---Create word for vim.CompletedItem +---@return string +entry.get_word = function(self) + return self.cache:ensure('get_word', function() + --NOTE: This is nvim-cmp specific implementation. + if misc.safe(self.completion_item.word) then + return self.completion_item.word + end + + local word + if misc.safe(self.completion_item.textEdit) then + word = str.trim(self.completion_item.textEdit.newText) + local _, after = self:get_overwrite() + if 0 < after or self.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then + word = str.get_word(word, string.byte(self.context.cursor_after_line, 1)) + end + elseif misc.safe(self.completion_item.insertText) then + word = str.trim(self.completion_item.insertText) + if self.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then + word = str.get_word(word) + end + else + word = str.trim(self.completion_item.label) + end + return word + end) +end + +---Get overwrite information +---@return number, number +entry.get_overwrite = function(self) + return self.cache:ensure('get_overwrite', function() + if misc.safe(self.completion_item.textEdit) then + local r = misc.safe(self.completion_item.textEdit.insert) or misc.safe(self.completion_item.textEdit.range) + local s = vim.str_byteindex(self.context.cursor_line, r.start.character) + 1 + local e = vim.str_byteindex(self.context.cursor_line, r['end'].character) + 1 + local before = self.context.cursor.col - s + local after = e - self.context.cursor.col + return before, after + end + return 0, 0 + end) +end + +---Create filter text +---@return string +entry.get_filter_text = function(self) + return self.cache:ensure('get_filter_text', function() + local word + if misc.safe(self.completion_item.filterText) then + word = self.completion_item.filterText + else + word = str.trim(self.completion_item.label) + end + + -- @see https://github.com/clangd/clangd/issues/815 + if misc.safe(self.completion_item.textEdit) then + local diff = self.source_offset - self:get_offset() + if diff > 0 then + if char.is_symbol(string.byte(self.context.cursor_line, self:get_offset())) then + local prefix = string.sub(self.context.cursor_line, self:get_offset(), self:get_offset() + diff) + if string.find(word, prefix, 1, true) ~= 1 then + word = prefix .. word + end + end + end + end + + return word + end) +end + +---Get LSP's insert text +---@return string +entry.get_insert_text = function(self) + return self.cache:ensure('get_insert_text', function() + local word + if misc.safe(self.completion_item.textEdit) then + word = str.trim(self.completion_item.textEdit.newText) + if self.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then + word = str.remove_suffix(str.remove_suffix(word, '$0'), '${0}') + end + elseif misc.safe(self.completion_item.insertText) then + word = str.trim(self.completion_item.insertText) + if self.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then + word = str.remove_suffix(str.remove_suffix(word, '$0'), '${0}') + end + else + word = str.trim(self.completion_item.label) + end + return word + end) +end + +---Make vim.CompletedItem +---@param suggeset_offset number +---@return vim.CompletedItem +entry.get_vim_item = function(self, suggeset_offset) + return self.cache:ensure({ 'get_vim_item', suggeset_offset }, function() + local item = config.get().formatting.format(self, suggeset_offset) + item.equal = 1 + item.empty = 1 + item.dup = self.completion_item.dup or 1 + item.user_data = { cmp = self.id } + return item + end) +end + +---Get commit characters +---@return string[] +entry.get_commit_characters = function(self) + return misc.safe(self:get_completion_item().commitCharacters) or {} +end + +---Return insert range +---@return lsp.Range|nil +entry.get_insert_range = function(self) + local insert_range + if misc.safe(self.completion_item.textEdit) then + if misc.safe(self.completion_item.textEdit.insert) then + insert_range = self.completion_item.textEdit.insert + else + insert_range = self.completion_item.textEdit.range + end + else + insert_range = { + start = { + line = self.context.cursor.row - 1, + character = math.min(vim.str_utfindex(self.context.cursor_line, self:get_offset() - 1), self.source_insert_range.start.character), + }, + ['end'] = self.source_insert_range['end'], + } + end + return insert_range +end + +---Return replace range +---@return vim.Range|nil +entry.get_replace_range = function(self) + return self.cache:ensure('get_replace_range', function() + local replace_range + if misc.safe(self.completion_item.textEdit) then + if misc.safe(self.completion_item.textEdit.replace) then + replace_range = self.completion_item.textEdit.replace + else + replace_range = self.completion_item.textEdit.range + end + else + replace_range = { + start = { + line = self.source_replace_range.start.line, + character = math.min(vim.str_utfindex(self.context.cursor_line, self:get_offset() - 1), self.source_replace_range.start.character), + }, + ['end'] = self.source_replace_range['end'], + } + end + return replace_range + end) +end + +---Get resolved completion item if possible. +---@return lsp.CompletionItem +entry.get_completion_item = function(self) + if self.resolved_completion_item then + return self.resolved_completion_item + end + return self.completion_item +end + +---Create documentation +---@return string +entry.get_documentation = function(self) + local item = self:get_completion_item() + + local documents = {} + + -- detail + if misc.safe(item.detail) and item.detail ~= '' then + table.insert(documents, { + kind = types.lsp.MarkupKind.Markdown, + value = ('```%s\n%s\n```'):format(self.context.filetype, str.trim(item.detail)), + }) + end + + if type(item.documentation) == 'string' and item.documentation ~= '' then + table.insert(documents, { + kind = types.lsp.MarkupKind.PlainText, + value = str.trim(item.documentation), + }) + elseif type(item.documentation) == 'table' and item.documentation.value ~= '' then + table.insert(documents, item.documentation) + end + + return vim.lsp.util.convert_input_to_markdown_lines(documents) +end + +---Get completion item kind +---@return lsp.CompletionItemKind +entry.get_kind = function(self) + return misc.safe(self.completion_item.kind) or types.lsp.CompletionItemKind.Text +end + +---Execute completion item's command. +---@param callback fun() +entry.execute = function(self, callback) + self.source:execute(self:get_completion_item(), callback) +end + +---Resolve completion item. +---@param callback fun() +entry.resolve = function(self, callback) + if self.resolved_completion_item then + return callback() + end + table.insert(self.resolved_callbacks, callback) + + if not self.resolving then + self.resolving = true + self.source:resolve(self.completion_item, function(completion_item) + self.resolved_completion_item = misc.safe(completion_item) or self.completion_item + for _, c in ipairs(self.resolved_callbacks) do + c() + end + end) + end +end + +return entry diff --git a/lua/cmp/entry_spec.lua b/lua/cmp/entry_spec.lua new file mode 100644 index 0000000..01e8d5f --- /dev/null +++ b/lua/cmp/entry_spec.lua @@ -0,0 +1,253 @@ +local spec = require('cmp.utils.spec') + +local entry = require('cmp.entry') + +describe('entry', function() + before_each(spec.before) + + it('one char', function() + local state = spec.state('@.', 1, 3) + local e = entry.new(state.press('@'), state.source(), { + label = '@', + }) + assert.are.equal(e:get_offset(), 3) + assert.are.equal(e:get_vim_item(e:get_offset()).word, '@') + end) + + it('word length (no fix)', function() + local state = spec.state('a.b', 1, 4) + local e = entry.new(state.press('.'), state.source(), { + label = 'b', + }) + assert.are.equal(e:get_offset(), 5) + assert.are.equal(e:get_vim_item(e:get_offset()).word, 'b') + end) + + it('word length (fix)', function() + local state = spec.state('a.b', 1, 4) + local e = entry.new(state.press('.'), state.source(), { + label = 'b.', + }) + assert.are.equal(e:get_offset(), 3) + assert.are.equal(e:get_vim_item(e:get_offset()).word, 'b.') + end) + + it('semantic index (no fix)', function() + local state = spec.state('a.bc', 1, 5) + local e = entry.new(state.press('.'), state.source(), { + label = 'c.', + }) + assert.are.equal(e:get_offset(), 6) + assert.are.equal(e:get_vim_item(e:get_offset()).word, 'c.') + end) + + it('semantic index (fix)', function() + local state = spec.state('a.bc', 1, 5) + local e = entry.new(state.press('.'), state.source(), { + label = 'bc.', + }) + assert.are.equal(e:get_offset(), 3) + assert.are.equal(e:get_vim_item(e:get_offset()).word, 'bc.') + end) + + it('[vscode-html-language-server] 1', function() + local state = spec.state(' ', 1, 7) + local e = entry.new(state.press('.'), state.source(), { + label = '/div', + textEdit = { + range = { + start = { + line = 0, + character = 0, + }, + ['end'] = { + line = 0, + character = 6, + }, + }, + newText = ' foo') + assert.are.equal(e:get_filter_text(), '.foo') + end) + + it('[typescript-language-server] 1', function() + local state = spec.state('Promise.resolve()', 1, 18) + local e = entry.new(state.press('.'), state.source(), { + label = 'catch', + }) + -- The offset will be 18 in this situation because the server returns `[Symbol]` as candidate. + assert.are.equal(e:get_vim_item(18).word, '.catch') + assert.are.equal(e:get_filter_text(), 'catch') + end) + + it('[typescript-language-server] 2', function() + local state = spec.state('Promise.resolve()', 1, 18) + local e = entry.new(state.press('.'), state.source(), { + filterText = '.Symbol', + label = 'Symbol', + textEdit = { + newText = '[Symbol]', + range = { + ['end'] = { + character = 18, + line = 0, + }, + start = { + character = 17, + line = 0, + }, + }, + }, + }) + assert.are.equal(e:get_vim_item(18).word, '[Symbol]') + assert.are.equal(e:get_filter_text(), '.Symbol') + end) + + it('[lua-language-server] 1', function() + local state = spec.state("local m = require'cmp.confi", 1, 28) + local e + + -- press g + e = entry.new(state.press('g'), state.source(), { + insertTextFormat = 2, + label = 'cmp.config', + textEdit = { + newText = 'cmp.config', + range = { + ['end'] = { + character = 27, + line = 1, + }, + start = { + character = 18, + line = 1, + }, + }, + }, + }) + assert.are.equal(e:get_vim_item(19).word, 'cmp.config') + assert.are.equal(e:get_filter_text(), 'cmp.config') + + -- press ' + e = entry.new(state.press("'"), state.source(), { + insertTextFormat = 2, + label = 'cmp.config', + textEdit = { + newText = 'cmp.config', + range = { + ['end'] = { + character = 27, + line = 1, + }, + start = { + character = 18, + line = 1, + }, + }, + }, + }) + assert.are.equal(e:get_vim_item(19).word, 'cmp.config') + assert.are.equal(e:get_filter_text(), 'cmp.config') + end) + + it('[lua-language-server] 2', function() + local state = spec.state("local m = require'cmp.confi", 1, 28) + local e + + -- press g + e = entry.new(state.press('g'), state.source(), { + insertTextFormat = 2, + label = 'lua.cmp.config', + textEdit = { + newText = 'lua.cmp.config', + range = { + ['end'] = { + character = 27, + line = 1, + }, + start = { + character = 18, + line = 1, + }, + }, + }, + }) + assert.are.equal(e:get_vim_item(19).word, 'lua.cmp.config') + assert.are.equal(e:get_filter_text(), 'lua.cmp.config') + + -- press ' + e = entry.new(state.press("'"), state.source(), { + insertTextFormat = 2, + label = 'lua.cmp.config', + textEdit = { + newText = 'lua.cmp.config', + range = { + ['end'] = { + character = 27, + line = 1, + }, + start = { + character = 18, + line = 1, + }, + }, + }, + }) + assert.are.equal(e:get_vim_item(19).word, 'lua.cmp.config') + assert.are.equal(e:get_filter_text(), 'lua.cmp.config') + end) + + it('[intelephense] 1', function() + local state = spec.state('\t\t', 1, 4) + + -- press g + local e = entry.new(state.press('$'), state.source(), { + detail = '\\Nico_URLConf', + kind = 6, + label = '$this', + sortText = '$this', + textEdit = { + newText = '$this', + range = { + ['end'] = { + character = 3, + line = 1, + }, + start = { + character = 2, + line = 1, + }, + }, + }, + }) + assert.are.equal(e:get_vim_item(e:get_offset()).word, '$this') + assert.are.equal(e:get_filter_text(), '$this') + end) +end) diff --git a/lua/cmp/float.lua b/lua/cmp/float.lua new file mode 100644 index 0000000..8124078 --- /dev/null +++ b/lua/cmp/float.lua @@ -0,0 +1,112 @@ +local async = require('cmp.utils.async') +local config = require('cmp.config') + +---@class cmp.Float +---@field public entry cmp.Entry|nil +---@field public buf number|nil +---@field public win number|nil +local float = {} + +---Create new floating window module +float.new = function() + local self = setmetatable({}, { __index = float }) + self.entry = nil + self.win = nil + self.buf = nil + return self +end + +---Show floating window +---@param e cmp.Entry +float.show = function(self, e) + float.close.stop() + + local documentation = config.get().documentation + + -- update buffer content if needed. + if not self.entry or e.id ~= self.entry.id then + self.entry = e + self.buf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_option(self.buf, 'bufhidden', 'wipe') + + local documents = e:get_documentation() + if #documents == 0 then + return self:close() + end + vim.lsp.util.stylize_markdown(self.buf, documents, { + max_width = documentation.maxwidth, + max_height = documentation.maxheight, + }) + end + + local width, height = vim.lsp.util._make_floating_popup_size(vim.api.nvim_buf_get_lines(self.buf, 0, -1, false), { + max_width = documentation.maxwidth, + max_height = documentation.maxheight, + }) + if width <= 0 or height <= 0 then + return self:close() + end + + local pum = vim.fn.pum_getpos() or {} + if not pum.col then + return self:close() + end + + local right_col = pum.col + pum.width + (pum.scrollbar and 1 or 0) + local right_space = vim.o.columns - right_col - 1 + local left_col = pum.col - width - 3 -- TODO: Why is this needed -3? + local left_space = pum.col - 1 + + local col + if right_space >= width and left_space >= width then + if right_space < left_space then + col = left_col + else + col = right_col + end + elseif right_space >= width then + col = right_col + elseif left_space >= width then + col = left_col + else + return self:close() + end + + local style = { + relative = 'editor', + style = 'minimal', + width = width, + height = height, + row = pum.row, + col = col, + border = documentation.border, + } + + if self.win and vim.api.nvim_win_is_valid(self.win) then + vim.api.nvim_win_set_buf(self.win, self.buf) + vim.api.nvim_win_set_config(self.win, style) + else + self.win = vim.api.nvim_open_win(self.buf, false, style) + vim.api.nvim_win_set_option(self.win, 'conceallevel', 2) + vim.api.nvim_win_set_option(self.win, 'concealcursor', 'n') + vim.api.nvim_win_set_option(self.win, 'winhighlight', config.get().documentation.winhighlight) + vim.api.nvim_win_set_option(self.win, 'foldenable', false) + vim.api.nvim_win_set_option(self.win, 'wrap', true) + vim.api.nvim_win_set_option(self.win, 'scrolloff', 0) + end +end + +---Close floating window +float.close = async.throttle( + vim.schedule_wrap(function(self) + if self.win and vim.api.nvim_win_is_valid(self.win) then + vim.api.nvim_win_close(self.win, true) + end + self.entry = nil + self.buf = nil + self.win = nil + end), + 0 +) + +return float diff --git a/lua/cmp/init.lua b/lua/cmp/init.lua new file mode 100644 index 0000000..27ccfd4 --- /dev/null +++ b/lua/cmp/init.lua @@ -0,0 +1,79 @@ +local core = require('cmp.core') +local types = require('cmp.types') +local source = require('cmp.source') +local config = require('cmp.config') +local autocmd = require('cmp.autocmd') + +local cmp = {} + +---Expose types +for k, v in pairs(require('cmp.types.cmp')) do + cmp[k] = v +end +cmp.lsp = require('cmp.types.lsp') +cmp.vim = require('cmp.types.vim') + +---Register completion sources +---@param name string +---@param s cmp.Source +---@return number +cmp.register_source = function(name, s) + local src = source.new(name, s) + core.register_source(src) + return src.id +end + +---Unregister completion source +---@param id number +cmp.unregister_source = function(id) + core.unregister_source(id) +end + +---@type cmp.Setup +cmp.setup = setmetatable({ + global = function(c) + config.set_global(c) + end, + buffer = function(c) + config.set_buffer(c, vim.api.nvim_get_current_buf()) + end, +}, { + __call = function(self, c) + self.global(c) + end, +}) + +---Invoke completion manually +cmp.complete = function() + core.complete(core.get_context({ + reason = types.cmp.ContextReason.Manual, + })) +end + +---Close completion +cmp.close = function() + core.reset() +end + +---Internal expand snippet function. +---TODO: It should be removed when we remove `autoload/cmp.vim`. +---@param args cmp.SnippetExpansionParams +cmp._expand_snippet = function(args) + return config.get().snippet.expand(args) +end + +---Handle events +autocmd.subscribe('InsertEnter', function() + core.prepare() + core.autocomplete('InsertEnter') +end) + +autocmd.subscribe('TextChanged', function() + core.autocomplete('TextChanged') +end) + +autocmd.subscribe('InsertLeave', function() + core.reset() +end) + +return cmp diff --git a/lua/cmp/matcher.lua b/lua/cmp/matcher.lua new file mode 100644 index 0000000..f97be6f --- /dev/null +++ b/lua/cmp/matcher.lua @@ -0,0 +1,248 @@ +local char = require('cmp.utils.char') + +local matcher = {} + +matcher.WORD_BOUNDALY_ORDER_FACTOR = 5 + +matcher.PREFIX_FACTOR = 8 +matcher.NOT_FUZZY_FACTOR = 6 + +---@type function +matcher.debug = function(...) + return ... +end + +--- score +-- +-- ### The score +-- +-- The `score` is `matched char count` generally. +-- +-- But cmp will fix the score with some of the below points so the actual score is not `matched char count`. +-- +-- 1. Word boundary order +-- +-- cmp prefers the match that near by word-beggining. +-- +-- 2. Strict case +-- +-- cmp prefers strict match than ignorecase match. +-- +-- +-- ### Matching specs. +-- +-- 1. Prefix matching per word boundary +-- +-- `bora` -> `border-radius` # imaginary score: 4 +-- ^^~~ ^^ ~~ +-- +-- 2. Try sequential match first +-- +-- `woroff` -> `word_offset` # imaginary score: 6 +-- ^^^~~~ ^^^ ~~~ +-- +-- * The `woroff`'s second `o` should not match `word_offset`'s first `o` +-- +-- 3. Prefer early word boundary +-- +-- `call` -> `call` # imaginary score: 4.1 +-- ^^^^ ^^^^ +-- `call` -> `condition_all` # imaginary score: 4 +-- ^~~~ ^ ~~~ +-- +-- 4. Prefer strict match +-- +-- `Buffer` -> `Buffer` # imaginary score: 6.1 +-- ^^^^^^ ^^^^^^ +-- `buffer` -> `Buffer` # imaginary score: 6 +-- ^^^^^^ ^^^^^^ +-- +-- 5. Use remaining characters for substring match +-- +-- `fmodify` -> `fnamemodify` # imaginary score: 1 +-- ^~~~~~~ ^ ~~~~~~ +-- +-- 6. Avoid unexpected match detection +-- +-- `candlesingle` -> candle#accept#single +-- ^^^^^^~~~~~~ ^^^^^^ ~~~~~~ +-- +-- * The `accept`'s `a` should not match to `candle`'s `a` +-- +---Match entry +---@param input string +---@param word string +---@return number +matcher.match = function(input, word) + -- Empty input + if #input == 0 then + return matcher.PREFIX_FACTOR + matcher.NOT_FUZZY_FACTOR + end + + -- Ignore if input is long than word + if #input > #word then + return 0 + end + + --- Gather matched regions + local matches = {} + local input_start_index = 1 + local input_end_index = 1 + local word_index = 1 + local word_bound_index = 1 + while input_end_index <= #input and word_index <= #word do + local m = matcher.find_match_region(input, input_start_index, input_end_index, word, word_index) + if m and input_end_index <= m.input_match_end then + m.index = word_bound_index + input_start_index = m.input_match_start + 1 + input_end_index = m.input_match_end + 1 + word_index = char.get_next_semantic_index(word, m.word_match_end) + table.insert(matches, m) + else + word_index = char.get_next_semantic_index(word, word_index) + end + word_bound_index = word_bound_index + 1 + end + + if #matches == 0 then + return 0 + end + + -- Compute prefix match score + local score = 0 + local idx = 1 + for _, m in ipairs(matches) do + local s = 0 + for i = math.max(idx, m.input_match_start), m.input_match_end do + s = s + 1 + idx = i + end + idx = idx + 1 + if s > 0 then + score = score + (s * (1 + math.max(0, matcher.WORD_BOUNDALY_ORDER_FACTOR - m.index) / matcher.WORD_BOUNDALY_ORDER_FACTOR)) + score = score + (m.strict_match and 0.1 or 0) + end + end + + -- Add prefix bonus + score = score + ((matches[1].input_match_start == 1 and matches[1].word_match_start == 1) and matcher.PREFIX_FACTOR or 0) + + -- Check remaining input as fuzzy + if matches[#matches].input_match_end < #input then + if matcher.fuzzy(input, word, matches) then + return score + end + return 0 + end + + return score + matcher.NOT_FUZZY_FACTOR +end + +--- fuzzy +matcher.fuzzy = function(input, word, matches) + local last_match = matches[#matches] + + -- Lately specified middle of text. + local input_index = last_match.input_match_end + 1 + for i = 1, #matches - 1 do + local curr_match = matches[i] + local next_match = matches[i + 1] + local word_offset = 0 + local word_index = char.get_next_semantic_index(word, curr_match.word_match_end) + while word_offset + word_index < next_match.word_match_start and input_index <= #input do + if char.match(string.byte(word, word_index + word_offset), string.byte(input, input_index)) then + input_index = input_index + 1 + word_offset = word_offset + 1 + else + word_index = char.get_next_semantic_index(word, word_index + word_offset) + word_offset = 0 + end + end + end + + -- Remaining text fuzzy match. + local last_input_index = input_index + local matched = false + local word_offset = 0 + local word_index = last_match.word_match_end + 1 + while word_offset + word_index <= #word and input_index <= #input do + if char.match(string.byte(word, word_index + word_offset), string.byte(input, input_index)) then + matched = true + input_index = input_index + 1 + elseif matched then + input_index = last_input_index + end + word_offset = word_offset + 1 + end + if input_index >= #input then + return true + end + return false +end + +--- find_match_region +matcher.find_match_region = function(input, input_start_index, input_end_index, word, word_index) + -- determine input position ( woroff -> word_offset ) + while input_start_index < input_end_index do + if char.match(string.byte(input, input_end_index), string.byte(word, word_index)) then + break + end + input_end_index = input_end_index - 1 + end + + -- Can't determine input position + if input_end_index < input_start_index then + return nil + end + + local strict_match_count = 0 + local input_match_start = -1 + local input_index = input_end_index + local word_offset = 0 + while input_index <= #input and word_index + word_offset <= #word do + local c1 = string.byte(input, input_index) + local c2 = string.byte(word, word_index + word_offset) + if char.match(c1, c2) then + -- Match start. + if input_match_start == -1 then + input_match_start = input_index + end + + -- Increase strict_match_count + if c1 == c2 then + strict_match_count = strict_match_count + 1 + end + + word_offset = word_offset + 1 + else + -- Match end (partial region) + if input_match_start ~= -1 then + return { + input_match_start = input_match_start, + input_match_end = input_index - 1, + word_match_start = word_index, + word_match_end = word_index + word_offset - 1, + strict_match = strict_match_count == input_index - input_match_start, + } + else + return nil + end + end + input_index = input_index + 1 + end + + -- Match end (whole region) + if input_match_start ~= -1 then + return { + input_match_start = input_match_start, + input_match_end = input_index - 1, + word_match_start = word_index, + word_match_end = word_index + word_offset - 1, + strict_match = strict_match_count == input_index - input_match_start, + } + end + + return nil +end + +return matcher diff --git a/lua/cmp/matcher_spec.lua b/lua/cmp/matcher_spec.lua new file mode 100644 index 0000000..6dca9f2 --- /dev/null +++ b/lua/cmp/matcher_spec.lua @@ -0,0 +1,33 @@ +local spec = require('cmp.utils.spec') + +local matcher = require('cmp.matcher') + +describe('matcher', function() + before_each(spec.before) + + it('match', function() + assert.is.truthy(matcher.match('', 'a') >= 1) + assert.is.truthy(matcher.match('a', 'a') >= 1) + assert.is.truthy(matcher.match('ab', 'a') == 0) + assert.is.truthy(matcher.match('ab', 'ab') > matcher.match('ab', 'a_b')) + assert.is.truthy(matcher.match('ab', 'a_b_c') > matcher.match('ac', 'a_b_c')) + + assert.is.truthy(matcher.match('bora', 'border-radius') >= 1) + assert.is.truthy(matcher.match('woroff', 'word_offset') >= 1) + assert.is.truthy(matcher.match('call', 'call') > matcher.match('call', 'condition_all')) + assert.is.truthy(matcher.match('Buffer', 'Buffer') > matcher.match('Buffer', 'buffer')) + assert.is.truthy(matcher.match('fmodify', 'fnamemodify') >= 1) + assert.is.truthy(matcher.match('candlesingle', 'candle#accept#single') >= 1) + assert.is.truthy(matcher.match('conso', 'console') > matcher.match('conso', 'ConstantSourceNode')) + assert.is.truthy(matcher.match('var_', 'var_dump') >= 1) + end) + + it('debug', function() + assert.is.truthy(true) + + matcher.debug = function(...) + print(vim.inspect({ ... })) + end + print('score', matcher.match('vsnipnextjump', 'vsnip-jump-next')) + end) +end) diff --git a/lua/cmp/menu.lua b/lua/cmp/menu.lua new file mode 100644 index 0000000..da1929d --- /dev/null +++ b/lua/cmp/menu.lua @@ -0,0 +1,242 @@ +local debug = require('cmp.utils.debug') +local async = require('cmp.utils.async') +local float = require('cmp.float') +local types = require('cmp.types') +local config = require('cmp.config') +local autocmd = require('cmp.autocmd') + +---@class cmp.Menu +---@field public float cmp.Float +---@field public cache cmp.Cache +---@field public offset number +---@field public on_select fun(e: cmp.Entry) +---@field public items vim.CompletedItem[] +---@field public entries cmp.Entry[] +---@field public entry_map table +---@field public selected_entry cmp.Entry|nil +---@field public context cmp.Context +---@field public resolve_dedup fun(callback: function) +local menu = {} + +---Create menu +---@return cmp.Menu +menu.new = function() + local self = setmetatable({}, { __index = menu }) + self.float = float.new() + self.resolve_dedup = async.dedup() + self.on_select = function() end + self:reset() + autocmd.subscribe('CompleteChanged', function() + local e = self:get_selected_entry() + if e then + self:select(e) + else + self:unselect() + end + end) + return self +end + +---Close menu +menu.close = function(self) + if vim.fn.pumvisible() == 1 then + vim.fn.complete(1, {}) + end + self:unselect() +end + +---Reset menu +menu.reset = function(self) + self.offset = nil + self.items = {} + self.entries = {} + self.entry_map = {} + self.context = nil + self.preselect = 0 + self:close() +end + +---Update menu +---@param ctx cmp.Context +---@param sources cmp.Source[] +---@return cmp.Menu +menu.update = function(self, ctx, sources) + if not (ctx.mode == 'i' or ctx.mode == 'ic') then + return + end + + local entries = {} + local entry_map = {} + + -- check the source triggered by character + local has_triggered_by_character_source = false + for _, s in ipairs(sources) do + if s:has_items() then + if s.trigger_kind == types.lsp.CompletionTriggerKind.TriggerCharacter then + has_triggered_by_character_source = true + break + end + end + end + + -- create filtered entries. + local offset = ctx.cursor.col + for i, s in ipairs(sources) do + if s:has_items() and s.offset <= offset then + if not has_triggered_by_character_source or s.trigger_kind == types.lsp.CompletionTriggerKind.TriggerCharacter then + -- source order priority bonus. + local priority = (#sources - i - 1) * 2 + + local filtered = s:get_entries(ctx) + for _, e in ipairs(filtered) do + e.score = e.score + priority + table.insert(entries, e) + entry_map[e.id] = e + end + if #filtered > 0 then + offset = math.min(offset, s.offset) + end + end + end + end + + -- sort. + config.get().sorting.sort(entries) + + -- create vim items. + local items = {} + local abbrs = {} + local preselect = 0 + for i, e in ipairs(entries) do + if preselect == 0 and e.completion_item.preselect then + preselect = i + end + + local item = e:get_vim_item(offset) + if not abbrs[item.abbr] or item.dup == 1 then + table.insert(items, item) + abbrs[item.abbr] = true + end + end + + -- save recent pum state. + self.offset = offset + self.items = items + self.entries = entries + self.entry_map = entry_map + self.preselect = preselect + self.context = ctx + self:show() + + if #self.entries == 0 then + self:unselect() + end +end + +---Restore previous menu +---@param ctx cmp.Context +menu.restore = function(self, ctx) + if not (ctx.mode == 'i' or ctx.mode == 'ic') then + return + end + + if not ctx.pumvisible then + if #self.items > 0 then + if self.offset <= ctx.cursor.col then + debug.log('menu/restore') + self:show() + end + end + end +end + +---Show completion item +menu.show = function(self) + if vim.fn.pumvisible() == 0 and #self.entries == 0 then + return + end + + local completeopt = vim.o.completeopt + if self.preselect == 1 then + vim.cmd('set completeopt=menuone,noinsert') + else + vim.cmd('set completeopt=' .. config.get().completion.completeopt) + end + vim.fn.complete(self.offset, self.items) + vim.cmd('set completeopt=' .. completeopt) + + if self.preselect > 0 then + vim.api.nvim_select_popupmenu_item(self.preselect - 1, false, false, {}) + end +end + +---Select current item +---@param e cmp.Entry +menu.select = function(self, e) + -- Documentation (always invoke to follow to the pum position) + e:resolve(self.resolve_dedup(vim.schedule_wrap(function() + if self:get_selected_entry() == e then + self.float:show(e) + end + end))) + + self.on_select(e) +end + +---Select current item +menu.unselect = function(self) + self.float:close() +end + +---Geta current active entry +---@return cmp.Entry|nil +menu.get_active_entry = function(self) + local completed_item = vim.v.completed_item or {} + if vim.fn.pumvisible() == 0 or not completed_item.user_data then + return nil + end + + local id = completed_item.user_data.cmp + if id then + return self.entry_map[id] + end + return nil +end + +---Get current selected entry +---@return cmp.Entry|nil +menu.get_selected_entry = function(self) + local info = vim.fn.complete_info({ 'items', 'selected' }) + if info.selected == -1 then + return nil + end + + local completed_item = info.items[math.max(info.selected, 0) + 1] or {} + if not completed_item.user_data then + return nil + end + + local id = completed_item.user_data.cmp + if id then + return self.entry_map[id] + end + return nil +end + +---Get first entry +---@param self cmp.Entry|nil +menu.get_first_entry = function(self) + local info = vim.fn.complete_info({ 'items' }) + local completed_item = info.items[1] or {} + if not completed_item.user_data then + return nil + end + + local id = completed_item.user_data.cmp + if id then + return self.entry_map[id] + end + return nil +end + +return menu diff --git a/lua/cmp/source.lua b/lua/cmp/source.lua new file mode 100644 index 0000000..a6badd7 --- /dev/null +++ b/lua/cmp/source.lua @@ -0,0 +1,281 @@ +local context = require('cmp.context') +local config = require('cmp.config') +local matcher = require('cmp.matcher') +local entry = require('cmp.entry') +local debug = require('cmp.utils.debug') +local misc = require('cmp.utils.misc') +local cache = require('cmp.utils.cache') +local types = require('cmp.types') +local async = require('cmp.utils.async') +local pattern = require('cmp.utils.pattern') + +---@class cmp.Source +---@field public id number +---@field public name string +---@field public source any +---@field public cache cmp.Cache +---@field public revision number +---@field public context cmp.Context +---@field public trigger_kind lsp.CompletionTriggerKind|nil +---@field public incomplete boolean +---@field public entries cmp.Entry[] +---@field public offset number|nil +---@field public status cmp.SourceStatus +---@field public complete_dedup function +local source = {} + +---@alias cmp.SourceStatus "1" | "2" | "3" +source.SourceStatus = {} +source.SourceStatus.WAITING = 1 +source.SourceStatus.FETCHING = 2 +source.SourceStatus.COMPLETED = 3 + +---@alias cmp.SourceChangeKind "1" | "2" | "3" +source.SourceChangeKind = {} +source.SourceChangeKind.RETRIEVE = 1 +source.SourceChangeKind.CONTINUE = 2 + +---@return cmp.Source +source.new = function(name, s) + local self = setmetatable({}, { __index = source }) + self.id = misc.id('source') + self.name = name + self.source = s + self.cache = cache.new() + self.complete_dedup = async.dedup() + self.revision = 0 + self:reset() + return self +end + +---Reset current completion state +---@return boolean +source.reset = function(self) + self.cache:clear() + self.revision = self.revision + 1 + self.context = context.empty() + self.trigger_kind = nil + self.incomplete = false + self.entries = {} + self.offset = -1 + self.status = source.SourceStatus.WAITING + self.complete_dedup(function() end) +end + +---Return source option +---@return table +source.get_option = function(self) + return config.get_source_option(self.name) +end + +---Return the source has items or not. +---@return boolean +source.has_items = function(self) + return self.offset ~= -1 +end + +---Get fetching time +source.get_fetching_time = function(self) + if self.status == source.SourceStatus.FETCHING then + return vim.loop.now() - self.context.time + end + return 100 * 1000 -- return pseudo time if source isn't fetching. +end + +---Return filtered entries +---@param ctx cmp.Context +---@return cmp.Entry[] +source.get_entries = function(self, ctx) + if not self:has_items() then + return {} + end + + local prev_entries = (function() + local key = { 'get_entries', self.revision } + for i = ctx.cursor.col, self.offset, -1 do + key[3] = string.sub(ctx.cursor_before_line, 1, i) + local prev_entries = self.cache:get(key) + if prev_entries then + return prev_entries + end + end + return nil + end)() + + return self.cache:ensure({ 'get_entries', self.revision, ctx.cursor_before_line }, function() + debug.log('filter', self.name, self.id, #(prev_entries or self.entries)) + + local inputs = {} + local entries = {} + for _, e in ipairs(prev_entries or self.entries) do + local o = e:get_offset() + if not inputs[o] then + inputs[o] = string.sub(ctx.cursor_before_line, o) + end + e.score = matcher.match(inputs[o], e:get_filter_text()) + e.exact = inputs[o] == e:get_filter_text() + if e.score >= 1 then + table.insert(entries, e) + end + end + return entries + end) +end + +---Get default insert range +---@return lsp.Range|nil +source.get_default_insert_range = function(self) + if not self.context then + return nil + end + + return self.cache:ensure({ 'get_default_insert_range', self.revision }, function() + return { + start = { + line = self.context.cursor.row - 1, + character = vim.str_utfindex(self.context.cursor_line, self.offset - 1), + }, + ['end'] = { + line = self.context.cursor.row - 1, + character = vim.str_utfindex(self.context.cursor_line, self.context.cursor.col - 1), + }, + } + end) +end + +---Get default replace range +---@return lsp.Range|nil +source.get_default_replace_range = function(self) + if not self.context then + return nil + end + + return self.cache:ensure({ 'get_default_replace_range', self.revision }, function() + local _, e = pattern.offset('^' .. self:get_keyword_pattern(), string.sub(self.context.cursor_line, self.offset)) + return { + start = { + line = self.context.cursor.row - 1, + character = vim.str_utfindex(self.context.cursor_line, self.offset - 1), + }, + ['end'] = { + line = self.context.cursor.row - 1, + character = vim.str_utfindex(self.context.cursor_line, e and self.offset + e - 2 or self.context.cursor.col - 1), + }, + } + end) +end + +---Get keyword_pattern +---@return string +source.get_keyword_pattern = function(self) + if self.source.get_keyword_pattern then + return self.source:get_keyword_pattern() + end + return config.get().completion.keyword_pattern +end + +---Get trigger_characters +---@return string[] +source.get_trigger_characters = function(self) + if self.source.get_trigger_characters then + return self.source:get_trigger_characters() or {} + end + return {} +end + +---Invoke completion +---@param ctx cmp.Context +---@param callback function +---@return boolean Return true if not trigger completion. +source.complete = function(self, ctx, callback) + local c = config.get() + + local offset = ctx:get_offset(self:get_keyword_pattern()) + if offset == ctx.cursor.col then + self:reset() + end + + local completion_context + if ctx:get_reason() == types.cmp.ContextReason.Manual then + completion_context = { + triggerKind = types.lsp.CompletionTriggerKind.Invoked, + } + elseif vim.tbl_contains(self:get_trigger_characters(), ctx.before_char) then + completion_context = { + triggerKind = types.lsp.CompletionTriggerKind.TriggerCharacter, + triggerCharacter = ctx.before_char, + } + elseif c.completion.keyword_length <= (ctx.cursor.col - offset) and self.offset ~= offset then + completion_context = { + triggerKind = types.lsp.CompletionTriggerKind.Invoked, + } + elseif self.incomplete and offset ~= ctx.cursor.col then + completion_context = { + triggerKind = types.lsp.CompletionTriggerKind.TriggerForIncompleteCompletions, + } + end + if not completion_context then + debug.log('skip empty context', self.name, self.id) + return + end + + debug.log('request', self.name, self.id, offset, vim.inspect(completion_context)) + local prev_status = self.status + self.status = source.SourceStatus.FETCHING + self.offset = offset + self.context = ctx + self.source:complete( + { + context = ctx, + offset = self.offset, + option = self:get_option(), + completion_context = completion_context, + }, + vim.schedule_wrap(self.complete_dedup(function(response) + self.revision = self.revision + 1 + if #(misc.safe(response) and response.items or response or {}) > 0 then + debug.log('retrieve', self.name, self.id, #(response.items or response)) + self.status = source.SourceStatus.COMPLETED + self.trigger_kind = completion_context.triggerKind + self.incomplete = response.isIncomplete or false + self.entries = {} + for i, item in ipairs(response.items or response) do + local e = entry.new(ctx, self, item) + self.entries[i] = e + self.offset = math.min(self.offset, e:get_offset()) + end + else + debug.log('continue', self.name, self.id, 'nil') + self.status = prev_status + end + callback() + end)) + ) + return true +end + +---Resolve CompletionItem +---@param item lsp.CompletionItem +---@param callback fun(item: lsp.CompletionItem) +source.resolve = function(self, item, callback) + if not self.source.resolve then + return callback(item) + end + self.source:resolve(item, function(resolved_item) + callback(resolved_item or item) + end) +end + +---Execute command +---@param item lsp.CompletionItem +---@param callback fun() +source.execute = function(self, item, callback) + if not self.source.execute then + return callback() + end + self.source:execute(item, function() + callback() + end) +end + +return source diff --git a/lua/cmp/source_spec.lua b/lua/cmp/source_spec.lua new file mode 100644 index 0000000..a9e28a5 --- /dev/null +++ b/lua/cmp/source_spec.lua @@ -0,0 +1,11 @@ +local spec = require('cmp.utils.spec') + +-- local source = require "cmp.source" + +describe('source', function() + before_each(spec.before) + + it('new', function() + -- local s = source.new() + end) +end) diff --git a/lua/cmp/types/cmp.lua b/lua/cmp/types/cmp.lua new file mode 100644 index 0000000..b4367ee --- /dev/null +++ b/lua/cmp/types/cmp.lua @@ -0,0 +1,84 @@ +local cmp = {} + +---@alias cmp.ConfirmBehavior "'insert'" | "'replace'" +cmp.ConfirmBehavior = {} +cmp.ConfirmBehavior.Insert = 'insert' +cmp.ConfirmBehavior.Replace = 'replace' + +---@alias cmp.ContextReason "'auto'" | "'manual'" | "'none'" +cmp.ContextReason = {} +cmp.ContextReason.Auto = 'auto' +cmp.ContextReason.Manual = 'manual' +cmp.ContextReason.None = 'none' + +---@alias cmp.TriggerEvent "'InsertEnter'" | "'TextChanged'" +cmp.TriggerEvent = {} +cmp.TriggerEvent.InsertEnter = 'InsertEnter' +cmp.TriggerEvent.TextChanged = 'TextChanged' + +---@class cmp.ContextOption +---@field public reason cmp.ContextReason|nil + +---@class cmp.ConfirmOption +---@field public behavior cmp.ConfirmBehavior + +---@class cmp.SnippetExpansionParams +---@field public body string +---@field public insert_text_mode number + +---@class cmp.Setup +---@field public __call fun(c: cmp.ConfigSchema) +---@field public buffer fun(c: cmp.ConfigSchema) +---@field public global fun(c: cmp.ConfigSchema) + +---@class cmp.CompletionRequest +---@field public context cmp.Context +---@field public option table +---@field public offset number +---@field public completion_context lsp.CompletionContext + +---@class cmp.ConfigSchema +---@field private revision number +---@field public completion cmp.CompletionConfig +---@field public documentation cmp.DocumentationConfig +---@field public confirmation cmp.ConfirmationConfig +---@field public sorting cmp.SortingConfig +---@field public formatting cmp.FormattingConfig +---@field public snippet cmp.SnippetConfig +---@field public sources cmp.SourceConfig[] + +---@class cmp.CompletionConfig +---@field public autocomplete cmp.TriggerEvent[] +---@field public completeopt string +---@field public keyword_pattern string +---@field public keyword_length number + +---@class cmp.DocumentationConfig +---@field public border string[] +---@field public winhighlight string +---@field public maxwidth number|nil +---@field public maxheight number|nil + +---@class cmp.ConfirmationConfig +---@field public default_behavior cmp.ConfirmBehavior +---@field public mapping table + +---@class cmp.ConfirmMappingConfig +---@field behavior cmp.ConfirmBehavior +---@field select boolean + +---@class cmp.SortingConfig +---@field public sort fun(entries: cmp.Entry[]): cmp.Entry[] + +---@class cmp.FormattingConfig +---@field public format fun(entry: cmp.Entry, suggeset_offset: number): vim.CompletedItem + +---@class cmp.SnippetConfig +---@field public expand fun(args: cmp.SnippetExpansionParams) + +---@class cmp.SourceConfig +---@field public name string +---@field public opts table + +return cmp + diff --git a/lua/cmp/types/init.lua b/lua/cmp/types/init.lua new file mode 100644 index 0000000..b830ed3 --- /dev/null +++ b/lua/cmp/types/init.lua @@ -0,0 +1,8 @@ +local types = {} + +types.cmp = require('cmp.types.cmp') +types.lsp = require('cmp.types.lsp') +types.vim = require('cmp.types.vim') + +return types + diff --git a/lua/cmp/types/lsp.lua b/lua/cmp/types/lsp.lua new file mode 100644 index 0000000..1273d61 --- /dev/null +++ b/lua/cmp/types/lsp.lua @@ -0,0 +1,205 @@ +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/ +---@class lsp +local lsp = {} + +lsp.Position = {} + +---Convert lsp.Position to vim.Position +---@param buf number|string +---@param position lsp.Position +---@return vim.Position +lsp.Position.to_vim = function(buf, position) + if not vim.api.nvim_buf_is_loaded(buf) then + vim.fn.bufload(buf) + end + local lines = vim.api.nvim_buf_get_lines(buf, position.line, position.line + 1, false) + if #lines > 0 then + for i = position.character, 1, -1 do + local s, v = pcall(function() + return { + row = position.line + 1, + col = vim.str_byteindex(lines[1], i) + 1 + } + end) + if s then + return v + end + end + end + return { + row = position.line + 1, + col = position.character + 1, + } +end + +---Convert lsp.Position to vim.Position +---@param buf number|string +---@param position vim.Position +---@return lsp.Position +lsp.Position.to_lsp = function(buf, position) + if not vim.api.nvim_buf_is_loaded(buf) then + vim.fn.bufload(buf) + end + local lines = vim.api.nvim_buf_get_lines(buf, position.row - 1, position.row, false) + if #lines > 0 then + return { + line = position.row - 1, + character = vim.str_utfindex(lines[1], math.max(0, math.min(position.col - 1, #lines[1]))), + } + end + return { + line = position.row - 1, + character = position.col - 1, + } +end + +lsp.Range = {} + +---Convert lsp.Position to vim.Position +---@param buf number|string +---@param range lsp.Range +---@return vim.Range +lsp.Range.to_vim = function(buf, range) + return { + start = lsp.Position.to_vim(buf, range.start), + ['end'] = lsp.Position.to_vim(buf, range['end']), + } +end + +---Convert lsp.Position to vim.Position +---@param buf number|string +---@param range vim.Range +---@return lsp.Range +lsp.Range.to_lsp = function(buf, range) + return { + start = lsp.Position.to_lsp(buf, range.start), + ['end'] = lsp.Position.to_lsp(buf, range['end']), + } +end + +---@alias lsp.CompletionTriggerKind "1" | "2" | "3" +lsp.CompletionTriggerKind = {} +lsp.CompletionTriggerKind.Invoked = 1 +lsp.CompletionTriggerKind.TriggerCharacter = 2 +lsp.CompletionTriggerKind.TriggerForIncompleteCompletions = 3 + +---@class lsp.CompletionContext +---@field public triggerKind lsp.CompletionTriggerKind +---@field public triggerCharacter string|nil + +---@alias lsp.InsertTextFormat "1" | "2" +lsp.InsertTextFormat = {} +lsp.InsertTextFormat.PlainText = 1 +lsp.InsertTextFormat.Snippet = 2 +lsp.InsertTextFormat = vim.tbl_add_reverse_lookup(lsp.InsertTextFormat) + +---@alias lsp.InsertTextMode "1" | "2" +lsp.InsertTextMode = {} +lsp.InsertTextMode.AsIs = 0 +lsp.InsertTextMode.AdjustIndentation = 1 +lsp.InsertTextMode = vim.tbl_add_reverse_lookup(lsp.InsertTextMode) + +---@alias lsp.MarkupKind "'plaintext'" | "'markdown'" +lsp.MarkupKind = {} +lsp.MarkupKind.PlainText = 'plaintext' +lsp.MarkupKind.Markdown = 'markdown' +lsp.MarkupKind.Markdown = 'markdown' +lsp.MarkupKind = vim.tbl_add_reverse_lookup(lsp.MarkupKind) + +---@alias lsp.CompletionItemTag "1" +lsp.CompletionItemTag = {} +lsp.CompletionItemTag.Deprecated = 1 +lsp.CompletionItemTag = vim.tbl_add_reverse_lookup(lsp.CompletionItemTag) + +---@alias lsp.CompletionItemKind "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12" | "13" | "14" | "15" | "16" | "17" | "18" | "19" | "20" | "21" | "22" | "23" | "24" | "25" +lsp.CompletionItemKind = {} +lsp.CompletionItemKind.Text = 1 +lsp.CompletionItemKind.Method = 2 +lsp.CompletionItemKind.Function = 3 +lsp.CompletionItemKind.Constructor = 4 +lsp.CompletionItemKind.Field = 5 +lsp.CompletionItemKind.Variable = 6 +lsp.CompletionItemKind.Class = 7 +lsp.CompletionItemKind.Interface = 8 +lsp.CompletionItemKind.Module = 9 +lsp.CompletionItemKind.Property = 10 +lsp.CompletionItemKind.Unit = 11 +lsp.CompletionItemKind.Value = 12 +lsp.CompletionItemKind.Enum = 13 +lsp.CompletionItemKind.Keyword = 14 +lsp.CompletionItemKind.Snippet = 15 +lsp.CompletionItemKind.Color = 16 +lsp.CompletionItemKind.File = 17 +lsp.CompletionItemKind.Reference = 18 +lsp.CompletionItemKind.Folder = 19 +lsp.CompletionItemKind.EnumMember = 20 +lsp.CompletionItemKind.Constant = 21 +lsp.CompletionItemKind.Struct = 22 +lsp.CompletionItemKind.Event = 23 +lsp.CompletionItemKind.Operator = 24 +lsp.CompletionItemKind.TypeParameter = 25 +lsp.CompletionItemKind = vim.tbl_add_reverse_lookup(lsp.CompletionItemKind) + +---@class lsp.CompletionList +---@field public isIncomplete boolean +---@field public items lsp.CompletionItem[] + +---@alias lsp.CompletionResponse lsp.CompletionList|lsp.CompletionItem[]|nil + +---@class lsp.MarkupContent +---@field public kind lsp.MarkupKind +---@field public value string + +---@class lsp.Position +---@field public line number +---@field public character number + +---@class lsp.Range +---@field public start lsp.Position +---@field public end lsp.Position + +---@class lsp.Command +---@field public title string +---@field public command string +---@field public arguments any[]|nil + +---@class lsp.TextEdit +---@field public range lsp.Range|nil +---@field public newText string + +---@class lsp.InsertReplaceTextEdit +---@field public insert lsp.Range|nil +---@field public replace lsp.Range|nil +---@field public newText string + +---@class lsp.CompletionItemLabelDetails +---@field public parameters string|nil +---@field public qualifier string|nil +---@field public type string|nil + +---@class lsp.CompletionItem +---@field public label string +---@field public labelDetails lsp.CompletionItemLabelDetails|nil +---@field public kind lsp.CompletionItemKind|nil +---@field public tags lsp.CompletionItemTag[]|nil +---@field public detail string|nil +---@field public documentation lsp.MarkupContent|string|nil +---@field public deprecated boolean|nil +---@field public preselect boolean|nil +---@field public sortText string|nil +---@field public filterText string|nil +---@field public insertText string|nil +---@field public insertTextFormat lsp.InsertTextFormat +---@field public insertTextMode lsp.InsertTextMode +---@field public textEdit lsp.TextEdit|lsp.InsertReplaceTextEdit|nil +---@field public additionalTextEdits lsp.TextEdit[] +---@field public commitCharacters string[]|nil +---@field public command lsp.Command|nil +---@field public data any|nil +--- +---TODO: Should send the issue for upstream? +---@field public word string|nil +---@field public dup boolean|nil + +return lsp + diff --git a/lua/cmp/types/lsp_spec.lua b/lua/cmp/types/lsp_spec.lua new file mode 100644 index 0000000..e5adc2c --- /dev/null +++ b/lua/cmp/types/lsp_spec.lua @@ -0,0 +1,47 @@ +local spec = require'cmp.utils.spec' +local lsp = require'cmp.types.lsp' + +describe('types.lsp', function () + before_each(spec.before) + describe('Position', function () + vim.fn.setline('1', { + 'あいうえお', + 'かきくけこ', + 'さしすせそ', + }) + local vim_position, lsp_position + + vim_position = lsp.Position.to_vim('%', { line = 1, character = 3 }) + assert.are.equal(vim_position.row, 2) + assert.are.equal(vim_position.col, 10) + lsp_position = lsp.Position.to_lsp('%', vim_position) + assert.are.equal(lsp_position.line, 1) + assert.are.equal(lsp_position.character, 3) + + vim_position = lsp.Position.to_vim('%', { line = 1, character = 0 }) + assert.are.equal(vim_position.row, 2) + assert.are.equal(vim_position.col, 1) + lsp_position = lsp.Position.to_lsp('%', vim_position) + assert.are.equal(lsp_position.line, 1) + assert.are.equal(lsp_position.character, 0) + + vim_position = lsp.Position.to_vim('%', { line = 1, character = 5 }) + assert.are.equal(vim_position.row, 2) + assert.are.equal(vim_position.col, 16) + lsp_position = lsp.Position.to_lsp('%', vim_position) + assert.are.equal(lsp_position.line, 1) + assert.are.equal(lsp_position.character, 5) + + -- overflow (lsp -> vim) + vim_position = lsp.Position.to_vim('%', { line = 1, character = 6 }) + assert.are.equal(vim_position.row, 2) + assert.are.equal(vim_position.col, 16) + + -- overflow(vim -> lsp) + vim_position.col = vim_position.col + 1 + lsp_position = lsp.Position.to_lsp('%', vim_position) + assert.are.equal(lsp_position.line, 1) + assert.are.equal(lsp_position.character, 5) + end) +end) + diff --git a/lua/cmp/types/vim.lua b/lua/cmp/types/vim.lua new file mode 100644 index 0000000..1d2c263 --- /dev/null +++ b/lua/cmp/types/vim.lua @@ -0,0 +1,18 @@ +---@class vim.CompletedItem +---@field public word string +---@field public abbr string|nil +---@field public kind string|nil +---@field public menu string|nil +---@field public equal "1"|nil +---@field public empty "1"|nil +---@field public dup "1"|nil +---@field public id any + +---@class vim.Position +---@field public row number +---@field public col number + +---@class vim.Range +---@field public start vim.Position +---@field public end vim.Position + diff --git a/lua/cmp/utils/async.lua b/lua/cmp/utils/async.lua new file mode 100644 index 0000000..3484075 --- /dev/null +++ b/lua/cmp/utils/async.lua @@ -0,0 +1,55 @@ +local async = {} + +---@class cmp.AsyncThrottle +---@field public timeout number +---@field public stop function +---@field public __call function + +---@param fn function +---@param timeout number +---@return cmp.AsyncThrottle +async.throttle = function(fn, timeout) + local time = nil + local timer = vim.loop.new_timer() + return setmetatable({ + timeout = timeout, + stop = function() + time = nil + timer:stop() + end, + }, { + __call = function(self, ...) + local args = { ... } + + if time == nil then + time = vim.loop.now() + end + timer:stop() + + local delta = math.max(0, self.timeout - (vim.loop.now() - time)) + timer:start(delta, 0, vim.schedule_wrap(function() + time = nil + fn(unpack(args)) + end)) + end + }) +end + +---Create deduplicated callback +---@return function +async.dedup = function() + local id = 0 + return function(callback) + id = id + 1 + + local current = id + return function(...) + if current == id then + callback(...) + end + end + end +end + +return async + diff --git a/lua/cmp/utils/async_spec.lua b/lua/cmp/utils/async_spec.lua new file mode 100644 index 0000000..a5adc16 --- /dev/null +++ b/lua/cmp/utils/async_spec.lua @@ -0,0 +1,40 @@ +local async = require "cmp.utils.async" + +describe('utils.async', function() + + it('throttle', function() + local count = 0 + local now + local f = async.throttle(function() + count = count + 1 + end, 100) + + -- 1. delay for 100ms + now = vim.loop.now() + f.timeout = 100 + f() + vim.wait(1000, function() return count == 1 end) + assert.is.truthy(math.abs(f.timeout - (vim.loop.now() - now)) < 10) + + -- 2. delay for 500ms + now = vim.loop.now() + f.timeout = 500 + f() + vim.wait(1000, function() return count == 2 end) + assert.is.truthy(math.abs(f.timeout - (vim.loop.now() - now)) < 10) + + -- 4. delay for 500ms and wait 100ms (remain 400ms) + f.timeout = 500 + f() + vim.wait(100) -- remain 400ms + + -- 5. call immediately (100ms already elapsed from No.4) + now = vim.loop.now() + f.timeout = 100 + f() + vim.wait(1000, function() return count == 3 end) + assert.is.truthy(math.abs(vim.loop.now() - now) < 10) + end) + +end) + diff --git a/lua/cmp/utils/cache.lua b/lua/cmp/utils/cache.lua new file mode 100644 index 0000000..2f3e25f --- /dev/null +++ b/lua/cmp/utils/cache.lua @@ -0,0 +1,57 @@ +---@class cmp.Cache +---@field public entries any +local cache = {} + +cache.new = function() + local self = setmetatable({}, { __index = cache }) + self.entries = {} + return self +end + +---Get cache value +---@param key string +---@return any|nil +cache.get = function(self, key) + key = self:key(key) + if self.entries[key] ~= nil then + return unpack(self.entries[key]) + end + return nil +end + +---Set cache value explicitly +---@param key string +---@vararg any +cache.set = function(self, key, ...) + key = self:key(key) + self.entries[key] = { ... } +end + +---Ensure value by callback +---@param key string +---@param callback fun(): any +cache.ensure = function(self, key, callback) + local value = self:get(key) + if value == nil then + self:set(key, callback()) + end + return self:get(key) +end + +---Clear all cache entries +cache.clear = function(self) + self.entries = {} +end + +---Create key +---@param key string|table +---@return string +cache.key = function(_, key) + if type(key) == 'table' then + return table.concat(key, ':') + end + return key +end + +return cache + diff --git a/lua/cmp/utils/cache_spec.lua b/lua/cmp/utils/cache_spec.lua new file mode 100644 index 0000000..e69de29 diff --git a/lua/cmp/utils/char.lua b/lua/cmp/utils/char.lua new file mode 100644 index 0000000..269b7f3 --- /dev/null +++ b/lua/cmp/utils/char.lua @@ -0,0 +1,113 @@ +local alpha = {} +string.gsub('abcdefghijklmnopqrstuvwxyz', '.', function(char) + alpha[string.byte(char)] = true +end) + +local ALPHA = {} +string.gsub('ABCDEFGHIJKLMNOPQRSTUVWXYZ', '.', function(char) + ALPHA[string.byte(char)] = true +end) + +local digit = {} +string.gsub('1234567890', '.', function(char) + digit[string.byte(char)] = true +end) + +local white = {} +string.gsub(' \t\n', '.', function(char) + white[string.byte(char)] = true +end) + +local char = {} + +---@param byte number +---@return boolean +char.is_upper = function(byte) + return ALPHA[byte] +end + +---@param byte number +---@return boolean +char.is_alpha = function(byte) + return alpha[byte] or ALPHA[byte] +end + +---@param byte number +---@return boolean +char.is_digit = function(byte) + return digit[byte] +end + +---@param byte number +---@return boolean +char.is_white = function(byte) + return white[byte] +end + +---@param byte number +---@return boolean +char.is_symbol = function(byte) + return not (char.is_alnum(byte) or char.is_white(byte)) +end + +---@param byte number +---@return boolean +char.is_printable = function(byte) + return string.match(string.char(byte), '^%c$') == nil +end + +---@param byte number +---@return boolean +char.is_alnum = function(byte) + return char.is_alpha(byte) or char.is_digit(byte) +end + +---@param text string +---@param index number +---@return boolean +char.is_semantic_index = function(text, index) + if index <= 1 then + return true + end + + local prev = string.byte(text, index - 1) + local curr = string.byte(text, index) + + if not char.is_upper(prev) and char.is_upper(curr) then + return true + end + if char.is_symbol(curr) or char.is_white(curr) then + return true + end + if not char.is_alpha(prev) and char.is_alpha(curr) then + return true + end + return false +end + +---@param text string +---@param current_index number +---@return boolean +char.get_next_semantic_index = function(text, current_index) + for i = current_index + 1, #text do + if char.is_semantic_index(text, i) then + return i + end + end + return #text + 1 +end + +---Ignore case match +---@param byte1 number +---@param byte2 number +---@return boolean +char.match = function(byte1, byte2) + if not char.is_alpha(byte1) or not char.is_alpha(byte2) then + return byte1 == byte2 + end + local diff = byte1 - byte2 + return diff == 0 or diff == 32 or diff == -32 +end + +return char + diff --git a/lua/cmp/utils/debug.lua b/lua/cmp/utils/debug.lua new file mode 100644 index 0000000..e367fce --- /dev/null +++ b/lua/cmp/utils/debug.lua @@ -0,0 +1,20 @@ +local debug = {} + +local flag = false + +---Print log +---@vararg any +debug.log = function(...) + if flag then + local data = {} + for _, v in ipairs({ ... }) do + if not vim.tbl_contains({ 'string', 'number', 'boolean' }, type(v)) then + v = vim.inspect(v) + end + table.insert(data, v) + end + print(table.concat(data, '\t')) + end +end + +return debug diff --git a/lua/cmp/utils/keymap.lua b/lua/cmp/utils/keymap.lua new file mode 100644 index 0000000..5c5adc5 --- /dev/null +++ b/lua/cmp/utils/keymap.lua @@ -0,0 +1,134 @@ +local misc = require('cmp.utils.misc') +local cache = require('cmp.utils.cache') + +local keymap = {} + +---The mapping of vim notation and chars. +keymap._table = { + [''] = { '\n', '\r', '\r\n' }, + [''] = { '\t' }, + [''] = { '\\' }, + [''] = { '|' }, + [''] = { ' ' }, +} + +---Shortcut for nvim_replace_termcodes +---@param keys string +---@return string +keymap.t = function(keys) + return vim.api.nvim_replace_termcodes(keys, true, true, true) +end + +---Return vim notation keymapping (simple conversion). +---@param s string +---@return string +keymap.to_keymap = function(s) + return string.gsub(s, '.', function(c) + for key, chars in pairs(keymap._table) do + if vim.tbl_contains(chars, c) then + return key + end + end + return c + end) +end + +---Feedkeys with callback +keymap.feedkeys = setmetatable({ + callbacks = {}, +}, { +__call = function(self, keys, mode, callback) + vim.fn.feedkeys(keymap.t(keys), mode) + + if callback then + local current_mode = string.sub(vim.api.nvim_get_mode().mode, 1, 1) + local id = misc.id('cmp.utils.keymap.feedkeys') + local cb = ('(cmp-utils-keymap-feedkeys:%s)'):format(id) + self.callbacks[id] = function() + callback() + vim.api.nvim_buf_del_keymap(0, current_mode, cb) + return keymap.t('') + end + vim.api.nvim_buf_set_keymap(0, current_mode, cb, ('v:lua.cmp.utils.keymap.feedkeys.expr(%s)'):format(id), { + expr = true, + nowait = true, + silent = true, + }) + vim.fn.feedkeys(keymap.t(cb), '') + end + end +}) +misc.set(_G, { 'cmp', 'utils', 'keymap', 'feedkeys', 'expr' }, function(id) + if keymap.feedkeys.callbacks[id] then + keymap.feedkeys.callbacks[id]() + end + return keymap.t('') +end) + +---Register keypress handler. +keymap.listen = setmetatable({ + cache = cache.new(), +}, { + __call = function(_, keys, callback) + keys = keymap.to_keymap(keys) + + local bufnr = vim.api.nvim_get_current_buf() + if keymap.listen.cache:get({ bufnr, keys }) then + return + end + + local existing = nil + for _, map in ipairs(vim.api.nvim_buf_get_keymap(0, 'i')) do + if existing then + break + end + if map.lhs == keys then + existing = map + end + end + for _, map in ipairs(vim.api.nvim_get_keymap('i')) do + if existing then + break + end + if map.lhs == keys then + existing = map + break + end + end + existing = existing or { + lhs = keys, + rhs = keys, + expr = 0, + nowait = 0, + noremap = 1, + } + + keymap.listen.cache:set({ bufnr, keys }, { + existing = existing, + callback = callback, + }) + vim.api.nvim_buf_set_keymap(0, 'i', keys, ('v:lua.cmp.utils.keymap.expr("%s")'):format(keys), { + expr = true, + nowait = true, + noremap = true, + }) + end, +}) +misc.set(_G, { 'cmp', 'utils', 'keymap', 'expr' }, function(keys) + keys = keymap.to_keymap(keys) + local bufnr = vim.api.nvim_get_current_buf() + + local existing = keymap.listen.cache:get({ bufnr, keys }).existing + local callback = keymap.listen.cache:get({ bufnr, keys }).callback + callback(keys, function() + vim.api.nvim_buf_set_keymap(0, 'i', '(cmp-utils-keymap:_)', existing.rhs, { + expr = existing.expr == 1, + noremap = existing.noremap == 1, + }) + vim.fn.feedkeys(keymap.t('(cmp-utils-keymap:_)'), 'i') + end) + return keymap.t('') +end) + +return keymap + diff --git a/lua/cmp/utils/keymap_spec.lua b/lua/cmp/utils/keymap_spec.lua new file mode 100644 index 0000000..6024904 --- /dev/null +++ b/lua/cmp/utils/keymap_spec.lua @@ -0,0 +1,13 @@ +local spec = require('cmp.utils.spec') + +local keymap = require('cmp.utils.keymap') + +describe('keymap', function() + before_each(spec.before) + + it('to_keymap', function() + assert.are.equal(keymap.to_keymap('\n'), '') + assert.are.equal(keymap.to_keymap(''), '') + assert.are.equal(keymap.to_keymap('|'), '') + end) +end) diff --git a/lua/cmp/utils/misc.lua b/lua/cmp/utils/misc.lua new file mode 100644 index 0000000..d0f3247 --- /dev/null +++ b/lua/cmp/utils/misc.lua @@ -0,0 +1,112 @@ +local misc = {} + +---Return concatenated list +---@param list1 any[] +---@param list2 any[] +---@return any[] +misc.concat = function(list1, list2) + local new_list = {} + for _, v in ipairs(list1) do + table.insert(new_list, v) + end + for _, v in ipairs(list2) do + table.insert(new_list, v) + end + return new_list +end + +---Merge two tables recursively +---@generic T +---@param v1 T +---@param v2 T +---@return T +misc.merge = function(v1, v2) + local merge1 = type(v1) == "table" and not vim.tbl_islist(v1) + local merge2 = type(v2) == "table" and not vim.tbl_islist(v1) + if merge1 and merge2 then + local new_tbl = {} + for k, v in pairs(v2) do + new_tbl[k] = misc.merge(v1[k], v) + end + for k, v in pairs(v1) do + if v2[k] == nil then + new_tbl[k] = v + end + end + return new_tbl + end + return v1 or v2 +end + + +---Generate id for group name +misc.id = setmetatable({ + group = {} +}, { + __call = function(_, group) + misc.id.group[group] = misc.id.group[group] or 0 + misc.id.group[group] = misc.id.group[group] + 1 + return misc.id.group[group] + end +}) + +---Check the value is nil or not. +---@param v boolean +---@return boolean +misc.safe = function(v) + if v == nil or v == vim.NIL then + return nil + end + return v +end + +---Treat 1/0 as bool value +---@param v boolean|"1"|"0" +---@param def boolean +---@return boolean +misc.bool = function(v, def) + if misc.safe(v) == nil then + return def + end + return v == true or v == 1 +end + +---Set value to deep object +---@param t table +---@param keys string[] +---@param v any +misc.set = function(t, keys, v) + local c = t + for i = 1, #keys - 1 do + local key = keys[i] + c[key] = misc.safe(c[key]) or {} + c = c[key] + end + c[keys[#keys]] = v +end + +---Copy table +---@generic T +---@param tbl T +---@return T +misc.copy = function(tbl) + if type(tbl) ~= 'table' then + return tbl + end + + if vim.tbl_islist(tbl) then + local copy = {} + for i, value in ipairs(tbl) do + copy[i] = misc.copy(value) + end + return copy + end + + local copy = {} + for key, value in pairs(tbl) do + copy[key] = misc.copy(value) + end + return copy +end + +return misc diff --git a/lua/cmp/utils/patch.lua b/lua/cmp/utils/patch.lua new file mode 100644 index 0000000..6be758f --- /dev/null +++ b/lua/cmp/utils/patch.lua @@ -0,0 +1,32 @@ +local keymap = require('cmp.utils.keymap') +local types = require('cmp.types') + +local patch = {} + +---@type table +patch.callbacks = {} + +---Apply oneline textEdit +---@param ctx cmp.Context +---@param range lsp.Range +---@param word string +---@param callback function +patch.apply = function(ctx, range, word, callback) + local ok = true + ok = ok and range.start.line == ctx.cursor.row - 1 + ok = ok and range.start.line == range['end'].line + if not ok then + error("text_edit's range must be current one line.") + end + range = types.lsp.Range.to_vim(ctx.bufnr, range) + + local before = string.sub(ctx.cursor_before_line, range.start.col) + local after = string.sub(ctx.cursor_after_line, ctx.cursor.col, range['end'].col) + local before_len = vim.fn.strchars(before) + local after_len = vim.fn.strchars(after) + local keys = string.rep('', after_len) .. string.rep('', after_len + before_len) .. word + keymap.feedkeys(keys, 'n', callback) +end + +return patch + diff --git a/lua/cmp/utils/pattern.lua b/lua/cmp/utils/pattern.lua new file mode 100644 index 0000000..68e2e34 --- /dev/null +++ b/lua/cmp/utils/pattern.lua @@ -0,0 +1,21 @@ +local pattern = {} + +pattern._regexes = {} + +pattern.regex = function(p) + if not pattern._regexes[p] then + pattern._regexes[p] = vim.regex(p) + end + return pattern._regexes[p] +end + +pattern.offset = function(p, text) + local s, e = pattern.regex(p):match_str(text) + if s then + return s + 1, e + 1 + end + return nil, nil +end + +return pattern + diff --git a/lua/cmp/utils/spec.lua b/lua/cmp/utils/spec.lua new file mode 100644 index 0000000..3a85e2a --- /dev/null +++ b/lua/cmp/utils/spec.lua @@ -0,0 +1,42 @@ +local context = require'cmp.context' +local source = require 'cmp.source' +local types = require('cmp.types') + +local spec = {} + +spec.before = function() + vim.cmd [[ + bdelete! + enew! + setlocal virtualedit=all + ]] +end + +spec.state = function(text, row, col) + vim.fn.setline(1, text) + vim.fn.cursor(row, col) + local ctx = context.empty() + local s = source.new('spec', { + complete = function() + end + }) + return { + context = function() + return ctx + end, + source = function() + return s + end, + press = function(char) + vim.fn.feedkeys(('i%s'):format(char), 'nx') + vim.fn.feedkeys(('l'):format(char), 'nx') + ctx.prev_context = nil + ctx = context.new(ctx, { reason = types.cmp.ContextReason.Manual }) + s:complete(ctx, function() end) + return ctx + end + } +end + +return spec + diff --git a/lua/cmp/utils/str.lua b/lua/cmp/utils/str.lua new file mode 100644 index 0000000..185e1a9 --- /dev/null +++ b/lua/cmp/utils/str.lua @@ -0,0 +1,150 @@ +local char = require'cmp.utils.char' +local pattern = require 'cmp.utils.pattern' + +local str = {} + +local INVALID_CHARS = {} +INVALID_CHARS[string.byte('=')] = true +INVALID_CHARS[string.byte('$')] = true +INVALID_CHARS[string.byte('(')] = true +INVALID_CHARS[string.byte('[')] = true +INVALID_CHARS[string.byte('"')] = true +INVALID_CHARS[string.byte("'")] = true +INVALID_CHARS[string.byte("\n")] = true + +local PAIR_CHARS = {} +PAIR_CHARS[string.byte('[')] = string.byte(']') +PAIR_CHARS[string.byte('(')] = string.byte(')') +PAIR_CHARS[string.byte('<')] = string.byte('>') + +---Return if specified text has prefix or not +---@param text string +---@param prefix string +---@return boolean +str.has_prefix = function(text, prefix) + if #text < #prefix then + return false + end + for i = 1, #prefix do + if not char.match(string.byte(text, i), string.byte(prefix, i)) then + return false + end + end + return true +end + +---Remove suffix +---@param text string +---@param suffix string +---@return string +str.remove_suffix = function(text, suffix) + if #text < #suffix then + return text + end + + local i = 0 + while i < #suffix do + if string.byte(text, #text - i) ~= string.byte(suffix, #suffix - i) then + return text + end + i = i + 1 + end + return string.sub(text, 1, -#suffix - 1) +end + +---strikethrough +---@param text string +---@return string +str.strikethrough = function(text) + local r = pattern.regex('.') + local buffer = '' + while text ~= '' do + local s, e = r:match_str(text) + if not s then + break + end + buffer = buffer .. string.sub(text, s, e) .. '̶' + text = string.sub(text, e + 1) + end + return buffer +end + +---omit +---@param text string +---@param width number +---@return string +str.omit = function(text, width) + if width == 0 then + return '' + end + + if not text then + text = '' + end + if #text > width then + return string.sub(text, 1, width + 1) .. '...' + end + return text +end + +---trim +---@param text string +---@return string +str.trim = function(text) + local s = 1 + for i = 1, #text do + if not char.is_white(string.byte(text, i)) then + s = i + break + end + end + + local e = #text + for i = #text, 1, -1 do + if not char.is_white(string.byte(text, i)) then + e = i + break + end + end + if s == 1 and e == #text then + return text + end + return string.sub(text, s, e) +end + +---get_word +---@param text string +---@return string +str.get_word = function(text, stop_char) + local valids = {} + local has_valid = false + for idx = 1, #text do + local c = string.byte(text, idx) + local invalid = INVALID_CHARS[c] and not (valids[c] and stop_char ~= c) + if has_valid and invalid then + return string.sub(text, 1, idx - 1) + end + valids[c] = true + if PAIR_CHARS[c] then + valids[PAIR_CHARS[c]] = true + end + has_valid = has_valid or not invalid + end + return text +end + +---Oneline +---@param text string +---@return string +str.oneline = function(text) + for i = 1, #text do + if string.byte(text, i) == string.byte('\n', 1) then + return string.sub(text, 1, i - 1) + end + end + return text +end + +return str + + diff --git a/lua/cmp/utils/str_spec.lua b/lua/cmp/utils/str_spec.lua new file mode 100644 index 0000000..c07763c --- /dev/null +++ b/lua/cmp/utils/str_spec.lua @@ -0,0 +1,26 @@ +local str = require "cmp.utils.str" + +describe('utils.str', function() + + it('get_word', function() + assert.are.equal(str.get_word('print'), 'print') + assert.are.equal(str.get_word('$variable'), '$variable') + assert.are.equal(str.get_word('print()'), 'print') + assert.are.equal(str.get_word('["cmp#confirm"]'), '["cmp#confirm"]') + assert.are.equal(str.get_word('"devDependencies":', string.byte('"')), '"devDependencies') + end) + + it('strikethrough', function() + assert.are.equal(str.strikethrough('あいうえお'), 'あ̶い̶う̶え̶お̶') + end) + + it('remove_suffix', function() + assert.are.equal(str.remove_suffix('log()', '$0'), 'log()') + assert.are.equal(str.remove_suffix('log()$0', '$0'), 'log()') + assert.are.equal(str.remove_suffix('log()${0}', '${0}'), 'log()') + assert.are.equal(str.remove_suffix('log()${0:placeholder}', '${0}'), 'log()${0:placeholder}') + end) + +end) + + diff --git a/plugin/cmp.lua b/plugin/cmp.lua new file mode 100644 index 0000000..fc30162 --- /dev/null +++ b/plugin/cmp.lua @@ -0,0 +1,38 @@ +if vim.g.loaded_cmp then + return +end +vim.g.loaded_cmp = true + +local cmp = require'cmp' +local misc = require'cmp.utils.misc' + +-- TODO: https://github.com/neovim/neovim/pull/14661 +vim.cmd [[ + augroup cmp + autocmd! + autocmd InsertEnter * lua require'cmp.autocmd'.emit('InsertEnter') + autocmd InsertLeave * lua require'cmp.autocmd'.emit('InsertLeave') + autocmd TextChangedI,TextChangedP * lua require'cmp.autocmd'.emit('TextChanged') + autocmd CompleteChanged * lua require'cmp.autocmd'.emit('CompleteChanged') + autocmd CompleteDone * lua require'cmp.autocmd'.emit('CompleteDone') + augroup END +]] + +if vim.fn.hlexists('CmpDocumentation') == 0 then + vim.cmd [[highlight link CmpDocumentation NormalFloat]] +end + +if vim.fn.hlexists('CmpDocumentationBorder') == 0 then + vim.cmd [[highlight link CmpDocumentationBorder NormalFloat]] +end + +misc.set(_G, { 'cmp', 'complete' }, function() + cmp.complete() + return vim.api.nvim_replace_termcodes('', true, true, true) +end) + +misc.set(_G, { 'cmp', 'close' }, function() + cmp.close() + return vim.api.nvim_replace_termcodes('', true, true, true) +end) + diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..e682e25 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,4 @@ +indent_type = "Spaces" +indent_width = 2 +column_width = 1200 +quote_style = "AutoPreferSingle"