Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement linkedEditingRange (experimental) #1022

Closed
wants to merge 11 commits into from
Closed
4 changes: 4 additions & 0 deletions autoload/lsp.vim
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function! lsp#enable() abort
call lsp#ui#vim#completion#_setup()
call lsp#internal#document_highlight#_enable()
call lsp#internal#diagnostics#_enable()
call lsp#internal#linked_editing_range#_enable()
call lsp#internal#show_message_request#_enable()
call lsp#internal#work_done_progress#_enable()
call s:register_events()
Expand Down Expand Up @@ -518,6 +519,9 @@ function! lsp#default_get_supported_capabilities(server_info) abort
\ 'dynamicRegistration': v:false,
\ 'linkSupport' : v:true
\ },
\ 'linkedEditingRange': {
\ 'dynamicRegistration': v:false,
\ },
\ 'rangeFormatting': {
\ 'dynamicRegistration': v:false,
\ },
Expand Down
4 changes: 4 additions & 0 deletions autoload/lsp/capabilities.vim
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ function! lsp#capabilities#has_implementation_provider(server_name) abort
return s:has_provider(a:server_name, 'implementationProvider')
endfunction

function! lsp#capabilities#has_linked_editing_range_provider(server_name) abort
return s:has_provider(a:server_name, 'linkedEditingRangeProvider')
endfunction

function! lsp#capabilities#has_code_action_provider(server_name) abort
let l:capabilities = lsp#get_server_capabilities(a:server_name)
if !empty(l:capabilities) && has_key(l:capabilities, 'codeActionProvider')
Expand Down
165 changes: 165 additions & 0 deletions autoload/lsp/internal/linked_editing_range.vim
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
let s:TextEdit = vital#lsp#import('VS.LSP.TextEdit')
let s:TextMark = vital#lsp#import('VS.Vim.Buffer.TextMark')

let s:TEXT_MARK_NAMESPACE = 'lsp#internal#linked_editing_range'

let s:state = {}
let s:state['bufnr'] = -1
let s:state['changenr'] = -1
let s:state['changedtick'] = -1

function! lsp#internal#linked_editing_range#_enable() abort
if !s:enabled()
return
endif

let s:Dispose = lsp#callbag#merge(
\ lsp#callbag#pipe(
\ lsp#callbag#fromEvent(['InsertEnter']),
\ lsp#callbag#filter({ -> g:lsp_linked_editing_range_enabled }),
\ lsp#callbag#flatMap({ -> s:request_sync() }),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason it needs to be sync?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I afraid to lost keypress if the user presses quickly. So I'm choosing it to be sync.

But if text-prop/extmark is working correct, we can choose debouncing it.

\ lsp#callbag#subscribe({
\ 'next': { x -> call('s:prepare', x) }
hrsh7th marked this conversation as resolved.
Show resolved Hide resolved
\ })
\ ),
\ lsp#callbag#pipe(
\ lsp#callbag#fromEvent(['InsertLeave']),
\ lsp#callbag#filter({ -> g:lsp_linked_editing_range_enabled }),
\ lsp#callbag#subscribe({ -> s:clear() })
\ ),
\ lsp#callbag#pipe(
\ lsp#callbag#fromEvent(['TextChanged', 'TextChangedI', 'TextChangedP']),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think for now it is ok. but TextChangedP is not available in all versions. One easy fix would be to check for TextChangedP support in s:enabled

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will also hopefully encourage folks to migrate to newer versions.

\ lsp#callbag#filter({ -> g:lsp_linked_editing_range_enabled }),
\ lsp#callbag#subscribe({ -> s:sync() })
hrsh7th marked this conversation as resolved.
Show resolved Hide resolved
\ ),
\ )
endfunction

function! lsp#internal#linked_editing_range#_disable() abort
if exists('s:Dispose')
call s:clear()
call s:Dispose()
unlet s:Dispose
endif
endfunction

function! lsp#internal#linked_editing_range#prepare() abort
if !s:enabled()
return ''
endif

call lsp#callbag#pipe(
\ s:request_sync(),
\ lsp#callbag#subscribe({
\ 'next': { x -> call('s:prepare', x) },
\ 'error': { -> {} },
\ })
\ )
return ''
endfunction

function! s:enabled(...) abort
return g:lsp_linked_editing_range_enabled && s:TextMark.is_available()
endfunction

