* dev

* Improve sync design

* Support buffer local mapping

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* stylua

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* integration

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* update

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp

* tmp
This commit is contained in:
hrsh7th 2021-08-04 01:07:12 +09:00 committed by GitHub
parent b32a6e7e77
commit d23d3533cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 4681 additions and 0 deletions

6
.githooks/pre-commit Executable file
View file

@ -0,0 +1,6 @@
#!/bin/sh
DIR="$(dirname $(dirname $( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )))"
cd $DIR
make pre-commit

49
.github/workflows/integration.yaml vendored Normal file
View file

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

2
.luacheckrc Normal file
View file

@ -0,0 +1,2 @@
globals = { 'vim', 'describe', 'it', 'before_each', 'after_each', 'assert', 'async' }
max_line_length = false

24
Makefile Normal file
View file

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

View file

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

32
autoload/cmp.vim Normal file
View file

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

9
autoload/vital/_cmp.vim Normal file
View file

@ -0,0 +1,9 @@
let s:_plugin_name = expand('<sfile>: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

View file

@ -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('<sfile>'), '<SNR>\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('<SNR>%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|<C-x><C-o><C-n><C-y> -> 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

View file

@ -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('<sfile>'), '<SNR>\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('<SNR>%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

View file

@ -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('<sfile>'), '<SNR>\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('<SNR>%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

View file

@ -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('<sfile>'), '<SNR>\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('<SNR>%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

View file

@ -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('<sfile>'), '<SNR>\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('<SNR>%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 <buffer=%s> 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

View file

@ -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('<sfile>'), '<SNR>\zs\d\+\ze__SID$')
endfunction
execute join(['function! vital#_cmp#VS#Vim#Option#import() abort', printf("return map({'define': ''}, \"vital#_cmp#function('<SNR>%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

330
autoload/vital/cmp.vim Normal file
View file

@ -0,0 +1,330 @@
let s:plugin_name = expand('<sfile>:t:r')
let s:vital_base_dir = expand('<sfile>:h')
let s:project_root = expand('<sfile>: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 =~# '^<lambda>\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 <SID> 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('<sfile>: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 <sfile> 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("\<SNR>")), '"[\\x" . printf("%0x", char2nr("\<SNR>"[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<SNR>%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('<SNR>%s_%s', a:sid, a:funcname)
endfunction

5
autoload/vital/cmp.vital Normal file
View file

@ -0,0 +1,5 @@
cmp
5828301d6bae0858e9ea21012913544f5ef8e375
VS.LSP.CompletionItem
VS.LSP.Position

7
init.sh Executable file
View file

@ -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/*

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

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

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

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

View file

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

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

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

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

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

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

View file

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

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

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

View file

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

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

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

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

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

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

View file

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

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

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

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

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

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

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

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

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

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

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

View file

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

38
plugin/cmp.lua Normal file
View file

@ -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('<Ignore>', true, true, true)
end)
misc.set(_G, { 'cmp', 'close' }, function()
cmp.close()
return vim.api.nvim_replace_termcodes('<Ignore>', true, true, true)
end)

4
stylua.toml Normal file
View file

@ -0,0 +1,4 @@
indent_type = "Spaces"
indent_width = 2
column_width = 1200
quote_style = "AutoPreferSingle"