ale/test/test_codefix.vader
Tomáš Janoušek efa563826d
codefix: Fix code actions that return Command[] directly instead of CodeAction[] (#3929)
According to
https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#textDocument_codeAction,
the response to textDocument/codeAction is:

    (Command | CodeAction)[] | null

and the code only handled the case where it was a CodeAction that either
specified an edit or a command, but didn't handle a direct Command.

Note that the specification also says that both can be specified and
then the edit is applied first, then the command. Furthermore, there
seems to be some hacky code handling arguments directly, which I suspect
is non-standard and only works with a specific LSP server that happens
to pass the edits in the arguments unmodified.
2021-10-07 11:29:00 +09:00

550 lines
20 KiB
Plaintext

Before:
call ale#test#SetDirectory('/testplugin/test')
call ale#test#SetFilename('dummy.txt')
Save g:ale_buffer_info
let g:ale_buffer_info = {}
let g:old_filename = expand('%:p')
let g:Callback = ''
let g:expr_list = []
let g:message_list = []
let g:handle_code_action_called = 0
let g:code_actions = []
let g:options = {}
let g:capability_checked = ''
let g:conn_id = v:null
let g:InitCallback = v:null
runtime autoload/ale/lsp_linter.vim
runtime autoload/ale/lsp.vim
runtime autoload/ale/util.vim
runtime autoload/ale/codefix.vim
runtime autoload/ale/code_action.vim
function! ale#lsp_linter#StartLSP(buffer, linter, Callback) abort
let g:conn_id = ale#lsp#Register('executable', '/foo/bar', {})
call ale#lsp#MarkDocumentAsOpen(g:conn_id, a:buffer)
if a:linter.lsp is# 'tsserver'
call ale#lsp#MarkConnectionAsTsserver(g:conn_id)
endif
let l:details = {
\ 'command': 'foobar',
\ 'buffer': a:buffer,
\ 'connection_id': g:conn_id,
\ 'project_root': '/foo/bar',
\}
let g:InitCallback = {-> ale#lsp_linter#OnInit(a:linter, l:details, a:Callback)}
endfunction
function! ale#lsp#HasCapability(conn_id, capability) abort
let g:capability_checked = a:capability
return 1
endfunction
function! ale#lsp#RegisterCallback(conn_id, callback) abort
let g:Callback = a:callback
endfunction
function! ale#lsp#Send(conn_id, message) abort
call add(g:message_list, a:message)
return 42
endfunction
function! ale#util#Execute(expr) abort
call add(g:expr_list, a:expr)
endfunction
function! ale#code_action#HandleCodeAction(code_action, options) abort
let g:handle_code_action_called = 1
Assert !get(a:options, 'should_save')
call add(g:code_actions, a:code_action)
endfunction
function! ale#util#Input(message, value) abort
return '2'
endfunction
After:
Restore
if g:conn_id isnot v:null
call ale#lsp#RemoveConnectionWithID(g:conn_id)
endif
call ale#test#RestoreDirectory()
call ale#linter#Reset()
unlet! g:capability_checked
unlet! g:InitCallback
unlet! g:old_filename
unlet! g:conn_id
unlet! g:Callback
unlet! g:message_list
unlet! g:expr_list
unlet! b:ale_linters
unlet! g:options
unlet! g:code_actions
unlet! g:handle_code_action_called
runtime autoload/ale/lsp_linter.vim
runtime autoload/ale/lsp.vim
runtime autoload/ale/util.vim
runtime autoload/ale/codefix.vim
runtime autoload/ale/code_action.vim
Execute(Failed codefix responses should be handled correctly):
call ale#codefix#HandleTSServerResponse(
\ 1,
\ {'command': 'getCodeFixes', 'request_seq': 3}
\)
AssertEqual g:handle_code_action_called, 0
Given typescript(Some typescript file):
foo
somelongerline ()
bazxyzxyzxyz
Execute(getCodeFixes from tsserver should be handled):
call ale#codefix#SetMap({3: {}})
call ale#codefix#HandleTSServerResponse(1, {
\ 'command': 'getCodeFixes',
\ 'request_seq': 3,
\ 'success': v:true,
\ 'type': 'response',
\ 'body': [
\ {
\ 'description': 'Import default "x" from module "./z"',
\ 'fixName': 'import',
\ 'changes': [
\ {
\ 'fileName': "/foo/bar/file1.ts",
\ 'textChanges': [
\ {
\ 'end': {
\ 'line': 2,
\ 'offset': 1,
\ },
\ 'newText': 'import x from "./z";^@',
\ 'start': {
\ 'line': 2,
\ 'offset': 1,
\ }
\ }
\ ]
\ }
\ ]
\ }
\ ]
\})
AssertEqual g:handle_code_action_called, 1
AssertEqual
\ [
\ {
\ 'description': 'codefix',
\ 'changes': [
\ {
\ 'fileName': "/foo/bar/file1.ts",
\ 'textChanges': [
\ {
\ 'end': {
\ 'line': 2,
\ 'offset': 1
\ },
\ 'newText': 'import x from "./z";^@',
\ 'start': {
\ 'line': 2,
\ 'offset': 1
\ }
\ }
\ ]
\ }
\ ]
\ }
\ ],
\ g:code_actions
Execute(getCodeFixes from tsserver should be handled with user input if there are more than one action):
call ale#codefix#SetMap({3: {}})
call ale#codefix#HandleTSServerResponse(1, {
\ 'command': 'getCodeFixes',
\ 'request_seq': 3,
\ 'success': v:true,
\ 'type': 'response',
\ 'body': [
\ {
\ 'description': 'Import default "x" from module "./z"',
\ 'fixName': 'import',
\ 'changes': [
\ {
\ 'fileName': "/foo/bar/file1.ts",
\ 'textChanges': [
\ {
\ 'end': {
\ 'line': 2,
\ 'offset': 1,
\ },
\ 'newText': 'import x from "./z";^@',
\ 'start': {
\ 'line': 2,
\ 'offset': 1,
\ }
\ }
\ ]
\ }
\ ]
\ },
\ {
\ 'description': 'Import default "x" from module "./y"',
\ 'fixName': 'import',
\ 'changes': [
\ {
\ 'fileName': "/foo/bar/file1.ts",
\ 'textChanges': [
\ {
\ 'end': {
\ 'line': 2,
\ 'offset': 1,
\ },
\ 'newText': 'import x from "./y";^@',
\ 'start': {
\ 'line': 2,
\ 'offset': 1,
\ }
\ }
\ ]
\ }
\ ]
\ }
\ ]
\})
AssertEqual g:handle_code_action_called, 1
AssertEqual
\ [
\ {
\ 'description': 'codefix',
\ 'changes': [
\ {
\ 'fileName': "/foo/bar/file1.ts",
\ 'textChanges': [
\ {
\ 'end': {
\ 'line': 2,
\ 'offset': 1
\ },
\ 'newText': 'import x from "./y";^@',
\ 'start': {
\ 'line': 2,
\ 'offset': 1
\ }
\ }
\ ]
\ }
\ ]
\ }
\ ],
\ g:code_actions
Execute(Prints a tsserver error message when getCodeFixes unsuccessful):
call ale#codefix#SetMap({3: {}})
call ale#codefix#HandleTSServerResponse(1, {
\ 'command': 'getCodeFixes',
\ 'request_seq': 3,
\ 'success': v:false,
\ 'message': 'something is wrong',
\})
AssertEqual g:handle_code_action_called, 0
AssertEqual ['echom ''Error while getting code fixes. Reason: something is wrong'''], g:expr_list
Execute(Does nothing when where are no code fixes):
call ale#codefix#SetMap({3: {}})
call ale#codefix#HandleTSServerResponse(1, {
\ 'command': 'getCodeFixes',
\ 'request_seq': 3,
\ 'success': v:true,
\ 'body': []
\})
AssertEqual g:handle_code_action_called, 0
AssertEqual ['echom ''No code fixes available.'''], g:expr_list
Execute(tsserver codefix requests should be sent):
call ale#linter#Reset()
runtime ale_linters/typescript/tsserver.vim
let g:ale_buffer_info = {bufnr(''): {'loclist': [{'lnum': 2, 'col': 5, 'code': 2304, 'linter_name': 'tsserver'}]}}
call setpos('.', [bufnr(''), 2, 16, 0])
" ALECodeAction
call ale#codefix#Execute(0)
" We shouldn't register the callback yet.
AssertEqual '''''', string(g:Callback)
AssertEqual type(function('type')), type(g:InitCallback)
call g:InitCallback()
AssertEqual 'code_actions', g:capability_checked
AssertEqual
\ 'function(''ale#codefix#HandleTSServerResponse'')',
\ string(g:Callback)
AssertEqual
\ [
\ ale#lsp#tsserver_message#Change(bufnr('')),
\ [0, 'ts@getCodeFixes', {
\ 'startLine': 2,
\ 'startOffset': 16,
\ 'endLine': 2,
\ 'endOffset': 17,
\ 'file': expand('%:p'),
\ 'errorCodes': [2304],
\ }]
\ ],
\ g:message_list
Execute(tsserver codefix requests should be sent only for error with code):
call ale#linter#Reset()
runtime ale_linters/typescript/tsserver.vim
let g:ale_buffer_info = {bufnr(''): {'loclist': [{'lnum': 2, 'col': 16, 'linter_name': 'tsserver'}, {'lnum': 2, 'col': 16, 'code': 2304, 'linter_name': 'tsserver'}]}}
call setpos('.', [bufnr(''), 2, 16, 0])
" ALECodeAction
call ale#codefix#Execute(0)
" We shouldn't register the callback yet.
AssertEqual '''''', string(g:Callback)
AssertEqual type(function('type')), type(g:InitCallback)
call g:InitCallback()
AssertEqual 'code_actions', g:capability_checked
AssertEqual
\ 'function(''ale#codefix#HandleTSServerResponse'')',
\ string(g:Callback)
AssertEqual
\ [
\ ale#lsp#tsserver_message#Change(bufnr('')),
\ [0, 'ts@getCodeFixes', {
\ 'startLine': 2,
\ 'startOffset': 16,
\ 'endLine': 2,
\ 'endOffset': 17,
\ 'file': expand('%:p'),
\ 'errorCodes': [2304],
\ }]
\ ],
\ g:message_list
Execute(getApplicableRefactors from tsserver should be handled):
call ale#codefix#SetMap({3: {
\ 'buffer': expand('%:p'),
\ 'line': 1,
\ 'column': 2,
\ 'end_line': 3,
\ 'end_column': 4,
\ 'connection_id': 0,
\}})
call ale#codefix#HandleTSServerResponse(1,
\ {'seq': 0, 'request_seq': 3, 'type': 'response', 'success': v:true, 'body': [{'actions': [{'description': 'Extract to constant in enclosing scope', 'name': 'constant_scope_0'}], 'description': 'Extract constant', 'name': 'Extract Symbol'}, {'actions': [{'description': 'Extract to function in module scope', 'name': 'function_scope_1'}], 'description': 'Extract function', 'name': 'Extract Symbol'}], 'command': 'getApplicableRefactors'})
AssertEqual
\ [
\ [0, 'ts@getEditsForRefactor', {
\ 'startLine': 1,
\ 'startOffset': 2,
\ 'endLine': 3,
\ 'endOffset': 5,
\ 'file': expand('%:p'),
\ 'refactor': 'Extract Symbol',
\ 'action': 'function_scope_1',
\ }]
\ ],
\ g:message_list
Execute(getApplicableRefactors should print error on failure):
call ale#codefix#SetMap({3: {
\ 'buffer': expand('%:p'),
\ 'line': 1,
\ 'column': 2,
\ 'end_line': 3,
\ 'end_column': 4,
\ 'connection_id': 0,
\}})
call ale#codefix#HandleTSServerResponse(1,
\ {'seq': 0, 'request_seq': 3, 'type': 'response', 'success': v:false, 'message': 'oops', 'command': 'getApplicableRefactors'})
AssertEqual ['echom ''Error while getting applicable refactors. Reason: oops'''], g:expr_list
Execute(getApplicableRefactors should do nothing if there are no refactors):
call ale#codefix#SetMap({3: {
\ 'buffer': expand('%:p'),
\ 'line': 1,
\ 'column': 2,
\ 'end_line': 3,
\ 'end_column': 4,
\ 'connection_id': 0,
\}})
call ale#codefix#HandleTSServerResponse(1,
\ {'seq': 0, 'request_seq': 3, 'type': 'response', 'success': v:true, 'body': [], 'command': 'getApplicableRefactors'})
AssertEqual ['echom ''No applicable refactors available.'''], g:expr_list
Execute(getEditsForRefactor from tsserver should be handled):
call ale#codefix#SetMap({3: {}})
call ale#codefix#HandleTSServerResponse(1,
\{'seq': 0, 'request_seq': 3, 'type': 'response', 'success': v:true, 'body': {'edits': [{'fileName': '/foo/bar/file.ts', 'textChanges': [{'end': {'offset': 35, 'line': 9}, 'newText': 'newFunction(app);', 'start': {'offset': 3, 'line': 8}}, {'end': {'offset': 4, 'line': 19}, 'newText': '^@function newFunction(app: Router) {^@ app.use(booExpressCsrf());^@ app.use(booExpressRequireHttps);^@}^@', 'start': {'offset': 4, 'line': 19}}]}], 'renameLocation': {'offset': 3, 'line': 8}, 'renameFilename': '/foo/bar/file.ts'}, 'command': 'getEditsForRefactor' }
\)
AssertEqual g:handle_code_action_called, 1
AssertEqual
\ [
\ {
\ 'description': 'editsForRefactor',
\ 'changes': [{'fileName': '/foo/bar/file.ts', 'textChanges': [{'end': {'offset': 35, 'line': 9}, 'newText': 'newFunction(app);', 'start': {'offset': 3, 'line': 8}}, {'end': {'offset': 4, 'line': 19}, 'newText': '^@function newFunction(app: Router) {^@ app.use(booExpressCsrf());^@ app.use(booExpressRequireHttps);^@}^@', 'start': {'offset': 4, 'line': 19}}]}],
\ }
\ ],
\ g:code_actions
Execute(getEditsForRefactor should print error on failure):
call ale#codefix#SetMap({3: {}})
call ale#codefix#HandleTSServerResponse(1,
\{'seq': 0, 'request_seq': 3, 'type': 'response', 'success': v:false, 'message': 'oops', 'command': 'getEditsForRefactor' }
\)
AssertEqual ['echom ''Error while getting edits for refactor. Reason: oops'''], g:expr_list
Execute(Failed LSP responses should be handled correctly):
call ale#codefix#HandleLSPResponse(
\ 1,
\ {'method': 'workspace/applyEdit', 'request_seq': 3}
\)
AssertEqual g:handle_code_action_called, 0
Given python(Some python file):
def main():
a = 1
b = a + 2
Execute("workspace/applyEdit" from LSP should be handled):
call ale#codefix#SetMap({3: {}})
call ale#codefix#HandleLSPResponse(1,
\ {'id': 0, 'jsonrpc': '2.0', 'method': 'workspace/applyEdit', 'params': {'edit': {'changes': {'file:///foo/bar/file.ts': [{'range': {'end': {'character': 27, 'line': 7}, 'start': {'character': 27, 'line': 7}}, 'newText': ', Config'}, {'range': {'end': {'character': 12, 'line': 96}, 'start': {'character': 2, 'line': 94}}, 'newText': 'await newFunction(redis, imageKey, cover, config);'}, {'range': {'end': {'character': 2, 'line': 99}, 'start': {'character': 2, 'line': 99}}, 'newText': '^@async function newFunction(redis: IRedis, imageKey: string, cover: Buffer, config: Config) {^@ try {^@ await redis.set(imageKey, cover, ''ex'', parseInt(config.coverKeyTTL, 10));^@ }^@ catch { }^@}^@'}]}}}})
AssertEqual g:handle_code_action_called, 1
AssertEqual
\ [{'description': 'applyEdit', 'changes': [{'fileName': '/foo/bar/file.ts', 'textChanges': [{'end': {'offset': 28, 'line': 8}, 'newText': ', Config', 'start': {'offset': 28, 'line': 8}}, {'end': {'offset': 13, 'line': 97}, 'newText': 'await newFunction(redis, imageKey, cover, config);', 'start': {'offset': 3, 'line': 95}}, {'end': {'offset': 3, 'line': 100}, 'newText': '^@async function newFunction(redis: IRedis, imageKey: string, cover: Buffer, config: Config) {^@ try {^@ await redis.set(imageKey, cover, ''ex'', parseInt(config.coverKeyTTL, 10));^@ }^@ catch { }^@}^@', 'start': {'offset': 3, 'line': 100}}]}]}],
\ g:code_actions
Execute(Code Actions from LSP should be handled when returned with documentChanges):
call ale#codefix#SetMap({2: {}})
call ale#codefix#HandleLSPResponse(1,
\ {'id': 2, 'jsonrpc': '2.0', 'result': [{'diagnostics': v:null, 'edit': {'changes': v:null, 'documentChanges': [{'edits': [{'range': {'end': {'character': 4, 'line': 2}, 'start': {'character': 4, 'line': 1}}, 'newText': ''}, {'range': {'end': {'character': 9, 'line': 2}, 'start': {'character': 8, 'line': 2}}, 'newText': '(1)'}], 'textDocument': {'uri': 'file:///foo/bar/test.py', 'version': v:null}}]}, 'kind': 'refactor.inline', 'title': 'Inline variable', 'command': v:null}, {'diagnostics': v:null, 'edit': {'changes': v:null, 'documentChanges': [{'edits': [{'range': {'end': {'character': 0, 'line': 0}, 'start': {'character': 0, 'line': 0}}, 'newText': 'def func_bomdjnxh():^@ a = 1return a^@^@^@'}, {'range': {'end': {'character': 9, 'line': 1}, 'start': {'character': 8, 'line': 1}}, 'newText': 'func_bomdjnxh()^@'}], 'textDocument': {'uri': 'file:///foo/bar/test.py', 'version': v:null}}]}, 'kind': 'refactor.extract', 'title': 'Extract expression into function ''func_bomdjnxh''', 'command': v:null}]})
AssertEqual g:handle_code_action_called, 1
AssertEqual
\ [{'description': 'codeaction', 'changes': [{'fileName': '/foo/bar/test.py', 'textChanges': [{'end': {'offset': 1, 'line': 1}, 'newText': 'def func_bomdjnxh():^@ a = 1return a^@^@^@', 'start': {'offset': 1, 'line': 1}}, {'end': {'offset': 10, 'line': 2}, 'newText': 'func_bomdjnxh()^@', 'start': {'offset': 9, 'line': 2}}]}]}],
\ g:code_actions
Execute(LSP Code Actions handles CodeAction responses):
call ale#codefix#SetMap({3: {
\ 'connection_id': 0,
\}})
call ale#codefix#HandleLSPResponse(1,
\ {'id': 3, 'jsonrpc': '2.0', 'result': [{'kind': 'refactor', 'title': 'Extract to inner function in function ''getVideo''', 'command': {'arguments': [{'file': '/foo/bar/file.ts', 'endOffset': 0, 'action': 'function_scope_0', 'startOffset': 1, 'startLine': 65, 'refactor': 'Extract Symbol', 'endLine': 68}], 'title': 'Extract to inner function in function ''getVideo''', 'command': '_typescript.applyRefactoring'}}, {'kind': 'refactor', 'title': 'Extract to function in module scope', 'command': {'arguments': [{'file': '/foo/bar/file.ts', 'endOffset': 0, 'action': 'function_scope_1', 'startOffset': 1, 'startLine': 65, 'refactor': 'Extract Symbol', 'endLine': 68}], 'title': 'Extract to function in module scope', 'command': '_typescript.applyRefactoring'}}]})
AssertEqual
\ [[0, 'workspace/executeCommand', {'arguments': [{'file': '/foo/bar/file.ts', 'action': 'function_scope_1', 'endOffset': 0, 'refactor': 'Extract Symbol', 'endLine': 68, 'startLine': 65, 'startOffset': 1}], 'command': '_typescript.applyRefactoring'}]],
\ g:message_list
Execute(LSP Code Actions handles Command responses):
call ale#codefix#SetMap({2: {
\ 'connection_id': 2,
\}})
call ale#codefix#HandleLSPResponse(1,
\ {'id': 2, 'jsonrpc': '2.0', 'result': [{'title': 'fake for testing'}, {'arguments': [{'documentChanges': [{'edits': [{'range': {'end': {'character': 31, 'line': 2}, 'start': {'character': 31, 'line': 2}}, 'newText': ', createVideo'}], 'textDocument': {'uri': 'file:///foo/bar/file.ts', 'version': 1}}]}], 'title': 'Add ''createVideo'' to existing import declaration from "./video"', 'command': '_typescript.applyWorkspaceEdit'}]})
AssertEqual
\ [[0, 'workspace/executeCommand', {'arguments': [{'documentChanges': [{'edits': [{'range': {'end': {'character': 31, 'line': 2}, 'start': {'character': 31, 'line': 2}}, 'newText': ', createVideo'}], 'textDocument': {'uri': 'file:///foo/bar/file.ts', 'version': 1}}]}], 'command': '_typescript.applyWorkspaceEdit'}]],
\ g:message_list
Execute(Prints message when LSP code action returns no results):
call ale#codefix#SetMap({3: {}})
call ale#codefix#HandleLSPResponse(1,
\ {'id': 3, 'jsonrpc': '2.0', 'result': []})
AssertEqual g:handle_code_action_called, 0
AssertEqual ['echom ''No code actions received from server'''], g:expr_list
Execute(LSP code action requests should be sent):
call ale#linter#Reset()
runtime ale_linters/python/jedils.vim
let g:ale_buffer_info = {bufnr(''): {'loclist': [{'lnum': 2, 'col': 5, 'end_lnum': 2, 'end_col': 6, 'code': 2304, 'text': 'oops'}]}}
call setpos('.', [bufnr(''), 2, 5, 0])
" ALECodeAction
call ale#codefix#Execute(0)
" We shouldn't register the callback yet.
AssertEqual '''''', string(g:Callback)
AssertEqual type(function('type')), type(g:InitCallback)
call g:InitCallback()
AssertEqual 'code_actions', g:capability_checked
AssertEqual
\ 'function(''ale#codefix#HandleLSPResponse'')',
\ string(g:Callback)
AssertEqual
\ [
\ [0, 'textDocument/codeAction', {
\ 'context': {
\ 'diagnostics': [{'range': {'end': {'character': 6, 'line': 1}, 'start': {'character': 4, 'line': 1}}, 'code': 2304, 'message': 'oops'}]
\ },
\ 'range': {'end': {'character': 5, 'line': 1}, 'start': {'character': 4, 'line': 1}},
\ 'textDocument': {'uri': ale#path#ToURI(expand('%:p'))}
\ }]
\ ],
\ g:message_list[-1:]
Execute(LSP code action requests should be sent only for error with code):
call ale#linter#Reset()
runtime ale_linters/python/jedils.vim
let g:ale_buffer_info = {bufnr(''): {'loclist': [{'lnum': 2, 'col': 5, 'end_lnum': 2, 'end_col': 6, 'code': 2304, 'text': 'oops'}]}}
call setpos('.', [bufnr(''), 2, 5, 0])
" ALECodeAction
call ale#codefix#Execute(0)
" We shouldn't register the callback yet.
AssertEqual '''''', string(g:Callback)
AssertEqual type(function('type')), type(g:InitCallback)
call g:InitCallback()
AssertEqual 'code_actions', g:capability_checked
AssertEqual
\ 'function(''ale#codefix#HandleLSPResponse'')',
\ string(g:Callback)
AssertEqual
\ [
\ [0, 'textDocument/codeAction', {
\ 'context': {
\ 'diagnostics': [{'range': {'end': {'character': 6, 'line': 1}, 'start': {'character': 4, 'line': 1}}, 'code': 2304, 'message': 'oops'}]
\ },
\ 'range': {'end': {'character': 5, 'line': 1}, 'start': {'character': 4, 'line': 1}},
\ 'textDocument': {'uri': ale#path#ToURI(expand('%:p'))}
\ }]
\ ],
\ g:message_list[-1:]