function! s:request_sync() abort
let l:server = lsp#get_allowed_servers(&filetype)
let l:server = filter(l:server, 'lsp#capabilities#has_linked_editing_range_provider(v:val)')
let l:server = get(l:server, 0, v:null)
if empty(l:server)
return lsp#callbag#of([v:null])
endif

return lsp#callbag#of(
\ lsp#callbag#pipe(
\ lsp#request(l:server, {
\ 'method': 'textDocument/linkedEditingRange',
\ 'params': {
\ 'textDocument': lsp#get_text_document_identifier(),
\ 'position': lsp#get_position(),
\ }
\ }),
\ lsp#callbag#toList()
\ ).wait({ 'sleep': 1, 'timeout': 200 })
hrsh7th marked this conversation as resolved.
Show resolved Hide resolved
\ )
endfunction

function! s:prepare(x) abort
if empty(a:x) || empty(get(a:x, 'response')) || empty(get(a:x['response'], 'result')) || empty(get(a:x['response']['result'], 'ranges'))
return
endif

call s:clear()
call s:TextMark.set(bufnr('%'), s:TEXT_MARK_NAMESPACE, map(a:x['response']['result']['ranges'], { _, range -> {
\ 'start_pos': lsp#utils#position#lsp_to_vim('%', range['start']),
\ 'end_pos': lsp#utils#position#lsp_to_vim('%', range['end']),
\ 'highlight': 'Underlined',
\ } }))
let s:state['bufnr'] = bufnr('%')
let s:state['changenr'] = changenr()
let s:state['changedtick'] = b:changedtick
endfunction

function! s:clear() abort
call s:TextMark.clear(bufnr('%'), s:TEXT_MARK_NAMESPACE)
endfunction

function! s:sync() abort
let l:bufnr = bufnr('%')
if s:state['bufnr'] != l:bufnr
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for callbags this is usually not preferred. you can instead use switchMap and takeUntil similar to this https://github.com/prabirshrestha/vim-lsp/blob/master/autoload/lsp/internal/document_highlight.vim

Feel free to change or ignore it for now and I can refactor it later when it is merged.

I usually use RxJS or other Rx libraries does to understand callbag.

High level these are what it means.

  1. switchMap -> when ever you have a next, cancel the existing one and start a new one. Good example for this is autocomplete where if I type he and then type hello I want to cancel he and automatically start hello.
  2. takeUntil -> this is use for cancellation. basically it means when a next is called in takeuntil cancel the stream.

But since you are using wait what you have should be ok.

Some good reading here. https://medium.com/@benlesh/rxjs-dont-unsubscribe-6753ed4fda87

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. I learned a lot.

return
endif
if s:state['changedtick'] == b:changedtick
hrsh7th marked this conversation as resolved.
Show resolved Hide resolved
return
endif
if s:state['changenr'] > changenr()
return
endif

" get current mark and related marks.
let l:pos = getpos('.')[1 : 2]
let l:current_mark = v:null
let l:related_marks = []
for l:mark in s:TextMark.get(l:bufnr, s:TEXT_MARK_NAMESPACE)
let l:start_pos = l:mark['start_pos']
let l:end_pos = l:mark['end_pos']

let l:contains = v:true
let l:contains = l:contains && (l:start_pos[0] < l:pos[0] || l:start_pos[0] == l:pos[0] && l:start_pos[1] <= l:pos[1])
let l:contains = l:contains && (l:end_pos[0] > l:pos[0] || l:end_pos[0] == l:pos[0] && l:end_pos[1] >= l:pos[1])
if l:contains
let l:current_mark = l:mark
else
let l:related_marks += [l:mark]
endif
endfor

" ignore if current mark is not detected.
if empty(l:current_mark)
return
endif

" apply new text for related marks.
let l:new_text = lsp#utils#range#_get_text(l:bufnr, {
\ 'start': lsp#utils#position#vim_to_lsp('%', l:current_mark['start_pos']),
\ 'end': lsp#utils#position#vim_to_lsp('%', l:current_mark['end_pos']),
\ })
if l:new_text !~# '^\k*$'
call s:clear()
call feedkeys("\<C-G>u", 'n')
return
endif

call lsp#utils#text_edit#apply_text_edits(l:bufnr, map(l:related_marks, { _, mark -> {
\ 'range': {
\ 'start': lsp#utils#position#vim_to_lsp('%', mark['start_pos']),
\ 'end': lsp#utils#position#vim_to_lsp('%', mark['end_pos']),
\ },
\ 'newText': l:new_text
\ } }))

