Change text edit implementation to support \r (#718)

* Change text edit implementation to support \r

* Fix edit on unloaded buffer

* Add \r\n tests

* Fix for perf

Co-authored-by: mattn <mattn.jp@gmail.com>
This commit is contained in:
hrsh7th
2020-02-15 00:39:40 +09:00
committed by GitHub
parent a79931dcc5
commit 3e207c0ee4
5 changed files with 192 additions and 240 deletions

View File

@@ -319,3 +319,8 @@ function! lsp#utils#make_valid_word(str) abort
endif
return l:str
endfunction
function! lsp#utils#_split_by_eol(text) abort
return split(a:text, '\r\n\|\r\|\n', v:true)
endfunction

View File

@@ -26,6 +26,10 @@ function! s:get_fixendofline(buf) abort
endif
endfunction
function! lsp#utils#buffer#_get_fixendofline(bufnr) abort
return s:get_fixendofline(a:bufnr)
endfunction
function! lsp#utils#buffer#_get_lines(buf) abort
let l:lines = getbufline(a:buf, 1, '$')
if s:get_fixendofline(a:buf)

View File

@@ -1,260 +1,140 @@
function! lsp#utils#text_edit#apply_text_edits(uri, text_edits) abort
" https://microsoft.github.io/language-server-protocol/specification#textedit
" The order in the array defines the order in which the inserted string
" appear in the resulting text.
"
" The edits must be applied in the reverse order so the early edits will
" not interfere with the position of later edits, they need to be applied
" one at the time or put together as a single command.
"
" Example: {"range": {"end": {"character": 45, "line": 5}, "start":
" {"character": 45, "line": 5}}, "newText": "\n"}, {"range": {"end":
" {"character": 45, "line": 5}, "start": {"character": 45, "line": 5}},
" "newText": "import javax.ws.rs.Consumes;"}]}}
"
" If we apply the \n first we will need adjust the line range of the next
" command (so the import will be written on the next line) , but if we
" write the import first and then the \n everything will be fine.
" If you do not apply a command one at time, you will need to adjust the
" range columns after which edit. You will get this (only one execution):
"
" execute 'keepjumps normal! 6G045laimport javax.ws.rs.Consumes;'" |
" execute 'keepjumps normal! 6G045la\n'
"
" resulting in this:
" import javax.servlet.http.HttpServletRequest;i
" mport javax.ws.rs.Consumes;
"
" instead of this (multiple executions):
" execute 'keepjumps normal! 6G045laimport javax.ws.rs.Consumes;'"
" execute 'keepjumps normal! 6G045li\n'
"
" resulting in this:
" import javax.servlet.http.HttpServletRequest;
" import javax.ws.rs.Consumes;
"
"
" The sort is also necessary since the LSP specification does not
" guarantee that text edits are sorted.
"
" Example:
" Initial text: "abcdef"
" Edits:
" ((0, 0), (0, 1), "") - remove first character 'a'
" ((0, 4), (0, 5), "") - remove fifth character 'e'
" ((0, 2), (0, 3), "") - remove third character 'c'
if empty(a:text_edits)
return
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 l:current_bufname == l:target_bufname
let l:length = strlen(getline(l:cursor_pos[0]))
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
let l:text_edits = sort(deepcopy(a:text_edits), '<SID>sort_text_edit_desc')
let l:i = 0
while l:i < len(l:text_edits)
let l:merged_text_edit = s:merge_same_range(l:i, l:text_edits)
let l:cmd = s:build_cmd(a:uri, l:merged_text_edit['merged'])
try
let l:was_paste = &paste
let l:was_selection = &selection
let l:was_virtualedit = &virtualedit
let l:was_view = winsaveview()
set paste
let l:start_line = l:merged_text_edit['merged']['range']['start']['line']
let l:end_line = l:merged_text_edit['merged']['range']['end']['line']
let l:end_character = l:merged_text_edit['merged']['range']['end']['character']
if l:start_line < l:end_line && l:end_character <= 0
" set inclusive if end position was newline character.
set selection=inclusive
else
set selection=exclusive
endif
set virtualedit=onemore
silent execute l:cmd
finally
let &paste = l:was_paste
let &selection = l:was_selection
let &virtualedit = l:was_virtualedit
call winrestview(l:was_view)
endtry
let l:i = l:merged_text_edit['end_index']
endwhile
endfunction
" Merge the edits on the same range so we do not have to reverse the
" text_edits that are inserts, also from the specification:
" If multiple inserts have the same position, the order in the array
" defines the order in which the inserted strings appear in the
" resulting text
function! s:merge_same_range(start_index, text_edits) abort
let l:i = a:start_index + 1
let l:merged = deepcopy(a:text_edits[a:start_index])
while l:i < len(a:text_edits) &&
\ s:is_same_range(l:merged['range'], a:text_edits[l:i]['range'])
let l:merged['newText'] .= a:text_edits[l:i]['newText']
let l:i += 1
endwhile
return {'merged': l:merged, 'end_index': l:i}
endfunction
function! s:is_same_range(range1, range2) abort
return a:range1['start']['line'] == a:range2['start']['line'] &&
\ a:range1['end']['line'] == a:range2['end']['line'] &&
\ a:range1['start']['character'] == a:range2['start']['character'] &&
\ a:range1['end']['character'] == a:range2['end']['character']
endfunction
" https://microsoft.github.io/language-server-protocol/specification#textedit
function! s:is_insert(range) abort
return a:range['start']['line'] == a:range['end']['line'] &&
\ a:range['start']['character'] == a:range['end']['character']
endfunction
" Compares two text edits, based on the starting position of the range.
" Assumes that edits have non-overlapping ranges.
"
" `text_edit1` and `text_edit2` are dictionaries and represent LSP TextEdit type.
" _apply
"
" Returns 0 if both text edits starts at the same position (insert text),
" positive value if `text_edit1` starts before `text_edit2` and negative value
" otherwise.
function! s:sort_text_edit_desc(text_edit1, text_edit2) abort
if a:text_edit1['range']['start']['line'] != a:text_edit2['range']['start']['line']
return a:text_edit2['range']['start']['line'] - a:text_edit1['range']['start']['line']
endif
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)
if a:text_edit1['range']['start']['character'] != a:text_edit2['range']['start']['character']
return a:text_edit2['range']['start']['character'] - a:text_edit1['range']['start']['character']
endif
" 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
return !s:is_insert(a:text_edit1['range']) ? -1 :
\ s:is_insert(a:text_edit2['range']) ? 0 : 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
let l:new_lines_len = len(l:new_lines)
" fix cursor pos
let l:cursor_offset = 0
if a:text_edit.range.end.line <= 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
let l:buffer_length = len(getbufline(a:bufnr, '^', '$'))
execute printf('%s,%sdelete _',
\ l:new_lines_len + a:text_edit.range.start.line + 1,
\ min([l:buffer_length, l:new_lines_len + a:text_edit.range.end.line + 1])
\ )
return l:cursor_offset
endfunction
function! s:build_cmd(uri, text_edit) abort
let l:path = lsp#utils#uri_to_path(a:uri)
let l:buffer = bufnr(l:path)
let l:cmd = 'keepjumps keepalt ' . (l:buffer !=# -1 ? 'b ' . l:buffer : 'edit ' . l:path)
let s:text_edit = deepcopy(a:text_edit)
let s:text_edit['range'] = s:parse_range(s:text_edit['range'])
let l:sub_cmd = s:generate_sub_cmd(s:text_edit)
let l:escaped_sub_cmd = substitute(l:sub_cmd, '''', '''''', 'g')
let l:cmd = l:cmd . " | execute 'keepjumps normal! " . l:escaped_sub_cmd . "'"
call lsp#log('s:build_cmd', l:cmd)
return l:cmd
"
" _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
function! s:generate_sub_cmd(text_edit) abort
if s:is_insert(a:text_edit['range'])
return s:generate_sub_cmd_insert(a:text_edit)
else
return s:generate_sub_cmd_replace(a:text_edit)
"
" _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
function! s:generate_sub_cmd_insert(text_edit) abort
let l:start_line = a:text_edit['range']['start']['line']
let l:start_character = a:text_edit['range']['start']['character']
let l:sub_cmd = s:preprocess_cmd(a:text_edit['range'])
let l:sub_cmd .= s:generate_move_start_cmd(l:start_line, l:start_character)
if l:start_character >= strchars(getline(l:start_line))
let l:sub_cmd .= "\"=l:merged_text_edit['merged']['newText']\<CR>P"
else
let l:sub_cmd .= "\"=l:merged_text_edit['merged']['newText'].'?'\<CR>gPh\"_x"
endif
return l:sub_cmd
"
" _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
function! s:generate_sub_cmd_replace(text_edit) abort
let l:start_line = a:text_edit['range']['start']['line']
let l:start_character = a:text_edit['range']['start']['character']
let l:end_line = a:text_edit['range']['end']['line']
let l:end_character = a:text_edit['range']['end']['character']
let l:new_text = a:text_edit['newText']
let l:sub_cmd = s:preprocess_cmd(a:text_edit['range'])
let l:sub_cmd .= s:generate_move_start_cmd(l:start_line, l:start_character) " move to the first position
" If start and end position are 0, we are selecting a range of lines.
" Thus, we can use linewise-visual mode, which avoids some inconsistencies
" when applying text edits.
if l:start_character == 0 && l:end_character == 0
let l:sub_cmd .= 'V'
else
let l:sub_cmd .= 'v'
endif
let l:sub_cmd .= s:generate_move_end_cmd(l:end_line, l:end_character) " move to the last position
if len(l:new_text) == 0
let l:sub_cmd .= 'x'
elseif l:start_character == 0 && l:end_character == 0
let l:sub_cmd .= "\"=l:merged_text_edit['merged']['newText']\<CR>P"
else
let l:sub_cmd .= "\"=l:merged_text_edit['merged']['newText'].'?'\<CR>gph\"_x"
endif
return l:sub_cmd
"
" _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
function! s:generate_move_start_cmd(line_pos, character_pos) abort
let l:result = printf('%dG0', a:line_pos) " move the line and set to the cursor at the beginning
if a:character_pos > 0
let l:result .= printf('%dl', a:character_pos) " move right until the character
endif
return l:result
"
" _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! s:generate_move_end_cmd(line_pos, character_pos) abort
let l:result = printf('%dG0', a:line_pos) " move the line and set to the cursor at the beginning
if a:character_pos > 1
let l:result .= printf('%dl', a:character_pos) " move right until the character
elseif a:character_pos == 0
let l:result = printf('%dG$', a:line_pos - 1) " move most right
endif
return l:result
endfunction
function! s:preprocess_cmd(range) abort
" preprocess by opening the folds, this is needed because the line you are
" going might have a folding
let l:preprocess = ''
if foldlevel(a:range['start']['line']) > 0
let l:preprocess .= a:range['start']['line']
let l:preprocess .= 'GzO'
endif
if foldlevel(a:range['end']['line']) > 0
let l:preprocess .= a:range['end']['line']
let l:preprocess .= 'GzO'
endif
return l:preprocess
endfunction
" https://microsoft.github.io/language-server-protocol/specification#text-documents
" Position in a text document expressed as zero-based line and zero-based
" character offset, and since we are using the character as a offset position
" we do not have to fix its position
function! s:parse_range(range) abort
let s:range = deepcopy(a:range)
let s:range['start']['line'] = a:range['start']['line'] + 1
let s:range['end']['line'] = a:range['end']['line'] + 1
return s:range
endfunction

View File

@@ -193,4 +193,24 @@ Describe lsp#utils
Assert Equals(lsp#utils#make_valid_word("my-name\tdescription"), 'my-name')
End
End
Describe lsp#utils#_split_by_eol
It should split text by \r\n
Assert Equals(lsp#utils#_split_by_eol("あいうえお\r\nかきくけこ"), ['あいうえお', 'かきくけこ'])
End
It should split text by \r
Assert Equals(lsp#utils#_split_by_eol("あいうえお\rかきくけこ"), ['あいうえお', 'かきくけこ'])
End
It should split text by \r\n\r
Assert Equals(lsp#utils#_split_by_eol("あいうえお\r\n\rかきくけこ"), ['あいうえお', '', 'かきくけこ'])
End
It should split text by \r\n\n\r\r\n
Assert Equals(lsp#utils#_split_by_eol("あいうえお\r\n\n\r\r\nかきくけこ"), ['あいうえお', '', '', '', 'かきくけこ'])
End
End
End

View File

@@ -548,7 +548,30 @@ Describe lsp#utils#text_edit
\ }])
let l:buffer_text = s:get_text()
Assert Equals(l:buffer_text, ['x', 'y', 'z', 'b', ''])
Assert Equals(l:buffer_text, ['x', 'y', 'z', '', ''])
End
It should apply edit that contains \r\n
call s:set_text(['foo', 'b'])
call lsp#utils#text_edit#apply_text_edits(
\ expand('%'),
\ [{
\ 'range': {
\ 'start': {
\ 'line': 0,
\ 'character': 0
\ },
\ 'end': {
\ 'line': 1,
\ 'character': 1
\ }
\ },
\ 'newText': "x\r\ny\r\nz\r\n"
\ }])
let l:buffer_text = s:get_text()
Assert Equals(l:buffer_text, ['x', 'y', 'z', '', ''])
End
It adds imports correctly
@@ -599,6 +622,26 @@ Describe lsp#utils#text_edit
let l:buffer_text = s:get_text()
Assert Equals(l:buffer_text, l:text + [''])
End
It should apply edits to unloaded file
let l:target = globpath(&runtimepath, 'test/lsp/utils/text_edit.vimspec')
call themis#log(l:target)
call lsp#utils#text_edit#apply_text_edits(lsp#utils#path_to_uri(l:target), [{
\ 'range': {
\ 'start': {
\ 'line': 0,
\ 'character': 0,
\ },
\ 'end': {
\ 'line': 0,
\ 'character': 0,
\ }
\ },
\ 'newText': "aiueo\n"
\ }])
Assert Equals(getbufline(l:target, 1), ['aiueo'])
End
End
End