Open entropitor opened 4 years ago
Yep! It totally makes sense to do that here. Want to submit a PR?
Yeah, potentially, but I might need some help with the clearing of the text. Do you think it's best to just copy all the code that vim-lsp uses for this?
That works. Be sure to give them credit in the PR and I'll make sure to add credit to the README :)
function! s:escape_string(str) abort
let l:ret = substitute(a:str, '\%x00', '\\n', 'g')
let l:ret = substitute(l:ret, '"', '\\"', 'g')
return l:ret
endfunction
" This function can be error prone if the caller forgets to use +1 to vim line
" so use lsp#utils#position#lsp_to_vim instead
" Convert a character-index (0-based) to byte-index (1-based)
" This function requires a buffer specifier (expr, see :help bufname()),
" a line number (lnum, 1-based), and a character-index (char, 0-based).
function! s:to_col(expr, lnum, char) abort
let l:lines = getbufline(a:expr, a:lnum)
if l:lines == []
if type(a:expr) != v:t_string || !filereadable(a:expr)
" invalid a:expr
return a:char + 1
endif
" a:expr is a file that is not yet loaded as a buffer
let l:lines = readfile(a:expr, '', a:lnum)
endif
let l:linestr = l:lines[-1]
return strlen(strcharpart(l:linestr, 0, a:char)) + 1
endfunction
" Convert a byte-index (1-based) to a character-index (0-based)
" This function requires a buffer specifier (expr, see :help bufname()),
" a line number (lnum, 1-based), and a byte-index (char, 1-based).
function! _lsp_utils_to_char(expr, lnum, col) abort
let l:lines = getbufline(a:expr, a:lnum)
if l:lines == []
if type(a:expr) != v:t_string || !filereadable(a:expr)
" invalid a:expr
return a:col - 1
endif
" a:expr is a file that is not yet loaded as a buffer
let l:lines = readfile(a:expr, '', a:lnum)
endif
let l:linestr = l:lines[-1]
return strchars(strpart(l:linestr, 0, a:col - 1))
endfunction
function! s:decode_uri(uri) abort
let l:ret = substitute(a:uri, '[?#].*', '', '')
return substitute(l:ret, '%\(\x\x\)', '\=printf("%c", str2nr(submatch(1), 16))', 'g')
endfunction
function! s:urlencode_char(c) abort
return printf('%%%02X', char2nr(a:c))
endfunction
function! s:get_prefix(path) abort
return matchstr(a:path, '\(^\w\+::\|^\w\+://\)')
endfunction
function! s:encode_uri(path, start_pos_encode, default_prefix) abort
let l:prefix = s:get_prefix(a:path)
let l:path = a:path[len(l:prefix):]
if len(l:prefix) == 0
let l:prefix = a:default_prefix
endif
let l:result = strpart(a:path, 0, a:start_pos_encode)
for l:i in range(a:start_pos_encode, len(l:path) - 1)
" Don't encode '/' here, `path` is expected to be a valid path.
if l:path[l:i] =~# '^[a-zA-Z0-9_.~/-]$'
let l:result .= l:path[l:i]
else
let l:result .= s:urlencode_char(l:path[l:i])
endif
endfor
return l:prefix . l:result
endfunction
if has('win32') || has('win64')
function! _lsp_utils_path_to_uri(path) abort
if empty(a:path)
return a:path
else
" You must not encode the volume information on the path if
" present
let l:end_pos_volume = matchstrpos(a:path, '\c[A-Z]:')[2]
if l:end_pos_volume == -1
let l:end_pos_volume = 0
endif
return s:encode_uri(substitute(a:path, '\', '/', 'g'), l:end_pos_volume, 'file:///')
endif
endfunction
else
function! _lsp_utils_path_to_uri(path) abort
if empty(a:path)
return a:path
else
return s:encode_uri(a:path, 0, 'file://')
endif
endfunction
endif
if has('win32') || has('win64')
function! _lsp_utils_uri_to_path(uri) abort
return substitute(s:decode_uri(a:uri[len('file:///'):]), '/', '\\', 'g')
endfunction
else
function! _lsp_utils_uri_to_path(uri) abort
return s:decode_uri(a:uri[len('file://'):])
endfunction
endif
function! _lsp_utils_get_buffer_uri(...) abort
return _lsp_utils_path_to_uri(expand((a:0 > 0 ? '#' . a:1 : '%') . ':p'))
endfunction
"
" _check
"
" LSP Spec says `multiple text edits can not overlap those ranges`.
" This function check it. But does not throw error.
"
function! s:_check(text_edits) abort
if len(a:text_edits) > 1
let l:range = a:text_edits[0].range
for l:text_edit in a:text_edits[1 : -1]
if l:range.end.line > l:text_edit.range.start.line || (
\ l:range.end.line == l:text_edit.range.start.line &&
\ l:range.end.character > l:text_edit.range.start.character
\ )
call lsp#log('text_edit: range overlapped.')
endif
let l:range = l:text_edit.range
endfor
endif
return a:text_edits
endfunction
"
" _normalize
"
function! s:_normalize(text_edits) abort
let l:text_edits = type(a:text_edits) == type([]) ? a:text_edits : [a:text_edits]
let l:text_edits = filter(copy(l:text_edits), { _, text_edit -> type(text_edit) == type({}) })
let l:text_edits = s:_range(l:text_edits)
let l:text_edits = sort(copy(l:text_edits), function('s:_compare', [], {}))
let l:text_edits = s:_check(l:text_edits)
return reverse(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
"
" _range
"
function! s:_range(text_edits) abort
for l:text_edit in a:text_edits
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
endfor
return a:text_edits
endfunction
"
" _switch
"
function! s:_switch(path) abort
if bufnr(a:path) >= 0
execute printf('keepalt keepjumps %sbuffer!', bufnr(a:path))
else
execute printf('keepalt keepjumps edit! %s', fnameescape(a:path))
endif
endfunction
function! _lsp_utils_text_edit_apply_text_edits(uri, text_edits) abort
let l:current_bufname = bufname('%')
let l:target_bufname = _lsp_utils_uri_to_path(a:uri)
let l:cursor_pos = getpos('.')[1 : 3]
let l:cursor_offset = 0
let l:topline = line('w0')
call s:_switch(l:target_bufname)
for l:text_edit in s:_normalize(a:text_edits)
let l:cursor_offset += s:_apply(bufnr(l:target_bufname), l:text_edit, l:cursor_pos)
endfor
call s:_switch(l:current_bufname)
if bufnr(l:current_bufname) == bufnr(l:target_bufname)
let l:length = strlen(getline(l:cursor_pos[0])) + 1
let l:cursor_pos[2] = max([0, l:cursor_pos[1] + l:cursor_pos[2] - l:length])
let l:cursor_pos[1] = min([l:length, l:cursor_pos[1] + l:cursor_pos[2]])
call cursor(l:cursor_pos)
call winrestview({ 'topline': l:topline + l:cursor_offset })
endif
endfunction
function! _lsp_utils__split_by_eol(text) abort
return split(a:text, '\r\n\|\r\|\n', v:true)
endfunction
let s:fixendofline_exists = exists('+fixendofline')
function! _lsp_utils_buffer__get_fixendofline(buf) abort
let l:eol = getbufvar(a:buf, '&endofline')
let l:binary = getbufvar(a:buf, '&binary')
if s:fixendofline_exists
let l:fixeol = getbufvar(a:buf, '&fixendofline')
if !l:binary
" When 'binary' is off and 'fixeol' is on, 'endofline' is not used
"
" When 'binary' is off and 'fixeol' is off, 'endofline' is used to
" remember the presence of a <EOL>
return l:fixeol || l:eol
else
" When 'binary' is on, the value of 'fixeol' doesn't matter
return l:eol
endif
else
" When 'binary' is off the value of 'endofline' is not used
"
" When 'binary' is on 'endofline' is used to remember the presence of
" a <EOL>
return !l:binary || l:eol
endif
endfunction
"
" _apply
"
function! s:_apply(bufnr, text_edit, cursor_pos) abort
" create before/after line.
let l:start_line = getline(a:text_edit.range.start.line + 1)
let l:end_line = getline(a:text_edit.range.end.line + 1)
let l:before_line = strcharpart(l:start_line, 0, a:text_edit.range.start.character)
let l:after_line = strcharpart(l:end_line, a:text_edit.range.end.character, strchars(l:end_line) - a:text_edit.range.end.character)
" create new lines.
let l:new_lines = _lsp_utils__split_by_eol(a:text_edit.newText)
let l:new_lines[0] = l:before_line . l:new_lines[0]
let l:new_lines[-1] = l:new_lines[-1] . l:after_line
" fixendofline
let l:buffer_length = len(getbufline(a:bufnr, '^', '$'))
let l:should_fixendofline = _lsp_utils_buffer__get_fixendofline(a:bufnr)
let l:should_fixendofline = l:should_fixendofline && l:new_lines[-1] ==# ''
let l:should_fixendofline = l:should_fixendofline && l:buffer_length <= a:text_edit['range']['end']['line']
let l:should_fixendofline = l:should_fixendofline && a:text_edit['range']['end']['character'] == 0
if l:should_fixendofline
call remove(l:new_lines, -1)
endif
let l:new_lines_len = len(l:new_lines)
" fix cursor pos
let l:cursor_offset = 0
if a:text_edit.range.end.line + 1 < a:cursor_pos[0]
let l:cursor_offset = l:new_lines_len - (a:text_edit.range.end.line - a:text_edit.range.start.line) - 1
let a:cursor_pos[0] += l:cursor_offset
endif
" append new lines.
call append(a:text_edit.range.start.line, l:new_lines)
" remove old lines
execute printf('%s,%sdelete _',
\ l:new_lines_len + a:text_edit.range.start.line + 1,
\ min([l:new_lines_len + a:text_edit.range.end.line + 1, line('$')])
\ )
return l:cursor_offset
endfunction
" @param expr = see :help bufname()
" @param position = {
" 'line': 1,
" 'character': 1
" }
" @returns [
" line,
" col
" ]
function! _lsp_utils_position_lsp_to_vim(expr, position) abort
let l:line = a:position['line'] + 1
let l:char = a:position['character']
let l:col = s:to_col(a:expr, l:line, l:char)
return [l:line, l:col]
endfunction
function! s:clear_inserted_text(line, done_position, completed_item, completion_item) abort
" Remove commit characters.
call setline('.', a:line)
" Create range to remove v:completed_item.
let l:range = {
\ 'start': {
\ 'line': a:done_position[1] - 1,
\ 'character': _lsp_utils_to_char('%', a:done_position[1], a:done_position[2] + a:done_position[3]) - strchars(a:completed_item['word'])
\ },
\ 'end': {
\ 'line': a:done_position[1] - 1,
\ 'character': _lsp_utils_to_char('%', a:done_position[1], a:done_position[2] + a:done_position[3])
\ }
\ }
" Expand remove range to textEdit.
if has_key(a:completion_item, 'textEdit')
let l:range = {
\ 'start': {
\ 'line': a:completion_item['textEdit']['range']['start']['line'],
\ 'character': a:completion_item['textEdit']['range']['start']['character'],
\ },
\ 'end': {
\ 'line': a:completion_item['textEdit']['range']['end']['line'],
\ 'character': a:completion_item['textEdit']['range']['end']['character'] + strchars(a:completed_item['word'])
\ }
\ }
endif
" Remove v:completed_item.word (and textEdit range if need).
call _lsp_utils_text_edit_apply_text_edits(_lsp_utils_get_buffer_uri(bufnr('%')), [{
\ 'range': l:range,
\ 'newText': ''
\ }])
" Move to complete start position.
call cursor(_lsp_utils_position_lsp_to_vim('%', l:range['start']))
endfunction
function! s:get_expand_text(completed_item, completion_item) abort
let l:text = a:completed_item['word']
if has_key(a:completion_item, 'textEdit') && type(a:completion_item['textEdit']) == v:t_dict
let l:text = a:completion_item['textEdit']['newText']
elseif has_key(a:completion_item, 'insertText')
let l:text = a:completion_item['insertText']
endif
return l:text
endfunction
function! s:handle_completion() abort
if empty(v:completed_item)
return
endif
echo v:completed_item
let l:completion_item = v:completed_item.user_data.nvim.lsp.completion_item
let l:new_text = s:get_expand_text(v:completed_item, l:completion_item)
if strlen(l:new_text) > 0
let l:line = getline('.')
call s:clear_inserted_text(
\ l:line,
\ getpos('.'),
\ copy(v:completed_item),
\ l:completion_item,
\ )
call feedkeys("\<C-r>=UltiSnips#Anon(\"" . s:escape_string(l:new_text) . "\", \"\")\<CR>")
endif
endfunction
autocmd CompleteDone * call s:handle_completion()
This works more or less but it's off because of the completion character typed (so the .
might come before the completed text instead of after π
). Not sure how to fix that exactly π
Awesome! Mind sending it as a PR? I want to ensure you get proper credit for this
Also, do you know how of a good LSP server and a sample project I can test this out on? I don't use snippets personally π
I would, if it was fully working but it's not π
let x = scanner.
would currently expand into
let x = .scanner
(with scanner
being autocompleted the moment I press .
so something is off but I've no idea how to fix that. I guess we need to know the trigger characters but I've no idea how to get that)
You can test it using https://github.com/vscode-langservers/vscode-json-languageserver for package.json autocompletion for example, although it's not working very well. Rust-analyzer also has snippet support.
With some pretty minor tweaks, this works super well with neosnippet. I've not been able to replicate the autocompleted .
though since I haven't found a snippet with it yet
Do you have some example projects or anything I can test that out with?
@DonnieWest if you use https://rust-analyzer.github.io/manual.html#installation, you should be able to use any rust project (e.g. cargo init --bin test
)
Write something like:
let x= Ok(true);
x.ok()$0
This is what you get without this change for the autocompletion for x, whit this change, you have the problem of the dot being in the wrong place.
Haven't had the time to look into the insertion issue, but this looks highly promising. It'd be great if Neovim handled this stuff almost entirely and we just needed a little glue code and nothing else to handle LSP snippets
https://github.com/neovim/neovim/commit/5a9226c800d3075821203952da7c38626180680d
@DonnieWest I agree completely: https://github.com/neovim/neovim/pull/12118#issuecomment-632666575
https://asciinema.org/a/MFgYKxOO4tC0p79u7tuHzZlcS
Seems to work pretty well with the modified Neosnippet support provided below
```
function! s:escape_string(str) abort
let l:ret = substitute(a:str, '\%x00', '\\n', 'g')
let l:ret = substitute(l:ret, '"', '\\"', 'g')
return l:ret
endfunction
function! s:escape_snippet(text) abort
" neosnippet.vim expects the tabstops to have curly braces around
" them, e.g. ${1} instead of $1, so we add these in now.
let l:snippet = substitute(a:text, '\$\(\d\+\)', '${\1}', 'g')
" Escape single quotes
let l:snippet = substitute(l:snippet, "'", "''", 'g')
" Make sure the snippet ends in ${0}
if l:snippet !~# "\${0}$"
let l:snippet .= "${0}"
endif
return l:snippet
endfunction
" This function can be error prone if the caller forgets to use +1 to vim line
" so use lsp#utils#position#lsp_to_vim instead
" Convert a character-index (0-based) to byte-index (1-based)
" This function requires a buffer specifier (expr, see :help bufname()),
" a line number (lnum, 1-based), and a character-index (char, 0-based).
function! s:to_col(expr, lnum, char) abort
let l:lines = getbufline(a:expr, a:lnum)
if l:lines == []
if type(a:expr) != v:t_string || !filereadable(a:expr)
" invalid a:expr
return a:char + 1
endif
" a:expr is a file that is not yet loaded as a buffer
let l:lines = readfile(a:expr, '', a:lnum)
endif
let l:linestr = l:lines[-1]
return strlen(strcharpart(l:linestr, 0, a:char)) + 1
endfunction
" Convert a byte-index (1-based) to a character-index (0-based)
" This function requires a buffer specifier (expr, see :help bufname()),
" a line number (lnum, 1-based), and a byte-index (char, 1-based).
function! _lsp_utils_to_char(expr, lnum, col) abort
let l:lines = getbufline(a:expr, a:lnum)
if l:lines == []
if type(a:expr) != v:t_string || !filereadable(a:expr)
" invalid a:expr
return a:col - 1
endif
" a:expr is a file that is not yet loaded as a buffer
let l:lines = readfile(a:expr, '', a:lnum)
endif
let l:linestr = l:lines[-1]
return strchars(strpart(l:linestr, 0, a:col - 1))
endfunction
function! s:decode_uri(uri) abort
let l:ret = substitute(a:uri, '[?#].*', '', '')
return substitute(l:ret, '%\(\x\x\)', '\=printf("%c", str2nr(submatch(1), 16))', 'g')
endfunction
function! s:urlencode_char(c) abort
return printf('%%%02X', char2nr(a:c))
endfunction
function! s:get_prefix(path) abort
return matchstr(a:path, '\(^\w\+::\|^\w\+://\)')
endfunction
function! s:encode_uri(path, start_pos_encode, default_prefix) abort
let l:prefix = s:get_prefix(a:path)
let l:path = a:path[len(l:prefix):]
if len(l:prefix) == 0
let l:prefix = a:default_prefix
endif
let l:result = strpart(a:path, 0, a:start_pos_encode)
for l:i in range(a:start_pos_encode, len(l:path) - 1)
" Don't encode '/' here, `path` is expected to be a valid path.
if l:path[l:i] =~# '^[a-zA-Z0-9_.~/-]$'
let l:result .= l:path[l:i]
else
let l:result .= s:urlencode_char(l:path[l:i])
endif
endfor
return l:prefix . l:result
endfunction
if has('win32') || has('win64')
function! _lsp_utils_path_to_uri(path) abort
if empty(a:path)
return a:path
else
" You must not encode the volume information on the path if
" present
let l:end_pos_volume = matchstrpos(a:path, '\c[A-Z]:')[2]
if l:end_pos_volume == -1
let l:end_pos_volume = 0
endif
return s:encode_uri(substitute(a:path, '\', '/', 'g'), l:end_pos_volume, 'file:///')
endif
endfunction
else
function! _lsp_utils_path_to_uri(path) abort
if empty(a:path)
return a:path
else
return s:encode_uri(a:path, 0, 'file://')
endif
endfunction
endif
if has('win32') || has('win64')
function! _lsp_utils_uri_to_path(uri) abort
return substitute(s:decode_uri(a:uri[len('file:///'):]), '/', '\\', 'g')
endfunction
else
function! _lsp_utils_uri_to_path(uri) abort
return s:decode_uri(a:uri[len('file://'):])
endfunction
endif
function! _lsp_utils_get_buffer_uri(...) abort
return _lsp_utils_path_to_uri(expand((a:0 > 0 ? '#' . a:1 : '%') . ':p'))
endfunction
"
" _check
"
" LSP Spec says `multiple text edits can not overlap those ranges`.
" This function check it. But does not throw error.
"
function! s:_check(text_edits) abort
if len(a:text_edits) > 1
let l:range = a:text_edits[0].range
for l:text_edit in a:text_edits[1 : -1]
if l:range.end.line > l:text_edit.range.start.line || (
\ l:range.end.line == l:text_edit.range.start.line &&
\ l:range.end.character > l:text_edit.range.start.character
\ )
call lsp#log('text_edit: range overlapped.')
endif
let l:range = l:text_edit.range
endfor
endif
return a:text_edits
endfunction
"
" _normalize
"
function! s:_normalize(text_edits) abort
let l:text_edits = type(a:text_edits) == type([]) ? a:text_edits : [a:text_edits]
let l:text_edits = filter(copy(l:text_edits), { _, text_edit -> type(text_edit) == type({}) })
let l:text_edits = s:_range(l:text_edits)
let l:text_edits = sort(copy(l:text_edits), function('s:_compare', [], {}))
let l:text_edits = s:_check(l:text_edits)
return reverse(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
"
" _range
"
function! s:_range(text_edits) abort
for l:text_edit in a:text_edits
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
endfor
return a:text_edits
endfunction
"
" _switch
"
function! s:_switch(path) abort
if bufnr(a:path) >= 0
execute printf('keepalt keepjumps %sbuffer!', bufnr(a:path))
else
execute printf('keepalt keepjumps edit! %s', fnameescape(a:path))
endif
endfunction
function! _lsp_utils_text_edit_apply_text_edits(uri, text_edits) abort
let l:current_bufname = bufname('%')
let l:target_bufname = _lsp_utils_uri_to_path(a:uri)
let l:cursor_pos = getpos('.')[1 : 3]
let l:cursor_offset = 0
let l:topline = line('w0')
call s:_switch(l:target_bufname)
for l:text_edit in s:_normalize(a:text_edits)
let l:cursor_offset += s:_apply(bufnr(l:target_bufname), l:text_edit, l:cursor_pos)
endfor
call s:_switch(l:current_bufname)
if bufnr(l:current_bufname) == bufnr(l:target_bufname)
let l:length = strlen(getline(l:cursor_pos[0])) + 1
let l:cursor_pos[2] = max([0, l:cursor_pos[1] + l:cursor_pos[2] - l:length])
let l:cursor_pos[1] = min([l:length, l:cursor_pos[1] + l:cursor_pos[2]])
call cursor(l:cursor_pos)
call winrestview({ 'topline': l:topline + l:cursor_offset })
endif
endfunction
function! _lsp_utils__split_by_eol(text) abort
return split(a:text, '\r\n\|\r\|\n', v:true)
endfunction
let s:fixendofline_exists = exists('+fixendofline')
function! _lsp_utils_buffer__get_fixendofline(buf) abort
let l:eol = getbufvar(a:buf, '&endofline')
let l:binary = getbufvar(a:buf, '&binary')
if s:fixendofline_exists
let l:fixeol = getbufvar(a:buf, '&fixendofline')
if !l:binary
" When 'binary' is off and 'fixeol' is on, 'endofline' is not used
"
" When 'binary' is off and 'fixeol' is off, 'endofline' is used to
" remember the presence of a
Hmm, must be something to do with the difference between ultisnips and neosnippet then :/
@entropitor could you please submit a PR out with your original work? I'd like to add the work I did here to get us neosnippet support and get it merged in. We can use that as a jumping point to also add Ultisnips support.
I'd like to give you proper credit π
It would be nice if we have snippet support. i.e. that LSP snippets would expand using UltiSnips
I found some code already but I didn't nail every part down yet. Do you think it makes sense to implement that in this plugin?
https://github.com/thomasfaingnaert/vim-lsp-ultisnips/blob/master/autoload/lsp_ultisnips.vim#L7-L9 Applying the snippet: https://github.com/prabirshrestha/vim-lsp/blob/2de8f4d479229eedcb9bf41a878b94fb7e2662ed/autoload/lsp/ui/vim/completion.vim#L57-L117 Clearing the text: https://github.com/prabirshrestha/vim-lsp/blob/2de8f4d479229eedcb9bf41a878b94fb7e2662ed/autoload/lsp/ui/vim/completion.vim#L173-L224 Applying the text edits: https://github.com/prabirshrestha/vim-lsp/blob/2de8f4d479229eedcb9bf41a878b94fb7e2662ed/autoload/lsp/utils/text_edit.vim#L1-L21
This leads to this code already but the
s:clear_inserted_text
still needs to be fixed + have a way to actually clear the line as it's using vim-lsp code under the hood.