" save state.
let s:state['bufnr'] = l:bufnr
let s:state['changenr'] = changenr()
let s:state['changedtick'] = b:changedtick
endfunction
32 changes: 32 additions & 0 deletions autoload/lsp/utils/range.vim
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,38 @@ function! lsp#utils#range#_get_current_line_range() abort
return l:range
endfunction

" Returns the range contains specified position or not.
function! lsp#utils#range#_contains(range, position) abort
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like this can be used to solve this issue now. #888

if !(
\ a:range['start']['line'] <= a:position['line'] && (
\ a:range['start']['line'] == a:position['line'] &&
\ a:range['start']['character'] <= a:position['character']
\ )
\ )
return v:false
endif
if !(
\ a:range['end']['line'] >= a:position['line'] && (
\ a:range['end']['line'] == a:position['line'] &&
\ a:range['end']['character'] >= a:position['character']
\ )
\ )
return v:false
endif
return v:true
endfunction

" Return the range of text for the specified expr.
function! lsp#utils#range#_get_text(expr, range) abort
let l:lines = []
for l:line in range(a:range['start']['line'], a:range['end']['line'])
let l:lines += getbufline(a:expr, l:line + 1)
endfor
let l:lines[-1] = strcharpart(l:lines[-1], 0, a:range['end']['character'])
let l:lines[0] = strcharpart(l:lines[0], a:range['start']['character'], strchars(l:lines[0]))
return join(l:lines, "\n")
endfunction

" Convert a LSP range to one or more vim match positions.
" If the range spans over multiple lines, break it down to multiple
" positions, one for each line.
Expand Down
155 changes: 4 additions & 151 deletions autoload/lsp/utils/text_edit.vim
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
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_position = lsp#get_position()

call s:_switch(l:target_bufname)
for l:text_edit in s:_normalize(a:text_edits)
call s:_apply(bufnr(l:target_bufname), l:text_edit, l:cursor_position)
endfor
call s:_switch(l:current_bufname)
let s:TextEdit = vital#lsp#import('VS.LSP.TextEdit')

if bufnr(l:current_bufname) == bufnr(l:target_bufname)
call cursor(lsp#utils#position#lsp_to_vim('%', l:cursor_position))
endif
function! lsp#utils#text_edit#apply_text_edits(uri, text_edits) abort
return s:TextEdit.apply(lsp#utils#uri_to_path(a:uri), deepcopy(a:text_edits))
endfunction


" @summary Use this to convert textedit to vim list that is compatible with
" quickfix and locllist items
" @param uri = DocumentUri
Expand Down Expand Up @@ -78,141 +69,3 @@ function! s:lsp_text_edit_item_to_vim(uri, text_edit, cache) abort
\ }
endfunction

"
" _apply
"
function! s:_apply(bufnr, text_edit, cursor_position) 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

" save length.
let l:new_lines_len = len(l:new_lines)
let l:range_len = (a:text_edit['range']['end']['line'] - a:text_edit['range']['start']['line']) + 1

" 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

" fix cursor pos
if a:text_edit['range']['end']['line'] < a:cursor_position['line']
" fix cursor line
let a:cursor_position['line'] += l:new_lines_len - l:range_len
elseif a:text_edit['range']['end']['line'] == a:cursor_position['line'] && a:text_edit['range']['end']['character'] <= a:cursor_position['character']
" fix cursor line and col
let a:cursor_position['line'] += l:new_lines_len - l:range_len
let l:end_character = strchars(l:new_lines[-1]) - strchars(l:after_line)
let l:end_offset = a:cursor_position['character'] - a:text_edit['range']['end']['character']
let a:cursor_position['character'] = l:end_character + l:end_offset
endif

" append or delete lines.
if l:new_lines_len > l:range_len
call append(a:text_edit['range']['start']['line'], repeat([''], l:new_lines_len - l:range_len))
elseif l:new_lines_len < l:range_len
let l:offset = l:range_len - l:new_lines_len
call s:delete(a:bufnr, a:text_edit['range']['start']['line'] + 1, a:text_edit['range']['start']['line'] + l:offset)
endif

" set lines.
call setline(a:text_edit['range']['start']['line'] + 1, l:new_lines)
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

"
" _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

"
" _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

"
" _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

"
" _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

"
" delete
"
function! s:delete(bufnr, start, end) abort
if exists('*deletebufline')
call deletebufline(a:bufnr, a:start, a:end)
else
let l:foldenable = &foldenable
setlocal nofoldenable
execute printf('%s,%sdelete _', a:start, a:end)
let &foldenable = l:foldenable
endif
endfunction

Loading