From 2550f5d952caea4ac607ec0796d8f1fae4d7b9a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Janou=C5=A1ek?= Date: Sat, 20 Feb 2021 17:16:47 +0100 Subject: [PATCH] Fixes to code actions (cursor moving, tests, EOL/EOF corner cases) (#3478) * code_action: Don't move cursor when change covers entire file * code_action: Refactor/simplify ApplyChanges * code_action: Fix EOL at EOF corner cases while performing no changes * code_action: Fix column around EOL corner cases * code_action: Handle positions out of bounds * code_action: Add instructions for verifying corner case tests against vscode --- autoload/ale/code_action.vim | 95 +++++++----- test/test_code_action.vader | 22 ++- test/test_code_action_corner_cases.vader | 179 +++++++++++++++++++++++ test/test_code_action_python.vader | 2 +- 4 files changed, 250 insertions(+), 48 deletions(-) create mode 100644 test/test_code_action_corner_cases.vader diff --git a/autoload/ale/code_action.vim b/autoload/ale/code_action.vim index 69d40933..be4a25da 100644 --- a/autoload/ale/code_action.vim +++ b/autoload/ale/code_action.vim @@ -71,6 +71,11 @@ function! ale#code_action#ApplyChanges(filename, changes, should_save) abort if l:buffer > 0 let l:lines = getbufline(l:buffer, 1, '$') + + " Add empty line if there's trailing newline, like readfile() does. + if getbufvar(l:buffer, '&eol') + let l:lines += [''] + endif else let l:lines = readfile(a:filename, 'b') endif @@ -89,62 +94,82 @@ function! ale#code_action#ApplyChanges(filename, changes, should_save) abort let l:end_column = l:code_edit.end.offset let l:text = l:code_edit.newText - " Adjust the ends according to previous edits. - if l:end_line > len(l:lines) - let l:end_line_len = 0 - else - let l:end_line_len = len(l:lines[l:end_line - 1]) - endif - let l:insertions = split(l:text, '\n', 1) - if l:line is 1 - " Same logic as for column below. Vimscript's slice [:-1] will not - " be an empty list. - let l:start = [] - else - let l:start = l:lines[: l:line - 2] + " Fix invalid columns + let l:column = l:column > 0 ? l:column : 1 + let l:end_column = l:end_column > 0 ? l:end_column : 1 + + " Clamp start to BOF + if l:line < 1 + let [l:line, l:column] = [1, 1] endif - " Special case when text must be added after new line - if l:column > len(l:lines[l:line - 1]) - call extend(l:start, [l:lines[l:line - 1]]) - let l:column = 1 + " Clamp start to EOF + if l:line > len(l:lines) || l:line == len(l:lines) && l:column > len(l:lines[-1]) + 1 + let [l:line, l:column] = [len(l:lines), len(l:lines[-1]) + 1] + " Special case when start is after EOL + elseif l:line < len(l:lines) && l:column > len(l:lines[l:line - 1]) + 1 + let [l:line, l:column] = [l:line + 1, 1] endif - if l:column is 1 - " We need to handle column 1 specially, because we can't slice an - " empty string ending on index 0. - let l:middle = [l:insertions[0]] - else - let l:middle = [l:lines[l:line - 1][: l:column - 2] . l:insertions[0]] + " Adjust end: clamp if invalid and/or adjust if we moved start + if l:end_line < l:line || l:end_line == l:line && l:end_column < l:column + let [l:end_line, l:end_column] = [l:line, l:column] endif - call extend(l:middle, l:insertions[1:]) - - if l:end_line <= len(l:lines) - " Only extend the last line if end_line is within the range of - " lines. - let l:middle[-1] .= l:lines[l:end_line - 1][l:end_column - 1 :] + " Clamp end to EOF + if l:end_line > len(l:lines) || l:end_line == len(l:lines) && l:end_column > len(l:lines[-1]) + 1 + let [l:end_line, l:end_column] = [len(l:lines), len(l:lines[-1]) + 1] + " Special case when end is after EOL + elseif l:end_line < len(l:lines) && l:end_column > len(l:lines[l:end_line - 1]) + 1 + let [l:end_line, l:end_column] = [l:end_line + 1, 1] endif + " Careful, [:-1] is not an empty list + let l:start = l:line is 1 ? [] : l:lines[: l:line - 2] + let l:middle = l:column is 1 ? [''] : [l:lines[l:line - 1][: l:column - 2]] + + let l:middle[-1] .= l:insertions[0] + let l:middle += l:insertions[1:] + let l:middle[-1] .= l:lines[l:end_line - 1][l:end_column - 1 :] + + let l:end_line_len = len(l:lines[l:end_line - 1]) let l:lines_before_change = len(l:lines) let l:lines = l:start + l:middle + l:lines[l:end_line :] let l:current_line_offset = len(l:lines) - l:lines_before_change let l:column_offset = len(l:middle[-1]) - l:end_line_len - let l:pos = s:UpdateCursor(l:pos, - \ [l:line, l:column], - \ [l:end_line, l:end_column], - \ [l:current_line_offset, l:column_offset]) + " Keep cursor where it was (if outside of changes) or move it after + " the changed text (if inside), but don't touch it when the change + " spans the entire buffer, in which case we have no clue and it's + " better to not do anything. + if l:line isnot 1 || l:column isnot 1 + \|| l:end_line < l:lines_before_change + \|| l:end_line == l:lines_before_change && l:end_column <= l:end_line_len + let l:pos = s:UpdateCursor(l:pos, + \ [l:line, l:column], + \ [l:end_line, l:end_column], + \ [l:current_line_offset, l:column_offset]) + endif endfor - if l:lines[-1] is# '' + if l:buffer > 0 + " Make sure ale#util#{Writefile,SetBufferContents} add trailing + " newline if and only if it should be added. + if l:lines[-1] is# '' && getbufvar(l:buffer, '&eol') + call remove(l:lines, -1) + else + call setbufvar(l:buffer, '&eol', 0) + endif + elseif exists('+fixeol') && &fixeol && l:lines[-1] is# '' + " Not in buffer, ale#util#Writefile can't check &eol and always adds + " newline if &fixeol: remove to prevent double trailing newline. call remove(l:lines, -1) endif - if a:should_save + if a:should_save || l:buffer < 0 call ale#util#Writefile(l:buffer, l:lines, a:filename) else call ale#util#SetBufferContents(l:buffer, l:lines) diff --git a/test/test_code_action.vader b/test/test_code_action.vader index 7eabb759..c613222c 100644 --- a/test/test_code_action.vader +++ b/test/test_code_action.vader @@ -1,14 +1,7 @@ Before: Save g:ale_enabled - let g:ale_enabled = 0 - " Enable fix end-of-line as tests below expect that - set fixeol - - runtime autoload/ale/code_action.vim - runtime autoload/ale/util.vim - let g:file1 = tempname() let g:file2 = tempname() let g:test = {} @@ -42,8 +35,6 @@ Before: endfunction! After: - Restore - " Close the extra buffers if we opened it. if bufnr(g:file1) != -1 execute ':bp! | :bd! ' . bufnr(g:file1) @@ -65,8 +56,7 @@ After: unlet! g:changes delfunction WriteFileAndEdit - runtime autoload/ale/code_action.vim - runtime autoload/ale/util.vim + Restore Execute(It should modify and save multiple files): @@ -214,7 +204,6 @@ Execute(End of file can be modified): \) AssertEqual g:test.text + [ - \ '', \ 'type A: string', \ 'type B: number', \ '', @@ -364,6 +353,15 @@ Execute(Cursor will not move when changes happening on lines >= cursor, but afte AssertEqual ' value: number', getline('.') AssertEqual [2, 3], getpos('.')[1:2] +Execute(Cursor will not move when change covers entire file): + call WriteFileAndEdit() + call setpos('.', [0, 2, 3, 0]) + call ale#code_action#HandleCodeAction( + \ g:test.create_change(1, 1, len(g:test.text) + 1, 1, + \ join(g:test.text + ['x'], "\n")), + \ {'should_save': 1}) + AssertEqual [2, 3], getpos('.')[1:2] + Execute(It should just modify file when should_save is set to v:false): call WriteFileAndEdit() let g:test.change = g:test.create_change(1, 1, 1, 1, "import { writeFile } from 'fs';\n") diff --git a/test/test_code_action_corner_cases.vader b/test/test_code_action_corner_cases.vader new file mode 100644 index 00000000..c44cf0ea --- /dev/null +++ b/test/test_code_action_corner_cases.vader @@ -0,0 +1,179 @@ +" Tests for various corner cases of applying code changes from LSP. +" +" These can be verified against the reference vscode implementation using the +" following javascript program: +" +" const { TextDocument } = require('vscode-languageserver-textdocument'); +" const { TextEdit, Position, Range } = require('vscode-languageserver-types'); +" function MkPos(line, offset) { return Position.create(line - 1, offset - 1); } +" function MkInsert(pos, newText) { return TextEdit.insert(pos, newText); } +" function MkDelete(start, end) { return TextEdit.del(Range.create(start, end)); } +" function TestChanges(s, es) { +" return TextDocument.applyEdits(TextDocument.create(null, null, null, s), es); +" } +" +" const fs = require("fs"); +" const assert = require('assert').strict; +" const testRegex = /(?